diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index c4b9437..e59b91a 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -47,7 +47,7 @@ export type StreamEvent = | { readonly type: "thinking-delta"; readonly text: string } | { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string } | { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number } - | { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number } + | { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly input?: unknown; readonly durationMs?: number } | { readonly type: "finish" }; export type StreamCallback = (event: StreamEvent) => void; @@ -232,8 +232,9 @@ export async function runAgent(req: RunRequest): Promise { options, }); - // Track tool start timestamps for duration calculation + // Track tool start timestamps and names/inputs for duration + tool-result attribution. const toolStartTimestamps = new Map(); + const toolMetaByUseId = new Map(); for await (const message of conversation) { switch (message.type) { @@ -254,6 +255,10 @@ export async function runAgent(req: RunRequest): Promise { if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") { const toolUseId = evt.content_block.id; toolStartTimestamps.set(toolUseId, Date.now()); + toolMetaByUseId.set(toolUseId, { + name: evt.content_block.name, + input: undefined, + }); onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId }); } if (evt.type === "content_block_stop") { @@ -275,6 +280,7 @@ export async function runAgent(req: RunRequest): Promise { const durationMs = toolStartTimestamps.has(block.id) ? Date.now() - (toolStartTimestamps.get(block.id) ?? 0) : undefined; + toolMetaByUseId.set(block.id, { name: block.name, input: block.input }); onStream?.({ type: "tool-end", toolName: block.name, @@ -308,12 +314,14 @@ export async function runAgent(req: RunRequest): Promise { const isError = block.is_error === true; const resultText = extractToolResultText(block.content); const durationMs = toolStartTimestamps.get(toolUseId); + const meta = toolMetaByUseId.get(toolUseId); onStream?.({ type: "tool-result", toolUseId, - toolName: toolUseId, + toolName: meta?.name ?? toolUseId, result: resultText, isError, + ...(meta?.input !== undefined ? { input: meta.input } : {}), ...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}), }); } diff --git a/hub/src/agent/todoList.ts b/hub/src/agent/todoList.ts index 51c029e..0942111 100644 --- a/hub/src/agent/todoList.ts +++ b/hub/src/agent/todoList.ts @@ -1,11 +1,16 @@ /** - * Parse Claude Agent SDK TodoWrite tool input into a stable checklist model - * for Feishu card progress rendering (Manus-style live todos). + * Parse agent checklist tools into a stable model for Feishu card progress. + * + * Supports: + * - Claude TodoWrite (and hub mcp todo_write): full-list replace + * - Claude TaskCreate / TaskUpdate tools used in headless agent mode */ export type AgentTodoStatus = "pending" | "in_progress" | "completed"; export interface AgentTodoItem { + /** Present for Task* tools; optional for whole-list TodoWrite payloads. */ + readonly id: string | undefined; readonly content: string; readonly status: AgentTodoStatus; /** Present-tense label while the item is active, when the model supplies it. */ @@ -25,9 +30,25 @@ export function isTodoWriteTool(toolName: string): boolean { ); } +export function isTaskChecklistTool(toolName: string): boolean { + const base = stripToolSuffix(toolName); + return ( + base === "TaskCreate" || + base === "TaskUpdate" || + base === "TaskList" || + base === "TaskGet" || + base === "TaskStop" || + base === "TaskOutput" + ); +} + +export function isChecklistProgressTool(toolName: string): boolean { + return isTodoWriteTool(toolName) || isTaskChecklistTool(toolName); +} + /** - * Extract the full todo list from a TodoWrite tool_use input. - * Returns null when the payload is not a usable TodoWrite body. + * Extract the full todo list from a TodoWrite / todo_write tool_use input. + * Returns null when the payload is not a usable body. */ export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null { if (typeof input !== "object" || input === null || Array.isArray(input)) return null; @@ -49,11 +70,40 @@ export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | typeof record.activeForm === "string" && record.activeForm.trim() !== "" ? record.activeForm.trim() : undefined; - todos.push({ content, status, activeForm }); + const id = + typeof record.id === "string" && record.id.trim() !== "" ? record.id.trim() : undefined; + todos.push({ id, content, status, activeForm }); } return todos.length === 0 ? null : todos; } +/** + * Fold a Task* / TodoWrite tool event into the running checklist. + * Returns null when the event does not change checklist state. + */ +export function applyChecklistToolEvent( + current: readonly AgentTodoItem[], + params: { + readonly toolName: string; + readonly input: unknown; + readonly result: unknown; + readonly toolUseId?: string | undefined; + }, +): readonly AgentTodoItem[] | null { + if (isTodoWriteTool(params.toolName)) { + return parseTodoWriteInput(params.input); + } + + const baseName = stripToolSuffix(params.toolName); + if (baseName === "TaskCreate") { + return applyTaskCreate(current, params.input, params.result, params.toolUseId); + } + if (baseName === "TaskUpdate") { + return applyTaskUpdate(current, params.input); + } + return null; +} + export function todoProgressSummary(todos: readonly AgentTodoItem[]): { readonly completed: number; readonly total: number; @@ -67,3 +117,150 @@ export function todoProgressSummary(todos: readonly AgentTodoItem[]): { } return { completed, total: todos.length, inProgress }; } + +function stripToolSuffix(toolName: string): string { + // SDK sometimes emits TaskCreate_0 sequential copies in the card title path. + const bare = toolName.includes("__") ? (toolName.split("__").pop() ?? toolName) : toolName; + return bare.replace(/_\d+$/, ""); +} + +function applyTaskCreate( + current: readonly AgentTodoItem[], + input: unknown, + result: unknown, + toolUseId: string | undefined, +): readonly AgentTodoItem[] | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) return null; + const record = input as Record; + const subject = + typeof record.subject === "string" + ? record.subject.trim() + : typeof record.description === "string" + ? record.description.trim() + : ""; + if (subject === "") return null; + const activeForm = + typeof record.activeForm === "string" && record.activeForm.trim() !== "" + ? record.activeForm.trim() + : undefined; + const idFromResult = extractTaskIdFromResult(result); + const provisionalId = toolUseId ?? `task-${current.length + 1}`; + const id = idFromResult ?? provisionalId; + + // Replace any provisional row for this tool use or same pending subject. + const without = current.filter( + (t) => + t.id !== provisionalId && + t.id !== toolUseId && + !(t.content === subject && t.status === "pending" && t.id !== id), + ); + const existing = without.find((t) => taskIdsMatch(t.id, id)); + if (existing !== undefined) { + return without.map((t) => + taskIdsMatch(t.id, id) + ? { + id, + content: subject, + status: existing.status, + activeForm: activeForm ?? existing.activeForm, + } + : t, + ); + } + return [ + ...without, + { + id, + content: subject, + status: "pending", + activeForm, + }, + ]; +} + +function applyTaskUpdate( + current: readonly AgentTodoItem[], + input: unknown, +): readonly AgentTodoItem[] | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) return null; + const record = input as Record; + const taskId = + typeof record.taskId === "string" + ? record.taskId.trim() + : typeof record.id === "string" + ? record.id.trim() + : ""; + if (taskId === "") return null; + + const statusRaw = typeof record.status === "string" ? record.status : undefined; + if (statusRaw === "deleted") { + const next = current.filter((t) => !taskIdsMatch(t.id, taskId)); + return next.length === current.length ? null : next; + } + + const status: AgentTodoStatus | undefined = + statusRaw !== undefined && STATUSES.has(statusRaw as AgentTodoStatus) + ? (statusRaw as AgentTodoStatus) + : undefined; + const subject = + typeof record.subject === "string" && record.subject.trim() !== "" + ? record.subject.trim() + : undefined; + const activeForm = + typeof record.activeForm === "string" && record.activeForm.trim() !== "" + ? record.activeForm.trim() + : undefined; + + if (status === undefined && subject === undefined && activeForm === undefined) return null; + + let found = false; + const next = current.map((todo) => { + if (!taskIdsMatch(todo.id, taskId)) return todo; + found = true; + return { + id: todo.id ?? taskId, + content: subject ?? todo.content, + status: status ?? todo.status, + activeForm: activeForm ?? todo.activeForm, + }; + }); + if (found) return next; + + // Update arrived before create (or id mismatch): synthesize a row so the + // teacher still sees lifecycle updates. + if (subject === undefined && status === undefined) return null; + return [ + ...current, + { + id: taskId, + content: subject ?? `任务 ${taskId}`, + status: status ?? "pending", + activeForm, + }, + ]; +} + +function extractTaskIdFromResult(result: unknown): string | undefined { + const text = + typeof result === "string" + ? result + : result !== null && typeof result === "object" && "content" in result + ? String((result as { content: unknown }).content) + : ""; + if (text === "") return undefined; + const hash = text.match(/Task\s*#\s*([0-9A-Za-z_-]+)/i); + if (hash?.[1]) return hash[1]; + const bare = text.match(/\bid\s*[:=]\s*["']?([0-9A-Za-z_-]+)/i); + if (bare?.[1]) return bare[1]; + return undefined; +} + +function taskIdsMatch(a: string | undefined, b: string): boolean { + if (a === undefined) return false; + return normalizeTaskId(a) === normalizeTaskId(b); +} + +function normalizeTaskId(id: string | undefined): string { + if (id === undefined) return ""; + return id.trim().replace(/^#/, ""); +} diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index 081611d..5434057 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -59,11 +59,17 @@ function toolIcon(toolName: string): string { function isTodoToolName(toolName: string): boolean { const lower = toolName.toLowerCase(); + const bare = lower.includes("__") ? (lower.split("__").pop() ?? lower) : lower; + const stripped = bare.replace(/_\d+$/, ""); return ( - lower === "todowrite" || - lower === "todo_write" || - lower.endsWith("__todo_write") || - lower.endsWith("__todowrite") + stripped === "todowrite" || + stripped === "todo_write" || + stripped === "taskcreate" || + stripped === "taskupdate" || + stripped === "tasklist" || + stripped === "taskget" || + stripped === "taskstop" || + stripped === "taskoutput" ); } diff --git a/hub/src/feishu/card/streaming-card.ts b/hub/src/feishu/card/streaming-card.ts index ae7f3e7..f703e0c 100644 --- a/hub/src/feishu/card/streaming-card.ts +++ b/hub/src/feishu/card/streaming-card.ts @@ -34,7 +34,11 @@ import { getToolUseTraceSteps, } from "./trace-store.js"; import { buildAgentCard, type CardPhase } from "./builder.js"; -import { isTodoWriteTool, parseTodoWriteInput, type AgentTodoItem } from "../../agent/todoList.js"; +import { + applyChecklistToolEvent, + isChecklistProgressTool, + type AgentTodoItem, +} from "../../agent/todoList.js"; import { type CardContentSegment, maskMarkdownImagesForStreaming, @@ -125,8 +129,13 @@ export class StreamingAgentCard { error: string | undefined; durationMs: number | undefined; }): void { - if (isTodoWriteTool(params.toolName)) { - const next = parseTodoWriteInput(params.input); + if (isChecklistProgressTool(params.toolName)) { + const next = applyChecklistToolEvent(this.todos, { + toolName: params.toolName, + input: params.input, + result: params.result, + toolUseId: params.toolUseId, + }); if (next !== null) this.todos = next; } recordToolUseEnd({ runId: this.runId, ...params }); diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index d8cce1e..da4ecb0 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -581,7 +581,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { card.onToolEnd({ toolName: event.toolName, toolUseId: event.toolUseId, - input: undefined, + input: event.input, result: event.result, error: event.isError ? event.result : undefined, durationMs: event.durationMs, diff --git a/hub/test/unit/todo-list-card.test.ts b/hub/test/unit/todo-list-card.test.ts index 8259efa..8769df1 100644 --- a/hub/test/unit/todo-list-card.test.ts +++ b/hub/test/unit/todo-list-card.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + applyChecklistToolEvent, isTodoWriteTool, parseTodoWriteInput, todoProgressSummary, @@ -16,17 +17,49 @@ describe("todo list parse", () => { ], }); expect(todos).toEqual([ - { content: "搜题", status: "completed", activeForm: "正在搜题" }, - { content: "写报告", status: "in_progress", activeForm: "正在写报告" }, - { content: "发卡片", status: "pending", activeForm: undefined }, + { id: undefined, content: "搜题", status: "completed", activeForm: "正在搜题" }, + { id: undefined, content: "写报告", status: "in_progress", activeForm: "正在写报告" }, + { id: undefined, content: "发卡片", status: "pending", activeForm: undefined }, ]); expect(todoProgressSummary(todos!)).toEqual({ completed: 1, total: 3, inProgress: 1 }); }); - it("rejects empty or invalid bodies", () => { - expect(parseTodoWriteInput(null)).toBeNull(); - expect(parseTodoWriteInput({ todos: [] })).toBeNull(); - expect(parseTodoWriteInput({ todos: [{ status: "pending" }] })).toBeNull(); + it("folds TaskCreate + TaskUpdate into a checklist", () => { + let todos = applyChecklistToolEvent([], { + toolName: "TaskCreate", + input: { subject: "说你好", description: "greet" }, + result: "Task #1 created successfully: 说你好", + }); + expect(todos).toEqual([ + { id: "1", content: "说你好", status: "pending", activeForm: undefined }, + ]); + + todos = applyChecklistToolEvent(todos!, { + toolName: "TaskCreate_1", + input: { subject: "算 1+1", description: "math" }, + result: "Task #2 created successfully: 算 1+1", + }); + todos = applyChecklistToolEvent(todos!, { + toolName: "TaskUpdate", + input: { taskId: "1", status: "in_progress", activeForm: "正在问好" }, + result: "Updated task #1 status", + }); + todos = applyChecklistToolEvent(todos!, { + toolName: "TaskUpdate_4", + input: { taskId: "1", status: "completed" }, + result: "Updated task #1 status", + }); + todos = applyChecklistToolEvent(todos!, { + toolName: "TaskUpdate", + input: { taskId: "2", status: "completed" }, + result: "Updated task #2 status", + }); + + expect(todos).toEqual([ + { id: "1", content: "说你好", status: "completed", activeForm: "正在问好" }, + { id: "2", content: "算 1+1", status: "completed", activeForm: undefined }, + ]); + expect(todoProgressSummary(todos!)).toEqual({ completed: 2, total: 2, inProgress: 0 }); }); it("recognizes TodoWrite tool names", () => { @@ -43,15 +76,15 @@ describe("agent card todo panel", () => { text: "", reasoningText: undefined, todos: [ - { content: "A", status: "completed", activeForm: undefined }, - { content: "B", status: "in_progress", activeForm: "Doing B" }, - { content: "C", status: "pending", activeForm: undefined }, + { id: "1", content: "A", status: "completed", activeForm: undefined }, + { id: "2", content: "B", status: "in_progress", activeForm: "Doing B" }, + { id: "3", content: "C", status: "pending", activeForm: undefined }, ], toolUseSteps: [ { id: "1", seq: 1, - toolName: "TodoWrite", + toolName: "TaskCreate", toolUseId: "t1", input: {}, result: undefined, @@ -72,5 +105,7 @@ describe("agent card todo panel", () => { expect(json).toContain("任务进度 1/3"); expect(json).toContain("Doing B"); expect(json).toContain("collapsible_panel"); + // TaskCreate noise filtered when checklist present (no separate tool panel if only Task*) + expect(json).not.toContain("TaskCreate"); }); });