diff --git a/hub/admin-web/src/lib/constants.ts b/hub/admin-web/src/lib/constants.ts index 857981a..1748bbd 100644 --- a/hub/admin-web/src/lib/constants.ts +++ b/hub/admin-web/src/lib/constants.ts @@ -12,6 +12,7 @@ export const TOOL_OPTIONS: ToolOption[] = [ { id: 'bash', label: 'Bash 命令', group: 'Shell' }, { id: 'web_fetch', label: 'WebFetch', group: '网络' }, { id: 'web_search', label: 'WebSearch', group: '网络' }, + { id: 'todo', label: '任务清单 TodoWrite', group: '规划' }, { id: 'cph_check', label: 'cph check', group: 'CPH' }, { id: 'cph_build', label: 'cph build', group: 'CPH' }, { id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, diff --git a/hub/src/agent/roleTools.ts b/hub/src/agent/roleTools.ts index 3c4ba2e..969ac1e 100644 --- a/hub/src/agent/roleTools.ts +++ b/hub/src/agent/roleTools.ts @@ -6,6 +6,7 @@ export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [ "Grep", "WebFetch", "WebSearch", + "TodoWrite", ] as const; export const CPH_HUB_MCP_SERVER_NAME = "cph_hub"; @@ -40,6 +41,8 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly> cph_build: ["Bash"], web_fetch: ["WebFetch"], web_search: ["WebSearch"], + todo: ["TodoWrite"], + TodoWrite: ["TodoWrite"], Read: ["Read"], Write: ["Write"], Bash: ["Bash"], diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 989bb31..cdc23b1 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -160,8 +160,14 @@ export async function runAgent(req: RunRequest): Promise { cwd: security.cwd, // `skills` controls discovery/allowlisting, but an explicit `tools` // list still has to expose the Skill dispatcher itself. - tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])], - allowedTools: [...toolConfig.allowedTools], + // TodoWrite is always available so multi-step runs can surface a live checklist + // on the Feishu card (Manus-style progress), even when a role whitelists tools. + tools: uniqueTools([ + ...toolConfig.tools, + "TodoWrite", + ...(hasSkills ? ["Skill"] : []), + ]), + allowedTools: uniqueTools([...toolConfig.allowedTools, "TodoWrite"]), maxTurns: cap, includePartialMessages: true, // ADR-0018: bypass interactive prompts (headless server); the sandbox @@ -178,7 +184,10 @@ export async function runAgent(req: RunRequest): Promise { // The project workspace is untrusted input. Do not load user/project // settings that could widen tools, hooks, MCP servers, or sandbox paths. settingSources: [], - settings: { disableBundledSkills: true }, + settings: { + disableBundledSkills: true, + todoFeatureEnabled: true, + }, ...(hasSkills && security.skillPluginRoot !== undefined ? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] } : {}), @@ -405,3 +414,11 @@ function extractToolResultText(content: unknown): string { } return parts.join("\n"); } + +function uniqueTools(tools: readonly string[]): string[] { + const out: string[] = []; + for (const tool of tools) { + if (!out.includes(tool)) out.push(tool); + } + return out; +} diff --git a/hub/src/agent/todoList.ts b/hub/src/agent/todoList.ts new file mode 100644 index 0000000..2a7bd2b --- /dev/null +++ b/hub/src/agent/todoList.ts @@ -0,0 +1,63 @@ +/** + * Parse Claude Agent SDK TodoWrite tool input into a stable checklist model + * for Feishu card progress rendering (Manus-style live todos). + */ + +export type AgentTodoStatus = "pending" | "in_progress" | "completed"; + +export interface AgentTodoItem { + 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 the SDK TodoWrite dispatcher. */ +export function isTodoWriteTool(toolName: string): boolean { + return toolName === "TodoWrite" || toolName.endsWith("__TodoWrite"); +} + +/** + * Extract the full todo list from a TodoWrite tool_use input. + * Returns null when the payload is not a usable TodoWrite 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; + todos.push({ content, status, activeForm }); + } + return todos.length === 0 ? null : todos; +} + +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 }; +} diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index 99a837a..38e9aef 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -2,9 +2,10 @@ * Feishu interactive card builder for agent run output. * * Produces card JSON with: - * 1. A collapsible tool-use panel (tool steps with status, params, results) - * 2. A collapsible reasoning panel (thinking text) - * 3. The streaming/final answer text (markdown) + * 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress) + * 2. A collapsible tool-use panel (tool steps with status, params, results) + * 3. A collapsible reasoning panel (thinking text) + * 4. The streaming/final answer text (markdown) * * Adapted from openclaw-lark's builder.ts, simplified for our * message.patch-based approach (no CardKit 2.0 streaming_mode). @@ -12,6 +13,8 @@ */ import type { ToolUseTraceStep } from "./trace-store.js"; +import type { AgentTodoItem } from "../../agent/todoList.js"; +import { todoProgressSummary } from "../../agent/todoList.js"; import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js"; // --------------------------------------------------------------------------- @@ -39,6 +42,7 @@ const TOOL_ICONS: Record = { glob: "search-filled", grep: "search-filled", edit: "edit-filled", + todowrite: "todo-filled", send_file: "send-filled", request_approval: "thumb-up-filled", feishu_read_context: "search-filled", @@ -61,20 +65,32 @@ export function buildAgentCard(params: { text: string; contentSegments?: readonly CardContentSegment[] | undefined; reasoningText: string | undefined; + todos: readonly AgentTodoItem[] | undefined; toolUseSteps: ToolUseTraceStep[]; toolUseElapsedMs: number | undefined; isError: boolean | undefined; interrupted: boolean | undefined; runId: string | undefined; }): Record { - const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params; + const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params; const elements: unknown[] = []; - // Tool-use panel (always present if there are steps) - if (toolUseSteps.length > 0) { - elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete")); - } else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) { - elements.push(buildPendingToolUsePanel()); + // Todo checklist panel — primary progress signal; hide bare TodoWrite noise below. + if (todos !== undefined && todos.length > 0) { + elements.push(buildTodoPanel(todos, phase !== "complete")); + } + + // Tool-use panel (exclude TodoWrite itself — already shown as checklist) + const visibleToolSteps = toolUseSteps.filter((step) => step.toolName.toLowerCase() !== "todowrite"); + if (visibleToolSteps.length > 0) { + elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete")); + } else if ( + todos === undefined || + todos.length === 0 + ) { + if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) { + elements.push(buildPendingToolUsePanel()); + } } // Reasoning panel @@ -161,6 +177,62 @@ function buildInterruptAction(runId: string): unknown { }; } +// --------------------------------------------------------------------------- +// Todo checklist panel +// --------------------------------------------------------------------------- + +function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown { + const { completed, total, inProgress } = todoProgressSummary(todos); + const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`]; + if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`); + const lines = todos.map((todo) => formatTodoLine(todo)); + return { + tag: "collapsible_panel", + expanded, + header: { + title: { + tag: "plain_text", + content: titleParts.join(" "), + text_color: completed === total && total > 0 ? "green" : "grey", + text_size: "notation", + }, + vertical_align: "center", + icon: { + tag: "standard_icon", + token: "down-small-ccm_outlined", + color: "grey", + size: "16px 16px", + }, + icon_position: "right", + icon_expanded_angle: -180, + }, + border: { color: "grey", corner_radius: "5px" }, + vertical_spacing: "4px", + padding: "8px 8px 8px 8px", + elements: [ + { + tag: "markdown", + content: lines.join("\n"), + text_size: "notation", + }, + ], + }; +} + +function formatTodoLine(todo: AgentTodoItem): string { + const label = + todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== "" + ? todo.activeForm + : todo.content; + if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`; + if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`; + return `- [ ] ${escapeMd(todo.content)}`; +} + +function escapeMd(text: string): string { + return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1"); +} + // --------------------------------------------------------------------------- // Tool-use panel // --------------------------------------------------------------------------- diff --git a/hub/src/feishu/card/streaming-card.ts b/hub/src/feishu/card/streaming-card.ts index 9719e63..ae7f3e7 100644 --- a/hub/src/feishu/card/streaming-card.ts +++ b/hub/src/feishu/card/streaming-card.ts @@ -34,6 +34,7 @@ import { getToolUseTraceSteps, } from "./trace-store.js"; import { buildAgentCard, type CardPhase } from "./builder.js"; +import { isTodoWriteTool, parseTodoWriteInput, type AgentTodoItem } from "../../agent/todoList.js"; import { type CardContentSegment, maskMarkdownImagesForStreaming, @@ -66,6 +67,7 @@ export class StreamingAgentCard { private currentMessageId: string | null = null; private text = ""; private reasoningText = ""; + private todos: readonly AgentTodoItem[] = []; private runStartedAt = Date.now(); private toolUseElapsedMs: number | undefined; private flushChain: Promise = Promise.resolve(); @@ -123,6 +125,10 @@ export class StreamingAgentCard { error: string | undefined; durationMs: number | undefined; }): void { + if (isTodoWriteTool(params.toolName)) { + const next = parseTodoWriteInput(params.input); + if (next !== null) this.todos = next; + } recordToolUseEnd({ runId: this.runId, ...params }); this.scheduleFlush(); } @@ -235,6 +241,7 @@ export class StreamingAgentCard { ? contentSegments : undefined, reasoningText: this.reasoningText || undefined, + todos: this.todos.length > 0 ? this.todos : undefined, toolUseSteps, toolUseElapsedMs: this.toolUseElapsedMs, isError, @@ -253,6 +260,7 @@ export class StreamingAgentCard { phase, text: chunk, reasoningText: undefined, + todos: undefined, toolUseSteps: [], toolUseElapsedMs: undefined, isError, @@ -274,6 +282,7 @@ export class StreamingAgentCard { phase, text: chunk, reasoningText: undefined, + todos: undefined, toolUseSteps: [], toolUseElapsedMs: undefined, isError, diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index 7ddd450..24e8394 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -113,8 +113,10 @@ describe("runAgent", () => { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, settingSources: [], - settings: { disableBundledSkills: true }, + settings: { disableBundledSkills: true, todoFeatureEnabled: true }, skills: [], + tools: expect.arrayContaining(["TodoWrite"]), + allowedTools: expect.arrayContaining(["TodoWrite"]), strictMcpConfig: true, sandbox: expect.objectContaining({ enabled: true, @@ -183,8 +185,9 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { - tools: ["Read", "Bash"], - allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"], + tools: ["Read", "Bash", "TodoWrite"], + allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file", "TodoWrite"], + settings: expect.objectContaining({ todoFeatureEnabled: true }), }, }); }); @@ -205,8 +208,9 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { - tools: [], - allowedTools: [], + tools: ["TodoWrite"], + allowedTools: ["TodoWrite"], + settings: expect.objectContaining({ todoFeatureEnabled: true }), }, }); }); @@ -234,9 +238,10 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { - tools: ["Skill"], + tools: ["TodoWrite", "Skill"], plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], skills: ["cph-runtime:typst"], + settings: expect.objectContaining({ todoFeatureEnabled: true }), }, }); }); diff --git a/hub/test/unit/todo-list-card.test.ts b/hub/test/unit/todo-list-card.test.ts new file mode 100644 index 0000000..227a7a4 --- /dev/null +++ b/hub/test/unit/todo-list-card.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + isTodoWriteTool, + parseTodoWriteInput, + todoProgressSummary, +} from "../../src/agent/todoList.js"; +import { buildAgentCard } from "../../src/feishu/card/builder.js"; + +describe("todo list parse", () => { + it("accepts TodoWrite payloads", () => { + const todos = parseTodoWriteInput({ + todos: [ + { content: "搜题", status: "completed", activeForm: "正在搜题" }, + { content: "写报告", status: "in_progress", activeForm: "正在写报告" }, + { content: "发卡片", status: "pending" }, + ], + }); + expect(todos).toEqual([ + { content: "搜题", status: "completed", activeForm: "正在搜题" }, + { content: "写报告", status: "in_progress", activeForm: "正在写报告" }, + { 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("recognizes TodoWrite tool names", () => { + expect(isTodoWriteTool("TodoWrite")).toBe(true); + expect(isTodoWriteTool("Bash")).toBe(false); + }); +}); + +describe("agent card todo panel", () => { + it("renders a progress checklist when todos are present", () => { + const card = buildAgentCard({ + phase: "streaming", + text: "", + reasoningText: undefined, + todos: [ + { content: "A", status: "completed", activeForm: undefined }, + { content: "B", status: "in_progress", activeForm: "Doing B" }, + { content: "C", status: "pending", activeForm: undefined }, + ], + toolUseSteps: [ + { + id: "1", + seq: 1, + toolName: "TodoWrite", + toolUseId: "t1", + input: {}, + result: undefined, + error: undefined, + status: "success", + startedAt: 0, + finishedAt: 1, + durationMs: 1, + }, + ], + toolUseElapsedMs: 10, + isError: undefined, + interrupted: undefined, + runId: "run-1", + }); + + const json = JSON.stringify(card); + expect(json).toContain("任务进度 1/3"); + expect(json).toContain("Doing B"); + expect(json).toContain("collapsible_panel"); + }); +});