Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/core/todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,9 +25,10 @@ const STATUS_MARK: Record<TodoStatus, string> = {
pending: "[ ]",
in_progress: "[~]",
completed: "[x]",
cancelled: "[-]",
};

const VALID_STATUS = new Set<TodoStatus>(["pending", "in_progress", "completed"]);
const VALID_STATUS = new Set<TodoStatus>(["pending", "in_progress", "completed", "cancelled"]);

export class TodoStore {
private items: TodoItem[] = [];
Expand All @@ -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")}`;
}
}

Expand All @@ -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 };
});
}
Expand All @@ -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: {
Expand All @@ -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"],
},
Expand Down
4 changes: 3 additions & 1 deletion src/server/protocol.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/todos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading