summaryrefslogtreecommitdiffhomepage
path: root/packages/todo/src/extension.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 14:34:22 +0900
committerAdam Malczewski <[email protected]>2026-06-21 14:34:22 +0900
commitd56fe9cf64719bb330c17b2daee58c0bafa057c9 (patch)
treeb80a25aaee57f959454d468e03f100c38e224b82 /packages/todo/src/extension.test.ts
parent8a4a624d16422467a8e85434c674bb591877e8ea (diff)
downloaddispatch-d56fe9cf64719bb330c17b2daee58c0bafa057c9.tar.gz
dispatch-d56fe9cf64719bb330c17b2daee58c0bafa057c9.zip
feat(todo): per-conversation task list tool + surface
New standard tool extension with a single todo_write tool (opencode todowrite pattern: full-list replace, returns JSON, no business-rule enforcement — the description guides the model). Per-conversation in-memory state + per-conversation surface (rendererId: todo, scope: conversation) via subscriber-notify (message-queue pattern). Wave 0 (kernel contract): added conversationId?: string to ToolExecuteContext (additive, backward-compatible). Wired in dispatch.ts — the kernel already had it but wasn't passing it through to tools. Wave 1 (todo extension): pure core (validateTodos — shape only; getTodos/ setTodos/clearTodos; buildTodoSpec; formatTodoResult). Shell: createTodoWriteTool + surface provider. Tool description matches opencode's todowrite.txt depth (when-to-use, examples, task states). Priority field removed (bloats the tool with little value). 25 tests. Wave 2 (host-bin): registered todo in CORE_EXTENSIONS + dep + root tsconfig ref. Verified: tsc EXIT 0, 1123 vitest, biome clean (314 files). Boot smoke clean. FE handoff: frontend-todo-handoff.md.
Diffstat (limited to 'packages/todo/src/extension.test.ts')
-rw-r--r--packages/todo/src/extension.test.ts138
1 files changed, 138 insertions, 0 deletions
diff --git a/packages/todo/src/extension.test.ts b/packages/todo/src/extension.test.ts
new file mode 100644
index 0000000..8b9e84b
--- /dev/null
+++ b/packages/todo/src/extension.test.ts
@@ -0,0 +1,138 @@
+import { createLogger, type HostAPI, type ToolExecuteContext } from "@dispatch/kernel";
+import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
+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,
+ };
+}
+
+interface FakeHost {
+ 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 };
+}
+
+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("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 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 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();
+ });
+});