diff options
Diffstat (limited to 'packages/todo/src')
| -rw-r--r-- | packages/todo/src/extension.test.ts | 230 | ||||
| -rw-r--r-- | packages/todo/src/extension.ts | 98 | ||||
| -rw-r--r-- | packages/todo/src/format.test.ts | 24 | ||||
| -rw-r--r-- | packages/todo/src/index.ts | 24 | ||||
| -rw-r--r-- | packages/todo/src/pure.ts | 118 | ||||
| -rw-r--r-- | packages/todo/src/store.test.ts | 70 | ||||
| -rw-r--r-- | packages/todo/src/tool.test.ts | 172 | ||||
| -rw-r--r-- | packages/todo/src/tool.ts | 104 | ||||
| -rw-r--r-- | packages/todo/src/validate.test.ts | 110 |
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); + }); }); |
