export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' export interface TodoItem { content: string id: string status: TodoStatus } const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled'] const isRecord = (v: unknown): v is Record => Boolean(v && typeof v === 'object' && !Array.isArray(v)) const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string) function parseArray(value: unknown[]): TodoItem[] { return value.flatMap(item => { if (!isRecord(item) || !isStatus(item.status)) { return [] } const id = String(item.id ?? '').trim() const content = String(item.content ?? '').trim() return id && content ? [{ content, id, status: item.status }] : [] }) } function parse(value: unknown, depth: number): null | TodoItem[] { if (depth > 2) { return null } if (Array.isArray(value)) { return parseArray(value) } if (typeof value === 'string' && value.trim()) { try { return parse(JSON.parse(value), depth + 1) } catch { return null } } if (isRecord(value) && Object.hasOwn(value, 'todos')) { return parse(value.todos, depth + 1) } return null } export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0) /** Latest parseable todo list from one message's aui content parts (tool-call * parts named `todo`; live parts carry `todos`, hydrated ones args/result). */ export function todosFromMessageContent(content: unknown): null | TodoItem[] { if (!Array.isArray(content)) { return null } let latest: null | TodoItem[] = null for (const part of content) { if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { continue } const parsed = parseTodos(part.todos) ?? parseTodos(part.result) ?? parseTodos(part.args) if (parsed !== null) { latest = parsed } } return latest } /** Current todo state for a whole transcript — the last list wins. */ export function latestSessionTodos(messages: readonly { parts?: unknown }[]): null | TodoItem[] { for (let i = messages.length - 1; i >= 0; i -= 1) { const todos = todosFromMessageContent(messages[i]?.parts) if (todos !== null) { return todos } } return null }