From 0832b126083983df2aa5b39318bdcc363d9945ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Feb 2026 18:15:23 +0700 Subject: [PATCH] Show task notifications as synthetic tool calls in chat (#79) * fix(server): render claude task notifications as synthetic tool calls * refactor(claude): extract task-notification tool-call mapper * refactor: drive tool icons from plain_text detail metadata * refactor(claude): make task-notification parsing schema-first --- packages/app/src/utils/tool-call-icon.test.ts | 21 ++ packages/app/src/utils/tool-call-icon.ts | 21 +- .../src/server/agent/agent-sdk-types.ts | 15 + ...agent.interrupt-restart-regression.test.ts | 19 +- .../agent/providers/claude-agent.test.ts | 75 ++++ .../server/agent/providers/claude-agent.ts | 78 ++-- .../task-notification-tool-call.test.ts | 87 +++++ .../claude/task-notification-tool-call.ts | 332 ++++++++++++++++++ .../claude/tool-call-detail-parser.ts | 1 + .../shared/messages.tool-call-schema.test.ts | 38 ++ packages/server/src/shared/messages.ts | 2 + 11 files changed, 659 insertions(+), 30 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts diff --git a/packages/app/src/utils/tool-call-icon.test.ts b/packages/app/src/utils/tool-call-icon.test.ts index a3e997c1e..4a484be53 100644 --- a/packages/app/src/utils/tool-call-icon.test.ts +++ b/packages/app/src/utils/tool-call-icon.test.ts @@ -7,6 +7,7 @@ const iconMocks = vi.hoisted(() => ({ MicVocal: () => null, Pencil: () => null, Search: () => null, + Sparkles: () => null, SquareTerminal: () => null, Wrench: () => null, })); @@ -48,4 +49,24 @@ describe("tool-call-icon", () => { expect(icon).toBe(iconMocks.Brain); }); + + it("uses custom icon from plain_text detail", () => { + const icon = resolveToolCallIcon("custom_tool", { + type: "plain_text", + label: "Background task completed", + icon: "sparkles", + text: "notification payload", + }); + + expect(icon).toBe(iconMocks.Sparkles); + }); + + it("does not special-case skill tool names", () => { + const icon = resolveToolCallIcon("skill", { + type: "plain_text", + label: "Skill output", + }); + + expect(icon).toBe(iconMocks.Wrench); + }); }); diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index dbcd44acb..9b6d1d1f1 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -10,7 +10,7 @@ import { SquareTerminal, Wrench, } from "lucide-react-native"; -import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import type { ToolCallDetail, ToolCallIconName } from "@server/server/agent/agent-sdk-types"; export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>; @@ -26,9 +26,25 @@ const TOOL_DETAIL_ICONS: Record = unknown: Wrench, }; +const TOOL_ICON_BY_NAME: Record = { + wrench: Wrench, + square_terminal: SquareTerminal, + eye: Eye, + pencil: Pencil, + search: Search, + bot: Bot, + sparkles: Sparkles, + brain: Brain, + mic_vocal: MicVocal, +}; + export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent { const lowerName = toolName.trim().toLowerCase(); + if (detail?.type === "plain_text" && detail.icon) { + return TOOL_ICON_BY_NAME[detail.icon]; + } + // Thoughts are rendered through ToolCall with unknown detail payloads. if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { return Brain; @@ -39,9 +55,6 @@ 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 92d940d64..6ae64d24e 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -103,6 +103,20 @@ export type AgentUsage = { totalCostUsd?: number; }; +export const TOOL_CALL_ICON_NAMES = [ + "wrench", + "square_terminal", + "eye", + "pencil", + "search", + "bot", + "sparkles", + "brain", + "mic_vocal", +] as const; + +export type ToolCallIconName = (typeof TOOL_CALL_ICON_NAMES)[number]; + export type ToolCallDetail = | { type: "shell"; @@ -164,6 +178,7 @@ export type ToolCallDetail = type: "plain_text"; label?: string; text?: string; + icon?: ToolCallIconName; } | { type: "unknown"; diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index b70cb7644..f7ea461ba 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -1207,6 +1207,24 @@ describe("ClaudeAgentSession interrupt restart regression", () => { } } + expect( + liveEvents.some( + (event) => + event.type === "timeline" && + event.item.type === "user_message" && + event.item.text.includes("") + ) + ).toBe(false); + expect( + liveEvents.some( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" && + event.item.status === "completed" + ) + ).toBe(true); + expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true); expect( liveEvents.some( (event) => @@ -1215,7 +1233,6 @@ describe("ClaudeAgentSession interrupt restart regression", () => { event.item.text.includes("SHOULD_STAY_SUPPRESSED") ) ).toBe(false); - expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true); expect( liveEvents.some( (event) => 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 1430435c5..0831c3965 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1482,6 +1482,81 @@ describe("convertClaudeHistoryEntry", () => { expect(mapBlocks).not.toHaveBeenCalled(); }); + test("maps user task notifications to synthetic tool calls", () => { + const content = + "\nbg-1\ncompleted\n"; + const entry = { + type: "user", + uuid: "task-note-user-1", + message: { + role: "user", + content, + }, + }; + + const mapBlocks = vi.fn().mockReturnValue([]); + const result = convertClaudeHistoryEntry(entry, mapBlocks); + + expect(result).toEqual([ + { + type: "tool_call", + callId: "task_notification_task-note-user-1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + text: content, + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-1", + status: "completed", + }, + }, + ]); + expect(mapBlocks).not.toHaveBeenCalled(); + }); + + test("maps system task notifications to synthetic failed tool calls", () => { + const entry = { + type: "system", + subtype: "task_notification", + uuid: "task-note-system-1", + task_id: "bg-fail-1", + status: "failed", + summary: "Background task failed", + output_file: "/tmp/bg-fail-1.txt", + }; + + const result = convertClaudeHistoryEntry(entry, () => []); + expect(result).toEqual([ + { + type: "tool_call", + callId: "task_notification_task-note-system-1", + name: "task_notification", + status: "failed", + error: { message: "Background task failed" }, + detail: { + type: "plain_text", + label: "Background task failed", + icon: "wrench", + text: "Background task failed", + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-fail-1", + status: "failed", + outputFile: "/tmp/bg-fail-1.txt", + }, + }, + ]); + }); + 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 74287d453..a2e6d23db 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -29,6 +29,11 @@ import { mapClaudeFailedToolCall, mapClaudeRunningToolCall, } from "./claude/tool-call-mapper.js"; +import { + isTaskNotificationUserContent, + mapTaskNotificationSystemRecordToToolCall, + mapTaskNotificationUserContentToToolCall, +} from "./claude/task-notification-tool-call.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import type { @@ -1391,26 +1396,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { }; } -function isTaskNotificationUserContent(content: unknown): boolean { - if (typeof content === "string") { - return content.includes(""); - } - if (!Array.isArray(content)) { - return false; - } - for (const block of content) { - if ( - block && - typeof block === "object" && - typeof (block as { text?: unknown }).text === "string" && - (block as { text: string }).text.includes("") - ) { - return true; - } - } - return false; -} - export class ClaudeAgentClient implements AgentClient { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; @@ -3436,6 +3421,17 @@ class ClaudeAgentSession implements AgentSession { }, provider: "claude", }); + } else if (message.subtype === "task_notification") { + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall( + message + ); + if (taskNotificationItem) { + events.push({ + type: "timeline", + item: taskNotificationItem, + provider: "claude", + }); + } } break; case "user": { @@ -3452,6 +3448,18 @@ class ClaudeAgentSession implements AgentSession { : undefined; this.rememberUserMessageId(messageId); const content = message.message?.content; + const taskNotificationItem = mapTaskNotificationUserContentToToolCall({ + content, + messageId, + }); + if (taskNotificationItem) { + events.push({ + type: "timeline", + item: taskNotificationItem, + provider: "claude", + }); + break; + } if (typeof content === "string" && content.length > 0) { // String content from user messages (e.g., local command output) events.push({ @@ -4390,6 +4398,15 @@ export function convertClaudeHistoryEntry( }]; } + if (entry.type === "system") { + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall( + entry + ); + if (taskNotificationItem) { + return [taskNotificationItem]; + } + } + if (entry.isCompactSummary) { return []; } @@ -4409,19 +4426,30 @@ export function convertClaudeHistoryEntry( ? content : normalizedBlocks; const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false; + const userMessageId = + entry.type === "user" && typeof entry.uuid === "string" && entry.uuid.length > 0 + ? entry.uuid + : null; + + if (entry.type === "user") { + const taskNotificationItem = mapTaskNotificationUserContentToToolCall({ + content, + messageId: userMessageId, + }); + if (taskNotificationItem) { + return [taskNotificationItem]; + } + } + const timeline: AgentTimelineItem[] = []; if (entry.type === "user") { const text = extractUserMessageText(content); if (text) { - const messageId = - typeof entry.uuid === "string" && entry.uuid.length > 0 - ? entry.uuid - : undefined; timeline.push({ type: "user_message", text, - ...(messageId ? { messageId } : {}), + ...(userMessageId ? { messageId: userMessageId } : {}), }); } } diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts new file mode 100644 index 000000000..afd947ace --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + isTaskNotificationUserContent, + mapTaskNotificationSystemRecordToToolCall, + mapTaskNotificationUserContentToToolCall, +} from "./task-notification-tool-call.js"; + +describe("task-notification-tool-call", () => { + it("detects task notification user content in string payloads", () => { + expect( + isTaskNotificationUserContent( + "\nbg-1\n" + ) + ).toBe(true); + expect(isTaskNotificationUserContent("hello")).toBe(false); + }); + + it("maps user content to completed synthetic tool call", () => { + const content = + "\nbg-1\ncompleted\n"; + const item = mapTaskNotificationUserContentToToolCall({ + content, + messageId: "task-note-user-1", + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "task_notification_task-note-user-1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + text: content, + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-1", + status: "completed", + }, + }); + }); + + it("maps system task notification to failed synthetic tool call", () => { + const item = mapTaskNotificationSystemRecordToToolCall({ + subtype: "task_notification", + uuid: "task-note-system-1", + task_id: "bg-fail-1", + status: "failed", + summary: "Background task failed", + output_file: "/tmp/bg-fail-1.txt", + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "task_notification_task-note-system-1", + name: "task_notification", + status: "failed", + error: { message: "Background task failed" }, + detail: { + type: "plain_text", + label: "Background task failed", + icon: "wrench", + text: "Background task failed", + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-fail-1", + status: "failed", + outputFile: "/tmp/bg-fail-1.txt", + }, + }); + }); + + it("returns null for non-task system records", () => { + const item = mapTaskNotificationSystemRecordToToolCall({ + subtype: "init", + }); + + expect(item).toBeNull(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts new file mode 100644 index 000000000..17bcc9f9e --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -0,0 +1,332 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +import type { AgentTimelineItem } from "../../agent-sdk-types.js"; + +const TASK_NOTIFICATION_MARKER = ""; +const TAG_NAME_PATTERN = /[.*+?^${}()|[\]\\]/g; + +const OptionalNonEmptyTrimmedStringSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim() : value), + z.string().min(1).optional() +); + +const TaskNotificationEnvelopeSchema = z.object({ + messageId: z.string().nullable(), + taskId: z.string().nullable(), + status: z.string().nullable(), + summary: z.string().nullable(), + outputFile: z.string().nullable(), + rawText: z.string().nullable(), +}); + +const TaskNotificationUserContentBlockSchema = z + .object({ + text: z.string().optional(), + input: z.string().optional(), + }) + .passthrough(); + +const TaskNotificationUserContentSchema = z.union([ + z.string(), + z.array(TaskNotificationUserContentBlockSchema), +]); + +const TaskNotificationSystemRecordSchema = z + .object({ + subtype: z.literal("task_notification"), + uuid: z.string().optional(), + message_id: z.string().optional(), + task_id: z.string().optional(), + status: z.string().optional(), + summary: z.string().optional(), + output_file: z.string().optional(), + content: z.string().optional(), + }) + .passthrough(); + +const MapTaskNotificationUserContentToToolCallInputSchema = z.object({ + content: z.unknown(), + messageId: z.string().optional().nullable(), +}); + +export type TaskNotificationEnvelope = z.infer; + +export type MapTaskNotificationUserContentToToolCallInput = z.infer< + typeof MapTaskNotificationUserContentToToolCallInputSchema +>; + +type TaskNotificationUserContent = z.infer; +type TaskNotificationSystemRecord = z.infer; + +type TaskNotificationLifecycle = + | { status: "completed"; error: null } + | { status: "failed"; error: unknown } + | { status: "canceled"; error: null }; + +type ReadTaskNotificationTagInput = { + text: string; + tagName: string; +}; + +type BuildTaskNotificationStatusInput = { + status: string | null; + summary: string | null; +}; + +type TaskNotificationToolCallItem = Extract; + +function toNonEmptyString(value: unknown): string | null { + const parsed = OptionalNonEmptyTrimmedStringSchema.safeParse(value); + if (!parsed.success) { + return null; + } + return parsed.data ?? null; +} + +function collectUserContentParts(content: TaskNotificationUserContent): string[] { + if (typeof content === "string") { + const normalized = toNonEmptyString(content); + return normalized ? [normalized] : []; + } + + const parts: string[] = []; + for (const block of content) { + const text = toNonEmptyString(block.text); + if (text) { + parts.push(text); + } + const input = toNonEmptyString(block.input); + if (input) { + parts.push(input); + } + } + return parts; +} + +function extractUserContentText(content: unknown): string | null { + const parsedContent = TaskNotificationUserContentSchema.safeParse(content); + if (!parsedContent.success) { + return null; + } + const parts = collectUserContentParts(parsedContent.data); + if (parts.length === 0) { + return null; + } + return parts.join("\n\n"); +} + +function readTaskNotificationTagValue(input: ReadTaskNotificationTagInput): string | null { + const escapedTagName = input.tagName.replace(TAG_NAME_PATTERN, "\\$&"); + const pattern = new RegExp( + `<${escapedTagName}>\\s*([\\s\\S]*?)\\s*`, + "i" + ); + const match = input.text.match(pattern); + if (!match) { + return null; + } + return toNonEmptyString(match[1]); +} + +function parseTaskNotificationFromUserContent( + input: MapTaskNotificationUserContentToToolCallInput +): TaskNotificationEnvelope | null { + const parsedInput = MapTaskNotificationUserContentToToolCallInputSchema.safeParse(input); + if (!parsedInput.success) { + return null; + } + + const rawText = extractUserContentText(parsedInput.data.content); + if (!rawText || !rawText.includes(TASK_NOTIFICATION_MARKER)) { + return null; + } + + return TaskNotificationEnvelopeSchema.parse({ + messageId: toNonEmptyString(parsedInput.data.messageId), + taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }), + status: readTaskNotificationTagValue({ text: rawText, tagName: "status" }), + summary: readTaskNotificationTagValue({ text: rawText, tagName: "summary" }), + outputFile: + readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ?? + readTaskNotificationTagValue({ text: rawText, tagName: "output_file" }), + rawText, + }); +} + +function parseTaskNotificationFromSystemRecord( + record: unknown +): TaskNotificationEnvelope | null { + const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record); + if (!parsedRecord.success) { + return null; + } + + const systemRecord: TaskNotificationSystemRecord = parsedRecord.data; + const rawText = toNonEmptyString(systemRecord.content); + + return TaskNotificationEnvelopeSchema.parse({ + messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id), + taskId: + toNonEmptyString(systemRecord.task_id) ?? + (rawText + ? readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }) + : null), + status: + toNonEmptyString(systemRecord.status) ?? + (rawText + ? readTaskNotificationTagValue({ text: rawText, tagName: "status" }) + : null), + summary: + toNonEmptyString(systemRecord.summary) ?? + (rawText + ? readTaskNotificationTagValue({ text: rawText, tagName: "summary" }) + : null), + outputFile: + toNonEmptyString(systemRecord.output_file) ?? + (rawText + ? readTaskNotificationTagValue({ text: rawText, tagName: "output-file" }) ?? + readTaskNotificationTagValue({ text: rawText, tagName: "output_file" }) + : null), + rawText, + }); +} + +function normalizeTaskNotificationCallIdSegment(segment: string): string | null { + const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_"); + return normalized.length > 0 ? normalized : null; +} + +function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string { + const messageSegment = envelope.messageId + ? normalizeTaskNotificationCallIdSegment(envelope.messageId) + : null; + if (messageSegment) { + return `task_notification_${messageSegment}`; + } + + const taskSegment = envelope.taskId + ? normalizeTaskNotificationCallIdSegment(envelope.taskId) + : null; + if (taskSegment) { + return `task_notification_${taskSegment}`; + } + + const seed = + [ + envelope.status, + envelope.summary, + envelope.outputFile, + envelope.rawText, + ] + .filter((value): value is string => typeof value === "string") + .join("|") || "task_notification"; + const digest = createHash("sha1").update(seed).digest("hex").slice(0, 12); + return `task_notification_${digest}`; +} + +function buildTaskNotificationLabel(envelope: TaskNotificationEnvelope): string { + if (envelope.summary) { + return envelope.summary; + } + if (envelope.status) { + return `Background task ${envelope.status.toLowerCase()}`; + } + return "Background task notification"; +} + +function buildTaskNotificationStatus( + input: BuildTaskNotificationStatusInput +): TaskNotificationLifecycle { + const normalizedStatus = input.status?.toLowerCase() ?? null; + if (normalizedStatus === "failed" || normalizedStatus === "error") { + return { + status: "failed", + error: { message: input.summary ?? "Background task failed" }, + }; + } + if (normalizedStatus === "canceled" || normalizedStatus === "cancelled") { + return { status: "canceled", error: null }; + } + return { status: "completed", error: null }; +} + +function toTaskNotificationToolCall( + envelope: TaskNotificationEnvelope +): TaskNotificationToolCallItem { + const lifecycle = buildTaskNotificationStatus({ + status: envelope.status, + summary: envelope.summary, + }); + const label = buildTaskNotificationLabel(envelope); + const detailText = envelope.rawText ?? envelope.summary ?? undefined; + const metadata: Record = { + synthetic: true, + source: "claude_task_notification", + ...(envelope.taskId ? { taskId: envelope.taskId } : {}), + ...(envelope.status ? { status: envelope.status } : {}), + ...(envelope.outputFile ? { outputFile: envelope.outputFile } : {}), + }; + + const base = { + type: "tool_call" as const, + callId: buildTaskNotificationCallId(envelope), + name: "task_notification", + detail: { + type: "plain_text" as const, + label, + icon: "wrench" as const, + ...(detailText ? { text: detailText } : {}), + }, + metadata, + }; + + if (lifecycle.status === "failed") { + return { + ...base, + status: "failed", + error: lifecycle.error, + }; + } + if (lifecycle.status === "canceled") { + return { + ...base, + status: "canceled", + error: null, + }; + } + + return { + ...base, + status: "completed", + error: null, + }; +} + +export function isTaskNotificationUserContent(content: unknown): boolean { + const rawText = extractUserContentText(content); + if (!rawText) { + return false; + } + return rawText.includes(TASK_NOTIFICATION_MARKER); +} + +export function mapTaskNotificationUserContentToToolCall( + input: MapTaskNotificationUserContentToToolCallInput +): TaskNotificationToolCallItem | null { + const parsed = parseTaskNotificationFromUserContent(input); + if (!parsed) { + return null; + } + return toTaskNotificationToolCall(parsed); +} + +export function mapTaskNotificationSystemRecordToToolCall( + record: unknown +): TaskNotificationToolCallItem | null { + const parsed = parseTaskNotificationFromSystemRecord(record); + if (!parsed) { + return null; + } + return toTaskNotificationToolCall(parsed); +} 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 f526adc7d..e6b63481e 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 @@ -117,6 +117,7 @@ const ClaudeToolDetailPass2Schema = z.union([ return { type: "plain_text" as const, label: skillName, + icon: "sparkles" as const, ...(output ? { text: output } : {}), } satisfies ToolCallDetail; } diff --git a/packages/server/src/shared/messages.tool-call-schema.test.ts b/packages/server/src/shared/messages.tool-call-schema.test.ts index 8b553f383..dac060bce 100644 --- a/packages/server/src/shared/messages.tool-call-schema.test.ts +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -146,4 +146,42 @@ describe("shared messages tool_call schema", () => { } } }); + + it("parses plain_text detail with icon and rejects unknown icon names", () => { + const parsed = AgentTimelineItemPayloadSchema.parse({ + type: "tool_call", + callId: "call_plain_text_1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + }, + }); + + expect(parsed.type).toBe("tool_call"); + if (parsed.type === "tool_call") { + expect(parsed.detail.type).toBe("plain_text"); + if (parsed.detail.type === "plain_text") { + expect(parsed.detail.icon).toBe("wrench"); + } + } + + const invalid = AgentTimelineItemPayloadSchema.safeParse({ + type: "tool_call", + callId: "call_plain_text_invalid", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "laser", + }, + }); + + expect(invalid.success).toBe(false); + }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e45b98366..f54eed8b8 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { AGENT_LIFECYCLE_STATUSES } from './agent-lifecycle.js' import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from '../server/agent/agent-title-limits.js' import { AgentProviderSchema } from '../server/agent/provider-manifest.js' +import { TOOL_CALL_ICON_NAMES } from '../server/agent/agent-sdk-types.js' import type { AgentCapabilityFlags, AgentModelDefinition, @@ -215,6 +216,7 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn type: z.literal('plain_text'), label: z.string().optional(), text: z.string().optional(), + icon: z.enum(TOOL_CALL_ICON_NAMES).optional(), }), z.object({ type: z.literal('unknown'),