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,
|
MicVocal: () => null,
|
||||||
Pencil: () => null,
|
Pencil: () => null,
|
||||||
Search: () => null,
|
Search: () => null,
|
||||||
|
Sparkles: () => null,
|
||||||
SquareTerminal: () => null,
|
SquareTerminal: () => null,
|
||||||
Wrench: () => null,
|
Wrench: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -48,4 +49,24 @@ describe("tool-call-icon", () => {
|
|||||||
|
|
||||||
expect(icon).toBe(iconMocks.Brain);
|
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,
|
SquareTerminal,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react-native";
|
} 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 }>;
|
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
|
||||||
|
|
||||||
@@ -26,9 +26,25 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
|||||||
unknown: Wrench,
|
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 {
|
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
|
||||||
const lowerName = toolName.trim().toLowerCase();
|
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.
|
// Thoughts are rendered through ToolCall with unknown detail payloads.
|
||||||
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
|
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
|
||||||
return Brain;
|
return Brain;
|
||||||
@@ -39,9 +55,6 @@ export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail):
|
|||||||
if (lowerName === "task") {
|
if (lowerName === "task") {
|
||||||
return Bot;
|
return Bot;
|
||||||
}
|
}
|
||||||
if (lowerName === "skill") {
|
|
||||||
return Sparkles;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
return TOOL_DETAIL_ICONS[detail.type];
|
return TOOL_DETAIL_ICONS[detail.type];
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ export type AgentUsage = {
|
|||||||
totalCostUsd?: number;
|
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 =
|
export type ToolCallDetail =
|
||||||
| {
|
| {
|
||||||
type: "shell";
|
type: "shell";
|
||||||
@@ -164,6 +178,7 @@ export type ToolCallDetail =
|
|||||||
type: "plain_text";
|
type: "plain_text";
|
||||||
label?: string;
|
label?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
icon?: ToolCallIconName;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "unknown";
|
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(
|
expect(
|
||||||
liveEvents.some(
|
liveEvents.some(
|
||||||
(event) =>
|
(event) =>
|
||||||
@@ -1215,7 +1233,6 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
|||||||
event.item.text.includes("SHOULD_STAY_SUPPRESSED")
|
event.item.text.includes("SHOULD_STAY_SUPPRESSED")
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
liveEvents.some(
|
liveEvents.some(
|
||||||
(event) =>
|
(event) =>
|
||||||
|
|||||||
@@ -1482,6 +1482,81 @@ describe("convertClaudeHistoryEntry", () => {
|
|||||||
expect(mapBlocks).not.toHaveBeenCalled();
|
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", () => {
|
test("passes thinking blocks to mapBlocks for assistant entries", () => {
|
||||||
const entry = {
|
const entry = {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import {
|
|||||||
mapClaudeFailedToolCall,
|
mapClaudeFailedToolCall,
|
||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./claude/tool-call-mapper.js";
|
} 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 { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||||
|
|
||||||
import type {
|
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 {
|
export class ClaudeAgentClient implements AgentClient {
|
||||||
readonly provider: "claude" = "claude";
|
readonly provider: "claude" = "claude";
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
@@ -3436,6 +3421,17 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
});
|
});
|
||||||
|
} else if (message.subtype === "task_notification") {
|
||||||
|
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(
|
||||||
|
message
|
||||||
|
);
|
||||||
|
if (taskNotificationItem) {
|
||||||
|
events.push({
|
||||||
|
type: "timeline",
|
||||||
|
item: taskNotificationItem,
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "user": {
|
case "user": {
|
||||||
@@ -3452,6 +3448,18 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
: undefined;
|
: undefined;
|
||||||
this.rememberUserMessageId(messageId);
|
this.rememberUserMessageId(messageId);
|
||||||
const content = message.message?.content;
|
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) {
|
if (typeof content === "string" && content.length > 0) {
|
||||||
// String content from user messages (e.g., local command output)
|
// String content from user messages (e.g., local command output)
|
||||||
events.push({
|
events.push({
|
||||||
@@ -4390,6 +4398,15 @@ export function convertClaudeHistoryEntry(
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.type === "system") {
|
||||||
|
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(
|
||||||
|
entry
|
||||||
|
);
|
||||||
|
if (taskNotificationItem) {
|
||||||
|
return [taskNotificationItem];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.isCompactSummary) {
|
if (entry.isCompactSummary) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -4409,19 +4426,30 @@ export function convertClaudeHistoryEntry(
|
|||||||
? content
|
? content
|
||||||
: normalizedBlocks;
|
: normalizedBlocks;
|
||||||
const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false;
|
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[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
|
|
||||||
if (entry.type === "user") {
|
if (entry.type === "user") {
|
||||||
const text = extractUserMessageText(content);
|
const text = extractUserMessageText(content);
|
||||||
if (text) {
|
if (text) {
|
||||||
const messageId =
|
|
||||||
typeof entry.uuid === "string" && entry.uuid.length > 0
|
|
||||||
? entry.uuid
|
|
||||||
: undefined;
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text,
|
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 {
|
return {
|
||||||
type: "plain_text" as const,
|
type: "plain_text" as const,
|
||||||
label: skillName,
|
label: skillName,
|
||||||
|
icon: "sparkles" as const,
|
||||||
...(output ? { text: output } : {}),
|
...(output ? { text: output } : {}),
|
||||||
} satisfies ToolCallDetail;
|
} 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 { AGENT_LIFECYCLE_STATUSES } from './agent-lifecycle.js'
|
||||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from '../server/agent/agent-title-limits.js'
|
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from '../server/agent/agent-title-limits.js'
|
||||||
import { AgentProviderSchema } from '../server/agent/provider-manifest.js'
|
import { AgentProviderSchema } from '../server/agent/provider-manifest.js'
|
||||||
|
import { TOOL_CALL_ICON_NAMES } from '../server/agent/agent-sdk-types.js'
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -215,6 +216,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
|||||||
type: z.literal('plain_text'),
|
type: z.literal('plain_text'),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
|
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('unknown'),
|
type: z.literal('unknown'),
|
||||||
|
|||||||
Reference in New Issue
Block a user