fix(hub): render Feishu checklist from TaskCreate/TaskUpdate

Headless Claud agents expose TaskCreate/TaskUpdate rather than TodoWrite.
Fold those tool events into the live card checklist, pass real tool names
and inputs through tool-result, and hide Task* noise once the panel is up.
This commit is contained in:
2026-07-23 23:21:34 +08:00
parent 326224b778
commit 74f5c4a02e
6 changed files with 282 additions and 27 deletions
+202 -5
View File
@@ -1,11 +1,16 @@
/**
* Parse Claude Agent SDK TodoWrite tool input into a stable checklist model
* for Feishu card progress rendering (Manus-style live todos).
* 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. */
@@ -25,9 +30,25 @@ export function isTodoWriteTool(toolName: string): boolean {
);
}
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 tool_use input.
* Returns null when the payload is not a usable TodoWrite body.
* 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;
@@ -49,11 +70,40 @@ export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] |
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
todos.push({ content, status, activeForm });
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;
@@ -67,3 +117,150 @@ export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
}
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<string, unknown>;
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<string, unknown>;
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(/^#/, "");
}