import type { TaskState } from "../state/state.js"; import type { Op } from "../state/state-reducer.js"; import { deriveBlocks } from "../state/task-graph.js"; import type { Task, TaskAction, TaskDetails, TaskMutationParams } from "./types.js"; /** * Format a single task as a `[status] #id subject [⛓ #dep,…]` line. * Used by the `list` content branch only — the overlay and `/todos` * formatting paths use `view/format.ts` for richer presentations. */ function formatListLine(t: Task): string { const block = t.blockedBy?.length ? ` ⛓ ${t.blockedBy.map((id) => `#${id}`).join(",")}` : ""; return `[${t.status}] #${t.id} ${t.subject}${block}`; } /** * Multi-line presentation for the `get` action: description, blockedBy, * reverse blocks, then owner. */ function formatGetLines(task: Task, state: TaskState): string { const blocks = deriveBlocks(state.tasks).get(task.id) ?? []; const lines = [`#${task.id} [${task.status}] ${task.subject}`]; if (task.description) lines.push(` description: ${task.description}`); if (task.blockedBy?.length) { lines.push(` blockedBy: ${task.blockedBy.map((id) => `#${id}`).join(", ")}`); } if (blocks.length) { lines.push(` blocks: ${blocks.map((id) => `#${id}`).join(", ")}`); } if (task.owner) lines.push(` owner: ${task.owner}`); return lines.join("\n"); } /** * Pure formatter: `(op, state) → string`. Closed switch on `op.kind` — * adding a new `Op` variant fails to compile here until a branch is added. * Create summaries use the status captured by the operation so a later update * in the same batch cannot rewrite the audited initial state. */ export function formatContent(op: Op, state: TaskState): string { switch (op.kind) { case "create": { const t = state.tasks.find((x) => x.id === op.taskId); // Defensive — `op.taskId` always resolves on success path. if (!t) return `Created #${op.taskId} (${op.status})`; return `Created #${t.id}: ${t.subject} (${op.status})`; } case "update": { const transition = op.fromStatus !== op.toStatus ? ` (${op.fromStatus} → ${op.toStatus})` : ""; return `Updated #${op.id}${transition}`; } case "delete": return `Deleted #${op.id}: ${op.subject}`; case "clear": return `Cleared ${op.count} tasks`; case "list": { let view = state.tasks; if (!op.includeDeleted) view = view.filter((t) => t.status !== "deleted"); if (op.statusFilter) view = view.filter((t) => t.status === op.statusFilter); return view.length === 0 ? "No tasks" : view.map(formatListLine).join("\n"); } case "get": return formatGetLines(op.task, state); case "batch": return [ `Applied ${op.operations.length} todo operations`, ...op.operations.map((operation) => `- ${formatContent(operation, state)}`), ].join("\n"); case "error": return `Error: ${op.message}`; } } /** * Build the LLM-facing tool envelope after the store has committed the * reducer's new state. `details` is the persistence + replay snapshot — * `state/replay.ts` consumes this exact shape on session lifecycle events. * * Mirrors `packages/rpiv-ask-user-question/tool/response-envelope.ts:13-47`. */ export function buildToolResult( action: TaskAction, params: TaskMutationParams, state: TaskState, op: Op, ): { content: Array<{ type: "text"; text: string }>; details: TaskDetails } { const text = formatContent(op, state); const details: TaskDetails = { schemaVersion: 1, action, params: params as Record, tasks: state.tasks, nextId: state.nextId, ...(op.kind === "error" ? { error: op.message } : {}), }; return { content: [{ type: "text", text }], details }; }