diff --git a/hub/admin-web/src/lib/constants.ts b/hub/admin-web/src/lib/constants.ts index 1748bbd..81e0979 100644 --- a/hub/admin-web/src/lib/constants.ts +++ b/hub/admin-web/src/lib/constants.ts @@ -12,7 +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: 'todo', label: '任务清单 (todo_write)', 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 9d5bc9f..79c8c22 100644 --- a/hub/src/agent/roleTools.ts +++ b/hub/src/agent/roleTools.ts @@ -19,6 +19,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [ "pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems", + "todo_write", ] as const; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; @@ -62,6 +63,9 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly { const allowedToolsOption = uniqueTools([ ...toolConfig.allowedTools, "TodoWrite", + "mcp__cph_hub__todo_write", ...skillExtras, ]); diff --git a/hub/src/agent/todoList.ts b/hub/src/agent/todoList.ts index 2a7bd2b..51c029e 100644 --- a/hub/src/agent/todoList.ts +++ b/hub/src/agent/todoList.ts @@ -14,9 +14,15 @@ export interface AgentTodoItem { const STATUSES = new Set(["pending", "in_progress", "completed"]); -/** True when the tool name is the SDK TodoWrite dispatcher. */ +/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */ export function isTodoWriteTool(toolName: string): boolean { - return toolName === "TodoWrite" || toolName.endsWith("__TodoWrite"); + const lower = toolName.toLowerCase(); + return ( + toolName === "TodoWrite" || + toolName.endsWith("__TodoWrite") || + lower === "todo_write" || + lower.endsWith("__todo_write") + ); } /** diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index 38e9aef..081611d 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -43,6 +43,7 @@ const TOOL_ICONS: Record = { grep: "search-filled", edit: "edit-filled", todowrite: "todo-filled", + todo_write: "todo-filled", send_file: "send-filled", request_approval: "thumb-up-filled", feishu_read_context: "search-filled", @@ -52,10 +53,20 @@ const TOOL_ICONS: Record = { }; function toolIcon(toolName: string): string { - const normalized = toolName.toLowerCase().replace(/^mcp_/, ""); + const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, ""); return TOOL_ICONS[normalized] ?? "tool-filled"; } +function isTodoToolName(toolName: string): boolean { + const lower = toolName.toLowerCase(); + return ( + lower === "todowrite" || + lower === "todo_write" || + lower.endsWith("__todo_write") || + lower.endsWith("__todowrite") + ); +} + // --------------------------------------------------------------------------- // Card builder // --------------------------------------------------------------------------- @@ -80,8 +91,8 @@ export function buildAgentCard(params: { 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"); + // Tool-use panel (exclude todo tools — already shown as checklist) + const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName)); if (visibleToolSteps.length > 0) { elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete")); } else if ( diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 51e56d3..c1de731 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -399,6 +399,50 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M ); } } + if (enabledTools.has("todo_write")) { + tools.push( + tool( + "todo_write", + "Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.", + { + todos: z + .array( + z.object({ + content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."), + status: z + .enum(["pending", "in_progress", "completed"]) + .describe("pending | in_progress | completed"), + activeForm: z + .string() + .optional() + .describe("Present continuous label while in_progress, e.g. Searching PBank."), + }), + ) + .min(1) + .max(32) + .describe("Full replacement list for the checklist (not a patch)."), + }, + async (args) => { + const completed = args.todos.filter((t) => t.status === "completed").length; + const inProgress = args.todos.filter((t) => t.status === "in_progress").length; + const lines = args.todos.map((t, i) => { + const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " "; + const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content; + return `${i + 1}. [${mark}] ${label}`; + }); + return { + content: [ + { + type: "text", + text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`, + }, + ], + }; + }, + { alwaysLoad: true }, + ), + ); + } const instructions = mcpInstructions(enabledTools); return createSdkMcpServer({ @@ -507,6 +551,12 @@ function mcpInstructions(enabledTools: ReadonlySet): string { "If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.", ); } + if (enabledTools.has("todo_write")) { + instructions.push( + "For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.", + "Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.", + ); + } instructions.push( "Role skill docs (when bound) are readable at .cph/runtime-skills//SKILL.md or $CPH_RUNTIME_SKILLS_DIR//SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.", ); diff --git a/hub/test/unit/feishu-download.test.ts b/hub/test/unit/feishu-download.test.ts index 225d3f4..58112ac 100644 --- a/hub/test/unit/feishu-download.test.ts +++ b/hub/test/unit/feishu-download.test.ts @@ -12,7 +12,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip; describe("Feishu message resource download", () => { it("exposes the download tool to default and explicitly configured roles", () => { expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); - expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]); + expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([ + "todo_write", + "feishu_download_resource", + ]); expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([ "mcp__cph_hub__feishu_download_resource", ]); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index e0cbd08..c51762c 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -186,7 +186,13 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: ["Read", "Bash", "TodoWrite"], - allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file", "TodoWrite"], + allowedTools: [ + "Read", + "Bash", + "mcp__cph_hub__send_file", + "TodoWrite", + "mcp__cph_hub__todo_write", + ], settings: expect.objectContaining({ todoFeatureEnabled: true }), }, }); @@ -209,7 +215,7 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: ["TodoWrite"], - allowedTools: ["TodoWrite"], + allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"], settings: expect.objectContaining({ todoFeatureEnabled: true }), }, }); @@ -239,6 +245,7 @@ describe("runAgent", () => { expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: ["TodoWrite", "Skill"], + allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "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 index 227a7a4..8259efa 100644 --- a/hub/test/unit/todo-list-card.test.ts +++ b/hub/test/unit/todo-list-card.test.ts @@ -31,6 +31,7 @@ describe("todo list parse", () => { it("recognizes TodoWrite tool names", () => { expect(isTodoWriteTool("TodoWrite")).toBe(true); + expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true); expect(isTodoWriteTool("Bash")).toBe(false); }); });