summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 21:29:08 +0900
committerAdam Malczewski <[email protected]>2026-05-19 21:29:08 +0900
commit0ae805b28b5160b8d9fb43635fa172961f6550cc (patch)
treee9fb7cbad0755cd7608ea56012c986a4a47b2aaf
parentde6df4abdd8a6eb9a0217050ce17e0925f04602b (diff)
downloaddispatch-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.ts5
-rw-r--r--packages/core/src/types/index.ts1
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts96
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte21
-rw-r--r--packages/frontend/src/lib/types.ts9
-rw-r--r--packages/frontend/tests/chat-store.test.ts157
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.");
+ });
});