fix(hub): ship checklist via cph_hub todo_write MCP tool

Native Claude TodoWrite is not registered in headless agent mode even with
--tools default. Add mcp__cph_hub__todo_write (always enabled), mirror the
TodoWrite schema, instruct multi-step runs to use it, and keep the Feishu
progress panel parsing both native and hub tool names.
This commit is contained in:
2026-07-23 23:08:42 +08:00
parent ceaf64c4f9
commit e3b463d390
9 changed files with 99 additions and 11 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'bash', label: 'Bash 命令', group: 'Shell' }, { id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' }, { id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', 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_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' }, { id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, { id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
+11 -2
View File
@@ -19,6 +19,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"pbank_search_problems", "pbank_search_problems",
"pbank_get_problem", "pbank_get_problem",
"pbank_get_many_problems", "pbank_get_many_problems",
"todo_write",
] as const; ] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -62,6 +63,9 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
pbank_search_problems: ["pbank_search_problems"], pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"], pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"], pbank_get_many_problems: ["pbank_get_many_problems"],
todo: ["todo_write"],
TodoWrite: ["todo_write"],
todo_write: ["todo_write"],
"mcp__cph_hub__send_file": ["send_file"], "mcp__cph_hub__send_file": ["send_file"],
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"], "mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"], "mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
@@ -70,6 +74,7 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcp
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"], "mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"], "mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"], "mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
"mcp__cph_hub__todo_write": ["todo_write"],
}; };
const SUPPORTED_ROLE_TOOLS = new Set([ const SUPPORTED_ROLE_TOOLS = new Set([
@@ -109,9 +114,13 @@ export function claudeSdkToolConfigForRole(
export function cphHubMcpToolsForRole( export function cphHubMcpToolsForRole(
roleTools: readonly string[] | null | undefined, roleTools: readonly string[] | null | undefined,
): readonly CphHubMcpToolId[] { ): readonly CphHubMcpToolId[] {
if (roleTools === undefined || roleTools === null) return [...CPH_HUB_MCP_TOOL_IDS]; // Always expose hub-side todo_write so progress cards work even when the
// native Claude TodoWrite tool is not registered in headless agent mode.
if (roleTools === undefined || roleTools === null) {
return [...CPH_HUB_MCP_TOOL_IDS];
}
const tools: CphHubMcpToolId[] = []; const tools: CphHubMcpToolId[] = ["todo_write"];
for (const roleTool of roleTools) { for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool); assertSupportedRoleTool(roleTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) { for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
+1
View File
@@ -169,6 +169,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const allowedToolsOption = uniqueTools([ const allowedToolsOption = uniqueTools([
...toolConfig.allowedTools, ...toolConfig.allowedTools,
"TodoWrite", "TodoWrite",
"mcp__cph_hub__todo_write",
...skillExtras, ...skillExtras,
]); ]);
+8 -2
View File
@@ -14,9 +14,15 @@ export interface AgentTodoItem {
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]); const STATUSES = new Set<AgentTodoStatus>(["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 { 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")
);
} }
/** /**
+14 -3
View File
@@ -43,6 +43,7 @@ const TOOL_ICONS: Record<string, string> = {
grep: "search-filled", grep: "search-filled",
edit: "edit-filled", edit: "edit-filled",
todowrite: "todo-filled", todowrite: "todo-filled",
todo_write: "todo-filled",
send_file: "send-filled", send_file: "send-filled",
request_approval: "thumb-up-filled", request_approval: "thumb-up-filled",
feishu_read_context: "search-filled", feishu_read_context: "search-filled",
@@ -52,10 +53,20 @@ const TOOL_ICONS: Record<string, string> = {
}; };
function toolIcon(toolName: string): string { 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"; 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 // Card builder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -80,8 +91,8 @@ export function buildAgentCard(params: {
elements.push(buildTodoPanel(todos, phase !== "complete")); elements.push(buildTodoPanel(todos, phase !== "complete"));
} }
// Tool-use panel (exclude TodoWrite itself — already shown as checklist) // Tool-use panel (exclude todo tools — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => step.toolName.toLowerCase() !== "todowrite"); const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
if (visibleToolSteps.length > 0) { if (visibleToolSteps.length > 0) {
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete")); elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
} else if ( } else if (
+50
View File
@@ -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); const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({ return createSdkMcpServer({
@@ -507,6 +551,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): 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 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( instructions.push(
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.", "Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
); );
+4 -1
View File
@@ -12,7 +12,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => { describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => { it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); 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([ expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
"mcp__cph_hub__feishu_download_resource", "mcp__cph_hub__feishu_download_resource",
]); ]);
+9 -2
View File
@@ -186,7 +186,13 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: { options: {
tools: ["Read", "Bash", "TodoWrite"], 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 }), settings: expect.objectContaining({ todoFeatureEnabled: true }),
}, },
}); });
@@ -209,7 +215,7 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: { options: {
tools: ["TodoWrite"], tools: ["TodoWrite"],
allowedTools: ["TodoWrite"], allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
settings: expect.objectContaining({ todoFeatureEnabled: true }), settings: expect.objectContaining({ todoFeatureEnabled: true }),
}, },
}); });
@@ -239,6 +245,7 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: { options: {
tools: ["TodoWrite", "Skill"], tools: ["TodoWrite", "Skill"],
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"], skills: ["cph-runtime:typst"],
settings: expect.objectContaining({ todoFeatureEnabled: true }), settings: expect.objectContaining({ todoFeatureEnabled: true }),
+1
View File
@@ -31,6 +31,7 @@ describe("todo list parse", () => {
it("recognizes TodoWrite tool names", () => { it("recognizes TodoWrite tool names", () => {
expect(isTodoWriteTool("TodoWrite")).toBe(true); expect(isTodoWriteTool("TodoWrite")).toBe(true);
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
expect(isTodoWriteTool("Bash")).toBe(false); expect(isTodoWriteTool("Bash")).toBe(false);
}); });
}); });