From f1724af8751b4e202e7cc41c3562db68d2b92f63 Mon Sep 17 00:00:00 2001 From: DirtySouthAlpha Date: Sun, 26 Jul 2026 14:20:13 -0400 Subject: [PATCH] feat(todos): add 'cancelled' status (V1 cancel capability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the vNext plan — advances the CLI's V1 orchestration core. The ROADMAP-V2 V1 DoD calls for todo cancel/pause/resume, but TodoStatus only had pending|in_progress|completed. Added a 'cancelled' status: - TodoStatus, STATUS_MARK ([-]), VALID_STATUS, the parse-error message, and the todo_write tool schema enum all now include 'cancelled'. - render() surfaces cancelled items separately so progress stays honest ("2/5 done, 1 cancelled") — cancelled is resolved but not "done". - protocol.ts now imports TodoStatus instead of re-declaring the narrow union, so the wire type can't drift from the model again. 3 new tests (accept+render, no-suffix-when-none, unknown-status message). tsc clean; todos/orchestration/serve-protocol suites green. The 5 unrelated pre-existing Windows-env failures (bwrap/atomic-write/secrets) are untouched. --- src/core/todos.ts | 18 ++++++++++++------ src/server/protocol.ts | 4 +++- tests/todos.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/core/todos.ts b/src/core/todos.ts index 1bc241d..2b7411e 100644 --- a/src/core/todos.ts +++ b/src/core/todos.ts @@ -14,7 +14,7 @@ import { ChatMessage, ToolCall, ToolDef } from "../ai/types.js"; export const TODO_TOOL_NAME = "todo_write"; -export type TodoStatus = "pending" | "in_progress" | "completed"; +export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; export interface TodoItem { content: string; @@ -25,9 +25,10 @@ const STATUS_MARK: Record = { pending: "[ ]", in_progress: "[~]", completed: "[x]", + cancelled: "[-]", }; -const VALID_STATUS = new Set(["pending", "in_progress", "completed"]); +const VALID_STATUS = new Set(["pending", "in_progress", "completed", "cancelled"]); export class TodoStore { private items: TodoItem[] = []; @@ -49,8 +50,12 @@ export class TodoStore { render(): string { if (this.items.length === 0) return "(no todos)"; const done = this.items.filter((t) => t.status === "completed").length; + const cancelled = this.items.filter((t) => t.status === "cancelled").length; const lines = this.items.map((t) => `${STATUS_MARK[t.status]} ${t.content}`); - return `Todos (${done}/${this.items.length} done):\n${lines.join("\n")}`; + // Cancelled items are resolved but not "done"; surface them separately so + // progress reads honestly (2/5 done, 1 cancelled). + const suffix = cancelled > 0 ? `, ${cancelled} cancelled` : ""; + return `Todos (${done}/${this.items.length} done${suffix}):\n${lines.join("\n")}`; } } @@ -63,7 +68,7 @@ function parseTodos(raw: unknown): TodoItem[] { const content = typeof e.content === "string" ? e.content.trim() : ""; if (!content) throw new Error(`todos[${i}].content is required`); const status = (typeof e.status === "string" ? e.status : "pending") as TodoStatus; - if (!VALID_STATUS.has(status)) throw new Error(`todos[${i}].status must be pending|in_progress|completed`); + if (!VALID_STATUS.has(status)) throw new Error(`todos[${i}].status must be pending|in_progress|completed|cancelled`); return { content, status }; }); } @@ -84,7 +89,8 @@ export function createTodoTool(store?: TodoStore): TodoToolHandle { "Create and manage a structured todo list for the current task. Call this with the " + "COMPLETE list every time (it replaces the previous list). Use it to plan multi-step " + "work, mark exactly one item 'in_progress' as you work, and flip items to 'completed' " + - "the moment they're done. Keeps you and the user aligned on progress.", + "the moment they're done. Mark an item 'cancelled' if it is abandoned or no longer " + + "needed. Keeps you and the user aligned on progress.", parameters: { type: "object", properties: { @@ -95,7 +101,7 @@ export function createTodoTool(store?: TodoStore): TodoToolHandle { type: "object", properties: { content: { type: "string", description: "The task, in imperative form." }, - status: { type: "string", enum: ["pending", "in_progress", "completed"], description: "pending|in_progress|completed" }, + status: { type: "string", enum: ["pending", "in_progress", "completed", "cancelled"], description: "pending|in_progress|completed|cancelled" }, }, required: ["content", "status"], }, diff --git a/src/server/protocol.ts b/src/server/protocol.ts index d57029e..5cf1581 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -1,6 +1,8 @@ // Wire protocol for `sentinel serve` — a local WebSocket bridge that exposes the // Sentinel engine to a GUI (or any client). All messages are JSON. +import type { TodoStatus } from "../core/todos.js"; + export type PermissionMode = "yolo" | "auto" | "gated" | "plan"; /** An image attachment carried over the wire from a GUI paste/drop. */ @@ -95,7 +97,7 @@ export type ServerMessage = | { type: "system"; text: string } | { type: "error"; message: string } | { type: "checkpoints"; items: { id: string; tool: string; path: string; existed: boolean; timestamp: number }[] } - | { type: "todos"; items: { content: string; status: "pending" | "in_progress" | "completed" }[] } + | { type: "todos"; items: { content: string; status: TodoStatus }[] } | { type: "config"; config: ConfigView } | { type: "busy"; busy: boolean } // Full conversation replay: sent after getState (and on reconnect) so the GUI diff --git a/tests/todos.test.ts b/tests/todos.test.ts index e43de39..ff2e25e 100644 --- a/tests/todos.test.ts +++ b/tests/todos.test.ts @@ -65,3 +65,33 @@ describe("todos", () => { expect(baseCalls).toBe(1); // not delegated to base }); }); + +describe("todos — cancelled status (V1 cancel capability)", () => { + it("accepts and renders a cancelled item with its own mark", () => { + const { execute, store } = createTodoTool(); + const out = execute({ + todos: [ + { content: "Ship feature", status: "completed" }, + { content: "Refactor later", status: "cancelled" }, + { content: "Write docs", status: "pending" }, + ], + }); + expect(out).toContain("1/3 done"); + expect(out).toContain("1 cancelled"); + expect(out).toContain("[-] Refactor later"); + expect(store.get().find((t) => t.content === "Refactor later")?.status).toBe("cancelled"); + }); + + it("does not append a cancelled suffix when there are none", () => { + const { execute } = createTodoTool(); + const out = execute({ todos: [{ content: "a", status: "pending" }] }); + expect(out).not.toContain("cancelled"); + }); + + it("rejects an unknown status with a message listing cancelled", () => { + const { execute } = createTodoTool(); + const out = execute({ todos: [{ content: "x", status: "bogus" }] }); + expect(out).toContain("ERROR"); + expect(out).toContain("cancelled"); + }); +});