forked from EduCraft/curriculum-project-hub
Merge pull request 'feat(hub): live TodoWrite checklist on Feishu agent cards' (#25) from feat/agent-todo-progress-card into main
This commit is contained in:
@@ -12,6 +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: '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: '飞书' },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
|||||||
"Grep",
|
"Grep",
|
||||||
"WebFetch",
|
"WebFetch",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
|
"TodoWrite",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||||
@@ -40,6 +41,8 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>>
|
|||||||
cph_build: ["Bash"],
|
cph_build: ["Bash"],
|
||||||
web_fetch: ["WebFetch"],
|
web_fetch: ["WebFetch"],
|
||||||
web_search: ["WebSearch"],
|
web_search: ["WebSearch"],
|
||||||
|
todo: ["TodoWrite"],
|
||||||
|
TodoWrite: ["TodoWrite"],
|
||||||
Read: ["Read"],
|
Read: ["Read"],
|
||||||
Write: ["Write"],
|
Write: ["Write"],
|
||||||
Bash: ["Bash"],
|
Bash: ["Bash"],
|
||||||
|
|||||||
+20
-3
@@ -160,8 +160,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
cwd: security.cwd,
|
cwd: security.cwd,
|
||||||
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
||||||
// list still has to expose the Skill dispatcher itself.
|
// list still has to expose the Skill dispatcher itself.
|
||||||
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
// TodoWrite is always available so multi-step runs can surface a live checklist
|
||||||
allowedTools: [...toolConfig.allowedTools],
|
// 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,
|
maxTurns: cap,
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||||
@@ -178,7 +184,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
// The project workspace is untrusted input. Do not load user/project
|
// The project workspace is untrusted input. Do not load user/project
|
||||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||||
settingSources: [],
|
settingSources: [],
|
||||||
settings: { disableBundledSkills: true },
|
settings: {
|
||||||
|
disableBundledSkills: true,
|
||||||
|
todoFeatureEnabled: true,
|
||||||
|
},
|
||||||
...(hasSkills && security.skillPluginRoot !== undefined
|
...(hasSkills && security.skillPluginRoot !== undefined
|
||||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -405,3 +414,11 @@ function extractToolResultText(content: unknown): string {
|
|||||||
}
|
}
|
||||||
return parts.join("\n");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<AgentTodoStatus>(["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<string, unknown>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
* Feishu interactive card builder for agent run output.
|
* Feishu interactive card builder for agent run output.
|
||||||
*
|
*
|
||||||
* Produces card JSON with:
|
* Produces card JSON with:
|
||||||
* 1. A collapsible tool-use panel (tool steps with status, params, results)
|
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
|
||||||
* 2. A collapsible reasoning panel (thinking text)
|
* 2. A collapsible tool-use panel (tool steps with status, params, results)
|
||||||
* 3. The streaming/final answer text (markdown)
|
* 3. A collapsible reasoning panel (thinking text)
|
||||||
|
* 4. The streaming/final answer text (markdown)
|
||||||
*
|
*
|
||||||
* Adapted from openclaw-lark's builder.ts, simplified for our
|
* Adapted from openclaw-lark's builder.ts, simplified for our
|
||||||
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
||||||
@@ -12,6 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
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";
|
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -39,6 +42,7 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
glob: "search-filled",
|
glob: "search-filled",
|
||||||
grep: "search-filled",
|
grep: "search-filled",
|
||||||
edit: "edit-filled",
|
edit: "edit-filled",
|
||||||
|
todowrite: "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",
|
||||||
@@ -61,20 +65,32 @@ export function buildAgentCard(params: {
|
|||||||
text: string;
|
text: string;
|
||||||
contentSegments?: readonly CardContentSegment[] | undefined;
|
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||||
reasoningText: string | undefined;
|
reasoningText: string | undefined;
|
||||||
|
todos: readonly AgentTodoItem[] | undefined;
|
||||||
toolUseSteps: ToolUseTraceStep[];
|
toolUseSteps: ToolUseTraceStep[];
|
||||||
toolUseElapsedMs: number | undefined;
|
toolUseElapsedMs: number | undefined;
|
||||||
isError: boolean | undefined;
|
isError: boolean | undefined;
|
||||||
interrupted: boolean | undefined;
|
interrupted: boolean | undefined;
|
||||||
runId: string | undefined;
|
runId: string | undefined;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||||
const elements: unknown[] = [];
|
const elements: unknown[] = [];
|
||||||
|
|
||||||
// Tool-use panel (always present if there are steps)
|
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
|
||||||
if (toolUseSteps.length > 0) {
|
if (todos !== undefined && todos.length > 0) {
|
||||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
elements.push(buildTodoPanel(todos, phase !== "complete"));
|
||||||
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
}
|
||||||
elements.push(buildPendingToolUsePanel());
|
|
||||||
|
// 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
|
// 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
|
// Tool-use panel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
getToolUseTraceSteps,
|
getToolUseTraceSteps,
|
||||||
} from "./trace-store.js";
|
} from "./trace-store.js";
|
||||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||||
|
import { isTodoWriteTool, parseTodoWriteInput, type AgentTodoItem } from "../../agent/todoList.js";
|
||||||
import {
|
import {
|
||||||
type CardContentSegment,
|
type CardContentSegment,
|
||||||
maskMarkdownImagesForStreaming,
|
maskMarkdownImagesForStreaming,
|
||||||
@@ -66,6 +67,7 @@ export class StreamingAgentCard {
|
|||||||
private currentMessageId: string | null = null;
|
private currentMessageId: string | null = null;
|
||||||
private text = "";
|
private text = "";
|
||||||
private reasoningText = "";
|
private reasoningText = "";
|
||||||
|
private todos: readonly AgentTodoItem[] = [];
|
||||||
private runStartedAt = Date.now();
|
private runStartedAt = Date.now();
|
||||||
private toolUseElapsedMs: number | undefined;
|
private toolUseElapsedMs: number | undefined;
|
||||||
private flushChain: Promise<void> = Promise.resolve();
|
private flushChain: Promise<void> = Promise.resolve();
|
||||||
@@ -123,6 +125,10 @@ export class StreamingAgentCard {
|
|||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
durationMs: number | undefined;
|
durationMs: number | undefined;
|
||||||
}): void {
|
}): void {
|
||||||
|
if (isTodoWriteTool(params.toolName)) {
|
||||||
|
const next = parseTodoWriteInput(params.input);
|
||||||
|
if (next !== null) this.todos = next;
|
||||||
|
}
|
||||||
recordToolUseEnd({ runId: this.runId, ...params });
|
recordToolUseEnd({ runId: this.runId, ...params });
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
@@ -235,6 +241,7 @@ export class StreamingAgentCard {
|
|||||||
? contentSegments
|
? contentSegments
|
||||||
: undefined,
|
: undefined,
|
||||||
reasoningText: this.reasoningText || undefined,
|
reasoningText: this.reasoningText || undefined,
|
||||||
|
todos: this.todos.length > 0 ? this.todos : undefined,
|
||||||
toolUseSteps,
|
toolUseSteps,
|
||||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||||
isError,
|
isError,
|
||||||
@@ -253,6 +260,7 @@ export class StreamingAgentCard {
|
|||||||
phase,
|
phase,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
reasoningText: undefined,
|
reasoningText: undefined,
|
||||||
|
todos: undefined,
|
||||||
toolUseSteps: [],
|
toolUseSteps: [],
|
||||||
toolUseElapsedMs: undefined,
|
toolUseElapsedMs: undefined,
|
||||||
isError,
|
isError,
|
||||||
@@ -274,6 +282,7 @@ export class StreamingAgentCard {
|
|||||||
phase,
|
phase,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
reasoningText: undefined,
|
reasoningText: undefined,
|
||||||
|
todos: undefined,
|
||||||
toolUseSteps: [],
|
toolUseSteps: [],
|
||||||
toolUseElapsedMs: undefined,
|
toolUseElapsedMs: undefined,
|
||||||
isError,
|
isError,
|
||||||
|
|||||||
@@ -113,8 +113,10 @@ describe("runAgent", () => {
|
|||||||
permissionMode: "bypassPermissions",
|
permissionMode: "bypassPermissions",
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
settingSources: [],
|
settingSources: [],
|
||||||
settings: { disableBundledSkills: true },
|
settings: { disableBundledSkills: true, todoFeatureEnabled: true },
|
||||||
skills: [],
|
skills: [],
|
||||||
|
tools: expect.arrayContaining(["TodoWrite"]),
|
||||||
|
allowedTools: expect.arrayContaining(["TodoWrite"]),
|
||||||
strictMcpConfig: true,
|
strictMcpConfig: true,
|
||||||
sandbox: expect.objectContaining({
|
sandbox: expect.objectContaining({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -183,8 +185,9 @@ describe("runAgent", () => {
|
|||||||
|
|
||||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: ["Read", "Bash"],
|
tools: ["Read", "Bash", "TodoWrite"],
|
||||||
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
|
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({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: [],
|
tools: ["TodoWrite"],
|
||||||
allowedTools: [],
|
allowedTools: ["TodoWrite"],
|
||||||
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -234,9 +238,10 @@ describe("runAgent", () => {
|
|||||||
|
|
||||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||||
options: {
|
options: {
|
||||||
tools: ["Skill"],
|
tools: ["TodoWrite", "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 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user