summaryrefslogtreecommitdiffhomepage
path: root/packages/todo/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/todo/src')
-rw-r--r--packages/todo/src/extension.test.ts230
-rw-r--r--packages/todo/src/extension.ts98
-rw-r--r--packages/todo/src/format.test.ts24
-rw-r--r--packages/todo/src/index.ts24
-rw-r--r--packages/todo/src/pure.ts118
-rw-r--r--packages/todo/src/store.test.ts70
-rw-r--r--packages/todo/src/tool.test.ts172
-rw-r--r--packages/todo/src/tool.ts104
-rw-r--r--packages/todo/src/validate.test.ts110
9 files changed, 475 insertions, 475 deletions
diff --git a/packages/todo/src/extension.test.ts b/packages/todo/src/extension.test.ts
index 8b9e84b..42aa670 100644
--- a/packages/todo/src/extension.test.ts
+++ b/packages/todo/src/extension.test.ts
@@ -4,135 +4,135 @@ import { describe, expect, it, vi } from "vitest";
import { activate, extension, manifest } from "./extension.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
- return {
- toolCallId: "test-call-1",
- onOutput: () => {},
- signal: new AbortController().signal,
- log: createLogger(
- { extensionId: "test" },
- { emit: () => {} },
- { now: () => 0, newId: () => "id" },
- ),
- ...overrides,
- };
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: new AbortController().signal,
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
}
interface FakeHost {
- readonly host: HostAPI;
- readonly registry: SurfaceRegistry;
- readonly defineTool: ReturnType<typeof vi.fn>;
- readonly getProvider: () => SurfaceProvider | undefined;
+ readonly host: HostAPI;
+ readonly registry: SurfaceRegistry;
+ readonly defineTool: ReturnType<typeof vi.fn>;
+ readonly getProvider: () => SurfaceProvider | undefined;
}
function makeFakeHost(): FakeHost {
- const defineTool = vi.fn();
- let provider: SurfaceProvider | undefined;
- const registry: SurfaceRegistry = {
- register(p) {
- provider = p;
- return () => {
- provider = undefined;
- };
- },
- getCatalog() {
- return provider === undefined ? [] : [provider.catalogEntry];
- },
- getSurface(id) {
- if (provider === undefined) return undefined;
- return provider.catalogEntry.id === id ? provider : undefined;
- },
- };
- const host = {
- defineTool,
- getService: () => registry,
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- span: vi.fn(() => ({ end: vi.fn() })),
- },
- } as unknown as HostAPI;
- return { host, registry, defineTool, getProvider: () => provider };
+ const defineTool = vi.fn();
+ let provider: SurfaceProvider | undefined;
+ const registry: SurfaceRegistry = {
+ register(p) {
+ provider = p;
+ return () => {
+ provider = undefined;
+ };
+ },
+ getCatalog() {
+ return provider === undefined ? [] : [provider.catalogEntry];
+ },
+ getSurface(id) {
+ if (provider === undefined) return undefined;
+ return provider.catalogEntry.id === id ? provider : undefined;
+ },
+ };
+ const host = {
+ defineTool,
+ getService: () => registry,
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ span: vi.fn(() => ({ end: vi.fn() })),
+ },
+ } as unknown as HostAPI;
+ return { host, registry, defineTool, getProvider: () => provider };
}
describe("todo manifest", () => {
- it("declares todo_write contribution + surface-registry dependency", () => {
- expect(manifest.id).toBe("todo");
- expect(manifest.activation).toBe("eager");
- expect(manifest.trust).toBe("bundled");
- expect(manifest.dependsOn).toEqual(["surface-registry"]);
- expect(manifest.contributes).toEqual({ tools: ["todo_write"] });
- expect(manifest.capabilities).toEqual({});
- });
+ it("declares todo_write contribution + surface-registry dependency", () => {
+ expect(manifest.id).toBe("todo");
+ expect(manifest.activation).toBe("eager");
+ expect(manifest.trust).toBe("bundled");
+ expect(manifest.dependsOn).toEqual(["surface-registry"]);
+ expect(manifest.contributes).toEqual({ tools: ["todo_write"] });
+ expect(manifest.capabilities).toEqual({});
+ });
- it("extension bundles the manifest + activate", () => {
- expect(extension.manifest).toBe(manifest);
- expect(typeof extension.activate).toBe("function");
- });
+ it("extension bundles the manifest + activate", () => {
+ expect(extension.manifest).toBe(manifest);
+ expect(typeof extension.activate).toBe("function");
+ });
});
describe("todo activation", () => {
- it("activate registers the todo_write tool", () => {
- const { host, defineTool } = makeFakeHost();
- activate(host);
- expect(defineTool).toHaveBeenCalledTimes(1);
- const tool = defineTool.mock.calls[0]?.[0];
- if (!tool) throw new Error("no tool registered");
- expect(tool.name).toBe("todo_write");
- expect(tool.concurrencySafe).toBe(false);
- });
+ it("activate registers the todo_write tool", () => {
+ const { host, defineTool } = makeFakeHost();
+ activate(host);
+ expect(defineTool).toHaveBeenCalledTimes(1);
+ const tool = defineTool.mock.calls[0]?.[0];
+ if (!tool) throw new Error("no tool registered");
+ expect(tool.name).toBe("todo_write");
+ expect(tool.concurrencySafe).toBe(false);
+ });
- it("activate registers a surface with scope 'conversation'", () => {
- const { host, getProvider } = makeFakeHost();
- activate(host);
- const provider = getProvider();
- if (!provider) throw new Error("no surface provider registered");
- expect(provider.catalogEntry.id).toBe("todo");
- expect(provider.catalogEntry.scope).toBe("conversation");
- expect(provider.catalogEntry.region).toBe("side");
- expect(provider.catalogEntry.title).toBe("Tasks");
- });
+ it("activate registers a surface with scope 'conversation'", () => {
+ const { host, getProvider } = makeFakeHost();
+ activate(host);
+ const provider = getProvider();
+ if (!provider) throw new Error("no surface provider registered");
+ expect(provider.catalogEntry.id).toBe("todo");
+ expect(provider.catalogEntry.scope).toBe("conversation");
+ expect(provider.catalogEntry.region).toBe("side");
+ expect(provider.catalogEntry.title).toBe("Tasks");
+ });
- it("surface getSpec returns todos for the conversation", async () => {
- const { host, defineTool, getProvider } = makeFakeHost();
- activate(host);
- const tool = defineTool.mock.calls[0]?.[0];
- if (!tool) throw new Error("no tool registered");
- await tool.execute(
- { todos: [{ content: "a", status: "pending" }] },
- stubCtx({ conversationId: "c1" }),
- );
- const provider = getProvider();
- if (!provider) throw new Error("no surface provider registered");
- const spec = await provider.getSpec({ conversationId: "c1" });
- expect(spec.id).toBe("todo");
- expect(spec.fields).toHaveLength(1);
- const field = spec.fields[0];
- if (field === undefined || field.kind !== "custom") {
- throw new Error("expected a custom field");
- }
- const payload = field.payload as { todos: readonly { content: string }[] };
- expect(payload.todos).toHaveLength(1);
- expect(payload.todos[0]?.content).toBe("a");
- });
+ it("surface getSpec returns todos for the conversation", async () => {
+ const { host, defineTool, getProvider } = makeFakeHost();
+ activate(host);
+ const tool = defineTool.mock.calls[0]?.[0];
+ if (!tool) throw new Error("no tool registered");
+ await tool.execute(
+ { todos: [{ content: "a", status: "pending" }] },
+ stubCtx({ conversationId: "c1" }),
+ );
+ const provider = getProvider();
+ if (!provider) throw new Error("no surface provider registered");
+ const spec = await provider.getSpec({ conversationId: "c1" });
+ expect(spec.id).toBe("todo");
+ expect(spec.fields).toHaveLength(1);
+ const field = spec.fields[0];
+ if (field === undefined || field.kind !== "custom") {
+ throw new Error("expected a custom field");
+ }
+ const payload = field.payload as { todos: readonly { content: string }[] };
+ expect(payload.todos).toHaveLength(1);
+ expect(payload.todos[0]?.content).toBe("a");
+ });
- it("surface subscribe notifies on todo_write", async () => {
- const { host, defineTool, getProvider } = makeFakeHost();
- activate(host);
- const provider = getProvider();
- if (!provider) throw new Error("no surface provider registered");
- const calls = { value: 0 };
- const unsub = provider.subscribe?.(() => {
- calls.value += 1;
- });
- const tool = defineTool.mock.calls[0]?.[0];
- if (!tool) throw new Error("no tool registered");
- await tool.execute(
- { todos: [{ content: "x", status: "pending" }] },
- stubCtx({ conversationId: "c1" }),
- );
- expect(calls.value).toBe(1);
- if (unsub) unsub();
- });
+ it("surface subscribe notifies on todo_write", async () => {
+ const { host, defineTool, getProvider } = makeFakeHost();
+ activate(host);
+ const provider = getProvider();
+ if (!provider) throw new Error("no surface provider registered");
+ const calls = { value: 0 };
+ const unsub = provider.subscribe?.(() => {
+ calls.value += 1;
+ });
+ const tool = defineTool.mock.calls[0]?.[0];
+ if (!tool) throw new Error("no tool registered");
+ await tool.execute(
+ { todos: [{ content: "x", status: "pending" }] },
+ stubCtx({ conversationId: "c1" }),
+ );
+ expect(calls.value).toBe(1);
+ if (unsub) unsub();
+ });
});
diff --git a/packages/todo/src/extension.ts b/packages/todo/src/extension.ts
index 80af7aa..fb67265 100644
--- a/packages/todo/src/extension.ts
+++ b/packages/todo/src/extension.ts
@@ -15,67 +15,67 @@ import { buildTodoSpec, getTodos, TODO_SURFACE_ID, type TodoState } from "./pure
import { createTodoWriteTool } from "./tool.js";
export const manifest: Manifest = {
- id: "todo",
- name: "Todo Tool",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- dependsOn: ["surface-registry"],
- capabilities: {},
- contributes: {
- tools: ["todo_write"],
- },
+ id: "todo",
+ name: "Todo Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: ["surface-registry"],
+ capabilities: {},
+ contributes: {
+ tools: ["todo_write"],
+ },
};
export function activate(host: HostAPI): void {
- const registry = host.getService(surfaceRegistryHandle);
+ const registry = host.getService(surfaceRegistryHandle);
- const state: TodoState = new Map();
- const subscribers = new Set<() => void>();
+ const state: TodoState = new Map();
+ const subscribers = new Set<() => void>();
- function notify(): void {
- for (const sub of subscribers) {
- sub();
- }
- }
+ function notify(): void {
+ for (const sub of subscribers) {
+ sub();
+ }
+ }
- host.defineTool(createTodoWriteTool({ state, notify }));
+ host.defineTool(createTodoWriteTool({ state, notify }));
- function getSpec(context?: SurfaceContext): SurfaceSpec {
- const convId = context?.conversationId;
- const todos = convId === undefined ? [] : getTodos(state, convId);
- return buildTodoSpec(todos);
- }
+ function getSpec(context?: SurfaceContext): SurfaceSpec {
+ const convId = context?.conversationId;
+ const todos = convId === undefined ? [] : getTodos(state, convId);
+ return buildTodoSpec(todos);
+ }
- function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void {
- // The todo surface is read-only: the model mutates the list via the
- // `todo_write` tool; no client-facing surface actions.
- }
+ function invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext): void {
+ // The todo surface is read-only: the model mutates the list via the
+ // `todo_write` tool; no client-facing surface actions.
+ }
- const provider: SurfaceProvider = {
- catalogEntry: {
- id: TODO_SURFACE_ID,
- region: "side",
- title: "Tasks",
- scope: "conversation",
- },
- getSpec,
- invoke,
- subscribe(onChange) {
- subscribers.add(onChange);
- return () => {
- subscribers.delete(onChange);
- };
- },
- };
+ const provider: SurfaceProvider = {
+ catalogEntry: {
+ id: TODO_SURFACE_ID,
+ region: "side",
+ title: "Tasks",
+ scope: "conversation",
+ },
+ getSpec,
+ invoke,
+ subscribe(onChange) {
+ subscribers.add(onChange);
+ return () => {
+ subscribers.delete(onChange);
+ };
+ },
+ };
- registry.register(provider);
+ registry.register(provider);
- host.logger.info("todo: registered");
+ host.logger.info("todo: registered");
}
export const extension: Extension = {
- manifest,
- activate,
+ manifest,
+ activate,
};
diff --git a/packages/todo/src/format.test.ts b/packages/todo/src/format.test.ts
index bffb8a3..e589db1 100644
--- a/packages/todo/src/format.test.ts
+++ b/packages/todo/src/format.test.ts
@@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest";
import { formatTodoResult, type TodoItem } from "./pure.js";
describe("formatTodoResult", () => {
- it("formatTodoResult: returns JSON string of the todos", () => {
- const todos: TodoItem[] = [
- { content: "alpha", status: "in_progress" },
- { content: "beta", status: "pending" },
- ];
- expect(formatTodoResult(todos)).toBe(JSON.stringify(todos, null, 2));
- // spot-check it is pretty-printed JSON (indented key)
- expect(formatTodoResult(todos)).toContain('"content": "alpha"');
- });
+ it("formatTodoResult: returns JSON string of the todos", () => {
+ const todos: TodoItem[] = [
+ { content: "alpha", status: "in_progress" },
+ { content: "beta", status: "pending" },
+ ];
+ expect(formatTodoResult(todos)).toBe(JSON.stringify(todos, null, 2));
+ // spot-check it is pretty-printed JSON (indented key)
+ expect(formatTodoResult(todos)).toContain('"content": "alpha"');
+ });
- it('formatTodoResult: empty array returns "[]"', () => {
- expect(formatTodoResult([])).toBe("[]");
- });
+ it('formatTodoResult: empty array returns "[]"', () => {
+ expect(formatTodoResult([])).toBe("[]");
+ });
});
diff --git a/packages/todo/src/index.ts b/packages/todo/src/index.ts
index 320b674..9c444de 100644
--- a/packages/todo/src/index.ts
+++ b/packages/todo/src/index.ts
@@ -8,17 +8,17 @@
export { extension, manifest } from "./extension.js";
export {
- buildTodoSpec,
- clearTodos,
- formatTodoResult,
- getTodos,
- setTodos,
- TODO_RENDERER_ID,
- TODO_SURFACE_ID,
- type TodoItem,
- type TodoState,
- type TodoStatus,
- type ValidationResult,
- validateTodos,
+ buildTodoSpec,
+ clearTodos,
+ formatTodoResult,
+ getTodos,
+ setTodos,
+ TODO_RENDERER_ID,
+ TODO_SURFACE_ID,
+ type TodoItem,
+ type TodoState,
+ type TodoStatus,
+ type ValidationResult,
+ validateTodos,
} from "./pure.js";
export { createTodoWriteTool, type TodoWriteToolDeps } from "./tool.js";
diff --git a/packages/todo/src/pure.ts b/packages/todo/src/pure.ts
index b6a6a32..f010197 100644
--- a/packages/todo/src/pure.ts
+++ b/packages/todo/src/pure.ts
@@ -24,8 +24,8 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
* pattern — the model passes the FULL list each call, so position is identity.
*/
export interface TodoItem {
- readonly content: string;
- readonly status: TodoStatus;
+ readonly content: string;
+ readonly status: TodoStatus;
}
/** The todo store: a per-conversation map of todo lists. */
@@ -37,10 +37,10 @@ export const TODO_SURFACE_ID = "todo";
export const TODO_RENDERER_ID = "todo";
const VALID_STATUSES: ReadonlySet<string> = new Set([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
+ "pending",
+ "in_progress",
+ "completed",
+ "cancelled",
]);
/** Result of `validateTodos`: the validated list, or an error message. */
@@ -52,37 +52,37 @@ export type ValidationResult = TodoItem[] | { readonly error: string };
* 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;
+ 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;
}
/**
@@ -91,9 +91,9 @@ export function validateTodos(args: unknown): ValidationResult {
* 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];
+ const existing = state.get(conversationId);
+ if (existing === undefined) return [];
+ return [...existing];
}
/**
@@ -103,17 +103,17 @@ export function getTodos(state: TodoState, conversationId: string): TodoItem[] {
* mutate live state through the returned value.
*/
export function setTodos(
- state: TodoState,
- conversationId: string,
- todos: readonly TodoItem[],
+ state: TodoState,
+ conversationId: string,
+ todos: readonly TodoItem[],
): TodoItem[] {
- state.set(conversationId, [...todos]);
- return getTodos(state, conversationId);
+ 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);
+ state.delete(conversationId);
}
/**
@@ -123,18 +123,18 @@ export function clearTodos(state: TodoState, conversationId: string): void {
* 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],
- };
+ 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],
+ };
}
/**
@@ -144,5 +144,5 @@ export function buildTodoSpec(todos: readonly TodoItem[]): SurfaceSpec {
* conversation history, so it needs no separate read tool.
*/
export function formatTodoResult(todos: readonly TodoItem[]): string {
- return JSON.stringify(todos, null, 2);
+ return JSON.stringify(todos, null, 2);
}
diff --git a/packages/todo/src/store.test.ts b/packages/todo/src/store.test.ts
index 92130a3..e61d77a 100644
--- a/packages/todo/src/store.test.ts
+++ b/packages/todo/src/store.test.ts
@@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest";
import { clearTodos, getTodos, setTodos, type TodoState } from "./pure.js";
describe("getTodos", () => {
- it("getTodos: returns fresh array copy (not the live array)", () => {
- const state: TodoState = new Map();
- setTodos(state, "c1", [{ content: "a", status: "pending" }]);
- const snap = getTodos(state, "c1");
- expect(snap).toHaveLength(1);
- // mutating the snapshot array does not affect live state
- snap.push({ content: "evil", status: "completed" });
- expect(getTodos(state, "c1")).toHaveLength(1);
- });
+ it("getTodos: returns fresh array copy (not the live array)", () => {
+ const state: TodoState = new Map();
+ setTodos(state, "c1", [{ content: "a", status: "pending" }]);
+ const snap = getTodos(state, "c1");
+ expect(snap).toHaveLength(1);
+ // mutating the snapshot array does not affect live state
+ snap.push({ content: "evil", status: "completed" });
+ expect(getTodos(state, "c1")).toHaveLength(1);
+ });
- it("getTodos: empty array for unknown conversation", () => {
- const state: TodoState = new Map();
- expect(getTodos(state, "nope")).toEqual([]);
- });
+ it("getTodos: empty array for unknown conversation", () => {
+ const state: TodoState = new Map();
+ expect(getTodos(state, "nope")).toEqual([]);
+ });
});
describe("setTodos", () => {
- it("setTodos: replaces the list and returns a copy", () => {
- const state: TodoState = new Map();
- setTodos(state, "c1", [{ content: "first", status: "pending" }]);
- // replace with a different list
- const snap = setTodos(state, "c1", [{ content: "second", status: "in_progress" }]);
- expect(snap).toHaveLength(1);
- const first = snap[0];
- if (first === undefined) throw new Error("expected an item");
- expect(first.content).toBe("second");
- // the previous list is gone
- expect(getTodos(state, "c1").map((t) => t.content)).toEqual(["second"]);
- });
+ it("setTodos: replaces the list and returns a copy", () => {
+ const state: TodoState = new Map();
+ setTodos(state, "c1", [{ content: "first", status: "pending" }]);
+ // replace with a different list
+ const snap = setTodos(state, "c1", [{ content: "second", status: "in_progress" }]);
+ expect(snap).toHaveLength(1);
+ const first = snap[0];
+ if (first === undefined) throw new Error("expected an item");
+ expect(first.content).toBe("second");
+ // the previous list is gone
+ expect(getTodos(state, "c1").map((t) => t.content)).toEqual(["second"]);
+ });
});
describe("clearTodos", () => {
- it("clearTodos: deletes the conversation's list", () => {
- const state: TodoState = new Map();
- setTodos(state, "c1", [{ content: "x", status: "pending" }]);
- expect(getTodos(state, "c1")).toHaveLength(1);
- clearTodos(state, "c1");
- expect(getTodos(state, "c1")).toEqual([]);
- // a second clear is a no-op
- clearTodos(state, "c1");
- expect(getTodos(state, "c1")).toEqual([]);
- });
+ it("clearTodos: deletes the conversation's list", () => {
+ const state: TodoState = new Map();
+ setTodos(state, "c1", [{ content: "x", status: "pending" }]);
+ expect(getTodos(state, "c1")).toHaveLength(1);
+ clearTodos(state, "c1");
+ expect(getTodos(state, "c1")).toEqual([]);
+ // a second clear is a no-op
+ clearTodos(state, "c1");
+ expect(getTodos(state, "c1")).toEqual([]);
+ });
});
diff --git a/packages/todo/src/tool.test.ts b/packages/todo/src/tool.test.ts
index a125786..7811570 100644
--- a/packages/todo/src/tool.test.ts
+++ b/packages/todo/src/tool.test.ts
@@ -4,98 +4,98 @@ import { getTodos, type TodoState } from "./pure.js";
import { createTodoWriteTool } from "./tool.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
- return {
- toolCallId: "test-call-1",
- onOutput: () => {},
- signal: new AbortController().signal,
- log: createLogger(
- { extensionId: "test" },
- { emit: () => {} },
- { now: () => 0, newId: () => "id" },
- ),
- ...overrides,
- };
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: new AbortController().signal,
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
}
describe("todo_write", () => {
- it("todo_write: replaces list + returns JSON result", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- const todos = [
- { content: "a", status: "pending" },
- { content: "b", status: "in_progress" },
- ];
- const result = await tool.execute({ todos }, stubCtx({ conversationId: "c1" }));
- expect(result.isError).toBeUndefined();
- expect(result.content).toBe(JSON.stringify(todos, null, 2));
- expect(getTodos(state, "c1")).toEqual(todos);
- });
+ it("todo_write: replaces list + returns JSON result", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ const todos = [
+ { content: "a", status: "pending" },
+ { content: "b", status: "in_progress" },
+ ];
+ const result = await tool.execute({ todos }, stubCtx({ conversationId: "c1" }));
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe(JSON.stringify(todos, null, 2));
+ expect(getTodos(state, "c1")).toEqual(todos);
+ });
- it("todo_write: calls notify after write", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- expect(notify).not.toHaveBeenCalled();
- await tool.execute(
- { todos: [{ content: "x", status: "pending" }] },
- stubCtx({ conversationId: "c1" }),
- );
- expect(notify).toHaveBeenCalledTimes(1);
- });
+ it("todo_write: calls notify after write", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ expect(notify).not.toHaveBeenCalled();
+ await tool.execute(
+ { todos: [{ content: "x", status: "pending" }] },
+ stubCtx({ conversationId: "c1" }),
+ );
+ expect(notify).toHaveBeenCalledTimes(1);
+ });
- it("todo_write: validation error returns isError", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- const result = await tool.execute(
- { todos: [{ content: "x", status: "bogus" }] },
- stubCtx({ conversationId: "c1" }),
- );
- expect(result.isError).toBe(true);
- expect(result.content).toContain("Error:");
- expect(notify).not.toHaveBeenCalled();
- });
+ it("todo_write: validation error returns isError", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ const result = await tool.execute(
+ { todos: [{ content: "x", status: "bogus" }] },
+ stubCtx({ conversationId: "c1" }),
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Error:");
+ expect(notify).not.toHaveBeenCalled();
+ });
- it("todo_write: uses conversationId from ctx", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- await tool.execute(
- { todos: [{ content: "x", status: "pending" }] },
- stubCtx({ conversationId: "conv-42" }),
- );
- expect(getTodos(state, "conv-42")).toHaveLength(1);
- // a different conversation is unaffected
- expect(getTodos(state, "conv-other")).toEqual([]);
- });
+ it("todo_write: uses conversationId from ctx", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ await tool.execute(
+ { todos: [{ content: "x", status: "pending" }] },
+ stubCtx({ conversationId: "conv-42" }),
+ );
+ expect(getTodos(state, "conv-42")).toHaveLength(1);
+ // a different conversation is unaffected
+ expect(getTodos(state, "conv-other")).toEqual([]);
+ });
- it("todo_write: errors when conversationId is absent", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- const result = await tool.execute({ todos: [{ content: "x", status: "pending" }] }, stubCtx());
- expect(result.isError).toBe(true);
- expect(result.content).toBe("Error: no conversation context for todo.");
- expect(notify).not.toHaveBeenCalled();
- expect(state.size).toBe(0);
- });
+ it("todo_write: errors when conversationId is absent", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ const result = await tool.execute({ todos: [{ content: "x", status: "pending" }] }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toBe("Error: no conversation context for todo.");
+ expect(notify).not.toHaveBeenCalled();
+ expect(state.size).toBe(0);
+ });
- it("todo_write: accepts empty array (clears list)", async () => {
- const state: TodoState = new Map();
- const notify = vi.fn();
- const tool = createTodoWriteTool({ state, notify });
- // seed
- await tool.execute(
- { todos: [{ content: "seed", status: "pending" }] },
- stubCtx({ conversationId: "c1" }),
- );
- expect(getTodos(state, "c1")).toHaveLength(1);
- // clear via empty list
- const result = await tool.execute({ todos: [] }, stubCtx({ conversationId: "c1" }));
- expect(result.isError).toBeUndefined();
- expect(result.content).toBe("[]");
- expect(getTodos(state, "c1")).toEqual([]);
- expect(notify).toHaveBeenCalledTimes(2);
- });
+ it("todo_write: accepts empty array (clears list)", async () => {
+ const state: TodoState = new Map();
+ const notify = vi.fn();
+ const tool = createTodoWriteTool({ state, notify });
+ // seed
+ await tool.execute(
+ { todos: [{ content: "seed", status: "pending" }] },
+ stubCtx({ conversationId: "c1" }),
+ );
+ expect(getTodos(state, "c1")).toHaveLength(1);
+ // clear via empty list
+ const result = await tool.execute({ todos: [] }, stubCtx({ conversationId: "c1" }));
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe("[]");
+ expect(getTodos(state, "c1")).toEqual([]);
+ expect(notify).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/packages/todo/src/tool.ts b/packages/todo/src/tool.ts
index d95e949..7ec8db0 100644
--- a/packages/todo/src/tool.ts
+++ b/packages/todo/src/tool.ts
@@ -15,10 +15,10 @@ import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/ker
import { formatTodoResult, setTodos, type TodoState, validateTodos } from "./pure.js";
export interface TodoWriteToolDeps {
- /** Per-conversation todo store (owned by the extension shell). */
- readonly state: TodoState;
- /** Fire surface subscribers after a successful write. */
- readonly notify: () => void;
+ /** Per-conversation todo store (owned by the extension shell). */
+ readonly state: TodoState;
+ /** Fire surface subscribers after a successful write. */
+ readonly notify: () => void;
}
const TODO_WRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for your current session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
@@ -189,52 +189,52 @@ When in doubt, use this tool. Being proactive with task management demonstrates
/** Create the `todo_write` tool, closing over the shared state + notifier. */
export function createTodoWriteTool(deps: TodoWriteToolDeps): ToolContract {
- const { state, notify } = deps;
- return {
- name: "todo_write",
- description: TODO_WRITE_DESCRIPTION,
- parameters: {
- type: "object",
- properties: {
- todos: {
- type: "array",
- description: "The updated todo list (replaces the existing list).",
- items: {
- type: "object",
- properties: {
- content: {
- type: "string",
- description: "Brief description of the task.",
- },
- status: {
- type: "string",
- enum: ["pending", "in_progress", "completed", "cancelled"],
- description: "Current status of the task.",
- },
- },
- required: ["content", "status"],
- },
- },
- },
- required: ["todos"],
- },
- concurrencySafe: false,
- async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
- const conversationId = ctx.conversationId;
- if (conversationId === undefined) {
- return { content: "Error: no conversation context for todo.", isError: true };
- }
- const validated = validateTodos(args);
- if (!Array.isArray(validated)) {
- return { content: validated.error, isError: true };
- }
- const snapshot = setTodos(state, conversationId, validated);
- notify();
- ctx.log.debug("todo_write: replaced list", {
- conversationId,
- count: snapshot.length,
- });
- return { content: formatTodoResult(snapshot) };
- },
- };
+ const { state, notify } = deps;
+ return {
+ name: "todo_write",
+ description: TODO_WRITE_DESCRIPTION,
+ parameters: {
+ type: "object",
+ properties: {
+ todos: {
+ type: "array",
+ description: "The updated todo list (replaces the existing list).",
+ items: {
+ type: "object",
+ properties: {
+ content: {
+ type: "string",
+ description: "Brief description of the task.",
+ },
+ status: {
+ type: "string",
+ enum: ["pending", "in_progress", "completed", "cancelled"],
+ description: "Current status of the task.",
+ },
+ },
+ required: ["content", "status"],
+ },
+ },
+ },
+ required: ["todos"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const conversationId = ctx.conversationId;
+ if (conversationId === undefined) {
+ return { content: "Error: no conversation context for todo.", isError: true };
+ }
+ const validated = validateTodos(args);
+ if (!Array.isArray(validated)) {
+ return { content: validated.error, isError: true };
+ }
+ const snapshot = setTodos(state, conversationId, validated);
+ notify();
+ ctx.log.debug("todo_write: replaced list", {
+ conversationId,
+ count: snapshot.length,
+ });
+ return { content: formatTodoResult(snapshot) };
+ },
+ };
}
diff --git a/packages/todo/src/validate.test.ts b/packages/todo/src/validate.test.ts
index c518041..720f208 100644
--- a/packages/todo/src/validate.test.ts
+++ b/packages/todo/src/validate.test.ts
@@ -2,65 +2,65 @@ import { describe, expect, it } from "vitest";
import { validateTodos } from "./pure.js";
describe("validateTodos", () => {
- it("validateTodos: accepts valid list", () => {
- const result = validateTodos({
- todos: [
- { content: "Do thing A", status: "pending" },
- { content: "Do thing B", status: "in_progress" },
- { content: "Do thing C", status: "completed" },
- ],
- });
- expect(result).toEqual([
- { content: "Do thing A", status: "pending" },
- { content: "Do thing B", status: "in_progress" },
- { content: "Do thing C", status: "completed" },
- ]);
- });
+ it("validateTodos: accepts valid list", () => {
+ const result = validateTodos({
+ todos: [
+ { content: "Do thing A", status: "pending" },
+ { content: "Do thing B", status: "in_progress" },
+ { content: "Do thing C", status: "completed" },
+ ],
+ });
+ expect(result).toEqual([
+ { content: "Do thing A", status: "pending" },
+ { content: "Do thing B", status: "in_progress" },
+ { content: "Do thing C", status: "completed" },
+ ]);
+ });
- it("validateTodos: accepts empty array (clears the list)", () => {
- const result = validateTodos({ todos: [] });
- expect(result).toEqual([]);
- });
+ it("validateTodos: accepts empty array (clears the list)", () => {
+ const result = validateTodos({ todos: [] });
+ expect(result).toEqual([]);
+ });
- it("validateTodos: rejects invalid status", () => {
- const result = validateTodos({
- todos: [{ content: "x", status: "bogus" }],
- });
- expect(result).toHaveProperty("error");
- });
+ it("validateTodos: rejects invalid status", () => {
+ const result = validateTodos({
+ todos: [{ content: "x", status: "bogus" }],
+ });
+ expect(result).toHaveProperty("error");
+ });
- it("validateTodos: rejects empty content", () => {
- const empty = validateTodos({
- todos: [{ content: "", status: "pending" }],
- });
- expect(empty).toHaveProperty("error");
- const whitespace = validateTodos({
- todos: [{ content: " ", status: "pending" }],
- });
- expect(whitespace).toHaveProperty("error");
- });
+ it("validateTodos: rejects empty content", () => {
+ const empty = validateTodos({
+ todos: [{ content: "", status: "pending" }],
+ });
+ expect(empty).toHaveProperty("error");
+ const whitespace = validateTodos({
+ todos: [{ content: " ", status: "pending" }],
+ });
+ expect(whitespace).toHaveProperty("error");
+ });
- it("validateTodos: rejects non-array todos", () => {
- const result = validateTodos({ todos: "not-an-array" });
- expect(result).toHaveProperty("error");
- });
+ it("validateTodos: rejects non-array todos", () => {
+ const result = validateTodos({ todos: "not-an-array" });
+ expect(result).toHaveProperty("error");
+ });
- it("validateTodos: rejects null/non-object args", () => {
- expect(validateTodos(null)).toHaveProperty("error");
- expect(validateTodos(undefined)).toHaveProperty("error");
- expect(validateTodos("string")).toHaveProperty("error");
- expect(validateTodos(42)).toHaveProperty("error");
- expect(validateTodos([])).toHaveProperty("error");
- });
+ it("validateTodos: rejects null/non-object args", () => {
+ expect(validateTodos(null)).toHaveProperty("error");
+ expect(validateTodos(undefined)).toHaveProperty("error");
+ expect(validateTodos("string")).toHaveProperty("error");
+ expect(validateTodos(42)).toHaveProperty("error");
+ expect(validateTodos([])).toHaveProperty("error");
+ });
- it("validateTodos: does NOT enforce one in_progress (allows multiple — description guides the model)", () => {
- const result = validateTodos({
- todos: [
- { content: "a", status: "in_progress" },
- { content: "b", status: "in_progress" },
- ],
- });
- expect(Array.isArray(result)).toBe(true);
- expect(result).toHaveLength(2);
- });
+ it("validateTodos: does NOT enforce one in_progress (allows multiple — description guides the model)", () => {
+ const result = validateTodos({
+ todos: [
+ { content: "a", status: "in_progress" },
+ { content: "b", status: "in_progress" },
+ ],
+ });
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toHaveLength(2);
+ });
});