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
This commit is contained in:
Mohamed Boudra
2026-02-24 18:15:23 +07:00
committed by GitHub
parent 9d2ec0e9e1
commit 0832b12608
11 changed files with 659 additions and 30 deletions
@@ -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);
});
});
+17 -4
View File
@@ -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<ToolCallDetail["type"], ToolCallIconComponent> =
unknown: Wrench,
};
const TOOL_ICON_BY_NAME: Record<ToolCallIconName, ToolCallIconComponent> = {
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];
@@ -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";
@@ -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("<task-notification>")
)
).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) =>
@@ -1482,6 +1482,81 @@ describe("convertClaudeHistoryEntry", () => {
expect(mapBlocks).not.toHaveBeenCalled();
});
test("maps user task notifications to synthetic tool calls", () => {
const content =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>";
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",
@@ -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("<task-notification>");
}
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("<task-notification>")
) {
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 } : {}),
});
}
}
@@ -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(
"<task-notification>\n<task-id>bg-1</task-id>\n</task-notification>"
)
).toBe(true);
expect(isTaskNotificationUserContent("hello")).toBe(false);
});
it("maps user content to completed synthetic tool call", () => {
const content =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>";
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();
});
});
@@ -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 = "<task-notification>";
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<typeof TaskNotificationEnvelopeSchema>;
export type MapTaskNotificationUserContentToToolCallInput = z.infer<
typeof MapTaskNotificationUserContentToToolCallInputSchema
>;
type TaskNotificationUserContent = z.infer<typeof TaskNotificationUserContentSchema>;
type TaskNotificationSystemRecord = z.infer<typeof TaskNotificationSystemRecordSchema>;
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<AgentTimelineItem, { type: "tool_call" }>;
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*</${escapedTagName}>`,
"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<string, unknown> = {
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);
}
@@ -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;
}
@@ -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);
});
});
+2
View File
@@ -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<ToolCallDetail> = 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'),