diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 27181166a..b58244619 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -245,6 +245,14 @@ export function ToolCallDetailsContent({ {detail.query} ); + } else if (detail?.type === "plain_text") { + if (detail.text) { + sections.push( + + {detail.text} + + ); + } } else if (detail?.type === "unknown") { const plainInputText = typeof detail.input === "string" && detail.output === null diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 10ba09bc0..dbcd44acb 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -6,6 +6,7 @@ import { MicVocal, Pencil, Search, + Sparkles, SquareTerminal, Wrench, } from "lucide-react-native"; @@ -21,6 +22,7 @@ const TOOL_DETAIL_ICONS: Record = search: Search, worktree_setup: SquareTerminal, sub_agent: Bot, + plain_text: Wrench, unknown: Wrench, }; @@ -37,6 +39,9 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): if (lowerName === "task") { return Bot; } + if (lowerName === "skill") { + return Sparkles; + } if (detail) { return TOOL_DETAIL_ICONS[detail.type]; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 15900de4e..92d940d64 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -160,6 +160,11 @@ export type ToolCallDetail = summary?: string; }>; } + | { + type: "plain_text"; + label?: string; + text?: string; + } | { type: "unknown"; input: unknown | null; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 64325baf9..1430435c5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1460,6 +1460,28 @@ describe("convertClaudeHistoryEntry", () => { expect(result).toEqual([]); }); + test("skips synthetic user entries", () => { + const entry = { + type: "user", + isSynthetic: true, + message: { + role: "user", + content: [ + { + type: "text", + text: "Base directory for this skill: /tmp/skill", + }, + ], + }, + }; + + const mapBlocks = vi.fn().mockReturnValue([]); + const result = convertClaudeHistoryEntry(entry, mapBlocks); + + expect(result).toEqual([]); + expect(mapBlocks).not.toHaveBeenCalled(); + }); + test("passes thinking blocks to mapBlocks for assistant entries", () => { const entry = { type: "assistant", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e99c61b05..74287d453 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1344,9 +1344,19 @@ function isMetadataOnlySdkMessage(message: SDKMessage): boolean { if (message.type !== "user") { return false; } + if (isSyntheticUserEntry(message)) { + return true; + } return isTaskNotificationUserContent(message.message?.content); } +function isSyntheticUserEntry(entry: unknown): boolean { + if (!entry || typeof entry !== "object") { + return false; + } + return (entry as { isSynthetic?: unknown }).isSynthetic === true; +} + export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { const root = message as unknown as Record; const messageType = readTrimmedString(root.type); @@ -3429,6 +3439,9 @@ class ClaudeAgentSession implements AgentSession { } break; case "user": { + if (isSyntheticUserEntry(message)) { + break; + } if (this.compacting) { this.compacting = false; break; @@ -4380,6 +4393,9 @@ export function convertClaudeHistoryEntry( if (entry.isCompactSummary) { return []; } + if (entry.type === "user" && isSyntheticUserEntry(entry)) { + return []; + } const message = entry?.message; if (!message || !("content" in message)) { @@ -4556,6 +4572,9 @@ async function parseClaudeSessionDescriptor( if (entry?.isSidechain) { continue; } + if (entry?.type === "user" && isSyntheticUserEntry(entry)) { + continue; + } if (!sessionId && typeof entry.sessionId === "string") { sessionId = entry.sessionId; } diff --git a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts index 825374ab2..f526adc7d 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts @@ -102,6 +102,25 @@ const ClaudeToolDetailPass2Schema = z.union([ toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) => toSearchToolDetail(input) ), + toolDetailBranchByName( + "Skill", + z.object({ skill: z.string() }).passthrough(), + z.union([ + z.object({ output: z.string() }).passthrough().transform((value) => value.output), + z.string(), + ]).nullable(), + (input, output) => { + const skillName = input?.skill; + if (!skillName) { + return undefined; + } + return { + type: "plain_text" as const, + label: skillName, + ...(output ? { text: output } : {}), + } satisfies ToolCallDetail; + } + ), ClaudeSpeakToolDetailSchema, ]); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 384837139..08ef0d95f 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -210,6 +210,11 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn }) ), }), + z.object({ + type: z.literal('plain_text'), + label: z.string().optional(), + text: z.string().optional(), + }), z.object({ type: z.literal('unknown'), input: UnknownValueSchema, diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index 63568bdf8..ea24da17a 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -91,6 +91,9 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall displayName = readString(input.detail.subAgentType) ?? "Task"; summary = readString(input.detail.description); break; + case "plain_text": + summary = input.detail.label; + break; case "unknown": break; }