/** * 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. */ readonly activeForm: string | undefined; } const STATUSES = new Set(["pending", "in_progress", "completed"]); /** True when the tool name is SDK TodoWrite or hub mcp todo_write. */ export function isTodoWriteTool(toolName: string): boolean { const lower = toolName.toLowerCase(); return ( toolName === "TodoWrite" || toolName.endsWith("__TodoWrite") || lower === "todo_write" || lower.endsWith("__todo_write") ); } 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 / 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; if (!("todos" in input)) return null; const rawTodos = (input as { todos?: unknown }).todos; if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null; const todos: AgentTodoItem[] = []; for (const raw of rawTodos) { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; const record = raw as Record; const content = typeof record.content === "string" ? record.content.trim() : ""; if (content === "") continue; const statusRaw = typeof record.status === "string" ? record.status : "pending"; const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus) ? (statusRaw as AgentTodoStatus) : "pending"; const activeForm = typeof record.activeForm === "string" && record.activeForm.trim() !== "" ? record.activeForm.trim() : undefined; 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; readonly inProgress: number; } { let completed = 0; let inProgress = 0; for (const todo of todos) { if (todo.status === "completed") completed += 1; else if (todo.status === "in_progress") inProgress += 1; } 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(/^#/, ""); }