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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
+18
-1
@@ -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,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'),
|
||||
|
||||
Reference in New Issue
Block a user