diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 21:29:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 21:29:08 +0900 |
| commit | 0ae805b28b5160b8d9fb43635fa172961f6550cc (patch) | |
| tree | e9fb7cbad0755cd7608ea56012c986a4a47b2aaf | |
| parent | de6df4abdd8a6eb9a0217050ce17e0925f04602b (diff) | |
| download | dispatch-0ae805b28b5160b8d9fb43635fa172961f6550cc.tar.gz dispatch-0ae805b28b5160b8d9fb43635fa172961f6550cc.zip | |
feat: inline tool display and thinking/reasoning support
- Tool calls now appear at their stream position within messages (ContentSegment model)
- Added reasoning/thinking display: collapsible <details> block above content
- Set DeepSeek V4 Flash reasoningEffort to max via providerOptions
- ChatMessage.content changed from string to ContentSegment[] (text | tool-call)
- Agent handles AI SDK reasoning events, yields reasoning-delta
- Fixed duplicate key in ChatMessage.svelte each block
| -rw-r--r-- | packages/core/src/agent/agent.ts | 5 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/lib/chat.svelte.ts | 96 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 21 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 9 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 157 |
6 files changed, 186 insertions, 103 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index c28d691..8e89bb9 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -80,6 +80,9 @@ export class Agent { messages: toCoreMessages(this.messages), tools: registry.getAISDKTools(), maxSteps: 10, + providerOptions: { + openaiCompatible: { reasoningEffort: "max" }, + }, }); let fullText = ""; @@ -90,6 +93,8 @@ export class Agent { if (event.type === "text-delta") { fullText += event.textDelta; yield { type: "text-delta", delta: event.textDelta }; + } else if (event.type === "reasoning") { + yield { type: "reasoning-delta", delta: event.textDelta }; } else if (event.type === "tool-call") { const toolCall: ToolCall = { id: event.toolCallId, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a408e7d..28e79ae 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -29,6 +29,7 @@ export type AgentStatus = "idle" | "running" | "error"; export type AgentEvent = | { type: "status"; status: AgentStatus } | { type: "text-delta"; delta: string } + | { type: "reasoning-delta"; delta: string } | { type: "tool-call"; toolCall: ToolCall } | { type: "tool-result"; toolResult: ToolResult } | { type: "error"; error: string } diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts index fc5f68c..78fb2db 100644 --- a/packages/frontend/src/lib/chat.svelte.ts +++ b/packages/frontend/src/lib/chat.svelte.ts @@ -1,5 +1,5 @@ import { config } from "./config.js"; -import type { AgentEvent, ChatMessage, DebugInfo, ToolCallDisplay } from "./types.js"; +import type { AgentEvent, ChatMessage, ContentSegment, DebugInfo } from "./types.js"; import { wsClient } from "./ws.svelte.js"; function generateId() { @@ -25,15 +25,20 @@ function formatConversation(msgs: ChatMessage[]): string { for (const msg of msgs) { const role = msg.role === "user" ? "User" : "Assistant"; lines.push(`--- ${role} ---`); - lines.push(msg.content); - if (msg.toolCalls && msg.toolCalls.length > 0) { - for (const tc of msg.toolCalls) { - lines.push(` [Tool: ${tc.name}]`); - lines.push(` Args: ${JSON.stringify(tc.arguments)}`); - if (tc.result !== undefined) { - const prefix = tc.isError ? " Error: " : " Result: "; - lines.push(`${prefix}${tc.result}`); + if (msg.thinking) { + lines.push(` [Thinking]: ${msg.thinking}`); + } + + for (const seg of msg.content) { + if (seg.type === "text") { + lines.push(seg.text); + } else if (seg.type === "tool-call") { + lines.push(` [Tool: ${seg.name}]`); + lines.push(` Args: ${JSON.stringify(seg.arguments)}`); + if (seg.result !== undefined) { + const prefix = seg.isError ? " Error: " : " Result: "; + lines.push(`${prefix}${seg.result}`); } } } @@ -88,8 +93,8 @@ function createChatStore() { const newMsg: ChatMessage = { id, role: "assistant", - content: "", - toolCalls: [], + content: [], + thinking: "", isStreaming: true, }; messages = [...messages, newMsg]; @@ -107,15 +112,28 @@ function createChatStore() { } break; } - case "text-delta": { + case "reasoning-delta": { + ensureCurrentAssistantMessage(); + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { ...m, thinking: (m.thinking ?? "") + event.delta }; + } + return m; + }); + break; + } + case "text-delta": { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { - ...m, - content: m.content + event.delta, - isStreaming: true, - }; + const segments = [...m.content]; + const last = segments[segments.length - 1]; + if (last && last.type === "text") { + segments[segments.length - 1] = { ...last, text: last.text + event.delta }; + } else { + segments.push({ type: "text", text: event.delta }); + } + return { ...m, content: segments, isStreaming: true }; } return m; }); @@ -123,15 +141,19 @@ function createChatStore() { } case "tool-call": { ensureCurrentAssistantMessage(); - const toolCall: ToolCallDisplay = { - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - isExpanded: false, - }; messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] }; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + isExpanded: false, + }, + ]; + return { ...m, content: segments }; } return m; }); @@ -142,15 +164,11 @@ function createChatStore() { if (m.id === currentAssistantId) { return { ...m, - toolCalls: (m.toolCalls ?? []).map((tc) => { - if (tc.id === event.toolResult.toolCallId) { - return { - ...tc, - result: event.toolResult.result, - isError: event.toolResult.isError, - }; + content: m.content.map((seg) => { + if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { + return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; } - return tc; + return seg; }), }; } @@ -161,11 +179,7 @@ function createChatStore() { case "done": { messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { - ...m, - content: event.message.content, - isStreaming: false, - }; + return { ...m, isStreaming: false }; } return m; }); @@ -176,7 +190,7 @@ function createChatStore() { const errMsg: ChatMessage = { id: generateId(), role: "assistant", - content: `Error: ${event.error}`, + content: [{ type: "text", text: `Error: ${event.error}` }], isStreaming: false, debugInfo: makeDebugInfo({ error: event.error, @@ -196,7 +210,7 @@ function createChatStore() { const userMsg: ChatMessage = { id: generateId(), role: "user", - content: text, + content: [{ type: "text", text }], }; messages = [...messages, userMsg]; currentAssistantId = null; @@ -213,7 +227,7 @@ function createChatStore() { const errMsg: ChatMessage = { id: generateId(), role: "assistant", - content: `Error: Failed to send message (HTTP ${res.status})`, + content: [{ type: "text", text: `Error: Failed to send message (HTTP ${res.status})` }], isStreaming: false, debugInfo: makeDebugInfo({ error: `POST ${url} returned ${res.status}`, @@ -229,7 +243,7 @@ function createChatStore() { const errMsg: ChatMessage = { id: generateId(), role: "assistant", - content: "Error: Could not reach the server", + content: [{ type: "text", text: "Error: Could not reach the server" }], isStreaming: false, debugInfo: makeDebugInfo({ error: `POST ${url} failed: ${errorText}`, diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index 447bb29..5212790 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -9,18 +9,21 @@ const isUser = $derived(message.role === "user"); <div class="chat {isUser ? 'chat-end' : 'chat-start'} mb-2"> <div class="chat-bubble {isUser ? 'chat-bubble-primary' : 'chat-bubble-secondary'} max-w-[80%] break-words"> - {#if message.content} - <span>{message.content}</span> + {#if message.thinking} + <details class="mb-2"> + <summary class="cursor-pointer text-sm text-base-content/60 italic">Thinking...</summary> + <p class="text-sm text-base-content/60 italic mt-1 whitespace-pre-wrap">{message.thinking}</p> + </details> {/if} + {#each message.content as segment, i (segment.type === "tool-call" ? segment.id : i)} + {#if segment.type === "text"} + <span>{segment.text}</span> + {:else if segment.type === "tool-call"} + <ToolCallDisplay toolCall={segment} /> + {/if} + {/each} {#if message.isStreaming} <span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5 align-middle">▌</span> {/if} - {#if message.toolCalls && message.toolCalls.length > 0} - <div class="mt-2"> - {#each message.toolCalls as toolCall (toolCall.id)} - <ToolCallDisplay {toolCall} /> - {/each} - </div> - {/if} </div> </div> diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 6edb550..93bc477 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -19,11 +19,15 @@ export interface DebugInfo { httpBody?: string; } +export type ContentSegment = + | { type: "text"; text: string } + | ({ type: "tool-call" } & ToolCallDisplay); + export interface ChatMessage { id: string; role: "user" | "assistant"; - content: string; - toolCalls?: ToolCallDisplay[]; + content: ContentSegment[]; + thinking?: string; isStreaming?: boolean; debugInfo?: DebugInfo; } @@ -33,6 +37,7 @@ export type ConnectionStatus = "connecting" | "connected" | "disconnected"; export type AgentEvent = | { type: "status"; status: "idle" | "running" | "error" } | { type: "text-delta"; delta: string } + | { type: "reasoning-delta"; delta: string } | { type: "tool-call"; toolCall: { diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index de988a2..11b7deb 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { AgentEvent } from "../src/lib/types.js"; +import type { AgentEvent, ContentSegment } from "../src/lib/types.js"; // We test the logic inline since runes require svelte compilation context. // The chat store logic is tested via a plain reimplementation of the same logic. @@ -8,20 +8,11 @@ function generateId() { return Math.random().toString(36).slice(2, 11); } -interface ToolCallDisplay { - id: string; - name: string; - arguments: Record<string, unknown>; - result?: string; - isError?: boolean; - isExpanded: boolean; -} - interface ChatMessage { id: string; role: "user" | "assistant"; - content: string; - toolCalls?: ToolCallDisplay[]; + content: ContentSegment[]; + thinking?: string; isStreaming?: boolean; } @@ -44,8 +35,8 @@ function createTestStore() { const newMsg: ChatMessage = { id, role: "assistant", - content: "", - toolCalls: [], + content: [], + thinking: "", isStreaming: true, }; messages = [...messages, newMsg]; @@ -63,15 +54,28 @@ function createTestStore() { } break; } + case "reasoning-delta": { + ensureCurrentAssistantMessage(); + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { ...m, thinking: (m.thinking ?? "") + event.delta }; + } + return m; + }); + break; + } case "text-delta": { ensureCurrentAssistantMessage(); messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { - ...m, - content: m.content + event.delta, - isStreaming: true, - }; + const segments = [...m.content]; + const last = segments[segments.length - 1]; + if (last && last.type === "text") { + segments[segments.length - 1] = { ...last, text: last.text + event.delta }; + } else { + segments.push({ type: "text", text: event.delta }); + } + return { ...m, content: segments, isStreaming: true }; } return m; }); @@ -79,15 +83,19 @@ function createTestStore() { } case "tool-call": { ensureCurrentAssistantMessage(); - const toolCall: ToolCallDisplay = { - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - isExpanded: false, - }; messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] }; + const segments: ContentSegment[] = [ + ...m.content, + { + type: "tool-call", + id: event.toolCall.id, + name: event.toolCall.name, + arguments: event.toolCall.arguments, + isExpanded: false, + }, + ]; + return { ...m, content: segments }; } return m; }); @@ -98,15 +106,11 @@ function createTestStore() { if (m.id === currentAssistantId) { return { ...m, - toolCalls: (m.toolCalls ?? []).map((tc) => { - if (tc.id === event.toolResult.toolCallId) { - return { - ...tc, - result: event.toolResult.result, - isError: event.toolResult.isError, - }; + content: m.content.map((seg) => { + if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { + return { ...seg, result: event.toolResult.result, isError: event.toolResult.isError }; } - return tc; + return seg; }), }; } @@ -117,7 +121,7 @@ function createTestStore() { case "done": { messages = messages.map((m) => { if (m.id === currentAssistantId) { - return { ...m, content: event.message.content, isStreaming: false }; + return { ...m, isStreaming: false }; } return m; }); @@ -130,7 +134,7 @@ function createTestStore() { { id: generateId(), role: "assistant", - content: `Error: ${event.error}`, + content: [{ type: "text", text: `Error: ${event.error}` }] as ContentSegment[], isStreaming: false, }, ]; @@ -145,7 +149,7 @@ function createTestStore() { const userMsg: ChatMessage = { id: generateId(), role: "user", - content: text, + content: [{ type: "text", text }], }; messages = [...messages, userMsg]; currentAssistantId = null; @@ -186,33 +190,45 @@ describe("chat store logic", () => { store.sendMessage("hello"); expect(store.messages).toHaveLength(1); expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[0]?.content).toBe("hello"); + expect(store.messages[0]?.content).toEqual([{ type: "text", text: "hello" }]); }); it("text-delta creates a streaming assistant message and appends deltas", () => { store.handleEvent({ type: "text-delta", delta: "Hello" }); expect(store.messages).toHaveLength(1); expect(store.messages[0]?.role).toBe("assistant"); - expect(store.messages[0]?.content).toBe("Hello"); + expect(store.messages[0]?.content).toEqual([{ type: "text", text: "Hello" }]); expect(store.messages[0]?.isStreaming).toBe(true); store.handleEvent({ type: "text-delta", delta: " world" }); - expect(store.messages[0]?.content).toBe("Hello world"); + expect(store.messages[0]?.content).toEqual([{ type: "text", text: "Hello world" }]); }); - it("tool-call adds to current assistant message toolCalls", () => { + it("text-delta appends to last text segment in same segment", () => { + store.handleEvent({ type: "text-delta", delta: "A" }); + store.handleEvent({ type: "text-delta", delta: "B" }); + // Should be one text segment, not two + expect(store.messages[0]?.content).toHaveLength(1); + expect(store.messages[0]?.content[0]).toEqual({ type: "text", text: "AB" }); + }); + + it("tool-call inserts as a segment after text", () => { store.handleEvent({ type: "text-delta", delta: "Calling tool..." }); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "search", arguments: { query: "test" } }, }); - const msg = store.messages[0]; - expect(msg?.toolCalls).toHaveLength(1); - expect(msg?.toolCalls?.[0]?.name).toBe("search"); - expect(msg?.toolCalls?.[0]?.id).toBe("tc1"); + const content = store.messages[0]?.content; + expect(content).toHaveLength(2); + expect(content?.[0]?.type).toBe("text"); + expect(content?.[1]?.type).toBe("tool-call"); + if (content?.[1]?.type === "tool-call") { + expect(content[1].name).toBe("search"); + expect(content[1].id).toBe("tc1"); + } }); - it("tool-result fills in result on matching tool call", () => { + it("tool-result fills in result on matching tool-call segment", () => { store.handleEvent({ type: "text-delta", delta: "..." }); store.handleEvent({ type: "tool-call", @@ -222,9 +238,38 @@ describe("chat store logic", () => { type: "tool-result", toolResult: { toolCallId: "tc1", result: "found it", isError: false }, }); - const tc = store.messages[0]?.toolCalls?.[0]; - expect(tc?.result).toBe("found it"); - expect(tc?.isError).toBe(false); + const tc = store.messages[0]?.content[1]; + if (tc?.type === "tool-call") { + expect(tc.result).toBe("found it"); + expect(tc.isError).toBe(false); + } + }); + + it("tool-call goes after previous tool-call, preserving both", () => { + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "read", arguments: {} }, + }); + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc2", name: "write", arguments: {} }, + }); + const content = store.messages[0]?.content; + expect(content).toHaveLength(2); + expect(content?.[0]?.type).toBe("tool-call"); + expect(content?.[1]?.type).toBe("tool-call"); + }); + + it("text after tool-call creates new text segment", () => { + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "read", arguments: {} }, + }); + store.handleEvent({ type: "text-delta", delta: "Result: here" }); + const content = store.messages[0]?.content; + expect(content).toHaveLength(2); + expect(content?.[0]?.type).toBe("tool-call"); + expect(content?.[1]).toEqual({ type: "text", text: "Result: here" }); }); it("done finalizes the current assistant message", () => { @@ -233,14 +278,14 @@ describe("chat store logic", () => { type: "done", message: { role: "assistant", content: "full content" }, }); - expect(store.messages[0]?.content).toBe("full content"); + expect(store.messages[0]?.content).toEqual([{ type: "text", text: "partial" }]); expect(store.messages[0]?.isStreaming).toBe(false); }); it("error event adds an error message and sets status to error", () => { store.handleEvent({ type: "error", error: "something went wrong" }); expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.content).toBe("Error: something went wrong"); + expect(store.messages[0]?.content).toEqual([{ type: "text", text: "Error: something went wrong" }]); expect(store.agentStatus).toBe("error"); }); @@ -258,4 +303,14 @@ describe("chat store logic", () => { expect(store.messages).toHaveLength(0); expect(store.agentStatus).toBe("idle"); }); + + it("reasoning-delta accumulates thinking text on current assistant message", () => { + store.handleEvent({ type: "reasoning-delta", delta: "First thought." }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("assistant"); + expect(store.messages[0]?.thinking).toBe("First thought."); + + store.handleEvent({ type: "reasoning-delta", delta: " Second thought." }); + expect(store.messages[0]?.thinking).toBe("First thought. Second thought."); + }); }); |
