forked from EduCraft/curriculum-project-hub
74f5c4a02e
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.
267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
/**
|
|
* 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. */
|
|
readonly activeForm: string | undefined;
|
|
}
|
|
|
|
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
|
|
|
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
|
|
export function isTodoWriteTool(toolName: string): boolean {
|
|
const lower = toolName.toLowerCase();
|
|
return (
|
|
toolName === "TodoWrite" ||
|
|
toolName.endsWith("__TodoWrite") ||
|
|
lower === "todo_write" ||
|
|
lower.endsWith("__todo_write")
|
|
);
|
|
}
|
|
|
|
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 / 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;
|
|
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;
|
|
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;
|
|
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 };
|
|
}
|
|
|
|
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(/^#/, "");
|
|
}
|