diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 14:34:22 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 14:34:22 +0900 |
| commit | d56fe9cf64719bb330c17b2daee58c0bafa057c9 (patch) | |
| tree | b80a25aaee57f959454d468e03f100c38e224b82 /packages/todo/src/pure.ts | |
| parent | 8a4a624d16422467a8e85434c674bb591877e8ea (diff) | |
| download | dispatch-d56fe9cf64719bb330c17b2daee58c0bafa057c9.tar.gz dispatch-d56fe9cf64719bb330c17b2daee58c0bafa057c9.zip | |
feat(todo): per-conversation task list tool + surface
New standard tool extension with a single todo_write tool (opencode
todowrite pattern: full-list replace, returns JSON, no business-rule
enforcement — the description guides the model). Per-conversation in-memory
state + per-conversation surface (rendererId: todo, scope: conversation)
via subscriber-notify (message-queue pattern).
Wave 0 (kernel contract): added conversationId?: string to ToolExecuteContext
(additive, backward-compatible). Wired in dispatch.ts — the kernel already
had it but wasn't passing it through to tools.
Wave 1 (todo extension): pure core (validateTodos — shape only; getTodos/
setTodos/clearTodos; buildTodoSpec; formatTodoResult). Shell:
createTodoWriteTool + surface provider. Tool description matches opencode's
todowrite.txt depth (when-to-use, examples, task states). Priority field
removed (bloats the tool with little value). 25 tests.
Wave 2 (host-bin): registered todo in CORE_EXTENSIONS + dep + root tsconfig ref.
Verified: tsc EXIT 0, 1123 vitest, biome clean (314 files). Boot smoke clean.
FE handoff: frontend-todo-handoff.md.
Diffstat (limited to 'packages/todo/src/pure.ts')
| -rw-r--r-- | packages/todo/src/pure.ts | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/packages/todo/src/pure.ts b/packages/todo/src/pure.ts new file mode 100644 index 0000000..b6a6a32 --- /dev/null +++ b/packages/todo/src/pure.ts @@ -0,0 +1,148 @@ +/** + * Pure core for todo — zero I/O, zero ambient state. + * + * Every function is input → output; testable without mocks. State is a plain + * `Map<conversationId, TodoItem[]>` OWNED by the caller (the extension + * shell); the pure functions mutate it in place and return snapshots (fresh + * array copies), so a caller can never reach into or mutate live state through + * a returned value. Mirrors message-queue's `pure.ts`. + * + * The `todo_write` tool VALIDATES SHAPE ONLY — it does not enforce business + * rules like "exactly one in_progress". The tool description guides the model; + * the validator checks that `args` is an object with a `todos` array whose + * items each have a non-empty `content` and valid `status`/`priority` enums. + * (Same posture as opencode's `todowrite`.) + */ + +import type { CustomField, SurfaceSpec } from "@dispatch/ui-contract"; + +/** A todo item's lifecycle state. */ +export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; + +/** + * A single todo item. Identity is the array index (no `id`), per the opencode + * pattern — the model passes the FULL list each call, so position is identity. + */ +export interface TodoItem { + readonly content: string; + readonly status: TodoStatus; +} + +/** The todo store: a per-conversation map of todo lists. */ +export type TodoState = Map<string, TodoItem[]>; + +/** Surface id this extension contributes (also the manifest + catalog id). */ +export const TODO_SURFACE_ID = "todo"; +/** The custom renderer id a frontend switches on to render the todo list. */ +export const TODO_RENDERER_ID = "todo"; + +const VALID_STATUSES: ReadonlySet<string> = new Set([ + "pending", + "in_progress", + "completed", + "cancelled", +]); + +/** Result of `validateTodos`: the validated list, or an error message. */ +export type ValidationResult = TodoItem[] | { readonly error: string }; + +/** + * Validate the `todo_write` tool's input SHAPE only — not business rules (the + * tool description guides the model: "one in_progress at a time" etc.). Accepts + * an empty array (the model clears the list). Pure: no I/O, no ambient state. + */ +export function validateTodos(args: unknown): ValidationResult { + if (args === null || typeof args !== "object" || Array.isArray(args)) { + return { error: "Error: todo_write args must be an object with a `todos` array." }; + } + const todos = (args as { todos?: unknown }).todos; + if (!Array.isArray(todos)) { + return { error: "Error: `todos` must be an array." }; + } + const validated: TodoItem[] = []; + for (let i = 0; i < todos.length; i++) { + const item = todos[i]; + if (item === null || typeof item !== "object" || Array.isArray(item)) { + return { error: `Error: todos[${i}] must be an object.` }; + } + const { content, status } = item as { + content?: unknown; + status?: unknown; + }; + if (typeof content !== "string" || content.trim().length === 0) { + return { error: `Error: todos[${i}].content must be a non-empty string.` }; + } + if (typeof status !== "string" || !VALID_STATUSES.has(status)) { + return { + error: `Error: todos[${i}].status must be one of pending|in_progress|completed|cancelled.`, + }; + } + validated.push({ + content, + status: status as TodoStatus, + }); + } + return validated; +} + +/** + * Current todo-list snapshot for a conversation — a fresh array copy. Empty + * array if the conversation has no list / is unknown. Mutating the returned + * array does not affect live state (items are readonly). + */ +export function getTodos(state: TodoState, conversationId: string): TodoItem[] { + const existing = state.get(conversationId); + if (existing === undefined) return []; + return [...existing]; +} + +/** + * Replace a conversation's todo list with `todos` (full-list replace — the + * opencode pattern: the model passes the complete list each call). Mutates + * `state` and returns a fresh array copy of the new list. The caller cannot + * mutate live state through the returned value. + */ +export function setTodos( + state: TodoState, + conversationId: string, + todos: readonly TodoItem[], +): TodoItem[] { + state.set(conversationId, [...todos]); + return getTodos(state, conversationId); +} + +/** Delete a conversation's todo list. No-op if the conversation has none. */ +export function clearTodos(state: TodoState, conversationId: string): void { + state.delete(conversationId); +} + +/** + * Build the per-conversation surface spec: a single `custom` field whose + * payload is the current todo snapshot (`{ todos }`). An empty `todos` array + * (idle / cleared conversation) renders as an empty list. Pure — no I/O; the + * surface-registry re-fetches this on every notify. Mirrors `buildQueueSpec`. + */ +export function buildTodoSpec(todos: readonly TodoItem[]): SurfaceSpec { + const payload: { todos: readonly TodoItem[] } = { todos }; + const field: CustomField = { + kind: "custom", + rendererId: TODO_RENDERER_ID, + payload, + }; + return { + id: TODO_SURFACE_ID, + region: "side", + title: "Tasks", + fields: [field], + }; +} + +/** + * Format the todo list as the `todo_write` tool's result content: raw + * pretty-printed JSON (the opencode pattern — `JSON.stringify(todos, null, 2)`), + * not a custom human-readable format. The model parses prior writes from + * conversation history, so it needs no separate read tool. + */ +export function formatTodoResult(todos: readonly TodoItem[]): string { + return JSON.stringify(todos, null, 2); +} |
