summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/tests
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/agent/agent.test.ts1791
-rw-r--r--packages/core/tests/agents/loader.test.ts233
-rw-r--r--packages/core/tests/chunks/append.test.ts534
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/config/loader.test.ts176
-rw-r--r--packages/core/tests/config/lsp-schema.test.ts110
-rw-r--r--packages/core/tests/config/merge.test.ts223
-rw-r--r--packages/core/tests/config/watcher.test.ts50
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts73
-rw-r--r--packages/core/tests/db/chunks.db.test.ts336
-rw-r--r--packages/core/tests/db/chunks.test.ts179
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
-rw-r--r--packages/core/tests/db/tabs.test.ts418
-rw-r--r--packages/core/tests/fixture/lsp/fake-lsp-server.js195
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts271
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
-rw-r--r--packages/core/tests/models/attachments.test.ts136
-rw-r--r--packages/core/tests/models/catalog.test.ts227
-rw-r--r--packages/core/tests/notifications/config.test.ts158
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts461
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts204
-rw-r--r--packages/core/tests/permission/evaluate.test.ts68
-rw-r--r--packages/core/tests/permission/service.test.ts95
-rw-r--r--packages/core/tests/permission/wildcard.test.ts50
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
-rw-r--r--packages/core/tests/tools/list-files.test.ts94
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/read-file.test.ts142
-rw-r--r--packages/core/tests/tools/read-tab.test.ts101
-rw-r--r--packages/core/tests/tools/registry.test.ts143
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
-rw-r--r--packages/core/tests/tools/search-code.test.ts511
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts185
-rw-r--r--packages/core/tests/tools/summon.test.ts349
-rw-r--r--packages/core/tests/tools/task-list.test.ts158
-rw-r--r--packages/core/tests/tools/write-file.test.ts152
-rw-r--r--packages/core/tests/types/reasoning-effort.test.ts48
43 files changed, 0 insertions, 9309 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
deleted file mode 100644
index 797aea2..0000000
--- a/packages/core/tests/agent/agent.test.ts
+++ /dev/null
@@ -1,1791 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { z } from "zod";
-import type { AgentConfig, AgentEvent } from "../../src/types/index.js";
-
-// Mock bun:sqlite to avoid Bun-only import in vitest/Node
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({})),
-}));
-
-// Mock the credentials module that depends on the DB
-vi.mock("../../src/credentials/claude.js", () => ({
- buildBillingHeaderValue: vi.fn(() => ""),
- SYSTEM_IDENTITY: "You are a test agent.",
-}));
-
-// Mock the ai module's streamText
-vi.mock("ai", async () => {
- const actual = await import("ai");
- return {
- ...actual,
- streamText: vi.fn(),
- };
-});
-
-// Mock the provider
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: vi.fn(() => (_model: string) => ({
- type: "language-model",
- modelId: _model,
- })),
-}));
-
-const { Agent, anthropicThinkingProviderOptions } = await import("../../src/agent/agent.js");
-const { streamText } = await import("ai");
-
-function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
- return {
- model: "test-model",
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- systemPrompt: "You are a helpful assistant.",
- tools: [],
- workingDirectory: "/tmp",
- ...overrides,
- };
-}
-
-async function* makeFullStream(
- events: Array<{ type: string; [key: string]: unknown }>,
-): AsyncGenerator<{ type: string; [key: string]: unknown }> {
- for (const event of events) {
- yield event;
- }
-}
-
-function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) {
- return {
- fullStream: makeFullStream(events),
- } as ReturnType<typeof import("ai").streamText>;
-}
-
-// v6 finish event — only finishReason, rawFinishReason, totalUsage (no usage/providerMetadata/response)
-const finishStop = {
- type: "finish",
- finishReason: "stop",
- rawFinishReason: "stop",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-const finishToolCalls = {
- type: "finish",
- finishReason: "tool-calls",
- rawFinishReason: "tool_use",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-describe("Agent", () => {
- it("starts in idle status", () => {
- const agent = new Agent(makeConfig());
- expect(agent.status).toBe("idle");
- });
-
- it("has empty messages initially", () => {
- const agent = new Agent(makeConfig());
- expect(agent.messages).toHaveLength(0);
- });
-
- it("yields running then idle status events around a simple message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello!" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const types = events.map((e) => e.type);
- expect(types[0]).toBe("status");
- expect(events[0]).toMatchObject({ type: "status", status: "running" });
-
- const lastStatusEvent = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatusEvent).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("yields text-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello" },
- { type: "text-delta", id: "t0", text: " world" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const textDeltas = events.filter((e) => e.type === "text-delta");
- expect(textDeltas).toHaveLength(2);
- expect(textDeltas[0]).toMatchObject({ delta: "Hello" });
- expect(textDeltas[1]).toMatchObject({ delta: " world" });
- });
-
- it("adds user message and assistant message to history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Response" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("my question")) {
- // consume generator
- }
-
- expect(agent.messages).toHaveLength(2);
- expect(agent.messages[0]).toMatchObject({
- role: "user",
- chunks: [{ type: "text", text: "my question" }],
- });
- expect(agent.messages[1]).toMatchObject({
- role: "assistant",
- chunks: [{ type: "text", text: "Response" }],
- });
- });
-
- it("yields done event with final message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done!" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const doneEvent = events.find((e) => e.type === "done");
- expect(doneEvent).toBeDefined();
- expect(doneEvent).toMatchObject({
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "Done!" }] },
- });
- });
-
- it("yields tool-call and tool-result events", async () => {
- // First call: LLM emits a tool-call
- // Second call (after tool execution): LLM emits text response with no tool calls
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- // v6: `input` replaces `args`
- input: { path: "hello.txt" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "Here is the file." },
- finishStop,
- ]),
- );
-
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (_args: Record<string, unknown>) => "file contents",
- };
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events = [];
- for await (const event of agent.run("read the file")) {
- events.push(event);
- }
-
- const toolCallEvent = events.find((e) => e.type === "tool-call");
- expect(toolCallEvent).toMatchObject({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read_file" },
- });
-
- const toolResultEvent = events.find((e) => e.type === "tool-result");
- expect(toolResultEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc1", result: "file contents" },
- });
- });
-
- it("does NOT swallow trailing queued messages into history at turn end", async () => {
- // Regression for the "queue not consumed after the turn ends" bug. A
- // message that lands on the queue after the last tool call (here: a
- // no-tool turn) must be LEFT on the queue for the orchestrator to start
- // a new turn — not silently appended to history with no response.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "answer me next", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const agent = new Agent(makeConfig(), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const before = agent.messages.length;
- for await (const _ of agent.run("hello")) {
- // consume
- }
-
- // The agent appended exactly the user turn + its own assistant reply;
- // it did NOT drain the queue or append a trailing user message for it.
- expect(dequeueMessages).not.toHaveBeenCalled();
- expect(queue).toHaveLength(1);
- const added = agent.messages.slice(before);
- expect(added.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(
- added.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "answer me next")),
- ).toBe(false);
- });
-
- it("still injects a mid-turn queued message into the last tool result", async () => {
- // The interrupt path (site 1) must be untouched by the turn-end fix: a
- // message present DURING a tool batch is folded into that batch's last
- // tool result as a [USER INTERRUPT], and the agent loops back to the LLM.
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "tc1", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "stop and do X", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async () => "file contents",
- };
- const agent = new Agent(makeConfig({ tools: [toolDef] }), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const events: AgentEvent[] = [];
- for await (const event of agent.run("read it")) {
- events.push(event);
- }
-
- expect(dequeueMessages).toHaveBeenCalled();
- const toolResult = events.find((e) => e.type === "tool-result") as
- | (AgentEvent & { toolResult: { result: string } })
- | undefined;
- expect(toolResult?.toolResult.result).toContain("[USER INTERRUPT]");
- expect(toolResult?.toolResult.result).toContain("stop and do X");
- });
-
- it("yields reasoning-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: reasoning-delta uses `text` (not `textDelta`)
- { type: "reasoning-delta", id: "r0", text: "thinking about this..." },
- { type: "reasoning-delta", id: "r0", text: " more thoughts" },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningDeltas = events.filter((e) => e.type === "reasoning-delta");
- expect(reasoningDeltas).toHaveLength(2);
- expect(reasoningDeltas[0]).toMatchObject({ delta: "thinking about this..." });
- expect(reasoningDeltas[1]).toMatchObject({ delta: " more thoughts" });
- });
-
- it("yields reasoning-end event when providerMetadata is present", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- providerMetadata: { anthropic: { signature: "sig-1" } },
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeDefined();
- expect(reasoningEndEvent).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "sig-1" } },
- });
- });
-
- it("does NOT yield reasoning-end event when providerMetadata is absent", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- // No providerMetadata — non-Anthropic model
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeUndefined();
- });
-
- // ─── New v6 round-trip tests ──────────────────────────────────────────────
-
- it("signed thinking round-trip: ThinkingChunk.metadata → ReasoningPart.providerOptions", async () => {
- // Pre-seed the agent with a prior assistant message containing a ThinkingChunk
- // with metadata (the Anthropic signature blob).
- // Anthropic-path provider — for openai-compatible the metadata
- // would be lifted into providerOptions.openaiCompatible instead;
- // that path is covered by the DeepSeek tests further down.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "prior user message" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "thinking",
- text: "I thought about it",
- metadata: { anthropic: { signature: "S" } },
- },
- { type: "text", text: "prior response" },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "New answer" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- // Inspect the messages passed to streamText in this (last) call
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- }>;
-
- // Find the assistant message in the rebuilt ModelMessage[]
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const reasoningPart = content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "I thought about it",
- providerOptions: { anthropic: { signature: "S" } },
- });
- });
-
- it("tool-call input round-trip: ToolBatchEntry.arguments → ToolCallPart.input (not args)", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-1",
- name: "read_file",
- arguments: { path: "/foo/bar.txt" },
- result: "file contents",
- },
- ],
- },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The assistant message should contain a tool-call part with `input` (not `args`)
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = content.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart).toMatchObject({
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "read_file",
- input: { path: "/foo/bar.txt" }, // v6: input not args
- });
- // Explicitly assert `args` is NOT present
- expect(toolCallPart).not.toHaveProperty("args");
- });
-
- it("tool-result output round-trip: result string → { type: 'text', value } ToolResultOutput", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-2",
- name: "read_file",
- arguments: { path: "/foo/baz.txt" },
- result: "the file content here",
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The tool message should contain a tool-result part with `output` (ToolResultOutput)
- const toolMsg = messages.find((m) => m.role === "tool");
- expect(toolMsg).toBeDefined();
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent[0]).toMatchObject({
- type: "tool-result",
- toolCallId: "call-2",
- toolName: "read_file",
- output: { type: "text", value: "the file content here" },
- });
- // Explicitly assert `result` (v4 raw string) is NOT present
- expect(toolContent[0]).not.toHaveProperty("result");
- });
-
- it("per-step segmentation: a [tool-batch, text] turn becomes [assistant(tool-call), tool(result), assistant(text)]", async () => {
- // `toModelMessages` segments a turn at each tool-batch boundary, so the
- // tool-batch (step 0) and the trailing text (step 1) land in SEPARATE
- // assistant messages — never a single invalid [tool_use, text] block.
- // This is the cache-stability fix and is applied for every provider.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Note: tool-batch appears BEFORE text in chunks — this is the
- // problematic ordering that Anthropic rejects
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-3",
- name: "read_file",
- arguments: { path: "/tmp/x.txt" },
- result: "x contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // No assistant message may mix tool-call and non-tool-call parts (the
- // invalid shape Anthropic rejects); segmentation guarantees this.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- const hasToolCall = c.some((p) => p.type === "tool-call");
- const hasNonToolCall = c.some((p) => p.type !== "tool-call");
- expect(hasToolCall && hasNonToolCall).toBe(false);
- }
-
- // The seeded turn yields a tool-call assistant message immediately
- // followed by its tool-result message (valid tool_use → tool_result).
- const toolOnlyIdx = messages.findIndex((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return m.role === "assistant" && Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolOnlyIdx).toBeGreaterThanOrEqual(0);
- expect(messages[toolOnlyIdx + 1]?.role).toBe("tool");
- });
-
- it("per-step segmentation also applies to the openai-compatible provider", async () => {
- // Segmentation is provider-agnostic: a [tool-batch, text] turn is split
- // into separate assistant messages for openai-compatible too, with the
- // tool result in its own tool message (the standard OpenAI shape).
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-4",
- name: "read_file",
- arguments: { path: "/tmp/y.txt" },
- result: "y contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The seeded [tool-batch, text] turn is segmented: a tool-call-only
- // assistant message, its tool message, and a separate text assistant
- // message (the new turn's "ok" reply adds one more). No assistant
- // message mixes tool-call and non-tool-call parts.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- expect(c.some((p) => p.type === "tool-call") && c.some((p) => p.type !== "tool-call")).toBe(
- false,
- );
- }
- expect(messages.some((m) => m.role === "tool")).toBe(true);
- const toolCallMsg = assistantMsgs.find((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolCallMsg).toBeDefined();
- });
-
- it("empty-text-part filter (Anthropic): empty text chunk is not sent", async () => {
- // Pre-seed an assistant message where a text chunk has empty text.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "hello" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- { type: "text", text: "" }, // empty text — should be filtered out
- { type: "text", text: "non-empty response" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty text part should have been filtered out
- const emptyTextParts = content.filter((p) => p.type === "text" && p.text === "");
- expect(emptyTextParts).toHaveLength(0);
-
- // The non-empty text part should still be there
- const nonEmptyTextParts = content.filter((p) => p.type === "text" && p.text !== "");
- expect(nonEmptyTextParts).toHaveLength(1);
- expect(nonEmptyTextParts[0]).toMatchObject({ text: "non-empty response" });
- });
-
- it("empty-reasoning-part filter (Anthropic): empty reasoning chunk is not sent", async () => {
- // Anthropic's adaptive thinking mode occasionally produces a signed-
- // but-empty thinking block. We persist it (for signature round-trip
- // fidelity) but strip the empty `reasoning` part before sending it
- // back, or Anthropic rejects with "thinking block must have content".
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "hi" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Signed-but-empty thinking block
- { type: "thinking", text: "", metadata: { anthropic: { signature: "sig-empty" } } },
- { type: "text", text: "answer" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty reasoning part must have been filtered out by the
- // Anthropic structural normalisation pass.
- const emptyReasoning = content.filter((p) => p.type === "reasoning" && p.text === "");
- expect(emptyReasoning).toHaveLength(0);
-
- // The text part should still be there
- expect(content.some((p) => p.type === "text" && p.text === "answer")).toBe(true);
- });
-
- it("toolCallId scrubbing (Anthropic): non-[a-zA-Z0-9_-] chars in tool IDs are sanitised", async () => {
- // Anthropic rejects toolCallId outside [a-zA-Z0-9_-]. Our internal
- // crypto.randomUUID IDs are safe, but defensively scrub for any
- // upstream-assigned IDs (subagent retrieval, provider-executed
- // tools, MCP, etc.). Mirrors opencode transform.ts:96-122.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "do the thing" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call.with/dots:and:slashes", // invalid chars
- name: "fake_tool",
- arguments: { x: 1 },
- result: "ok",
- isError: false,
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // Assistant tool-call part must have scrubbed ID
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const assistantContent = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = assistantContent.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart?.toolCallId).toBe("call_with_dots_and_slashes");
-
- // Matching tool-result message must use the SAME scrubbed ID
- // so Anthropic can pair them.
- const toolMsg = messages.find((m) => m.role === "tool");
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent?.[0]?.toolCallId).toBe("call_with_dots_and_slashes");
- });
-
- it("reasoning metadata captured from stream is round-tripped on the next turn", async () => {
- // End-to-end integrity for the providerMetadata round-trip — the
- // bug that prompted the entire migration. Stream a turn that
- // emits reasoning-delta + reasoning-end with metadata, then run
- // ANOTHER turn and verify the metadata reaches the model via
- // ReasoningPart.providerOptions.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- const sig = { anthropic: { signature: "round-trip-sig-1" } };
-
- // Turn 1: model emits reasoning + signed reasoning-end
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "let me think" },
- { type: "reasoning-end", id: "r0", providerMetadata: sig },
- { type: "text-delta", id: "t0", text: "answer" },
- finishStop,
- ]),
- );
-
- for await (const _ of agent.run("first question")) {
- /* consume */
- }
-
- // After turn 1, the persisted chunks should include a ThinkingChunk
- // with the captured metadata. The agent's messages array IS the
- // canonical persisted shape (the DB just JSON-stringifies it).
- const turn1Assistant = agent.messages.find(
- (m, i) => m.role === "assistant" && i === agent.messages.length - 1,
- );
- expect(turn1Assistant).toBeDefined();
- const thinkingChunk = turn1Assistant?.chunks.find((c) => c.type === "thinking");
- expect(thinkingChunk).toBeDefined();
- expect(thinkingChunk).toMatchObject({ text: "let me think", metadata: sig });
-
- // Turn 2: drive another turn, capture what streamText receives.
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t1", text: "follow-up answer" },
- finishStop,
- ]),
- );
- for await (const _ of agent.run("second question")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const turn2Assistant = messages
- .filter((m) => m.role === "assistant")
- .find((m) => Array.isArray(m.content));
- const turn2Content = turn2Assistant?.content as Array<Record<string, unknown>>;
- const reasoningPart = turn2Content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "let me think",
- providerOptions: sig,
- });
- });
-
- it("tool-error stream event yields a synthetic tool-result + error chunk and continues the turn", async () => {
- // Provider-executed tools (Anthropic server tools) bypass our
- // manual executor and surface as a `tool-error` stream event.
- // We must:
- // 1. Synthesize a tool-result with isError=true so the chunks
- // reflect that the tool ran and failed — this keeps the
- // tool-call/tool-result pairing complete and avoids the AI SDK
- // throwing MissingToolResultsError on the next round-trip.
- // 2. Emit an error chunk so the UI shows the failure.
- // 3. NOT transition to "error" status — the step breaks out of the
- // stream loop and the turn ends normally (here, with no further
- // tool calls pending, the agent completes to idle).
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-error",
- toolCallId: "tc_server",
- toolName: "server_tool",
- error: new Error("upstream tool failure"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- // Synthetic tool-result with the upstream error
- const trEvent = events.find((e) => e.type === "tool-result");
- expect(trEvent).toBeDefined();
- expect(trEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_server", isError: true },
- });
-
- // Error chunk for visibility
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(typeof errMsg).toBe("string");
- expect((errMsg as string).includes("upstream tool failure")).toBe(true);
-
- // Status does NOT transition to error — the turn completes to idle.
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
-
- // The turn produced a `done` event (it did not abort).
- expect(events.some((e) => e.type === "done")).toBe(true);
- });
-
- it("tool-error leaves sibling tool calls to be resolved by the executor (not orphaned)", async () => {
- // When one tool in a batch errors, its siblings — whose tool-call
- // events were already yielded — must still receive a result, otherwise
- // the tool-call IDs are orphaned in the chunks (no matching result)
- // and the next LLM round-trip throws MissingToolResultsError. The
- // tool-error handler breaks out of the stream loop WITHOUT executing
- // the unresolved siblings inline; the normal manual-executor pass then
- // runs them. Here `sibling_tool` is not a registered tool, so the
- // executor returns an "Unknown tool" error result — completing the
- // tool-call/tool-result pairing with `isError: true`.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc_sibling",
- toolName: "sibling_tool",
- input: {},
- },
- {
- type: "tool-error",
- toolCallId: "tc_failed",
- toolName: "failed_tool",
- error: new Error("boom"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- const toolResults = events.filter((e) => e.type === "tool-result");
- // One for the failed tool, one for the sibling resolved by the executor.
- const siblingResult = toolResults.find(
- (e) => "toolResult" in e && e.toolResult.toolCallId === "tc_sibling",
- );
- expect(siblingResult).toBeDefined();
- expect(siblingResult).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_sibling", isError: true },
- });
- const siblingMsg =
- siblingResult && "toolResult" in siblingResult ? siblingResult.toolResult.result : "";
- expect((siblingMsg as string).includes("sibling_tool")).toBe(true);
-
- // Status completes to idle (the turn continued, not aborted).
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("abort stream event surfaces as an error event and stops the turn", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "starting..." },
- { type: "abort", reason: "user cancelled" },
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(
- (errMsg as string).toLowerCase().includes("aborted") ||
- (errMsg as string).includes("user cancelled"),
- ).toBe(true);
-
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "error" });
- });
-
- it("openai-compatible reasoning round-trip: ThinkingChunk -> providerOptions.openaiCompatible.reasoning_content (DeepSeek scenario)", async () => {
- // Reproducer for the "reasoning_content must be passed back" error
- // from DeepSeek via OpenCode Go.
- //
- // applyOpenAICompatibleReasoningNormalisation strips the
- // `{ type: "reasoning", text }` parts and lifts the concatenated
- // text into `providerOptions.openaiCompatible.reasoning_content`.
- // The v6 SDK provider serializes the message-level
- // `providerOptions.openaiCompatible.*` into the wire `assistant`
- // message via its `metadata` spread (line 247 of the SDK dist).
- // This route emits `reasoning_content` regardless of empty/non-
- // empty text — which is what DeepSeek requires.
- const agent = new Agent(
- makeConfig({
- model: "deepseek-v4-pro",
- // no provider field → default openai-compatible path
- }),
- );
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "let me reason about this" },
- { type: "text", text: "ok done" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg || !Array.isArray(assistantMsg.content)) {
- throw new Error("expected structured assistant content");
- }
- const content = assistantMsg.content as Array<Record<string, unknown>>;
-
- // Reasoning parts have been stripped from content (lifted into
- // providerOptions instead).
- expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
-
- // reasoning_content is set on providerOptions.openaiCompatible.
- // This is what reaches DeepSeek and prevents the rejection.
- expect(assistantMsg.providerOptions?.openaiCompatible?.reasoning_content).toBe(
- "let me reason about this",
- );
-
- // The text part still survives in content.
- const textPart = content.find((p) => p.type === "text");
- expect(textPart).toMatchObject({ type: "text", text: "ok done" });
-
- // And critically, the message must NOT carry a providerMetadata
- // key (the v4-era misnamed key). v3 prompts use `providerOptions`.
- expect((assistantMsg as Record<string, unknown>).providerMetadata).toBeUndefined();
- });
-
- it("openai-compatible empty-reasoning edge case: forces reasoning_content='' so DeepSeek does not reject", async () => {
- // DeepSeek will reject the follow-up turn with "must be passed
- // back" if a prior assistant turn emitted reasoning AND the
- // follow-up doesn't include `reasoning_content` (even empty).
- // The v6 SDK's content-side path skips emission when reasoning
- // is empty (see `dist/index.mjs:245`); our normalisation routes
- // it via providerOptions instead, which fires unconditionally.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- // Empty thinking — captured but produced no actual text.
- { type: "thinking", text: "" },
- { type: "text", text: "answer" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg) throw new Error("expected assistant message");
-
- // The empty-string reasoning_content is explicitly set on
- // providerOptions. (`""` is intentional and required —
- // `assistantMsg.providerOptions?.openaiCompatible?.reasoning_content`
- // must not be `undefined`.)
- const rc = assistantMsg.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeDefined();
- expect(rc).toBe("");
- });
-
- it("openai-compatible normalisation does NOT run for messages without any reasoning parts", async () => {
- // DeepSeek only requires `reasoning_content` AFTER a thinking
- // turn. For purely-text assistant messages, we should leave
- // providerOptions alone.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "hi" }] },
- {
- role: "assistant",
- chunks: [{ type: "text", text: "hello back" }],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("again")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
-
- // No reasoning chunks → no providerOptions injection. (May still
- // be undefined entirely if nothing else set it.)
- const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeUndefined();
- });
-
- // ─── Prompt-caching: tool-result grouping & breakpoints (notes/claude-report.md) ──
-
- it("groups a turn's tool results into a SINGLE role:'tool' message (Root Cause 2)", async () => {
- // The agent batches three distinct read_file calls in one step. The
- // rebuilt ModelMessage[] must contain exactly ONE `role: "tool"` message
- // holding all three results (not three separate tool messages). Per-
- // result messages would strand the rolling cache breakpoints on the last
- // two adjacent tool results, wasting a breakpoint.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "b1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "b2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "b3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- // Inspect the step-1 request (sent after the batch executed) — its tail
- // is the assistant tool-calls + the grouped tool results.
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const toolMsgs = messages.filter((m) => m.role === "tool");
- expect(toolMsgs).toHaveLength(1);
- const toolContent = toolMsgs[0]?.content as Array<Record<string, unknown>>;
- expect(toolContent).toHaveLength(3);
- expect(toolContent.every((p) => p.type === "tool-result")).toBe(true);
- // IDs preserved per result.
- expect(toolContent.map((p) => p.toolCallId)).toEqual(["b1", "b2", "b3"]);
- });
-
- it("places cache breakpoints on [assistant, grouped-tool], not adjacent tool results (Root Cause 2)", async () => {
- // With grouping, the last two non-system messages of a mid-turn request
- // are [assistant(tool-calls), tool(all results)]. Both — plus the system
- // message — must carry an ephemeral cacheControl marker. The pre-fix bug
- // put both rolling breakpoints on two adjacent tool-result messages and
- // never marked the assistant turn.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "c1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "c3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }>;
-
- const isCached = (m?: {
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }) => m?.providerOptions?.anthropic?.cacheControl?.type === "ephemeral";
-
- // Exactly one tool message — no adjacent tool-result breakpoints.
- expect(messages.filter((m) => m.role === "tool")).toHaveLength(1);
-
- const systemMsg = messages.find((m) => m.role === "system");
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const toolMsg = messages.find((m) => m.role === "tool");
-
- expect(isCached(systemMsg)).toBe(true);
- expect(isCached(assistantMsg)).toBe(true);
- expect(isCached(toolMsg)).toBe(true);
- });
-
- it("does NOT attach cacheControl for the openai-compatible (non-Anthropic) path", async () => {
- // Sanity check: caching markers are Anthropic-only. The OpenAI-compatible
- // endpoints do automatic server-side prefix caching and reject explicit
- // cache_control, so no marker should be attached.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
- const agent = new Agent(makeConfig()); // default → openai-compatible
- for await (const _ of agent.run("hello")) {
- /* consume */
- }
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- for (const m of messages) {
- expect(m.providerOptions?.anthropic?.cacheControl).toBeUndefined();
- }
- });
-
- // ─── Tool-call dedup (notes/tool-runner-duplication-incident.md) ─────────────────
-
- it("deduplicates byte-identical tool calls within a single batch", async () => {
- // Claude can degenerate and emit the same tool call (same name + args)
- // many times in one batch. Each copy keeps its own id (and still gets its
- // own result), but the tool must execute only ONCE — re-running identical
- // idempotent reads wastes time/money and floods the context.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "d1",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d2",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d3",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events: AgentEvent[] = [];
- for await (const e of agent.run("read it thrice")) {
- events.push(e);
- }
-
- // Executed exactly once despite three identical calls.
- expect(execCount).toBe(1);
-
- // Every call id still received its own result, all with identical content.
- const results = events.filter(
- (e): e is Extract<AgentEvent, { type: "tool-result" }> => e.type === "tool-result",
- );
- expect(results).toHaveLength(3);
- expect(results.map((e) => e.toolResult.toolCallId).sort()).toEqual(["d1", "d2", "d3"]);
- for (const r of results) {
- expect(r.toolResult.result).toBe("contents of package.json");
- }
- });
-
- it("does NOT deduplicate tool calls with differing arguments", async () => {
- // Dedup is keyed on name + serialized arguments. Distinct args must each
- // execute — only byte-identical calls collapse.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "e1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "e2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "e3", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("read a, b, a")) {
- /* consume */
- }
-
- // a.txt + b.txt are distinct → two executions; the repeated a.txt reuses
- // the first result.
- expect(execCount).toBe(2);
- });
-
- // ─── Cache stability: per-step wire prefix is immutable ─────────────────────
-
- it("keeps earlier steps' wire messages byte-identical across requests (cache prefix is stable)", async () => {
- // A 3-step tool turn. The messages for steps 0 and 1 must serialize
- // identically in the step-2 request and the step-3 request — that
- // byte-stability is what lets Anthropic's rolling prompt cache extend
- // instead of re-writing the whole prefix every step (notes/cache-miss-report.md).
- // Uses the openai-compatible provider so no cacheControl markers (which
- // intentionally move each step) obscure the content comparison.
- let n = 0;
- // mock.calls accumulates across tests in this file — reset so our
- // `calls.length` assertions count only this run's requests.
- vi.mocked(streamText).mockClear();
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- const toolStep = (id: string, path: string) =>
- makeMockStreamResult([
- { type: "reasoning-delta", id: `r${id}`, text: `thinking ${id}` },
- { type: "text-delta", id: `t${id}`, text: `step ${id}` },
- { type: "tool-call", toolCallId: id, toolName: "read_file", input: { path } },
- finishToolCalls,
- ]);
- vi.mocked(streamText).mockImplementation(() => {
- n++;
- if (n === 1) return toolStep("s0", "a.txt");
- if (n === 2) return toolStep("s1", "b.txt");
- if (n === 3) return toolStep("s2", "c.txt");
- return makeMockStreamResult([{ type: "text-delta", id: "tf", text: "done" }, finishStop]);
- });
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("go")) {
- /* consume */
- }
-
- // 4 streamText calls (steps 0..3). Compare the step-2 request (call idx 2)
- // and step-3 request (call idx 3).
- const calls = vi.mocked(streamText).mock.calls;
- expect(calls.length).toBe(4);
- const req2 = calls[2]?.[0]?.messages as unknown[];
- const req3 = calls[3]?.[0]?.messages as unknown[];
-
- // Step-2 request = [system, user, a(s0), tool(s0), a(s1), tool(s1)] (6).
- // Step-3 request appends a(s2), tool(s2). The shared 6-message prefix
- // must be byte-identical.
- expect(req2).toHaveLength(6);
- expect(req3).toHaveLength(8);
- expect(JSON.stringify(req3.slice(0, 6))).toBe(JSON.stringify(req2));
-
- // And each step really is its own [assistant, tool] pair (not one merged
- // assistant message with all tool calls bunched together).
- const roles = (req3 as Array<{ role: string }>).map((m) => m.role);
- expect(roles).toEqual([
- "system",
- "user",
- "assistant",
- "tool",
- "assistant",
- "tool",
- "assistant",
- "tool",
- ]);
- });
-
- // ─── Usage / cache-rate telemetry ──────────────────────────────────────────
-
- it("emits a usage event from the finish-step part with the cache read/write split", async () => {
- // The per-step `usage` (with Anthropic's cache read/write split in
- // `inputTokenDetails`) rides on the `finish-step` part — NOT the terminal
- // `finish` part, which only carries the aggregate `totalUsage`. The agent
- // re-emits it as a `usage` AgentEvent that powers the Cache Rate view.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "hi" },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: 1000,
- outputTokens: 50,
- inputTokenDetails: {
- noCacheTokens: 200,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- },
- },
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- const usageEvents = events.filter(
- (e): e is Extract<AgentEvent, { type: "usage" }> => e.type === "usage",
- );
- // Exactly one usage event (from finish-step) — the terminal `finish`
- // part must NOT double-count.
- expect(usageEvents).toHaveLength(1);
- expect(usageEvents[0]?.usage).toEqual({
- inputTokens: 1000,
- outputTokens: 50,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- });
- });
-
- it("does NOT emit a usage event when no finish-step usage is present", async () => {
- // `finishStop` (type `finish`, aggregate `totalUsage` only) must not
- // trigger a usage event — and with no `finish-step` part there is no
- // per-step usage to emit.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- expect(events.some((e) => e.type === "usage")).toBe(false);
- });
-});
-
-describe("anthropicThinkingProviderOptions — adaptive-thinking model detection", () => {
- // Pure function: no provider construction, no streamText, no network I/O.
- // Mirrors opencode's transform.ts detection — Opus 4.7+ AND Opus/Sonnet 4.6
- // are adaptive; only Opus 4.7+ needs display:"summarized" to surface thinking.
-
- it("Opus 4.8 → adaptive + display:summarized (the reported bug)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "max")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "max",
- });
- });
-
- it("Opus 4.7 → adaptive + display:summarized (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive", display: "summarized" }, effort: "high" };
- expect(anthropicThinkingProviderOptions("claude-opus-4-7", "high")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-opus-4.7", "high")).toEqual(expected);
- });
-
- it("Sonnet 4.6 → adaptive WITHOUT display (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive" }, effort: "medium" };
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-6", "medium")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-sonnet-4.6", "medium")).toEqual(expected);
- });
-
- it("Opus 4.6 → adaptive WITHOUT display", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-6", "high")).toEqual({
- thinking: { type: "adaptive" },
- effort: "high",
- });
- });
-
- it("older Claude (Opus 4.5, dated Sonnet) → classic enabled thinking", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-5", "max")).toEqual({
- thinking: { type: "enabled", budgetTokens: 31999 },
- });
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-20250514", "high")).toEqual({
- thinking: { type: "enabled", budgetTokens: 16000 },
- });
- });
-
- it("uses a version parse, not a hardcoded string (future Opus 4.9 is adaptive)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-9", "high")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "high",
- });
- });
-
- it("maps reasoning effort → budgetTokens for enabled (non-adaptive) models", () => {
- const budget = (e: "low" | "medium" | "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("low")).toBe(2000);
- expect(budget("medium")).toBe(5000);
- expect(budget("high")).toBe(16000);
- expect(budget("xhigh")).toBe(24000);
- expect(budget("max")).toBe(31999);
- });
-
- it("xhigh budget sits strictly between high and max (ordering invariant)", () => {
- const budget = (e: "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("high")).toBeLessThan(budget("xhigh"));
- expect(budget("xhigh")).toBeLessThan(budget("max"));
- });
-
- it("forwards xhigh verbatim as the adaptive effort sibling (Opus 4.7+)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "xhigh")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "xhigh",
- });
- });
-
- describe("multimodal user content", () => {
- it("emits ordered text + image parts to the model when content is provided", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("here is image A: [image]", {
- content: [
- { type: "text", text: "here is image A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- expect(userMsg).toBeDefined();
- // Multimodal turn → content is an ordered parts array, not a string.
- expect(Array.isArray(userMsg?.content)).toBe(true);
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " });
- expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" });
- expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ==");
- });
-
- it("emits a FilePart for a PDF attachment with its filename", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("see [pdf]", {
- content: [
- { type: "text", text: "see " },
- { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- const filePart = parts.find((p) => p.type === "file");
- expect(filePart).toMatchObject({
- type: "file",
- mediaType: "application/pdf",
- filename: "doc.pdf",
- });
- expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ==");
- });
-
- it("persists the user turn as text only (no content) for history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("look: [image]", {
- content: [
- { type: "text", text: "look: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- // The in-memory user message keeps the text chunk for the render/persist
- // path; the ephemeral `content` rides alongside it but isn't a chunk.
- const userMsg = agent.messages.find((m) => m.role === "user");
- expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]);
- });
-
- it("falls back to a plain string when content has no attachment", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("plain text", {
- content: [{ type: "text", text: "plain text" }],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- // No attachment → plain string content (byte-identical to text-only path).
- expect(typeof userMsg?.content).toBe("string");
- expect(userMsg?.content).toBe("plain text");
- });
- });
-
- describe("warmCache (prompt-cache warming replay)", () => {
- function makeWarmStream(usage: {
- inputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- }) {
- return makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "." },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: usage.inputTokens,
- outputTokens: 1,
- inputTokenDetails: {
- noCacheTokens: usage.inputTokens - usage.cacheReadTokens - usage.cacheWriteTokens,
- cacheReadTokens: usage.cacheReadTokens,
- cacheWriteTokens: usage.cacheWriteTokens,
- },
- },
- },
- finishStop,
- ]);
- }
-
- const history = [
- { role: "user" as const, chunks: [{ type: "text" as const, text: "hello" }] },
- { role: "assistant" as const, chunks: [{ type: "text" as const, text: "hi there" }] },
- ];
-
- it("returns the request usage (cache read/write split) without throwing", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 1000, cacheReadTokens: 950, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- const usage = await agent.warmCache(history);
- expect(usage).toEqual({
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 950,
- cacheWriteTokens: 0,
- });
- });
-
- it("appends a single trivial throwaway user turn at the END of the history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- // system + 2 history messages + 1 throwaway user turn.
- expect(messages[0]?.role).toBe("system");
- const last = messages.at(-1);
- expect(last?.role).toBe("user");
- // The throwaway turn's text must be the trivial probe.
- const lastText = JSON.stringify(last?.content);
- expect(lastText).toContain("reply with just a .");
- // Exactly one extra user turn beyond the genuine history's single user msg.
- const userMsgs = messages.filter((m) => m.role === "user");
- expect(userMsgs).toHaveLength(2);
- });
-
- it("sends Anthropic cache_control breakpoints with the SAME toolChoice/thinking as a real turn", async () => {
- // Anthropic keys the MESSAGE cache on `tool_choice` AND the extended-
- // thinking parameters. If warming sent a different value than a real
- // turn, it would warm a DIFFERENT message-cache bucket and the user's
- // next real message would still miss. So warming MUST mirror run():
- // toolChoice "auto" + the thinking providerOptions for the effort.
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs?.toolChoice).toBe("auto");
- // Thinking providerOptions present (effort defaults to "max").
- expect(callArgs?.providerOptions?.anthropic).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- const hasBreakpoint = messages.some(
- (m) => m.providerOptions?.anthropic?.cacheControl !== undefined,
- );
- expect(hasBreakpoint).toBe(true);
- });
-
- it("warming and a real turn send IDENTICAL cache-affecting params (same bucket)", async () => {
- // The core invariant of the whole feature: warmCache() and run() must
- // produce the same toolChoice + thinking providerOptions + maxOutputTokens
- // so the warming replay refreshes the EXACT cache the next real message
- // reads. Drive both and compare the cache-key inputs streamText receives.
- const cfg = makeConfig({ provider: "anthropic" });
-
- // 1) Real turn for the same history + the probe text as the user msg.
- const realAgent = new Agent(cfg);
- realAgent.messages.push(...history.map((m) => ({ ...m })));
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "." }, finishStop]),
- );
- for await (const _ of realAgent.run("reply with just a .")) {
- // consume
- }
- const realArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // 2) Warming replay for the same history.
- const warmAgent = new Agent(cfg);
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- await warmAgent.warmCache(history);
- const warmArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // The cache-affecting parameters must be byte-identical.
- expect(warmArgs?.toolChoice).toEqual(realArgs?.toolChoice);
- expect(warmArgs?.maxOutputTokens).toEqual(realArgs?.maxOutputTokens);
- expect(warmArgs?.providerOptions).toEqual(realArgs?.providerOptions);
- });
-
- it("does NOT mutate the agent's own message history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- expect(agent.messages).toHaveLength(0);
- await agent.warmCache(history);
- // warmCache takes history as an argument and never touches `this.messages`.
- expect(agent.messages).toHaveLength(0);
- // And it must not have flipped the agent into a running state.
- expect(agent.status).toBe("idle");
- });
-
- it("throws a formatted error when the stream errors", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "error", error: new Error("boom") }]),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await expect(agent.warmCache(history)).rejects.toThrow(/boom/);
- });
- });
-});
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
deleted file mode 100644
index a223a4f..0000000
--- a/packages/core/tests/agents/loader.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- expandAgentToolNames,
- getAgentDirPaths,
- loadAgent,
- saveAgent,
-} from "../../src/agents/loader.js";
-
-describe("expandAgentToolNames", () => {
- it("expands 'read' into the granular read tools", () => {
- const out = expandAgentToolNames(["read"]);
- expect(out).toContain("read_file");
- expect(out).toContain("read_file_slice");
- expect(out).toContain("list_files");
- });
-
- it("expands 'edit' into write_file", () => {
- const out = expandAgentToolNames(["edit"]);
- expect(out).toContain("write_file");
- });
-
- it("expands 'bash' into run_shell", () => {
- const out = expandAgentToolNames(["bash"]);
- expect(out).toContain("run_shell");
- });
-
- it("passes through the tab tools as independent names (no tab_comm group)", () => {
- const out = expandAgentToolNames(["send_to_tab", "read_tab"]);
- expect(out).toContain("send_to_tab");
- expect(out).toContain("read_tab");
- // Granting only one must not pull in the other.
- const onlySend = expandAgentToolNames(["send_to_tab"]);
- expect(onlySend).toContain("send_to_tab");
- expect(onlySend).not.toContain("read_tab");
- });
-
- it("passes through non-group tool names unchanged", () => {
- const out = expandAgentToolNames([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]);
- expect(out).toEqual(
- expect.arrayContaining([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]),
- );
- });
-
- it("always includes 'todo' even when not requested", () => {
- expect(expandAgentToolNames([])).toContain("todo");
- expect(expandAgentToolNames(["read"])).toContain("todo");
- expect(expandAgentToolNames(["summon"])).toContain("todo");
- });
-
- it("deduplicates when groups overlap with explicit names", () => {
- const out = expandAgentToolNames(["read", "read_file"]);
- // Each name should appear at most once
- const counts = new Map<string, number>();
- for (const t of out) counts.set(t, (counts.get(t) ?? 0) + 1);
- for (const [, c] of counts) expect(c).toBe(1);
- });
-});
-
-describe("getAgentDirPaths", () => {
- it("returns just the global dir when no projectDir is supplied", () => {
- const paths = getAgentDirPaths();
- expect(paths).toHaveLength(1);
- expect(paths[0]).toContain(".config/dispatch/agents");
- });
-
- it("appends the project-scoped dir when projectDir is supplied", () => {
- const paths = getAgentDirPaths("/some/project");
- expect(paths).toHaveLength(2);
- expect(paths[1]).toBe("/some/project/.dispatch/agents");
- });
-});
-
-describe("loadAgent — project-scoped sandbox", () => {
- // `GLOBAL_AGENTS_DIR` is captured at module load via `os.homedir()`
- // and can't be redirected at runtime. The project-scoped path,
- // however, is computed per-call from the `projectDir` argument, so
- // we exercise that branch instead. This is also the more common
- // real-world case (per-project agent definitions).
- let tmpProject: string;
-
- beforeEach(() => {
- tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-loader-test-"));
- });
-
- afterEach(() => {
- fs.rmSync(tmpProject, { recursive: true, force: true });
- });
-
- function writeAgentToml(slug: string, body: string): void {
- const agentsDir = path.join(tmpProject, ".dispatch", "agents");
- fs.mkdirSync(agentsDir, { recursive: true });
- fs.writeFileSync(path.join(agentsDir, `${slug}.toml`), body, "utf-8");
- }
-
- // Uses a slug unlikely to collide with anything the user might
- // already have in ~/.config/dispatch/agents. `loadAgent` returns
- // the FIRST match it finds across all scanned directories, and
- // the global scope is scanned before the project scope — a slug
- // that exists in both would resolve to the global one (which is
- // real, not under our control). The "z-dispatch-test-*" prefix
- // gives this fixture exclusive ownership of the slug.
- const TEST_SLUG = "z-dispatch-test-fixture";
-
- it("returns null for an unknown slug within the project scope", () => {
- const agent = loadAgent("z-dispatch-test-does-not-exist", tmpProject);
- expect(agent).toBeNull();
- });
-
- it("loads a TOML definition written to the project's .dispatch/agents", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- 'description = "Sandbox fixture for loadAgent test."',
- "skills = []",
- 'tools = ["read", "bash"]',
- "is_subagent = true",
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent).not.toBeNull();
- expect(agent?.slug).toBe(TEST_SLUG);
- expect(agent?.name).toBe("Fixture");
- expect(agent?.tools).toEqual(["read", "bash"]);
- expect(agent?.is_subagent).toBe(true);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.scope).toBe(tmpProject);
- });
-
- it("parses a per-model effort when it is a recognised level", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "low"',
- "",
- "[[models]]",
- 'key_id = "claude-max"',
- 'model_id = "claude-opus-4-8"',
- 'effort = "xhigh"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "low" },
- { key_id: "claude-max", model_id: "claude-opus-4-8", effort: "xhigh" },
- ]);
- });
-
- it("drops an invalid effort value so the call site falls back to the default", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "turbo"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.models[0]).not.toHaveProperty("effort");
- });
-
- it("round-trips effort through saveAgent → loadAgent", () => {
- saveAgent({
- name: "Fixture",
- description: "",
- skills: [],
- tools: ["read"],
- models: [
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ],
- scope: tmpProject,
- slug: TEST_SLUG,
- });
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ]);
- });
-
- it("sanitizes the slug so path traversal can't reach outside the agents dir", () => {
- // Even if a caller passes something gnarly, the lookup is by
- // sanitized slug — no file outside the configured dirs should
- // ever be opened. The sanitized form ("etc-passwd") obviously
- // doesn't exist in the temp project, so the result is null.
- const agent = loadAgent("../../../etc/passwd", tmpProject);
- expect(agent).toBeNull();
- });
-});
diff --git a/packages/core/tests/chunks/append.test.ts b/packages/core/tests/chunks/append.test.ts
deleted file mode 100644
index dc277d2..0000000
--- a/packages/core/tests/chunks/append.test.ts
+++ /dev/null
@@ -1,534 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { appendEventToChunks, applySystemEvent } from "../../src/chunks/append.js";
-import type { AgentEvent, ChatMessage, Chunk } from "../../src/types/index.js";
-
-// ─── helpers ─────────────────────────────────────────────────────
-
-const td = (delta: string): AgentEvent => ({ type: "text-delta", delta });
-const rd = (delta: string): AgentEvent => ({ type: "reasoning-delta", delta });
-const re = (metadata?: Record<string, unknown>): AgentEvent => ({
- type: "reasoning-end",
- ...(metadata !== undefined ? { metadata } : {}),
-});
-const tc = (id: string, name = "fake_tool", args: Record<string, unknown> = {}): AgentEvent => ({
- type: "tool-call",
- toolCall: { id, name, arguments: args },
-});
-const tr = (toolCallId: string, result: string, isError = false): AgentEvent => ({
- type: "tool-result",
- toolResult: { toolCallId, toolName: "fake_tool", result, isError },
-});
-const so = (data: string, stream: "stdout" | "stderr" = "stdout"): AgentEvent => ({
- type: "shell-output",
- data,
- stream,
-});
-const err = (error: string, statusCode?: number): AgentEvent => ({
- type: "error",
- error,
- ...(statusCode !== undefined ? { statusCode } : {}),
-});
-const notice = (message: string): AgentEvent => ({ type: "notice", message });
-const modelChanged = (keyId: string, modelId: string): AgentEvent => ({
- type: "model-changed",
- keyId,
- modelId,
-});
-const configReload: AgentEvent = { type: "config-reload" };
-
-function run(events: AgentEvent[]): Chunk[] {
- const chunks: Chunk[] = [];
- for (const e of events) appendEventToChunks(chunks, e);
- return chunks;
-}
-
-// ─── Required cases from the plan ────────────────────────────────
-
-describe("appendEventToChunks — required cases from plan", () => {
- it("empty chunks + text-delta → one text chunk with the delta", () => {
- const chunks = run([td("Hello")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello" }]);
- });
-
- it("two consecutive text-deltas → one text chunk with concatenated text", () => {
- const chunks = run([td("Hello, "), td("world!")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello, world!" }]);
- });
-
- it("text-delta then reasoning-delta → two chunks (text, thinking)", () => {
- const chunks = run([td("ans: 42"), rd("I should explain")]);
- expect(chunks).toEqual([
- { type: "text", text: "ans: 42" },
- { type: "thinking", text: "I should explain" },
- ]);
- });
-
- it("text-delta then tool-call → two chunks (text, tool-batch with one entry)", () => {
- const chunks = run([td("Looking..."), tc("t1", "read_file", { path: "x" })]);
- expect(chunks).toEqual([
- { type: "text", text: "Looking..." },
- {
- type: "tool-batch",
- calls: [{ id: "t1", name: "read_file", arguments: { path: "x" } }],
- },
- ]);
- });
-
- it("two consecutive tool-calls → one tool-batch with two entries", () => {
- const chunks = run([tc("t1", "read_file"), tc("t2", "list_files")]);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [
- { id: "t1", name: "read_file" },
- { id: "t2", name: "list_files" },
- ],
- });
- });
-
- it("tool-call then tool-call then text → two chunks (tool-batch with 2 entries, text)", () => {
- const chunks = run([tc("t1"), tc("t2"), td("done")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [{ id: "t1" }, { id: "t2" }],
- });
- expect(chunks[1]).toEqual({ type: "text", text: "done" });
- });
-
- it("tool-result arrives → updates matching tool-call entry in the latest tool-batch chunk by id", () => {
- const chunks = run([
- tc("t1"),
- tc("t2"),
- tr("t1", "first-result"),
- tr("t2", "second-result", true),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- expect(batch.type).toBe("tool-batch");
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ id: "t1", result: "first-result", isError: false });
- expect(batch.calls[1]).toMatchObject({ id: "t2", result: "second-result", isError: true });
- });
-
- it("shell-output arrives → appends to the most recent tool-call's shellOutput", () => {
- const chunks = run([
- tc("t1", "run_shell"),
- so("hello\n", "stdout"),
- so("world\n", "stdout"),
- so("err!\n", "stderr"),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]?.shellOutput).toEqual({
- stdout: "hello\nworld\n",
- stderr: "err!\n",
- });
- });
-
- it("error event → opens an error chunk; subsequent events go to new chunks", () => {
- const chunks = run([td("partial..."), err("network failed", 503), td("recovery")]);
- expect(chunks).toEqual([
- { type: "text", text: "partial..." },
- { type: "error", message: "network failed", statusCode: 503 },
- { type: "text", text: "recovery" },
- ]);
- });
-
- it("system event during text run → closes text, opens system, would re-open text on next text-delta", () => {
- const chunks = run([td("first "), notice("model swap"), td("second")]);
- expect(chunks).toEqual([
- { type: "text", text: "first " },
- { type: "system", kind: "notice", text: "model swap" },
- { type: "text", text: "second" },
- ]);
- });
-
- it("two consecutive system events → two separate system chunks (no coalescing)", () => {
- const chunks = run([notice("a"), notice("b")]);
- expect(chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- ]);
- });
-
- it("interleaved think → text → think → tool → think → text → 6 chunks in order", () => {
- const chunks = run([
- rd("planning..."),
- td("here goes:"),
- rd("hmm, actually"),
- tc("t1", "read_file"),
- rd("ok now"),
- td("and so..."),
- ]);
- expect(chunks.map((c) => c.type)).toEqual([
- "thinking",
- "text",
- "thinking",
- "tool-batch",
- "thinking",
- "text",
- ]);
- expect(chunks[0]).toEqual({ type: "thinking", text: "planning..." });
- expect(chunks[1]).toEqual({ type: "text", text: "here goes:" });
- expect(chunks[2]).toEqual({ type: "thinking", text: "hmm, actually" });
- expect(chunks[3]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- expect(chunks[4]).toEqual({ type: "thinking", text: "ok now" });
- expect(chunks[5]).toEqual({ type: "text", text: "and so..." });
- });
-});
-
-// ─── Additional transition coverage ──────────────────────────────
-
-describe("appendEventToChunks — transition matrix", () => {
- it("thinking → thinking coalesces", () => {
- const chunks = run([rd("a"), rd("b")]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab" }]);
- });
-
- it("thinking → text opens a new text chunk", () => {
- const chunks = run([rd("think"), td("speak")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "think" },
- { type: "text", text: "speak" },
- ]);
- });
-
- it("tool-batch → text opens a new text chunk", () => {
- const chunks = run([tc("t1"), td("after tool")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toEqual({ type: "text", text: "after tool" });
- });
-
- it("text → reasoning-delta after a multi-delta text run still splits cleanly", () => {
- const chunks = run([td("a"), td("b"), rd("x"), rd("y"), td("c")]);
- expect(chunks).toEqual([
- { type: "text", text: "ab" },
- { type: "thinking", text: "xy" },
- { type: "text", text: "c" },
- ]);
- });
-
- it("error → text opens a fresh text chunk after the error", () => {
- const chunks = run([err("boom"), td("recovered")]);
- expect(chunks).toEqual([
- { type: "error", message: "boom" },
- { type: "text", text: "recovered" },
- ]);
- });
-
- it("two consecutive errors stay as two error chunks (no coalescing)", () => {
- const chunks = run([err("first"), err("second", 429)]);
- expect(chunks).toEqual([
- { type: "error", message: "first" },
- { type: "error", message: "second", statusCode: 429 },
- ]);
- });
-
- it("system → tool-call opens a new tool-batch (does not extend the system chunk)", () => {
- const chunks = run([notice("info"), tc("t1")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- });
-
- it("tool-result with no matching call is silently dropped", () => {
- const chunks = run([td("hi"), tr("no-such-id", "ignored")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("shell-output with no tool-batch in scope is silently dropped", () => {
- const chunks = run([td("hi"), so("orphan")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("tool-result for an earlier batch still updates the right call (results can arrive late)", () => {
- // Order: tc -> td -> tc(new batch) -> tr(for first batch's id)
- const chunks = run([
- tc("t1", "read_file"),
- td("midstream text"),
- tc("t2", "list_files"),
- tr("t1", "late result for first"),
- ]);
- // Two tool-batches, separated by the text chunk. The result must land
- // inside the FIRST batch (the one containing t1), not the most-recent.
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- if (first?.type !== "tool-batch") throw new Error("type guard");
- expect(first.calls[0]).toMatchObject({ id: "t1", result: "late result for first" });
- const second = chunks[2];
- if (second?.type !== "tool-batch") throw new Error("type guard");
- // t2 in the second batch has no result yet.
- expect(second.calls[0]?.result).toBeUndefined();
- });
-
- it("shell-output goes to the most recent tool-batch's most recent entry, even with intervening chunks", () => {
- // First batch's tool runs, emits output, then later a second batch starts and emits output.
- const chunks = run([
- tc("t1", "run_shell"),
- so("first-stdout\n"),
- td("interlude"),
- tc("t2", "run_shell"),
- so("second-stdout\n"),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- const second = chunks[2];
- if (first?.type !== "tool-batch" || second?.type !== "tool-batch") {
- throw new Error("type guard");
- }
- expect(first.calls[0]?.shellOutput).toEqual({ stdout: "first-stdout\n", stderr: "" });
- expect(second.calls[0]?.shellOutput).toEqual({ stdout: "second-stdout\n", stderr: "" });
- });
-
- it("model-changed event opens a system chunk with kind=model-changed", () => {
- const chunks = run([modelChanged("anthropic-1", "claude-sonnet-4")]);
- expect(chunks).toEqual([
- {
- type: "system",
- kind: "model-changed",
- text: "Switched to claude-sonnet-4 (anthropic-1)",
- },
- ]);
- });
-
- it("config-reload event opens a system chunk with kind=config-reload", () => {
- const chunks = run([configReload]);
- expect(chunks).toEqual([
- { type: "system", kind: "config-reload", text: "Configuration reloaded" },
- ]);
- });
-
- // ─── reasoning-end (v6 SDK metadata round-trip) ──────────────────
-
- it("reasoning-delta then reasoning-end seals the thinking chunk with metadata", () => {
- const meta = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("plan"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "plan", metadata: meta }]);
- });
-
- it("two reasoning-deltas then reasoning-end coalesces text and seals once", () => {
- const meta = { anthropic: { signature: "abc" } };
- const chunks = run([rd("a"), rd("b"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab", metadata: meta }]);
- });
-
- it("reasoning-delta → reasoning-end → reasoning-delta opens a NEW chunk", () => {
- // Each Anthropic thinking content block gets its own metadata.
- // Extending a sealed chunk would corrupt the text/metadata mapping.
- const meta1 = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("first"), re(meta1), rd("second")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: meta1 },
- { type: "thinking", text: "second" },
- ]);
- });
-
- it("rd → re → rd → re produces two independently sealed chunks", () => {
- const m1 = { anthropic: { signature: "s1" } };
- const m2 = { anthropic: { signature: "s2" } };
- const chunks = run([rd("first"), re(m1), rd("second"), re(m2)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: m1 },
- { type: "thinking", text: "second", metadata: m2 },
- ]);
- });
-
- it("orphan reasoning-end (no prior thinking chunk) is a no-op", () => {
- const chunks = run([re({ anthropic: { signature: "orphan" } })]);
- expect(chunks).toEqual([]);
- });
-
- it("reasoning-end after an already-sealed thinking chunk does NOT overwrite", () => {
- const m1 = { anthropic: { signature: "first" } };
- const m2 = { anthropic: { signature: "second" } };
- const chunks = run([rd("a"), re(m1), re(m2)]);
- expect(chunks).toEqual([{ type: "thinking", text: "a", metadata: m1 }]);
- });
-
- it("reasoning-end without metadata is a silent no-op (does not seal)", () => {
- // v6 may emit reasoning-end with no providerMetadata for
- // non-Anthropic providers. Don't seal those chunks — a subsequent
- // reasoning-delta should continue extending.
- const chunks = run([rd("hello"), re(), rd(" world")]);
- expect(chunks).toEqual([{ type: "thinking", text: "hello world" }]);
- });
-
- it("re walks back across an intervening text chunk to seal the right thinking", () => {
- // Defensive: even if a non-thinking chunk lands between the
- // reasoning text and its end-event, the metadata still attaches
- // to the unsealed thinking chunk.
- const meta = { anthropic: { signature: "late" } };
- const chunks = run([rd("plan"), td("midstream"), re(meta)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "plan", metadata: meta },
- { type: "text", text: "midstream" },
- ]);
- });
-
- it("interleaved rd / re / tool-call / rd / re produces correct chunk sequence", () => {
- // Anthropic's interleaved-thinking emits a thinking block, then
- // a tool call, then another thinking block. Each thinking block
- // gets its own metadata.
- const m1 = { anthropic: { signature: "before-tool" } };
- const m2 = { anthropic: { signature: "after-tool" } };
- const chunks = run([
- rd("plan tool"),
- re(m1),
- tc("t1", "read_file"),
- rd("plan response"),
- re(m2),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["thinking", "tool-batch", "thinking"]);
- expect(chunks[0]).toEqual({
- type: "thinking",
- text: "plan tool",
- metadata: m1,
- });
- expect(chunks[2]).toEqual({
- type: "thinking",
- text: "plan response",
- metadata: m2,
- });
- });
-
- it("non-content events (status / done / task-list-update / message-queued etc.) are no-ops", () => {
- const chunks = run([
- td("hello"),
- { type: "status", status: "running" },
- { type: "task-list-update", tasks: [] },
- {
- type: "tab-created",
- id: "tab1",
- title: "x",
- keyId: null,
- modelId: null,
- parentTabId: null,
- workingDirectory: null,
- },
- { type: "message-queued", tabId: "t", messageId: "m", message: "queued" },
- { type: "message-consumed", tabId: "t", messageIds: ["m"] },
- { type: "message-cancelled", tabId: "t", messageId: "m" },
- {
- type: "done",
- message: { role: "assistant", chunks: [] },
- },
- td(" world"),
- ]);
- expect(chunks).toEqual([{ type: "text", text: "hello world" }]);
- });
-
- it("error chunk omits statusCode when not provided", () => {
- const chunks = run([err("boom")]);
- expect(chunks).toEqual([{ type: "error", message: "boom" }]);
- // And no stray statusCode key:
- expect(Object.hasOwn(chunks[0], "statusCode")).toBe(false);
- });
-
- it("tool-result updates isError=false correctly (default success path)", () => {
- const chunks = run([tc("t1"), tr("t1", "ok", false)]);
- const batch = chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ result: "ok", isError: false });
- });
-});
-
-// ─── applySystemEvent routing ────────────────────────────────────
-
-describe("applySystemEvent", () => {
- type Msg = { id: string; role: "user" | "assistant" | "system"; chunks: Chunk[] };
-
- let counter = 0;
- const idFactory = () => `gen-${++counter}`;
-
- it("creates a new role:system message when message list is empty", () => {
- counter = 0;
- const messages: Msg[] = [];
- const result = applySystemEvent(messages, { kind: "notice", text: "hello" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toEqual([
- {
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "hello" }],
- },
- ]);
- });
-
- it("creates a new role:system message when last message is user", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- const result = applySystemEvent(messages, { kind: "model-changed", text: "swap" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toHaveLength(2);
- expect(messages[1]).toMatchObject({
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "model-changed", text: "swap" }],
- });
- });
-
- it("creates a new role:system message when last message is assistant", () => {
- counter = 0;
- const messages: Msg[] = [
- { id: "a1", role: "assistant", chunks: [{ type: "text", text: "done" }] },
- ];
- applySystemEvent(messages, { kind: "config-reload", text: "reloaded" }, idFactory);
- expect(messages).toHaveLength(2);
- expect(messages[1]?.role).toBe("system");
- });
-
- it("appends a chunk to the existing system message when last message is role:system", () => {
- counter = 0;
- const messages: Msg[] = [
- {
- id: "s1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "first" }],
- },
- ];
- const result = applySystemEvent(messages, { kind: "notice", text: "second" }, idFactory);
- expect(result.messageId).toBe("s1");
- expect(messages).toHaveLength(1);
- expect(messages[0]?.chunks).toEqual([
- { type: "system", kind: "notice", text: "first" },
- { type: "system", kind: "notice", text: "second" },
- ]);
- });
-
- it("multiple consecutive calls accumulate in the same system message", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- applySystemEvent(messages, { kind: "model-changed", text: "c" }, idFactory);
- expect(messages).toHaveLength(2);
- const sys = messages[1];
- expect(sys?.role).toBe("system");
- expect(sys?.chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- { type: "system", kind: "model-changed", text: "c" },
- ]);
- });
-
- it("returns the same messageId across appends to the same system message", () => {
- counter = 0;
- const messages: Msg[] = [];
- const first = applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- const second = applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- expect(first.messageId).toBe(second.messageId);
- });
-
- it("works against the core ChatMessage shape (with id added by caller)", () => {
- // Sanity: ChatMessage has {role, chunks}; the caller layers id on top.
- // This test exists to prove the generic constraint doesn't reject the
- // real persistence/in-memory shape we'll see in Phase 5.
- counter = 0;
- const messages: Array<ChatMessage & { id: string }> = [];
- const result = applySystemEvent(messages, { kind: "cancelled", text: "user stop" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages[0]?.role).toBe("system");
- expect(messages[0]?.chunks[0]).toMatchObject({ kind: "cancelled", text: "user stop" });
- });
-});
diff --git a/packages/core/tests/compaction/compaction.test.ts b/packages/core/tests/compaction/compaction.test.ts
deleted file mode 100644
index d6edd59..0000000
--- a/packages/core/tests/compaction/compaction.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
-} from "../../src/compaction/index.js";
-import type { ChatMessage } from "../../src/types/index.js";
-
-function user(text: string): ChatMessage {
- return { role: "user", chunks: [{ type: "text", text }] };
-}
-function assistant(text: string): ChatMessage {
- return { role: "assistant", chunks: [{ type: "text", text }] };
-}
-
-describe("selectHeadTail", () => {
- it("returns empty head when turns <= tailTurns (nothing to compact)", () => {
- const msgs = [user("u1"), assistant("a1"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(head).toEqual([]);
- expect(tail).toEqual(msgs);
- });
-
- it("keeps the last N turns verbatim and summarizes the rest", () => {
- const msgs = [
- user("u1"),
- assistant("a1"),
- user("u2"),
- assistant("a2"),
- user("u3"),
- assistant("a3"),
- ];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(tail[0]).toBe(msgs[2]);
- expect(tail.at(-1)).toBe(msgs[5]);
- expect(head).toEqual([msgs[0], msgs[1]]);
- });
-
- it("a turn includes trailing assistant/tool messages up to the next user", () => {
- const msgs = [user("u1"), assistant("a1a"), assistant("a1b"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 1);
- expect(head).toEqual([msgs[0], msgs[1], msgs[2]]);
- expect(tail).toEqual([msgs[3], msgs[4]]);
- });
-
- it("tailTurns<=0 → everything is head", () => {
- const msgs = [user("u1"), assistant("a1")];
- expect(selectHeadTail(msgs, 0)).toEqual({ head: msgs, tail: [] });
- });
-
- it("defaults to DEFAULT_TAIL_TURNS", () => {
- const msgs = [user("u1"), user("u2"), user("u3")];
- const def = selectHeadTail(msgs);
- const explicit = selectHeadTail(msgs, DEFAULT_TAIL_TURNS);
- expect(def).toEqual(explicit);
- });
-});
-
-describe("buildCompactionPrompt", () => {
- it("creates a fresh-summary instruction without a previous summary", () => {
- const p = buildCompactionPrompt({});
- expect(p).toContain("Create a new anchored summary");
- expect(p).toContain(SUMMARY_TEMPLATE);
- expect(p).not.toContain("<previous-summary>");
- });
-
- it("anchors on a previous summary when provided", () => {
- const p = buildCompactionPrompt({ previousSummary: "## Goal\n- old" });
- expect(p).toContain("Update the anchored summary");
- expect(p).toContain("<previous-summary>");
- expect(p).toContain("## Goal\n- old");
- expect(p).toContain(SUMMARY_TEMPLATE);
- });
-});
-
-describe("extractPreviousSummary", () => {
- it("returns undefined when the first user message is not a seeded summary", () => {
- expect(extractPreviousSummary([user("hello"), assistant("hi")])).toBeUndefined();
- });
-
- it("extracts the body of a seeded summary turn (marker stripped)", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- build X"));
- expect(extractPreviousSummary([seeded, assistant("ok")])).toBe("## Goal\n- build X");
- });
-});
-
-describe("renderTranscript", () => {
- it("renders user/assistant text blocks", () => {
- const t = renderTranscript([user("hello"), assistant("hi there")]);
- expect(t).toContain("## User\nhello");
- expect(t).toContain("## Assistant\nhi there");
- });
-
- it("renders tool calls and caps long tool results", () => {
- const big = "x".repeat(5000);
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [{ id: "c1", name: "read_file", arguments: { path: "a" }, result: big }],
- },
- ],
- };
- const t = renderTranscript([msg], 2000);
- expect(t).toContain('[tool read_file {"path":"a"}]');
- expect(t).toContain("chars truncated for summary");
- expect(t.length).toBeLessThan(5000 + 200);
- });
-
- it("skips a seeded prior-summary user turn", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- prior"));
- const t = renderTranscript([seeded, assistant("work")]);
- expect(t).not.toContain("prior");
- expect(t).toContain("## Assistant\nwork");
- });
-
- it("omits thinking/error/system chunks", () => {
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "secret reasoning" },
- { type: "text", text: "visible" },
- { type: "error", message: "boom" },
- ],
- };
- const t = renderTranscript([msg]);
- expect(t).toContain("visible");
- expect(t).not.toContain("secret reasoning");
- expect(t).not.toContain("boom");
- });
-});
-
-describe("buildCompactionRequest", () => {
- it("returns no prompt when there is nothing to compact", () => {
- const msgs = [user("u1"), assistant("a1")];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeUndefined();
- expect(req.head).toEqual([]);
- expect(req.tail).toEqual(msgs);
- });
-
- it("builds a prompt with transcript + instruction and exposes head/tail", () => {
- const msgs = [
- user("first task"),
- assistant("did stuff"),
- user("second"),
- assistant("more"),
- user("third"),
- assistant("done"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeDefined();
- expect(req.prompt).toContain("first task");
- expect(req.prompt).toContain("Create a new anchored summary");
- expect(req.tail[0]).toBe(msgs[2]);
- expect(req.head[0]).toBe(msgs[0]);
- });
-
- it("anchors on a prior seeded summary", () => {
- const msgs = [
- user(buildSummaryTurnText("## Goal\n- old goal")),
- assistant("ack"),
- user("new work"),
- assistant("did new"),
- user("more work"),
- assistant("did more"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.previousSummary).toBe("## Goal\n- old goal");
- expect(req.prompt).toContain("Update the anchored summary");
- // head = [seeded-summary, "ack"]; seeded summary is skipped in the
- // transcript, so "ack" represents the summarized head. "new work" lives
- // in the preserved tail (last 2 turns), not the summary body.
- expect(req.prompt).toContain("ack");
- expect(
- req.tail.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "new work")),
- ).toBe(true);
- });
-});
-
-describe("buildSummaryTurnText", () => {
- it("prefixes the marker so a later compaction can anchor", () => {
- const seeded = buildSummaryTurnText("## Goal\n- x");
- expect(seeded.startsWith(SUMMARY_MARKER)).toBe(true);
- expect(extractPreviousSummary([user(seeded)])).toBe("## Goal\n- x");
- });
-});
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
deleted file mode 100644
index 0d84d0b..0000000
--- a/packages/core/tests/config/loader.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join, sep } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { configToRuleset, loadConfig } from "../../src/config/loader.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-test");
-
-// Point the global config at a path that does not exist so these tests are
-// hermetic — they must not pick up this machine's real
-// ~/.config/dispatch/dispatch.toml.
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml");
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeToml(content: string): void {
- writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8");
-}
-
-describe("loadConfig", () => {
- it("returns empty permissions when dispatch.toml is missing", () => {
- const config = loadConfig(TMP);
- expect(config.permissions).toEqual({});
- });
-
- it("parses simple string permissions", () => {
- writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- expect(config.permissions.edit).toBe("deny");
- });
-
- it("parses nested pattern permissions", () => {
- writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("ignores comment lines", () => {
- writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- });
-
- it("handles ~ expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["~/projects/*"]).toBe("allow");
- });
-
- it("handles $HOME expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["$HOME/docs/*"]).toBe("allow");
- });
-
- it("parses quoted keys", () => {
- writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["git commit *"]).toBe("allow");
- expect(bash["rm *"]).toBe("deny");
- });
-
- it("handles multiple permission groups", () => {
- writeToml(
- `[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`,
- );
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- const edit = config.permissions.edit as Record<string, string>;
- expect(edit["*"]).toBe("ask");
- expect(edit["src/**"]).toBe("allow");
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("preserves # inside quoted string keys", () => {
- writeToml(`[permissions.bash]\n"file#1" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["file#1"]).toBe("allow");
- });
-
- it("strips inline comments on table headers", () => {
- writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["*"]).toBe("allow");
- });
-
- it("expands ~ with platform path separator", () => {
- // Simulate a path using the OS separator
- const pattern = `~${sep}projects${sep}*`;
- writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read[pattern]).toBe("allow");
- });
-
- it("throws on TOML parse errors", () => {
- writeToml("this is not valid TOML [[[");
- expect(() => loadConfig(TMP)).toThrow();
- });
-});
-
-describe("configToRuleset — new validations", () => {
- it("falls back to ask and warns for invalid action in string value", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { read: "allw" } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
- warn.mockRestore();
- });
-
- it("falls back to ask and warns for invalid action in nested pattern", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
- warn.mockRestore();
- });
-
- it("expands ~ with backslash separator in patterns", () => {
- const home = homedir();
- // Force backslash path even on Linux to test the regex
- const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
- });
-});
-
-describe("configToRuleset", () => {
- it("produces a rule with pattern * for string value", () => {
- const rules = configToRuleset({ permissions: { read: "allow" } });
- expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
- });
-
- it("produces rules for each pattern in an object value", () => {
- const rules = configToRuleset({
- permissions: { bash: { "npm test": "allow", "*": "ask" } },
- });
- expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
- expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
- });
-
- it("expands ~ in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
- });
-
- it("expands $HOME in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
- expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
- });
-
- it("handles empty permissions", () => {
- const rules = configToRuleset({ permissions: {} });
- expect(rules).toEqual([]);
- });
-});
diff --git a/packages/core/tests/config/lsp-schema.test.ts b/packages/core/tests/config/lsp-schema.test.ts
deleted file mode 100644
index 2b71cc2..0000000
--- a/packages/core/tests/config/lsp-schema.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { validateConfig } from "../../src/config/schema.js";
-
-describe("config schema — [lsp] block", () => {
- it("parses a valid custom server entry", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: { "luau-lsp": { platform: { type: "roblox" } } },
- },
- },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeDefined();
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.command).toEqual(["luau-lsp", "lsp"]);
- expect(entry?.extensions).toEqual([".luau"]);
- expect(entry?.initialization).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- });
-
- it("preserves env and nested initialization verbatim", () => {
- const { config } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- env: { PATH: "/custom/bin" },
- initialization: {
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- },
- });
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.env).toEqual({ PATH: "/custom/bin" });
- expect(entry?.initialization).toEqual({
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- });
- });
-
- it("rejects a custom server missing command", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { broken: { extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- expect(config.lsp).toBeUndefined();
- });
-
- it("rejects a custom server missing extensions", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: ["x"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.extensions")).toBe(true);
- });
-
- it("rejects an empty command array", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: [], extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- });
-
- it("keeps a disabled entry without requiring command/extensions", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { "luau-lsp": { disabled: true } },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp?.["luau-lsp"]?.disabled).toBe(true);
- });
-
- it("skips a malformed entry but keeps valid siblings", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- good: { command: ["a"], extensions: [".luau"] },
- bad: { extensions: [".luau"] },
- },
- });
- expect(config.lsp?.good).toBeDefined();
- expect(config.lsp?.bad).toBeUndefined();
- expect(errors.length).toBeGreaterThan(0);
- });
-
- it("omits lsp entirely when not present", () => {
- const { config, errors } = validateConfig({ permissions: {} });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeUndefined();
- });
-
- it("flags a non-object lsp value", () => {
- const { errors } = validateConfig({ permissions: {}, lsp: "nope" });
- expect(errors.some((e) => e.path === "lsp")).toBe(true);
- });
-});
diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts
deleted file mode 100644
index b9e4bbb..0000000
--- a/packages/core/tests/config/merge.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "../../src/config/loader.js";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { DispatchConfig } from "../../src/types/index.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-merge-test");
-const LOCAL_DIR = join(TMP, "project");
-const GLOBAL_PATH = join(TMP, "global", "dispatch.toml");
-
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(LOCAL_DIR, { recursive: true });
- mkdirSync(join(TMP, "global"), { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH;
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeGlobal(content: string): void {
- writeFileSync(GLOBAL_PATH, content, "utf-8");
-}
-
-function writeLocal(content: string): void {
- writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8");
-}
-
-// ─── mergeConfigs (pure) ─────────────────────────────────────────
-
-describe("mergeConfigs — lsp by id", () => {
- it("keeps non-conflicting servers from both global and local", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]);
- });
-
- it("local overrides global for the same server id", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(merged.lsp?.biome.command).toEqual(["local-biome"]);
- expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]);
- });
-
- it("omits lsp entirely when neither side declares one", () => {
- const merged = mergeConfigs({ permissions: {} }, { permissions: {} });
- expect(merged.lsp).toBeUndefined();
- });
-
- it("does not mutate inputs", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["g"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["l"], extensions: [".ts"] } },
- };
- mergeConfigs(global, local);
- expect(global.lsp?.biome.command).toEqual(["g"]);
- expect(local.lsp?.biome.command).toEqual(["l"]);
- });
-});
-
-describe("mergeConfigs — keys by id", () => {
- it("merges keys by id, local overriding global", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "a", provider: "x", base_url: "g-a" },
- { id: "b", provider: "x", base_url: "g-b" },
- ],
- };
- const local: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "b", provider: "x", base_url: "l-b" },
- { id: "c", provider: "x", base_url: "l-c" },
- ],
- };
- const merged = mergeConfigs(global, local);
- const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url]));
- expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" });
- });
-
- it("carries global keys through when local has none", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [{ id: "a", provider: "x", base_url: "g-a" }],
- };
- const merged = mergeConfigs(global, { permissions: {} });
- expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]);
- });
-});
-
-describe("mergeConfigs — permissions", () => {
- it("merges nested groups pattern-by-pattern with local winning", () => {
- const global: DispatchConfig = {
- permissions: { bash: { "git status": "allow", "*": "ask" } },
- };
- const local: DispatchConfig = {
- permissions: { bash: { "*": "allow" } },
- };
- const merged = mergeConfigs(global, local);
- const bash = merged.permissions.bash as Record<string, string>;
- expect(bash["git status"]).toBe("allow");
- expect(bash["*"]).toBe("allow"); // local override
- });
-
- it("local string value replaces a global nested group", () => {
- const global: DispatchConfig = {
- permissions: { read: { "src/**": "allow" } },
- };
- const local: DispatchConfig = { permissions: { read: "deny" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("deny");
- });
-
- it("keeps global-only permission groups", () => {
- const global: DispatchConfig = { permissions: { read: "allow" } };
- const local: DispatchConfig = { permissions: { edit: "ask" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("allow");
- expect(merged.permissions.edit).toBe("ask");
- });
-
- it("local wins at evaluation time (findLast ordering)", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "*": "ask" } } },
- { permissions: { bash: { "*": "allow" } } },
- );
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
- });
-
- // Regression: a SPECIFIC local override must not be shadowed by a more
- // GENERAL global pattern (e.g. "*") that happened to be declared lower in
- // the global block. `evaluate` uses findLast, so every local pattern must be
- // emitted AFTER all global patterns of the same group.
- it("specific local override beats a general global wildcard regardless of declaration order", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "npm test": "allow", "*": "ask" } } },
- { permissions: { bash: { "npm test": "deny" } } },
- );
- // Local "npm test" must be emitted after global "*".
- expect(Object.keys(merged.permissions.bash as object)).toEqual(["*", "npm test"]);
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "npm test", ruleset).action).toBe("deny");
- // And the inherited global wildcard still applies to other commands.
- expect(evaluate("bash", "rm -rf /", ruleset).action).toBe("ask");
- });
-});
-
-// ─── loadConfig (filesystem integration) ─────────────────────────
-
-describe("loadConfig — global + local integration", () => {
- it("returns global config when local dispatch.toml is missing", () => {
- writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["biome"]);
- });
-
- it("returns local config when global is missing", () => {
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- expect(config.lsp).toBeUndefined();
- });
-
- it("merges global LSP servers with local ones (local wins on id)", () => {
- writeGlobal(
- `[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`,
- );
- writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["local-biome"]);
- expect(config.lsp?.luau.command).toEqual(["luau-lsp"]);
- });
-
- it("a malformed global config is ignored, local still loads", () => {
- writeGlobal("not valid toml [[[");
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- });
-});
-
-describe("loadGlobalConfig", () => {
- it("returns empty default when the global file is missing", () => {
- expect(loadGlobalConfig()).toEqual({ permissions: {} });
- });
-
- it("loads the file at getGlobalConfigPath()", () => {
- writeGlobal(`[permissions]\nedit = "deny"\n`);
- expect(getGlobalConfigPath()).toBe(GLOBAL_PATH);
- expect(loadGlobalConfig().permissions.edit).toBe("deny");
- });
-});
diff --git a/packages/core/tests/config/watcher.test.ts b/packages/core/tests/config/watcher.test.ts
deleted file mode 100644
index 9388c8a..0000000
--- a/packages/core/tests/config/watcher.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { watchDirConfig } from "../../src/config/watcher.js";
-
-const TMP = join("/tmp/opencode", "dispatch-watchdir-test");
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
-});
-
-function wait(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-describe("watchDirConfig", () => {
- it("fires onChange (debounced) when <dir>/dispatch.toml changes", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- // Let chokidar finish its initial scan before mutating the file.
- await wait(200);
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nread = "allow"\n`, "utf-8");
- // 300ms debounce + chokidar latency.
- await wait(700);
-
- handle.close();
- expect(calls).toBeGreaterThanOrEqual(1);
- });
-
- it("does not fire after close()", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- await wait(200);
- handle.close();
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nedit = "deny"\n`, "utf-8");
- await wait(700);
-
- expect(calls).toBe(0);
- });
-});
diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts
deleted file mode 100644
index a97a00c..0000000
--- a/packages/core/tests/credentials/wake-probe.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// `claude.ts` transitively imports `db/index.js`, whose top-level
-// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node
-// runtime. Stub the db module — `buildWakeProbeBody` never touches it.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-const { buildWakeProbeBody, selectHaikuModel } = await import("../../src/credentials/claude.js");
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-describe("buildWakeProbeBody", () => {
- it("targets the requested model with a tiny token budget", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.model).toBe("claude-3-5-haiku-20241022");
- expect(body.max_tokens).toBe(16);
- });
-
- it("emits a Claude-Code-shaped system[]: billing first, identity second", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.system).toHaveLength(2);
-
- // system[0] is the billing header line (no cache_control on a probe).
- expect(body.system[0]).toEqual({
- type: "text",
- text: expect.stringMatching(/^x-anthropic-billing-header: /),
- });
- expect(body.system[0]).not.toHaveProperty("cache_control");
-
- // system[1] is the VERBATIM Claude Code identity string. Anthropic
- // rejects OAuth (Pro/Max) requests whose system[] lacks this.
- expect(body.system[1]).toEqual({ type: "text", text: IDENTITY });
- });
-
- it("carries a single short user message", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.messages).toEqual([{ role: "user", content: "hi" }]);
- });
-
- it("is deterministic for a given model (pure)", () => {
- const a = buildWakeProbeBody("claude-3-5-haiku-20241022");
- const b = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(a).toEqual(b);
- });
-});
-describe("selectHaikuModel", () => {
- it("returns the id whose name contains 'haiku'", () => {
- const models = ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("matches case-insensitively", () => {
- expect(selectHaikuModel(["Claude-HAIKU-Latest"])).toBe("Claude-HAIKU-Latest");
- });
-
- it("returns the FIRST match when several models contain 'haiku'", () => {
- // `/v1/models` returns newest-first, so first-match prefers the newest.
- const models = ["claude-haiku-4-5-20251001", "claude-3-5-haiku-20241022"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("returns null when no model contains 'haiku'", () => {
- expect(selectHaikuModel(["claude-sonnet-4-20250514", "claude-opus-4-20250514"])).toBeNull();
- });
-
- it("returns null for an empty list", () => {
- expect(selectHaikuModel([])).toBeNull();
- });
-});
diff --git a/packages/core/tests/db/chunks.db.test.ts b/packages/core/tests/db/chunks.db.test.ts
deleted file mode 100644
index 4f7d517..0000000
--- a/packages/core/tests/db/chunks.db.test.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ChunkRowDraft, UsageData } from "../../src/types/index.js";
-
-/**
- * Internal row shape — matches the production `chunks` table columns.
- * Kept loose at the `query()` boundary to mirror bun:sqlite's dynamic
- * return type.
- */
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database implementing only the queries
- * `chunks.ts` actually issues. Same approach as `tabs.test.ts`: match exact
- * normalized query strings as fixed branches (no SQL parser), so a query-string
- * change fails loudly as "unsupported" instead of silently returning wrong data.
- *
- * This lets the DB-backed `getChunksForTab` / `getTotalChunkCount` /
- * `getUsageStatsForTab` logic run under vitest, where `bun:sqlite` can't load.
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
- private idCounter = 0;
-
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /** bun:sqlite's `db.transaction(fn)` returns a callable that runs `fn`. */
- transaction(fn: () => void): () => void {
- return () => {
- fn();
- };
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- // appendChunks: next-seq lookup (counts ALL rows, incl. usage)
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
-
- // getChunksForTab — no options (usage excluded)
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
-
- // getChunksForTab — before + limit (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit"
- ) {
- const before = params?.$before as number;
- const limit = params?.$limit as number;
- return visible
- .filter((r) => r.seq < before)
- .sort((a, b) => b.seq - a.seq)
- .slice(0, limit);
- }
-
- // getChunksForTab — before only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC"
- ) {
- const before = params?.$before as number;
- return visible.filter((r) => r.seq < before).sort((a, b) => b.seq - a.seq);
- }
-
- // getChunksForTab — limit only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit"
- ) {
- const limit = params?.$limit as number;
- return [...visible].sort((a, b) => b.seq - a.seq).slice(0, limit);
- }
-
- // getTotalChunkCount (usage excluded)
- if (norm === "SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") {
- return [{ count: visible.length }];
- }
-
- // getUsageStatsForTab: usage rows only, in seq order
- if (
- norm ===
- "SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC"
- ) {
- return forTab
- .filter((r) => r.type === "usage")
- .sort((a, b) => a.seq - b.seq)
- .map((r) => ({ data_json: r.data_json }));
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // appendChunks: single-row insert
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: (params?.$id as string) ?? `c${this.idCounter++}`,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-const { appendChunks, getChunksForTab, getTotalChunkCount, getUsageStatsForTab } = await import(
- "../../src/db/chunks.js"
-);
-
-function usageDraft(turnId: string, u: UsageData): ChunkRowDraft {
- return { turnId, step: 0, role: "assistant", type: "usage", data: u };
-}
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// usage chunk persistence + side-channel invariants
-// ---------------------------------------------------------------------------
-describe("usage chunk rows (DB-backed)", () => {
- const TAB = "tab-usage";
-
- it("persists usage rows alongside content rows with contiguous seqs", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // All three rows landed with contiguous seqs.
- expect(fakeDb.rows.map((r) => r.seq)).toEqual([0, 1, 2]);
- expect(fakeDb.rows.map((r) => r.type)).toEqual(["text", "text", "usage"]);
- });
-
- it("excludes usage rows from getChunksForTab (all variants)", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 200,
- outputTokens: 20,
- cacheReadTokens: 150,
- cacheWriteTokens: 0,
- }),
- ]);
-
- // no options
- const all = getChunksForTab(TAB);
- expect(all.every((r) => r.type !== "usage")).toBe(true);
- expect(all.map((r) => r.type)).toEqual(["text", "text"]);
-
- // limit only
- const limited = getChunksForTab(TAB, { limit: 10 });
- expect(limited.every((r) => r.type !== "usage")).toBe(true);
- expect(limited).toHaveLength(2);
-
- // before only — `before` is a seq cursor; usage seqs must never surface
- const before = getChunksForTab(TAB, { before: 100 });
- expect(before.every((r) => r.type !== "usage")).toBe(true);
- expect(before).toHaveLength(2);
-
- // before + limit
- const bl = getChunksForTab(TAB, { before: 100, limit: 10 });
- expect(bl.every((r) => r.type !== "usage")).toBe(true);
- expect(bl).toHaveLength(2);
- });
-
- it("excludes usage rows from getTotalChunkCount", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // 3 rows total, but only 2 visible.
- expect(getTotalChunkCount(TAB)).toBe(2);
- });
-});
-
-// ---------------------------------------------------------------------------
-// getUsageStatsForTab — backend aggregate
-// ---------------------------------------------------------------------------
-describe("getUsageStatsForTab", () => {
- const TAB = "tab-agg";
-
- it("returns null when the tab has no usage rows", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- ]);
- expect(getUsageStatsForTab(TAB)).toBeNull();
- });
-
- it("sums cumulative tokens, counts requests, and reports the last request's split", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- }),
- usageDraft("t1", {
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- }),
- ]);
-
- const stats = getUsageStatsForTab(TAB);
- expect(stats).not.toBeNull();
- expect(stats?.requests).toBe(2);
- expect(stats?.inputTokens).toBe(2200);
- expect(stats?.outputTokens).toBe(100);
- expect(stats?.cacheReadTokens).toBe(1000);
- expect(stats?.cacheWriteTokens).toBe(1000);
- // `last` = the most recent (highest-seq) usage row.
- expect(stats?.last).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("is structurally identical to the frontend CacheStats shape (seeds directly)", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 5,
- outputTokens: 1,
- cacheReadTokens: 2,
- cacheWriteTokens: 3,
- }),
- ]);
- const stats = getUsageStatsForTab(TAB);
- expect(Object.keys(stats ?? {}).sort()).toEqual(
- [
- "cacheReadTokens",
- "cacheWriteTokens",
- "inputTokens",
- "last",
- "outputTokens",
- "requests",
- ].sort(),
- );
- });
-
- it("is scoped per tab", () => {
- appendChunks("tab-a", [
- usageDraft("t1", {
- inputTokens: 10,
- outputTokens: 1,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- appendChunks("tab-b", [
- usageDraft("t2", {
- inputTokens: 20,
- outputTokens: 2,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- expect(getUsageStatsForTab("tab-a")?.inputTokens).toBe(10);
- expect(getUsageStatsForTab("tab-b")?.inputTokens).toBe(20);
- });
-});
diff --git a/packages/core/tests/db/chunks.test.ts b/packages/core/tests/db/chunks.test.ts
deleted file mode 100644
index fe54628..0000000
--- a/packages/core/tests/db/chunks.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { explodeTurn, explodeUserText, groupRowsToMessages } from "../../src/chunks/transform.js";
-import type { Chunk, ChunkRow, ChunkRowDraft } from "../../src/types/index.js";
-
-// These tests cover the pure explode/group transforms — the heart of the flat
-// chunk-log storage model. No DB is required.
-
-/** Promote drafts to rows with synthetic seq/id/createdAt (as appendChunks would). */
-function toRows(drafts: ChunkRowDraft[], tabId = "tab-1", startSeq = 0): ChunkRow[] {
- return drafts.map((d, i) => ({
- id: `c${i}`,
- tabId,
- seq: startSeq + i,
- turnId: d.turnId,
- step: d.step,
- role: d.role,
- type: d.type,
- data: d.data,
- createdAt: 1000 + i,
- }));
-}
-
-describe("explodeTurn", () => {
- it("splits a tool-batch into separate tool_call (assistant) and tool_result (tool) rows", () => {
- const chunks: Chunk[] = [
- { type: "thinking", text: "hmm", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "let me read" },
- {
- type: "tool-batch",
- calls: [
- { id: "a1", name: "read_file", arguments: { path: "x" }, result: "X", isError: false },
- { id: "a2", name: "read_file", arguments: { path: "y" }, result: "Y", isError: false },
- ],
- },
- ];
- const drafts = explodeTurn("turn-1", chunks);
-
- // thinking, text, tool_call×2 (assistant), tool_result×2 (tool)
- expect(drafts.map((d) => `${d.role}/${d.type}`)).toEqual([
- "assistant/thinking",
- "assistant/text",
- "assistant/tool_call",
- "assistant/tool_call",
- "tool/tool_result",
- "tool/tool_result",
- ]);
- // All in the same step (one round-trip).
- expect(drafts.every((d) => d.step === 0)).toBe(true);
- expect(drafts.every((d) => d.turnId === "turn-1")).toBe(true);
- });
-
- it("increments step after each tool-batch (multi-step turn)", () => {
- const chunks: Chunk[] = [
- { type: "text", text: "s0" },
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "s1" },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "final" },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- const byStep = (s: number) => drafts.filter((d) => d.step === s).map((d) => d.type);
- expect(byStep(0)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(1)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(2)).toEqual(["text"]); // trailing final-step text, no tool-batch
- });
-
- it("omits tool_result rows for calls without a result", () => {
- const chunks: Chunk[] = [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {} }] },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- expect(drafts.map((d) => d.type)).toEqual(["tool_call"]);
- });
-});
-
-describe("groupRowsToMessages (round-trip)", () => {
- it("reconstructs a user message then an assistant message with a per-step tool-batch", () => {
- const rows = [
- ...toRows(explodeUserText("turn-1", "hello"), "tab-1", 0),
- ...toRows(
- explodeTurn("turn-1", [
- { type: "text", text: "reading" },
- {
- type: "tool-batch",
- calls: [
- {
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- },
- ],
- },
- { type: "text", text: "done" },
- ]),
- "tab-1",
- 1,
- ),
- ];
-
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(msgs[0]?.chunks).toEqual([{ type: "text", text: "hello" }]);
-
- const a = msgs[1];
- if (!a) throw new Error("no assistant message");
- // reconstructed: text, tool-batch(step0), text(step1)
- expect(a.chunks.map((c) => c.type)).toEqual(["text", "tool-batch", "text"]);
- const batch = a.chunks.find((c) => c.type === "tool-batch");
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- });
- });
-
- it("keeps each step's tool calls in its own tool-batch chunk", () => {
- const rows = toRows(
- explodeTurn("turn-1", [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "ra" }] },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "rb" }] },
- ]),
- );
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batches = msgs[0]?.chunks.filter((c) => c.type === "tool-batch") ?? [];
- expect(batches).toHaveLength(2);
- });
-
- it("round-trips a multi-step assistant turn back to its original chunk shape", () => {
- const original: Chunk[] = [
- { type: "thinking", text: "plan", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "step0" },
- {
- type: "tool-batch",
- calls: [
- { id: "a", name: "read_file", arguments: { path: "p" }, result: "R", isError: false },
- ],
- },
- { type: "text", text: "final" },
- ];
- const rows = toRows(explodeTurn("turn-1", original));
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- expect(msgs[0]?.chunks).toEqual(original);
- });
-
- it("tolerates an orphan tool_result whose tool_call was paged out", () => {
- const rows = toRows([
- {
- turnId: "turn-1",
- step: 0,
- role: "tool",
- type: "tool_result",
- data: { callId: "z", name: "t", result: "R", isError: false },
- },
- ]);
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batch = msgs[0]?.chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({ id: "z", result: "R" });
- });
-
- it("breaks the assistant grouping on a user or system row", () => {
- const rows = [
- ...toRows(explodeUserText("t1", "q1"), "tab", 0),
- ...toRows(explodeTurn("t1", [{ type: "text", text: "a1" }]), "tab", 1),
- ...toRows(explodeUserText("t2", "q2"), "tab", 2),
- ...toRows(explodeTurn("t2", [{ type: "system", kind: "notice", text: "n" }]), "tab", 3),
- ];
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant", "user", "system"]);
- });
-});
diff --git a/packages/core/tests/db/rekey-chunks.db.test.ts b/packages/core/tests/db/rekey-chunks.db.test.ts
deleted file mode 100644
index 7cdafe3..0000000
--- a/packages/core/tests/db/rekey-chunks.db.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * Minimal in-memory fake of bun:sqlite supporting only the queries
- * `appendChunks`, `getChunksForTab`, and `rekeyChunks` issue. Mirrors the
- * approach in chunks.db.test.ts (exact normalized-string branches).
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
-
- query(sql: string) {
- return {
- all: (params?: Record<string, unknown>) => this.execSelect(sql, params),
- get: (params?: Record<string, unknown>) => this.execSelect(sql, params)[0] ?? null,
- run: (params?: Record<string, unknown>) => this.execMutation(sql, params),
- };
- }
-
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): { changes: number } {
- const norm = sql.replace(/\s+/g, " ").trim();
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: params?.$id as string,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return { changes: 1 };
- }
- if (norm === "UPDATE chunks SET tab_id = $to WHERE tab_id = $from") {
- const from = params?.$from as string;
- const to = params?.$to as string;
- let changes = 0;
- for (const r of this.rows) {
- if (r.tab_id === from) {
- r.tab_id = to;
- changes++;
- }
- }
- return { changes };
- }
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-vi.mock("../../src/db/index.js", () => ({ getDatabase: vi.fn(() => fakeDb) }));
-
-const { appendChunks, getChunksForTab, rekeyChunks } = await import("../../src/db/chunks.js");
-
-beforeEach(() => {
- fakeDb = new FakeDatabase();
-});
-
-describe("rekeyChunks", () => {
- it("relocates all rows from one tab to another and reports the count", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- ]);
- expect(getChunksForTab("src")).toHaveLength(2);
-
- const moved = rekeyChunks("src", "backup");
- expect(moved).toBe(2);
- expect(getChunksForTab("src")).toHaveLength(0);
- const dst = getChunksForTab("backup");
- expect(dst).toHaveLength(2);
- // turn id + seq preserved on the destination
- expect(dst.map((r) => r.turnId)).toEqual(["t1", "t1"]);
- expect(dst.map((r) => r.seq)).toEqual([0, 1]);
- });
-
- it("returns 0 when the source tab has no rows", () => {
- expect(rekeyChunks("nope", "backup")).toBe(0);
- });
-
- it("does not touch unrelated tabs", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "a" } },
- ]);
- appendChunks("other", [
- { turnId: "t9", step: 0, role: "user", type: "text", data: { text: "b" } },
- ]);
- rekeyChunks("src", "backup");
- expect(getChunksForTab("other")).toHaveLength(1);
- });
-});
diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts
deleted file mode 100644
index 2cd226b..0000000
--- a/packages/core/tests/db/tabs.test.ts
+++ /dev/null
@@ -1,418 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-/**
- * Internal row shape — matches the production `tabs` table columns.
- * Kept loose (`Record`) on the `query()` boundary to mirror bun:sqlite's
- * dynamic return type.
- */
-interface TabRow {
- id: string;
- title: string;
- key_id: string | null;
- model_id: string | null;
- parent_tab_id: string | null;
- status: string;
- is_open: number;
- position: number;
- created_at: number;
- updated_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database that implements only the
- * queries actually issued by `tabs.ts`. This sidesteps two problems
- * the original test had:
- * 1. Vite's resolver can't load `bun:sqlite` (it's a Bun-native
- * module with no on-disk file).
- * 2. Even under `bun --bun vitest`, `vi.mock` doesn't intercept
- * module imports because Bun's loader bypasses Vite's transforms.
- *
- * By implementing the exact query strings as fixed branches we avoid
- * writing an SQL parser; if `tabs.ts` ever changes a query string,
- * tests will fail loudly with "Unsupported query" instead of
- * silently returning wrong data.
- */
-class FakeDatabase {
- rows: TabRow[] = [];
-
- /** Match production's `db.query(sql).get|all|run(params)` shape. */
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /**
- * Match Bun's `db.transaction(fn)` shape: returns a callable that runs
- * `fn` synchronously. The fake is in-memory and single-threaded, so we
- * don't emulate rollback — callers just need the wrapper to be invocable.
- */
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // getDescendantIds: children-of query
- if (norm === "SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") {
- return this.rows
- .filter((r) => r.parent_tab_id === params?.$id && r.is_open === 1)
- .map((r) => ({ id: r.id }));
- }
-
- // getTab: single-row lookup
- if (norm === "SELECT * FROM tabs WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- return row ? [row] : [];
- }
-
- // createTab: next-position lookup
- if (norm === "SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") {
- const positions = this.rows.filter((r) => r.is_open === 1).map((r) => r.position);
- const maxPos = positions.length > 0 ? Math.max(...positions) : -1;
- return [{ max_pos: maxPos }];
- }
-
- // resolveTabPrefix: open tabs whose id starts with a sanitized prefix.
- // The production query binds `$prefix` as `<sanitized>%`; emulate SQLite
- // LIKE prefix semantics here (case-insensitive, `%` = "rest of string").
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") {
- const raw = String(params?.$prefix ?? "");
- const needle = raw.endsWith("%") ? raw.slice(0, -1) : raw;
- return this.rows
- .filter((r) => r.is_open === 1 && r.id.toLowerCase().startsWith(needle.toLowerCase()))
- .sort((a, b) => a.position - b.position);
- }
-
- // shortestUniquePrefix: all open tab ids.
- if (norm === "SELECT id FROM tabs WHERE is_open = 1") {
- return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id }));
- }
-
- // listOpenTabs: every open tab ordered by position.
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") {
- return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position);
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // createTab: full-row insert (every column named, $-bound params)
- if (
- norm ===
- "INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)"
- ) {
- const id = params?.$id as string;
- if (this.rows.some((r) => r.id === id)) {
- throw new Error(`UNIQUE constraint failed: tabs.id (${id})`);
- }
- this.rows.push({
- id,
- title: (params?.$title as string) ?? "",
- key_id: (params?.$keyId as string | null) ?? null,
- model_id: (params?.$modelId as string | null) ?? null,
- parent_tab_id: (params?.$parentTabId as string | null) ?? null,
- status: "idle",
- is_open: 1,
- position: (params?.$position as number) ?? 0,
- created_at: (params?.$now as number) ?? 0,
- updated_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- // archiveTab: flip is_open to 0
- if (norm === "UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.is_open = 0;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- // updateTabPositions: rewrite a single tab's position (run per id inside a txn)
- if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.position = (params?.$position as number) ?? row.position;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-/**
- * Shared instance referenced by both the test setup and the
- * `vi.mock` factory below. Declared with `let` (not `const`) so the
- * factory's closure picks up the value assigned in `beforeAll`.
- */
-let fakeDb: FakeDatabase;
-
-// Mock the db module before importing `tabs.ts` so that `getDatabase()`
-// returns our in-memory fake instead of trying to open a real SQLite
-// file. Mirrors the same pattern used by `tests/agent/agent.test.ts`.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-// Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to
-// the very top of the file, so by the time this line runs the mock is
-// active for `./index.js` resolution inside `tabs.ts`).
-const {
- archiveTab,
- createTab,
- getDescendantIds,
- getTab,
- listOpenTabs,
- resolveTabPrefix,
- shortestUniquePrefix,
- updateTabPositions,
-} = await import("../../src/db/tabs.js");
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// getDescendantIds
-// ---------------------------------------------------------------------------
-describe("getDescendantIds", () => {
- it("returns only the id when the tab has no children", () => {
- createTab("root", "Root");
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["root"]);
- });
-
- it("returns leaf-first order for a linear chain (root → child → grandchild)", () => {
- createTab("root", "Root");
- createTab("child", "Child", { parentTabId: "root" });
- createTab("grandchild", "Grandchild", { parentTabId: "child" });
-
- const ids = getDescendantIds("root");
- // Leaves first: grandchild, child, root
- expect(ids).toEqual(["grandchild", "child", "root"]);
- });
-
- it("returns leaf-first for a branching tree", () => {
- createTab("a", "A");
- createTab("b1", "B1", { parentTabId: "a" });
- createTab("b2", "B2", { parentTabId: "a" });
- createTab("c1", "C1", { parentTabId: "b1" });
- createTab("c2", "C2", { parentTabId: "b1" });
-
- const ids = getDescendantIds("a");
- // BFS: a, b1, b2, c1, c2 → reverse: c2, c1, b2, b1, a
- expect(ids).toEqual(["c2", "c1", "b2", "b1", "a"]);
- });
-
- it("skips archived descendants (is_open = 0)", () => {
- createTab("root", "Root");
- // Open child of root — should appear
- createTab("open-child", "Open", { parentTabId: "root" });
- // Archived child — should be skipped together with its descendants
- createTab("archived-child", "Archived", { parentTabId: "root" });
- archiveTab("archived-child");
- // Child of archived — data drift, should NOT appear (parent is archived)
- createTab("orphan", "Orphan", { parentTabId: "archived-child" });
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["open-child", "root"]);
- expect(ids).not.toContain("archived-child");
- expect(ids).not.toContain("orphan");
- });
-
- it("handles a non-existent id gracefully", () => {
- const ids = getDescendantIds("does-not-exist");
- expect(ids).toEqual(["does-not-exist"]);
- });
-
- it("defends against accidental parent_tab_id cycles", () => {
- // Insert x first with a forward reference to y (y doesn't exist
- // yet — the schema has no foreign key enforcement). Then insert
- // y with parent_tab_id = x. Result: x.parent = y, y.parent = x.
- createTab("x", "X", { parentTabId: "y" });
- createTab("y", "Y", { parentTabId: "x" });
-
- // Must terminate — no infinite loop
- const ids = getDescendantIds("x");
- expect(ids).toContain("x");
- expect(ids).toContain("y");
- expect(ids).toHaveLength(2);
- });
-
- it("uses createTab helper and asserts is_open flag", () => {
- createTab("a1", "A1");
- createTab("b1", "B1", { parentTabId: "a1" });
- createTab("c1", "C1", { parentTabId: "b1" });
-
- // All three should be open
- expect(getTab("a1")?.isOpen).toBe(true);
- expect(getTab("b1")?.isOpen).toBe(true);
- expect(getTab("c1")?.isOpen).toBe(true);
-
- // getDescendantIds sees all three
- const ids = getDescendantIds("a1");
- expect(ids).toEqual(["c1", "b1", "a1"]);
-
- // Archive the leaf, then it should disappear
- archiveTab("c1");
- expect(getTab("c1")?.isOpen).toBe(false);
-
- const ids2 = getDescendantIds("a1");
- expect(ids2).toEqual(["b1", "a1"]);
- });
-});
-
-// ---------------------------------------------------------------------------
-// resolveTabPrefix — git-style short-handle resolution
-// ---------------------------------------------------------------------------
-describe("resolveTabPrefix", () => {
- it("returns none when the prefix is shorter than the minimum length", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- // 3 chars < MIN_TAB_PREFIX_LENGTH (4)
- expect(resolveTabPrefix("abc").status).toBe("none");
- });
-
- it("returns none when no open tab matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- expect(resolveTabPrefix("ffff").status).toBe("none");
- });
-
- it("resolves a unique 4-char prefix to the single matching tab", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ok");
- if (res.status === "ok") {
- expect(res.tab.id).toBe("abcd1234-0000-4000-8000-000000000000");
- expect(res.tab.title).toBe("Alpha");
- }
- });
-
- it("resolves the full UUID (a maximal prefix)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("abcd1234-0000-4000-8000-000000000000");
- expect(res.status).toBe("ok");
- });
-
- it("reports ambiguity when multiple open tabs share the prefix", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ambiguous");
- if (res.status === "ambiguous") {
- expect(res.matches).toHaveLength(2);
- expect(res.matches.map((m) => m.title).sort()).toEqual(["One", "Two"]);
- }
- });
-
- it("disambiguates when one more character is supplied", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd1");
- expect(res.status).toBe("ok");
- if (res.status === "ok") expect(res.tab.title).toBe("One");
- });
-
- it("matches case-insensitively (UUIDs are lowercase; LIKE is ASCII-CI)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("ABCD");
- expect(res.status).toBe("ok");
- });
-
- it("sanitizes LIKE wildcards so they cannot broaden the match", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- // `%` would match everything if not stripped; after sanitization the
- // query is effectively `abcd%` which matches only Alpha.
- const res = resolveTabPrefix("ab%d");
- // "ab%d" -> sanitized "abd" (3 chars) -> below min length -> none.
- expect(res.status).toBe("none");
- });
-
- it("excludes archived (closed) tabs from matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- archiveTab("abcd1234-0000-4000-8000-000000000000");
- expect(resolveTabPrefix("abcd").status).toBe("none");
- });
-});
-
-// ---------------------------------------------------------------------------
-// shortestUniquePrefix — display-handle derivation
-// ---------------------------------------------------------------------------
-describe("shortestUniquePrefix", () => {
- it("returns a 4-char prefix when no other open tab collides", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- expect(shortestUniquePrefix("abcd1234-0000-4000-8000-000000000000")).toBe("abcd");
- });
-
- it("grows the prefix one char at a time on a collision", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- // First differing char is at index 4, so a 5-char prefix is unique.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd1");
- expect(shortestUniquePrefix("abcd2222-0000-4000-8000-000000000000")).toBe("abcd2");
- });
-
- it("ignores closed tabs when computing uniqueness", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- archiveTab("abcd2222-0000-4000-8000-000000000000");
- // With Two closed, One no longer collides → back to 4 chars.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd");
- });
-});
-
-// ---------------------------------------------------------------------------
-// updateTabPositions — drag-and-drop reorder persistence
-// ---------------------------------------------------------------------------
-describe("updateTabPositions", () => {
- it("rewrites each tab's position to its index in the given order", () => {
- createTab("a", "A"); // position 0
- createTab("b", "B"); // position 1
- createTab("c", "C"); // position 2
-
- updateTabPositions(["c", "a", "b"]);
-
- // listOpenTabs orders by position → reflects the new order.
- expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]);
- expect(getTab("c")?.position).toBe(0);
- expect(getTab("a")?.position).toBe(1);
- expect(getTab("b")?.position).toBe(2);
- });
-
- it("is a no-op for an empty list", () => {
- createTab("a", "A");
- createTab("b", "B");
- updateTabPositions([]);
- expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]);
- });
-
- it("ignores ids that don't exist without throwing", () => {
- createTab("a", "A");
- expect(() => updateTabPositions(["ghost", "a"])).not.toThrow();
- // "a" took index 1 in the requested order.
- expect(getTab("a")?.position).toBe(1);
- });
-});
diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js
deleted file mode 100644
index d771ebd..0000000
--- a/packages/core/tests/fixture/lsp/fake-lsp-server.js
+++ /dev/null
@@ -1,195 +0,0 @@
-// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP
-// client without a real language server binary. Ported from opencode's
-// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and
-// manager exercise: initialize, didOpen/didChange, push + pull diagnostics).
-//
-// Test hooks (custom JSON-RPC methods the test driver can call):
-// test/get-initialize-params → returns the params sent to `initialize`
-// test/get-last-change → returns the last `didChange` params
-// test/publish-diagnostics → forwards a `publishDiagnostics` push
-// test/configure-pull-diagnostics → sets up pull-diagnostic responses
-// test/get-diagnostic-request-count→ how many pull requests were received
-
-let nextId = 1;
-let readBuffer = Buffer.alloc(0);
-let lastChange = null;
-let initializeParams = null;
-let diagnosticRequestCount = 0;
-let registeredCapability = false;
-let pullConfig = {
- registerOn: undefined,
- registrations: [],
- documentDiagnostics: [],
- workspaceDiagnostics: [],
- hasDiagnosticProvider: false,
-};
-
-function encode(message) {
- const json = JSON.stringify(message);
- const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`;
- return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]);
-}
-
-function decodeFrames(buffer) {
- const results = [];
- while (true) {
- const idx = buffer.indexOf("\r\n\r\n");
- if (idx === -1) break;
- const header = buffer.slice(0, idx).toString("utf8");
- const match = /Content-Length:\s*(\d+)/i.exec(header);
- const length = match ? parseInt(match[1], 10) : 0;
- const bodyStart = idx + 4;
- const bodyEnd = bodyStart + length;
- if (buffer.length < bodyEnd) break;
- results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"));
- buffer = buffer.slice(bodyEnd);
- }
- return { messages: results, rest: buffer };
-}
-
-function send(message) {
- process.stdout.write(encode(message));
-}
-function sendRequest(method, params) {
- const id = nextId++;
- send({ jsonrpc: "2.0", id, method, params });
- return id;
-}
-function sendResponse(id, result) {
- send({ jsonrpc: "2.0", id, result });
-}
-function sendNotification(method, params) {
- send({ jsonrpc: "2.0", method, params });
-}
-
-function maybeRegister(method) {
- if (pullConfig.registerOn !== method || registeredCapability) return;
- registeredCapability = true;
- sendRequest("client/registerCapability", {
- registrations: pullConfig.registrations.map((registration, index) => ({
- id: registration.id ?? `pull-${index}`,
- method: registration.method ?? "textDocument/diagnostic",
- registerOptions: registration.registerOptions ?? registration,
- })),
- });
-}
-
-function handle(raw) {
- let data;
- try {
- data = JSON.parse(raw);
- } catch {
- return;
- }
-
- if (data.method === "initialize") {
- initializeParams = data.params;
- sendResponse(data.id, {
- capabilities: {
- textDocumentSync: { change: 2, openClose: true },
- ...(pullConfig.hasDiagnosticProvider
- ? {
- diagnosticProvider: {
- identifier: "fake",
- interFileDependencies: false,
- workspaceDiagnostics: false,
- },
- }
- : {}),
- },
- });
- return;
- }
-
- if (data.method === "test/get-initialize-params") {
- sendResponse(data.id, initializeParams);
- return;
- }
-
- if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
- return;
- }
-
- if (data.method === "textDocument/didOpen") {
- maybeRegister("didOpen");
- return;
- }
-
- if (data.method === "textDocument/didChange") {
- lastChange = data.params;
- maybeRegister("didChange");
- return;
- }
-
- if (data.method === "workspace/didChangeWatchedFiles") {
- return;
- }
-
- if (data.method === "test/configure-pull-diagnostics") {
- pullConfig = {
- registerOn: data.params?.registerOn,
- registrations: data.params?.registrations ?? [],
- documentDiagnostics: data.params?.documentDiagnostics ?? [],
- workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
- hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false,
- };
- registeredCapability = false;
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/publish-diagnostics") {
- sendNotification("textDocument/publishDiagnostics", data.params);
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/get-last-change") {
- sendResponse(data.id, lastChange);
- return;
- }
-
- if (data.method === "test/get-diagnostic-request-count") {
- sendResponse(data.id, diagnosticRequestCount);
- return;
- }
-
- if (data.method === "textDocument/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics });
- return;
- }
-
- if (data.method === "workspace/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { items: pullConfig.workspaceDiagnostics });
- return;
- }
-
- if (data.method === "textDocument/hover") {
- sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } });
- return;
- }
-
- if (data.method === "textDocument/definition") {
- sendResponse(data.id, [
- {
- uri: data.params?.textDocument?.uri,
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- },
- ]);
- return;
- }
-
- // Default: respond null to any other request so the client never hangs.
- if (typeof data.id !== "undefined") {
- sendResponse(data.id, null);
- }
-}
-
-process.stdin.on("data", (chunk) => {
- readBuffer = Buffer.concat([readBuffer, chunk]);
- const { messages, rest } = decodeFrames(readBuffer);
- readBuffer = rest;
- for (const message of messages) handle(message);
-});
diff --git a/packages/core/tests/llm/anthropic-oauth-transform.test.ts b/packages/core/tests/llm/anthropic-oauth-transform.test.ts
deleted file mode 100644
index a8bb156..0000000
--- a/packages/core/tests/llm/anthropic-oauth-transform.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { transformClaudeOAuthBody } from "../../src/llm/anthropic-oauth-transform.js";
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.abc; cc_entrypoint=sdk-cli; cch=12345;";
-
-interface WireBody {
- system?: Array<{ type: string; text: string; cache_control?: unknown }>;
- messages?: Array<{ role: string; content: unknown }>;
-}
-
-/**
- * Build the body shape Dispatch produces: ONE system block holding
- * `<billing>\n<identity>\n\n<systemPrompt>`, marked with cache_control by
- * `applyAnthropicCaching`.
- */
-function dispatchBody(systemPrompt: string, firstUser = "hello"): string {
- return JSON.stringify({
- model: "claude-opus-4-8",
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\n${systemPrompt}`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: firstUser }],
- });
-}
-
-function parse(out: BodyInit | null | undefined): WireBody {
- return JSON.parse(out as string) as WireBody;
-}
-
-describe("transformClaudeOAuthBody", () => {
- it("isolates the billing header as system[0] WITHOUT cache_control", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[0]?.cache_control).toBeUndefined();
- });
-
- it("keeps the identity as a separate system block and carries the cache_control there", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[1]?.cache_control).toEqual({ type: "ephemeral" });
- // Only billing + identity remain in system[] — the third-party prompt was moved out.
- expect(sys).toHaveLength(2);
- });
-
- it("relocates the third-party system prompt into the first user message", () => {
- const result = parse(
- transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.", "do the thing")),
- );
- const sys = result.system ?? [];
- // The system prompt must NOT appear anywhere in system[].
- expect(sys.some((b) => b.text.includes("You are Dispatch"))).toBe(false);
- // It is prepended to the first user message.
- expect(result.messages?.[0]?.content).toBe("You are Dispatch. Use tools.\n\ndo the thing");
- });
-
- it("prepends a text block when the first user message uses array content", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch instructions here.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: [{ type: "text", text: "user text" }] }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const content = result.messages?.[0]?.content as Array<{ type: string; text: string }>;
- expect(content[0]).toEqual({ type: "text", text: "Dispatch instructions here." });
- expect(content[1]).toEqual({ type: "text", text: "user text" });
- });
-
- it("does not carry cache_control to the identity when the source had none", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: `${BILLING}\n${IDENTITY}\n\nInstr.` }],
- messages: [{ role: "user", content: "hi" }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- expect(result.system?.[1]?.cache_control).toBeUndefined();
- });
-
- it("keeps the prompt as a cached system block when there is no user message", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nInstr only.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[2]?.text).toBe("Instr only.");
- expect(sys[2]?.cache_control).toEqual({ type: "ephemeral" });
- });
-
- it("leaves non-Claude-Code bodies (no identity string) untouched", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: "Some unrelated system prompt." }],
- messages: [{ role: "user", content: "hi" }],
- });
- // Returned unchanged (same reference string, byte-identical).
- expect(transformClaudeOAuthBody(body)).toBe(body);
- });
-
- it("returns non-string bodies unchanged", () => {
- const buf = new Uint8Array([1, 2, 3]);
- expect(transformClaudeOAuthBody(buf)).toBe(buf);
- expect(transformClaudeOAuthBody(undefined)).toBeUndefined();
- expect(transformClaudeOAuthBody(null)).toBeNull();
- });
-
- it("returns invalid JSON unchanged", () => {
- const garbage = "{not json";
- expect(transformClaudeOAuthBody(garbage)).toBe(garbage);
- });
-
- it("never emits more than the 4 cache_control breakpoints Anthropic allows", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("Big system prompt.")));
- const all = result.system ?? [];
- const cacheBlocks = all.filter((b) => b.cache_control != null);
- // Only the identity block is marked here — well under the limit of 4.
- expect(cacheBlocks.length).toBeLessThanOrEqual(4);
- });
-});
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
deleted file mode 100644
index c8c0877..0000000
--- a/packages/core/tests/llm/provider.test.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// Mock @ai-sdk/anthropic to capture what options createAnthropic is called with
-const mockAnthropicInstance = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "anthropic.messages",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateAnthropic = vi.fn(() => mockAnthropicInstance);
-vi.mock("@ai-sdk/anthropic", () => ({
- createAnthropic: mockCreateAnthropic,
-}));
-
-// Mock @ai-sdk/openai-compatible to capture what options createOpenAICompatible
-// is called with, and what model id the returned factory is called with.
-const mockOpenAICompatibleFactory = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "openai-compatible",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateOpenAICompatible = vi.fn(() => mockOpenAICompatibleFactory);
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: mockCreateOpenAICompatible,
-}));
-
-const { createProvider } = await import("../../src/llm/provider.js");
-
-describe("createProvider (default OpenAI-compatible path)", () => {
- it("does not wrap the model in a middleware layer — v6 SDK handles reasoning round-trip natively", () => {
- mockCreateOpenAICompatible.mockClear();
- mockOpenAICompatibleFactory.mockClear();
-
- const model = createProvider({
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- })("deepseek-v4-pro");
-
- // The factory should have been invoked with the model id directly,
- // without going through `wrapLanguageModel`. If a middleware were
- // still in place, the returned object would carry an `_middleware`
- // property (set by our test mock pattern). The bare provider model
- // has no such property — verifying the v4-era normalizeMessages
- // middleware is gone.
- expect(mockOpenAICompatibleFactory).toHaveBeenCalledWith("deepseek-v4-pro");
- expect((model as { _middleware?: unknown })._middleware).toBeUndefined();
- });
-
- it("passes name, apiKey, baseURL to createOpenAICompatible", () => {
- mockCreateOpenAICompatible.mockClear();
-
- createProvider({
- apiKey: "zen-key",
- baseURL: "https://opencode.ai/zen/v1",
- })("deepseek-v4-pro");
-
- // We assert by property rather than full-object equality because the
- // provider also passes a `fetch:` wrapper (the debug-logger tee). The
- // load-bearing wiring is name/apiKey/baseURL; the fetch field is
- // tested separately via the wrap-fetch tests.
- expect(mockCreateOpenAICompatible).toHaveBeenCalledOnce();
- const zenArgs = mockCreateOpenAICompatible.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(zenArgs.name).toBe("opencode-zen");
- expect(zenArgs.apiKey).toBe("zen-key");
- expect(zenArgs.baseURL).toBe("https://opencode.ai/zen/v1");
- expect(typeof zenArgs.fetch).toBe("function");
- });
-});
-
-describe("createClaudeOAuthProvider", () => {
- it("passes authToken (not apiKey) to createAnthropic for OAuth flow", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "fallback-api-key",
- baseURL: "",
- claudeCredentials: { accessToken: "oauth-access-token" },
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("oauth-access-token");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("falls back to apiKey as authToken when claudeCredentials are absent", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "sk-ant-api-key",
- baseURL: "",
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("sk-ant-api-key");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("includes required Claude CLI headers", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- expect(callArgs.headers?.["anthropic-dangerous-direct-browser-access"]).toBe("true");
- expect(callArgs.headers?.["x-app"]).toBe("cli");
- expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/);
- });
-
- it("installs a fetch wrapper that restructures the body and stamps Claude Code session headers", async () => {
- mockCreateAnthropic.mockClear();
-
- // Capture what global fetch receives after the wrapper runs.
- const globalFetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
- const prevFetch = globalThis.fetch;
- globalThis.fetch = globalFetchMock as unknown as typeof fetch;
- try {
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-8");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as { fetch?: typeof fetch };
- expect(typeof callArgs.fetch).toBe("function");
-
- const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
- const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.x; cc_entrypoint=sdk-cli; cch=abcde;";
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch system prompt.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: "hi there" }],
- });
-
- await callArgs.fetch?.("https://api.anthropic.com/v1/messages", {
- method: "POST",
- body,
- headers: { "content-type": "application/json" },
- });
-
- expect(globalFetchMock).toHaveBeenCalledOnce();
- const [, init] = globalFetchMock.mock.calls[0] as unknown as [unknown, RequestInit];
-
- // Body was restructured: billing isolated, third-party prompt moved to user msg.
- const sent = JSON.parse(init.body as string) as {
- system: Array<{ text: string; cache_control?: unknown }>;
- messages: Array<{ content: string }>;
- };
- expect(sent.system).toHaveLength(2);
- expect(sent.system[0]?.text).toBe(BILLING);
- expect(sent.system[0]?.cache_control).toBeUndefined();
- expect(sent.system[1]?.text).toBe(IDENTITY);
- expect(sent.messages[0]?.content).toBe("Dispatch system prompt.\n\nhi there");
-
- // Claude Code session headers were stamped.
- const headers = new Headers(init.headers);
- expect(headers.get("X-Claude-Code-Session-Id")).toBeTruthy();
- expect(headers.get("x-client-request-id")).toBeTruthy();
- } finally {
- globalThis.fetch = prevFetch;
- }
- });
-
- it("sends the anthropic-beta header so prompt-caching is honored (notes/claude-report.md Root Cause 1)", () => {
- // Without `anthropic-beta: ...,prompt-caching-scope-2026-01-05,...` the
- // Anthropic API silently ignores every `cache_control` marker we attach
- // to messages, producing a 0% cache hit rate. `@ai-sdk/anthropic` does
- // NOT inject this beta on its own — it only derives betas from tool
- // definitions — so the OAuth provider MUST set it on its config headers.
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- const betaHeader = callArgs.headers?.["anthropic-beta"];
- expect(betaHeader).toBeDefined();
- const betas = (betaHeader ?? "").split(",").map((b) => b.trim());
- // The load-bearing caching + oauth betas must be present.
- expect(betas).toContain("prompt-caching-scope-2026-01-05");
- expect(betas).toContain("oauth-2025-04-20");
- });
-
- it("uses default Anthropic baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://api.anthropic.com/v1");
- });
-
- it("uses configured baseURL when provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "https://custom.proxy.example.com/v1",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://custom.proxy.example.com/v1");
- });
-});
-
-describe("createApiKeyAnthropicProvider", () => {
- it("passes apiKey (not authToken) to createAnthropic", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.apiKey).toBe("zen-api-key");
- expect(callArgs.authToken).toBeUndefined();
- });
-
- it("uses default OpenCode Zen baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://opencode.ai/zen/go/v1");
- });
-});
diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts
deleted file mode 100644
index 8daf8ab..0000000
--- a/packages/core/tests/lsp/client.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function spawnFakeServer(): LspServerHandle {
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as LspServerHandle["process"] };
-}
-
-const ERROR_DIAG: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
- severity: 1,
- message: "fake type error",
- source: "Fake",
-};
-
-describe("lsp/client (fake server)", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-"));
- });
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("completes the initialize handshake and forwards initializationOptions", async () => {
- const handle = spawnFakeServer();
- handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } };
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>(
- "test/get-initialize-params",
- {},
- );
- expect(params.initializationOptions).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- await client.shutdown();
- });
-
- it("opens a file and receives push diagnostics", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- const version = await client.notifyOpen(file);
- expect(version).toBe(0);
-
- // Drive a push from the fake server, then assert it lands in the map.
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [ERROR_DIAG],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error");
- await client.shutdown();
- });
-
- it("bumps the document version on re-open (didChange)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- expect(await client.notifyOpen(file)).toBe(0);
- await writeFile(file, "local x = 2\n");
- expect(await client.notifyOpen(file)).toBe(1);
-
- const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>(
- "test/get-last-change",
- {},
- );
- expect(lastChange?.textDocument?.version).toBe(1);
- await client.shutdown();
- });
-
- it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- // Tell the fake server (before initialize? no — it persists) to answer
- // pull requests. We configure AFTER connect; the static provider flag is
- // read at initialize, so this test exercises the dynamic registration
- // path instead.
- await client.connection.sendRequest("test/configure-pull-diagnostics", {
- registerOn: "didOpen",
- registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }],
- documentDiagnostics: [ERROR_DIAG],
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "bad\n");
- const version = await client.notifyOpen(file);
- await client.waitForDiagnostics({ path: file, version, mode: "document" });
-
- expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true);
- await client.shutdown();
- });
-
- it("request() passes through to the server (hover)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- await client.notifyOpen(file);
- const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- });
- expect(hover?.contents?.value).toBe("fake hover");
- await client.shutdown();
- });
-});
diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts
deleted file mode 100644
index 93ffde9..0000000
--- a/packages/core/tests/lsp/diagnostic.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { pretty, report } from "../../src/lsp/diagnostic.js";
-
-function diag(partial: Partial<Diagnostic> & { message: string }): Diagnostic {
- return {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- severity: 1,
- ...partial,
- };
-}
-
-describe("lsp/diagnostic", () => {
- describe("pretty", () => {
- it("renders 1-based line/col with severity label", () => {
- const out = pretty(
- diag({
- message: "Expected number",
- range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } },
- }),
- );
- expect(out).toBe("ERROR [5:3] Expected number");
- });
-
- it("maps severities to labels", () => {
- expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /);
- expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /);
- expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /);
- });
-
- it("defaults missing severity to ERROR", () => {
- expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /);
- });
- });
-
- describe("report", () => {
- it("returns empty string when there are no errors", () => {
- expect(report("a.luau", [])).toBe("");
- // Warnings only → still empty (errors-only).
- expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe("");
- });
-
- it("wraps errors in a <diagnostics file> block", () => {
- const out = report("src/a.luau", [diag({ message: "boom" })]);
- expect(out).toContain('<diagnostics file="src/a.luau">');
- expect(out).toContain("ERROR [1:1] boom");
- expect(out).toContain("</diagnostics>");
- });
-
- it("filters out non-error severities", () => {
- const out = report("a.luau", [
- diag({ message: "err" }),
- diag({ message: "warn", severity: 2 }),
- ]);
- expect(out).toContain("err");
- expect(out).not.toContain("warn");
- });
-
- it("caps at 20 and notes the remainder", () => {
- const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` }));
- const out = report("a.luau", issues);
- expect(out).toContain("... and 5 more");
- expect(out).toContain("e0");
- expect(out).not.toContain("e24");
- });
- });
-});
diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
deleted file mode 100644
index 381435b..0000000
--- a/packages/core/tests/lsp/luau-lsp.smoke.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { execSync } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { LspManager } from "../../src/lsp/manager.js";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-/**
- * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically
- * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's
- * platform-guarded launch test. When the binary IS present, it proves the
- * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics.
- */
-function hasLuauLsp(): boolean {
- try {
- execSync("luau-lsp --version", { stdio: "ignore" });
- return true;
- } catch {
- return false;
- }
-}
-
-const RUN = hasLuauLsp();
-
-describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("reports a real type error for a bad .luau file", async () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: {
- "luau-lsp": {
- platform: { type: "roblox" },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- });
-
- const file = join(root, "bad.luau");
- await writeFile(file, 'local x: number = "not a number"\nprint(x)\n');
-
- await manager.touchFile({ file, root, servers, mode: "document" });
- const diagnostics = manager.getDiagnostics({ root, servers, file });
- const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n");
-
- expect(messages.length).toBeGreaterThan(0);
- expect(messages.toLowerCase()).toContain("number");
- }, 60_000);
-});
diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts
deleted file mode 100644
index e720413..0000000
--- a/packages/core/tests/lsp/manager.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function makeServer(id: string, extensions: string[]) {
- const counter = { count: 0 };
- const server: ResolvedLspServer = {
- id,
- extensions,
- spawn() {
- counter.count += 1;
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as never };
- },
- };
- return { server, counter };
-}
-
-describe("lsp/manager (fake server)", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("hasServerForFile matches by extension", () => {
- const { server } = makeServer("fake", [".luau"]);
- expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true);
- expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false);
- });
-
- it("spawns lazily and reuses the client across calls", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
-
- const c1 = await manager.getClients({ file, root, servers: [server] });
- const c2 = await manager.getClients({ file, root, servers: [server] });
- expect(c1).toHaveLength(1);
- expect(c2).toHaveLength(1);
- expect(c1[0]).toBe(c2[0]);
- expect(counter.count).toBe(1);
- });
-
- it("does not spawn for a non-matching extension", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.ts");
- await writeFile(file, "const x = 1\n");
- const clients = await manager.getClients({ file, root, servers: [server] });
- expect(clients).toHaveLength(0);
- expect(counter.count).toBe(0);
- });
-
- it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "bad code\n");
-
- await manager.touchFile({ file, root, servers: [server] });
- const [client] = await manager.getClients({ file, root, servers: [server] });
- // Drive a push through the fake server.
- const diag: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
- severity: 1,
- message: "manager error",
- };
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [diag],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- const result = manager.getDiagnostics({ root, servers: [server], file });
- expect(result[file]?.[0]?.message).toBe("manager error");
- });
-
- it("request() forwards to clients and flattens results", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.touchFile({ file, root, servers: [server] });
-
- const results = await manager.request({
- file,
- root,
- servers: [server],
- method: "textDocument/definition",
- params: {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- },
- });
- expect(results.length).toBeGreaterThan(0);
- });
-
- it("shutdownAll clears state so the next call respawns", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(1);
- await manager.shutdownAll();
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(2);
- });
-});
diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts
deleted file mode 100644
index bdaf83d..0000000
--- a/packages/core/tests/lsp/server.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-describe("lsp/server resolveServersFromConfig", () => {
- it("returns [] for undefined config", () => {
- expect(resolveServersFromConfig(undefined)).toEqual([]);
- });
-
- it("resolves a server entry with id + extensions", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] },
- });
- expect(servers).toHaveLength(1);
- expect(servers[0]?.id).toBe("luau-lsp");
- expect(servers[0]?.extensions).toEqual([".luau"]);
- expect(typeof servers[0]?.spawn).toBe("function");
- });
-
- it("skips disabled entries", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true },
- });
- expect(servers).toEqual([]);
- });
-
- it("skips entries with empty command or extensions", () => {
- const servers = resolveServersFromConfig({
- noCommand: { command: [], extensions: [".luau"] },
- noExt: { command: ["x"], extensions: [] },
- });
- expect(servers).toEqual([]);
- });
-
- it("resolves multiple servers", () => {
- const servers = resolveServersFromConfig({
- a: { command: ["a"], extensions: [".luau"] },
- b: { command: ["b"], extensions: [".lua"] },
- });
- expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]);
- });
-});
diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts
deleted file mode 100644
index 11a9f82..0000000
--- a/packages/core/tests/models/attachments.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- base64ByteLength,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "../../src/models/attachments.js";
-import type { UserContentPart } from "../../src/types/index.js";
-
-/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */
-function base64OfBytes(bytes: number): string {
- // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output.
- const groups = Math.ceil(bytes / 3);
- return "A".repeat(groups * 4);
-}
-
-function imagePart(data: string, mediaType = "image/png"): UserContentPart {
- return { type: "attachment", mediaType, data };
-}
-
-describe("media-type predicates", () => {
- it("classifies image types", () => {
- expect(isImageMediaType("image/png")).toBe(true);
- expect(isImageMediaType("image/jpeg")).toBe(true);
- expect(isImageMediaType("image/webp")).toBe(true);
- expect(isImageMediaType("image/gif")).toBe(true);
- expect(isImageMediaType("application/pdf")).toBe(false);
- expect(isImageMediaType("image/svg+xml")).toBe(false);
- });
-
- it("classifies pdf + accepted types", () => {
- expect(isPdfMediaType("application/pdf")).toBe(true);
- expect(isPdfMediaType("image/png")).toBe(false);
- expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true);
- expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true);
- expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false);
- });
-});
-
-describe("base64ByteLength", () => {
- it("computes decoded length without padding", () => {
- // "AAAA" → 3 bytes.
- expect(base64ByteLength("AAAA")).toBe(3);
- });
-
- it("accounts for padding", () => {
- // "QQ==" → 1 byte ("A").
- expect(base64ByteLength("QQ==")).toBe(1);
- // "QUI=" → 2 bytes ("AB").
- expect(base64ByteLength("QUI=")).toBe(2);
- });
-
- it("tolerates a data: URI prefix and whitespace", () => {
- expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3);
- expect(base64ByteLength("AA\nAA")).toBe(3);
- });
-
- it("returns 0 for empty input", () => {
- expect(base64ByteLength("")).toBe(0);
- expect(base64ByteLength(" ")).toBe(0);
- });
-});
-
-describe("validateUserContent", () => {
- it("accepts a small image and ignores text parts", () => {
- const content: UserContentPart[] = [
- { type: "text", text: "hi" },
- imagePart(base64OfBytes(1024)),
- ];
- expect(validateUserContent(content)).toEqual({ ok: true, errors: [] });
- });
-
- it("accepts an empty / text-only content list", () => {
- expect(validateUserContent([]).ok).toBe(true);
- expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true);
- });
-
- it("rejects an unsupported media type", () => {
- const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]);
- expect(res.ok).toBe(false);
- expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" });
- });
-
- it("rejects an oversized image but allows a PDF of the same size", () => {
- const big = base64OfBytes(MAX_IMAGE_BYTES + 3);
- const imgRes = validateUserContent([imagePart(big, "image/png")]);
- expect(imgRes.ok).toBe(false);
- expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true);
-
- // Same byte size as a PDF is fine (PDF limit is much higher).
- const pdfRes = validateUserContent([imagePart(big, "application/pdf")]);
- expect(pdfRes.ok).toBe(true);
- });
-
- it("rejects an oversized PDF", () => {
- const res = validateUserContent([
- imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"),
- ]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true);
- });
-
- it("rejects an empty attachment payload", () => {
- const res = validateUserContent([imagePart("", "image/png")]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "empty")).toBe(true);
- });
-
- it("rejects too many attachments", () => {
- const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () =>
- imagePart(base64OfBytes(8)),
- );
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "too-many")).toBe(true);
- });
-
- it("rejects when the total payload exceeds the request ceiling", () => {
- // Several individually-legal PDFs that together exceed the total cap.
- const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3);
- const content: UserContentPart[] = [
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- ];
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true);
- });
-});
diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts
deleted file mode 100644
index f4bddc2..0000000
--- a/packages/core/tests/models/catalog.test.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { existsSync, rmSync, utimesSync, writeFileSync } from "node:fs";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
- __resetCatalogCacheForTests,
- getModelsCatalog,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "../../src/models/catalog.js";
-
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-// A trimmed models.dev-shaped catalog covering the providers we support.
-const CATALOG = {
- anthropic: {
- id: "anthropic",
- models: {
- "claude-sonnet-4-5": {
- limit: { context: 200000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- "claude-sonnet-4-6": {
- limit: { context: 1000000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- // A text-only model: definitively no image/pdf input.
- "text-only-model": {
- limit: { context: 100000, output: 8192 },
- modalities: { input: ["text"], output: ["text"] },
- },
- // An entry predating the modalities field → capability unknown.
- "legacy-model": { limit: { context: 100000, output: 8192 } },
- },
- },
- opencode: {
- id: "opencode",
- models: {
- "glm-4-6": {
- limit: { context: 131072, output: 8192 },
- modalities: { input: ["text", "image"], output: ["text"] },
- },
- },
- },
-};
-
-function mockFetchOnce(catalog: unknown, ok = true, status = 200) {
- const fn = vi.fn(() =>
- Promise.resolve({
- ok,
- status,
- text: () => Promise.resolve(JSON.stringify(catalog)),
- } as Response),
- );
- vi.stubGlobal("fetch", fn);
- return fn;
-}
-
-beforeEach(() => {
- __resetCatalogCacheForTests();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
- delete process.env.DISPATCH_DISABLE_MODELS_FETCH;
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
-});
-
-describe("resolveContextLimit", () => {
- it("resolves a known anthropic model to its context window", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBe(1000000);
- });
-
- it("maps opencode-anthropic to the anthropic catalog, then opencode fallback", async () => {
- mockFetchOnce(CATALOG);
- // Present in the anthropic catalog.
- expect(await resolveContextLimit("opencode-anthropic", "claude-sonnet-4-5")).toBe(200000);
- // Absent in anthropic, found in the opencode gateway catalog.
- expect(await resolveContextLimit("opencode-anthropic", "glm-4-6")).toBe(131072);
- });
-
- it("returns null for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider (no network needed)", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveContextLimit("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null when the model has no positive context limit", async () => {
- mockFetchOnce({
- anthropic: { id: "anthropic", models: { broken: { limit: { context: 0 } } } },
- });
- expect(await resolveContextLimit("anthropic", "broken")).toBeNull();
- });
-
- it("does not throw on a malformed provider entry missing `models`", async () => {
- // A provider object without a `models` map must degrade to null, not crash.
- mockFetchOnce({ anthropic: { id: "anthropic" } });
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- });
-
- it("does not throw when limit/context fields are absent", async () => {
- mockFetchOnce({ anthropic: { id: "anthropic", models: { m: {} } } });
- expect(await resolveContextLimit("anthropic", "m")).toBeNull();
- });
-});
-
-describe("getModelsCatalog caching", () => {
- it("fetches once and serves the in-process memo on subsequent calls", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- await resolveContextLimit("anthropic", "claude-sonnet-4-5");
- await resolveContextLimit("anthropic", "claude-sonnet-4-6");
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- });
-
- it("reuses a fresh disk cache without re-fetching across processes", async () => {
- // Simulate another process having written a fresh cache.
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- const fetchFn = vi.fn(() => Promise.reject(new Error("network should not be hit")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("falls back to a STALE disk cache when the network fails", async () => {
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- // Age the cache well past the TTL so the fetch path is taken.
- const old = Date.now() / 1000 - 3600;
- utimesSync(CACHE_PATH, old, old);
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-
- it("returns null when fetch fails and no cache exists", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-
- it("does not hit the network when DISPATCH_DISABLE_MODELS_FETCH is set", async () => {
- process.env.DISPATCH_DISABLE_MODELS_FETCH = "1";
- const fetchFn = vi.fn(() => Promise.reject(new Error("should not fetch")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("memoizes the fallback after a failed fetch so it does not re-hit the network", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- // First lookup triggers the (failing) fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- // Subsequent lookups within the penalty window must NOT re-fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBeNull();
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-});
-
-describe("resolveModelCapabilities", () => {
- it("reports image + pdf for a vision model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({
- image: true,
- pdf: true,
- });
- });
-
- it("reports image-only for a model whose modalities omit pdf", async () => {
- mockFetchOnce(CATALOG);
- // glm-4-6 lists image but not pdf (resolved via the opencode fallback).
- expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({
- image: true,
- pdf: false,
- });
- });
-
- it("reports a definitive no for a text-only model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({
- image: false,
- pdf: false,
- });
- });
-
- it("returns null (unknown) for an entry without modalities", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull();
- });
-
- it("returns null (unknown) for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider without hitting the network", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveModelCapabilities("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null (unknown) when the catalog is offline with no cache", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-});
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
deleted file mode 100644
index 71dc00c..0000000
--- a/packages/core/tests/notifications/config.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// In-memory fake for the settings table — mounted before the module under
-// test is imported (vi.mock is hoisted).
-const fakeSettings = new Map<string, string>();
-
-vi.mock("../../src/db/settings.js", () => ({
- getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null),
- setSetting: vi.fn((key: string, value: string) => {
- fakeSettings.set(key, value);
- }),
- deleteSetting: vi.fn((key: string) => {
- fakeSettings.delete(key);
- }),
-}));
-
-const {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- normalizeNtfyConfig,
- NTFY_CONFIG_KEY,
- redactNtfyConfig,
- saveNtfyConfig,
-} = await import("../../src/notifications/config.js");
-
-describe("defaultNtfyConfig", () => {
- it("disables notifications and ships sane per-event defaults", () => {
- const cfg = defaultNtfyConfig();
- expect(cfg.enabled).toBe(false);
- expect(cfg.topic).toBe("");
- expect(cfg.authToken).toBe("");
- expect(cfg.events["turn-completed"]).toBe(true);
- expect(cfg.events["turn-error"]).toBe(true);
- expect(cfg.events["permission-required"]).toBe(true);
- expect(cfg.events["agent-spawned"]).toBe(false);
- expect(cfg.notifySubagents).toBe(false);
- });
-});
-
-describe("normalizeNtfyConfig", () => {
- it("returns defaults for non-object input", () => {
- expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig());
- });
-
- it("fills in missing event toggles with defaults (newly-added types default OFF)", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- events: { "turn-completed": false },
- });
- expect(normalized.events["turn-completed"]).toBe(false);
- // Defaults preserved for fields the persisted blob doesn't have.
- expect(normalized.events["turn-error"]).toBe(true);
- expect(normalized.events["agent-spawned"]).toBe(false);
- });
-
- it("ignores extraneous fields and wrong-typed values", () => {
- const normalized = normalizeNtfyConfig({
- enabled: "yes", // wrong type ⇒ default
- topic: 42, // wrong type ⇒ default
- authToken: null, // wrong type ⇒ default
- events: { "turn-completed": "no", bogus: true },
- extra: "ignored",
- });
- expect(normalized.enabled).toBe(false);
- expect(normalized.topic).toBe("");
- expect(normalized.authToken).toBe("");
- expect(normalized.events["turn-completed"]).toBe(true); // default kept
- expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined();
- });
-});
-
-describe("normalizeNtfyConfig — notifySubagents", () => {
- it("defaults notifySubagents to false when absent", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-
- it("respects an explicit notifySubagents=true", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: true,
- });
- expect(normalized.notifySubagents).toBe(true);
- });
-
- it("falls back to default when notifySubagents is wrong-typed", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: "yes" as unknown,
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-});
-
-describe("load/save round-trip", () => {
- beforeEach(() => {
- fakeSettings.clear();
- });
-
- it("returns defaults when nothing is persisted", () => {
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("round-trips a complete config", () => {
- const cfg = {
- enabled: true,
- topic: "https://ntfy.sh/team",
- authToken: "tk_abc",
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: true,
- } as const;
- saveNtfyConfig({ ...cfg });
- const loaded = loadNtfyConfig();
- expect(loaded).toEqual(cfg);
- // Persisted as a JSON string under the documented key.
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- });
-
- it("returns defaults when stored JSON is corrupt", () => {
- fakeSettings.set(NTFY_CONFIG_KEY, "{ not json");
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("clearNtfyConfig removes the persisted entry", () => {
- saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" });
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- clearNtfyConfig();
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false);
- });
-});
-
-describe("redactNtfyConfig", () => {
- it("strips authToken and surfaces a hasAuthToken flag", () => {
- const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" };
- const redacted = redactNtfyConfig(cfg);
- expect(redacted.authToken).toBe("");
- expect(redacted.hasAuthToken).toBe(true);
- });
-
- it("hasAuthToken is false for blank tokens", () => {
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false);
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false);
- });
-});
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
deleted file mode 100644
index c2faba6..0000000
--- a/packages/core/tests/notifications/dispatcher.test.ts
+++ /dev/null
@@ -1,461 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively
-// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can
-// load this file. All tests inject `loadConfig` explicitly, so the real
-// settings table is never read.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({
- query: () => ({ get: () => null, run: () => {} }),
- run: () => {},
- })),
-}));
-
-const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js");
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "test-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- // Default to true in the test config so existing tests (which never
- // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc.
- // Tests of the new subagent gating override this explicitly.
- notifySubagents: true,
- ...overrides,
- };
-}
-
-interface FakeAgentSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void,
- ): () => void;
- emit(event: { type: string; tabId: string; [k: string]: unknown }): void;
-}
-
-function makeAgentSource(): FakeAgentSource {
- let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null;
- return {
- onEvent(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(event) {
- l?.(event);
- },
- };
-}
-
-interface FakePermissionSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
- emit(prompt: { id: string; permission: string; description: string }): void;
-}
-
-function makePermissionSource(): FakePermissionSource {
- let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null;
- return {
- onPromptAdded(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(p) {
- l?.(p);
- },
- };
-}
-
-// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)`
-// has a chance to settle before assertions.
-async function flush(): Promise<void> {
- await Promise.resolve();
- await Promise.resolve();
-}
-
-describe("NotificationDispatcher.notify", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("does not send when master switch is disabled", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ enabled: false }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("does not send when per-event-type toggle is off", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("sends when enabled and toggle is on", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not throw or block when the transport rejects", async () => {
- const send = vi.fn(async () => {
- throw new Error("boom");
- });
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow();
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalled();
- });
-
- it("dedupes events with the same dedupeKey within the window", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- dedupeWindowMs: 1000,
- });
- const event: NotificationEvent = {
- type: "permission-required",
- title: "p",
- message: "p",
- dedupeKey: "permission:42",
- };
- d.notify(event);
- d.notify(event);
- d.notify(event);
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not dedupe events without a dedupeKey", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("NotificationDispatcher.attachToAgentManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("maps `done` → turn-completed (with tab title in the body)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- getTabTitle: (id) => (id === "tab-1" ? "My chat" : null),
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-completed");
- expect(event.title).toContain("My chat");
- expect(event.tabId).toBe("tab-1");
- });
-
- it("maps `error` → turn-error and includes the error text", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-error");
- expect(event.message).toContain("Rate limit");
- expect(event.message).toContain("429");
- });
-
- it("ignores `status` events (would spam every transition)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "status", tabId: "tab-1", status: "running" });
- source.emit({ type: "status", tabId: "tab-1", status: "idle" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
-
- // Manual "new tab" with no agent slug ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-1",
- id: "tab-1",
- title: "New Tab",
- parentTabId: null,
- agentSlug: null,
- });
- // Subagent (has a parent) ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-2",
- id: "tab-2",
- title: "Subagent",
- parentTabId: "tab-1",
- agentSlug: "researcher",
- });
- // Top-level user agent ⇒ notify.
- source.emit({
- type: "tab-created",
- tabId: "tab-3",
- id: "tab-3",
- title: "Refactor auth code",
- parentTabId: null,
- agentSlug: "engineer",
- });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("agent-spawned");
- expect(event.message).toBe("Refactor auth code");
- expect(event.title).toContain("engineer");
- });
-
- it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher.attachToPermissionManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("notifies once per unique prompt id (dedupes re-emits)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makePermissionSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToPermissionManager(source);
-
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- const events = send.mock.calls.map((c) => c[1] as NotificationEvent);
- expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]);
- expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true);
- });
-});
-
-describe("NotificationDispatcher.dispose", () => {
- it("releases attached subscriptions", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- d.dispose();
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- const parents = new Map<string, string | null>([
- ["top-level", null],
- ["subagent", "top-level"],
- ]);
- const getTabParentId = (id: string): string | null | undefined => parents.get(id);
-
- it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "error", tabId: "subagent", error: "boom" });
- source.emit({ type: "error", tabId: "top-level", error: "boom" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("still notifies subagents when notifySubagents=true", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: true }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(2);
- });
-
- it("does NOT gate permission-required (subagents must still get human input)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const psource = makePermissionSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToPermissionManager(psource);
-
- psource.emit({ id: "p1", permission: "bash", description: "git status" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required");
- });
-
- it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- // intentionally NO getTabParentId
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } });
- await flush();
-
- // Without a lookup, the dispatcher can't prove this is a subagent; it
- // must err on the side of notifying so legitimate top-level events
- // aren't silently dropped.
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("falls back to notifying when getTabParentId throws or returns undefined", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId: () => {
- throw new Error("db unavailable");
- },
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
-
- const send2 = vi.fn(async () => ({ ok: true }));
- const source2 = makeAgentSource();
- const d2 = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send: send2,
- getTabParentId: () => undefined,
- });
- d2.attachToAgentManager(source2);
- source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send2).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
deleted file mode 100644
index 5f14a60..0000000
--- a/packages/core/tests/notifications/ntfy.test.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "my-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: false,
- ...overrides,
- };
-}
-
-function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent {
- return {
- type: "turn-completed",
- title: "Done",
- message: "all good",
- ...overrides,
- };
-}
-
-function makeFetch(
- response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {},
-) {
- const fetchImpl = vi.fn(async () => ({
- ok: response.ok ?? true,
- status: response.status ?? 200,
- statusText: response.statusText ?? "OK",
- text: async () => response.body ?? "",
- }));
- return fetchImpl;
-}
-
-describe("buildNtfyUrl", () => {
- it("prefixes the public ntfy.sh host", () => {
- expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`);
- });
-
- it("trims surrounding whitespace", () => {
- expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`);
- });
-
- it("URL-encodes the topic so any string yields a valid URL", () => {
- // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy
- // server is the final authority on what it accepts.
- expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`);
- expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`);
- expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-});
-
-describe("sendNtfy", () => {
- it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(
- makeConfig(),
- makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }),
- fetchImpl,
- );
- expect(result.ok).toBe(true);
- expect(fetchImpl).toHaveBeenCalledTimes(1);
- const [url, init] = fetchImpl.mock.calls[0];
- expect(url).toBe(`${NTFY_BASE_URL}/my-topic`);
- expect(init.method).toBe("POST");
- expect(init.headers.Title).toBe("Hello");
- expect(init.headers.Priority).toBe("4");
- expect(init.headers.Tags).toBe("bell");
- expect(init.headers["Content-Type"]).toMatch(/text\/plain/);
- expect(init.body).toBe("World");
- });
-
- it("accepts arbitrary topic strings without a client-side pattern check", async () => {
- const fetchImpl = makeFetch();
- // Things the old validator would have rejected — dots, spaces, unicode,
- // a single-word "any topic". All should POST and let ntfy decide.
- await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl);
- expect(fetchImpl).toHaveBeenCalledTimes(4);
- expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`);
- expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`);
- expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`);
- expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-
- it("uses per-event-type defaults for priority and tags", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"]
- expect(init.headers.Tags).toBe("rotating_light");
- });
-
- it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBe("Bearer tk_secret");
- });
-
- it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl);
- expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz");
-
- const fetchImpl2 = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2);
- expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed");
- });
-
- it("omits Authorization when authToken is blank", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBeUndefined();
- });
-
- it("attaches Click header when clickUrl is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Click).toBe("https://example.com/tab/abc");
- });
-
- it("sanitizes Click header (CR/LF injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }),
- fetchImpl,
- );
- const v = fetchImpl.mock.calls[0][1].headers.Click;
- expect(v).not.toContain("\n");
- expect(v).not.toContain("\r");
- });
-
- it("appends short tab tag when tabId is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }),
- fetchImpl,
- );
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Tags).toBe("bell,tab-abcdef01");
- });
-
- it("strips CR/LF/control chars from header values (injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Title).not.toContain("\n");
- expect(init.headers.Title).not.toContain("\r");
- expect(init.headers.Title).toBe("line1 Injected: yes");
- });
-
- it("returns ok:false when notifications are disabled", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/disabled/);
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => {
- const fetchImpl = makeFetch();
- const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl);
- expect(empty.ok).toBe(false);
- expect(empty.error).toMatch(/required/i);
-
- const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl);
- expect(ws.ok).toBe(false);
- expect(ws.error).toMatch(/required/i);
-
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false with status on non-2xx response", async () => {
- const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.status).toBe(403);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/nope/);
- });
-
- it("returns ok:false with error message on fetch throwing", async () => {
- const fetchImpl = vi.fn(async () => {
- throw new Error("ECONNREFUSED");
- });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/ECONNREFUSED/);
- });
-});
diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts
deleted file mode 100644
index c8f4541..0000000
--- a/packages/core/tests/permission/evaluate.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { Ruleset } from "../../src/permission/index.js";
-
-describe("evaluate", () => {
- it("returns default ask when no rules match", () => {
- const result = evaluate("bash", "ls -la");
- expect(result.action).toBe("ask");
- expect(result.permission).toBe("bash");
- expect(result.pattern).toBe("ls -la");
- });
-
- it("returns allow when matching rule is allow", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }];
- const result = evaluate("bash", "ls -la", rules);
- expect(result.action).toBe("allow");
- });
-
- it("returns deny when matching rule is deny", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later deny overrides earlier allow", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm *", action: "deny" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later allow overrides earlier deny", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "rm *", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("allow");
- });
-
- it("matches permission wildcard", () => {
- const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }];
- const result = evaluate("read", "anything", rules);
- expect(result.action).toBe("allow");
- });
-
- it("multiple rulesets are concatenated, last match wins across rulesets", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
- const result = evaluate("bash", "git status", baseRules, overrideRules);
- expect(result.action).toBe("allow");
- });
-
- it("second ruleset can deny what first ruleset allows", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", baseRules, overrideRules);
- expect(result.action).toBe("deny");
- });
-
- it("non-matching permission returns default ask", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const result = evaluate("read", "/some/path", rules);
- expect(result.action).toBe("ask");
- });
-});
diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts
deleted file mode 100644
index d1b39d9..0000000
--- a/packages/core/tests/permission/service.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { PermissionRequest, Ruleset } from "../../src/permission/index.js";
-import { PermissionService } from "../../src/permission/service.js";
-
-function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
- return {
- permission: "bash",
- patterns: ["git *"],
- always: ["git status"],
- description: "Run git status",
- metadata: {},
- ...overrides,
- };
-}
-
-describe("PermissionService", () => {
- it("resolves immediately with 'once' when rule is allow", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const reply = await svc.ask(makeRequest(), [rules]);
- expect(reply).toBe("once");
- });
-
- it("rejects immediately when rule is deny", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied");
- });
-
- it("creates pending request when rule is ask", () => {
- const svc = new PermissionService();
- svc.ask(makeRequest(), []);
- expect(svc.getPending()).toHaveLength(1);
- });
-
- it("reply 'once' resolves the specific pending request", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest(), []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- svc.reply(pending[0].id, "once");
- const result = await promise;
- expect(result).toBe("once");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("reply 'always' adds approved rules and resolves", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []);
- const pending = svc.getPending();
- svc.reply(pending[0].id, "always");
- const result = await promise;
- expect(result).toBe("always");
-
- // Now the same permission should be immediately allowed
- const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []);
- expect(reply2).toBe("once");
- });
-
- it("reply 'reject' rejects all pending requests (cascade)", async () => {
- const svc = new PermissionService();
- const p1 = svc.ask(makeRequest(), []);
- const p2 = svc.ask(makeRequest({ permission: "read" }), []);
-
- const pending = svc.getPending();
- expect(pending).toHaveLength(2);
-
- // Reject using the first id — should cascade to all
- svc.reply(pending[0].id, "reject");
-
- await expect(p1).rejects.toThrow("Permission rejected");
- await expect(p2).rejects.toThrow("Permission rejected");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("approved rules override config rulesets", async () => {
- const svc = new PermissionService();
- svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]);
-
- // Config says deny, but approved says allow — approved wins (last)
- const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]);
- expect(reply).toBe("once");
- });
-
- it("getPending returns all pending requests with id and request", () => {
- const svc = new PermissionService();
- const req = makeRequest();
- svc.ask(req, []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- expect(pending[0].id).toBeDefined();
- expect(pending[0].request.permission).toBe("bash");
- });
-});
diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts
deleted file mode 100644
index 8fa30a7..0000000
--- a/packages/core/tests/permission/wildcard.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { Wildcard } from "../../src/permission/wildcard.js";
-
-describe("Wildcard.match", () => {
- it("matches exact string", () => {
- expect(Wildcard.match("bash", "bash")).toBe(true);
- expect(Wildcard.match("bash", "read")).toBe(false);
- });
-
- it("matches * wildcard (any characters)", () => {
- expect(Wildcard.match("*", "bash")).toBe(true);
- expect(Wildcard.match("*", "anything")).toBe(true);
- expect(Wildcard.match("ba*", "bash")).toBe(true);
- expect(Wildcard.match("ba*", "ba")).toBe(true);
- expect(Wildcard.match("ba*", "read")).toBe(false);
- });
-
- it("matches ? wildcard (single character)", () => {
- expect(Wildcard.match("ba?h", "bash")).toBe(true);
- expect(Wildcard.match("ba?h", "bath")).toBe(true);
- expect(Wildcard.match("ba?h", "baXXh")).toBe(false);
- expect(Wildcard.match("?", "a")).toBe(true);
- expect(Wildcard.match("?", "ab")).toBe(false);
- });
-
- it("matches nested * patterns with path-like strings", () => {
- expect(Wildcard.match("/home/*", "/home/user")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false);
- });
-
- it("escapes regex special characters in pattern", () => {
- expect(Wildcard.match("git add .", "git add .")).toBe(true);
- expect(Wildcard.match("git add .", "git add X")).toBe(false);
- expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true);
- expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false);
- });
-
- it("is case-sensitive", () => {
- expect(Wildcard.match("Bash", "bash")).toBe(false);
- expect(Wildcard.match("BASH", "BASH")).toBe(true);
- });
-
- it("handles empty pattern and value", () => {
- expect(Wildcard.match("", "")).toBe(true);
- expect(Wildcard.match("", "x")).toBe(false);
- expect(Wildcard.match("*", "")).toBe(true);
- });
-});
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts
deleted file mode 100644
index a01a6a5..0000000
--- a/packages/core/tests/tools/bash-arity.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { prefix } from "../../src/tools/bash-arity.js";
-
-describe("BashArity.prefix", () => {
- it("returns arity-2 prefix for known command 'git'", () => {
- expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
- });
-
- it("returns arity-3 prefix for npm", () => {
- expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
- });
-
- it("returns arity-2 prefix for bun", () => {
- expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]);
- });
-
- it("returns just the command for unknown command", () => {
- expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]);
- });
-
- it("returns empty array for empty tokens", () => {
- expect(prefix([])).toEqual([]);
- });
-
- it("handles single token for unknown command", () => {
- expect(prefix(["ls"])).toEqual(["ls"]);
- });
-
- it("handles git with fewer tokens than arity", () => {
- expect(prefix(["git"])).toEqual(["git"]);
- });
-
- it("handles case-insensitive matching", () => {
- expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]);
- });
-});
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
deleted file mode 100644
index 643e30e..0000000
--- a/packages/core/tests/tools/key-usage.test.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
-// transitively imports `db/index.js` (top-level `import { Database } from
-// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
-// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
-// is enough to let the import chain resolve.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
-import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
-import {
- createKeyUsageTool,
- formatKeyUsage,
- type KeyUsageCallbacks,
-} from "../../src/tools/key-usage.js";
-import type { KeyDefinition, KeyState } from "../../src/types/index.js";
-
-// ─── Builders ─────────────────────────────────────────────────
-
-function keyState(
- def: Partial<KeyDefinition> & { id: string; provider: string },
- overrides: Partial<Omit<KeyState, "definition">> = {},
-): KeyState {
- return {
- definition: { base_url: "https://example.test", ...def },
- status: "active",
- ...overrides,
- };
-}
-
-function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
- return {
- id,
- label: id,
- source,
- credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
- };
-}
-
-/** Build the tool with explicit stub fetchers — no network, no DB. */
-function buildTool(opts: {
- keys: KeyState[];
- accounts?: ClaudeAccount[];
- anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}) {
- const callbacks: KeyUsageCallbacks = {
- listKeys: () => opts.keys,
- listClaudeAccounts: () => opts.accounts ?? [],
- fetchAnthropicUsage: opts.anthropic ?? (async () => null),
- fetchOpencodeUsage: opts.opencode ?? (async () => null),
- };
- return createKeyUsageTool(callbacks);
-}
-
-const HOUR = 3_600_000;
-
-describe("key_usage tool", () => {
- it("reports all keys when no key_id is given", async () => {
- const reset5h = Date.now() + 2 * HOUR;
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max", "/creds/max.json")],
- anthropic: async () => ({
- source: "live",
- report: {
- fiveHour: { utilization: 0.25, resetsAt: reset5h },
- sevenDay: { utilization: 0.6 },
- },
- }),
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.4 },
- monthly: { utilization: 0.7 },
- }),
- });
-
- const out = await tool.execute({});
-
- // Both keys present with providers.
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).toContain("[opencode-1] provider: opencode-go");
- // Remaining = (1 - utilization) * 100.
- expect(out).toContain("5-hour: 75% remaining");
- expect(out).toContain("week: 40% remaining");
- expect(out).toContain("5-hour: 90% remaining");
- expect(out).toContain("week: 60% remaining");
- expect(out).toContain("month: 30% remaining");
- expect(out).toContain("data: live (fetched just now)");
- });
-
- it("filters to a single key when key_id is given and does not fetch others", async () => {
- const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.2 } },
- }),
- opencode: opencodeFetch,
- });
-
- const out = await tool.execute({ key_id: "claude-max" });
-
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).not.toContain("opencode-1");
- expect(opencodeFetch).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error for an unknown key_id", async () => {
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- });
-
- const out = await tool.execute({ key_id: "nope" });
-
- expect(out).toContain('no key found with id "nope"');
- expect(out).toContain("claude-max");
- expect(out).toContain("opencode-1");
- });
-
- it("reports cached data with the source's last-fetched timestamp", async () => {
- const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "cache",
- cachedAt,
- report: { fiveHour: { utilization: 0.5 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
- expect(out).toContain("5-hour: 50% remaining");
- });
-
- it("omits the month window for anthropic (no monthly bucket)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour:");
- expect(out).toContain("week:");
- expect(out).not.toContain("month:");
- });
-
- it("includes the month window for opencode-go", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.2 },
- monthly: { utilization: 0.3 },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("month: 70% remaining");
- });
-
- it("surfaces exhausted status with the last error", async () => {
- const exhaustedAt = Date.now() - HOUR;
- const tool = buildTool({
- keys: [
- keyState(
- { id: "opencode-1", provider: "opencode-go" },
- { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
- ),
- ],
- opencode: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("status: EXHAUSTED");
- expect(out).toContain("last error: 429 rate limit exceeded");
- });
-
- it("flags providers without usage support", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "gem", provider: "google" })],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("[gem] provider: google");
- expect(out).toContain("not supported");
- });
-
- it("reports unavailable when a supported provider returns no usage", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- expect(out).toContain("no cached usage");
- });
-
- it("reports unavailable for anthropic keys with no account credentials", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("no Claude account credentials available");
- });
-
- it("treats a fetcher that throws as unavailable (does not crash)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => {
- throw new Error("network down");
- },
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- });
-
- it("reports when no keys are configured at all", async () => {
- const tool = buildTool({ keys: [] });
- const out = await tool.execute({});
- expect(out).toBe("No API keys are configured.");
- });
-
- it("clamps out-of-range utilization to 0–100%", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
- weekly: { utilization: -0.5 }, // negative → 100% remaining
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour: 0% remaining");
- expect(out).toContain("week: 100% remaining");
- });
-});
-
-describe("formatKeyUsage (pure)", () => {
- const now = Date.UTC(2025, 5, 1, 12, 0, 0);
-
- it("formats reset timestamps with ISO + relative time", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "claude-max",
- provider: "anthropic",
- status: "active",
- dataSource: "live",
- windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
- },
- ],
- now,
- );
-
- expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
- });
-
- it("renders a past reset/exhaustion time as 'ago'", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "opencode-1",
- provider: "opencode-go",
- status: "exhausted",
- exhaustedAt: now - 2 * HOUR,
- lastError: "boom",
- windows: [],
- },
- ],
- now,
- );
-
- expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
- expect(out).toContain("last error: boom");
- });
-
- it("returns a friendly message when no entries match", () => {
- expect(formatKeyUsage([], now)).toBe("No API keys matched.");
- });
-});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
deleted file mode 100644
index f371717..0000000
--- a/packages/core/tests/tools/list-files.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createListFilesTool } from "../../src/tools/list-files.js";
-
-describe("list_files tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("lists directory contents", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "file1.txt"), "a");
- await writeFile(join(workDir, "file2.txt"), "b");
- await mkdir(join(workDir, "subdir"));
- const result = await tool.execute({ path: "." });
- expect(result).toContain("file1.txt");
- expect(result).toContain("file2.txt");
- expect(result).toContain("subdir/");
- });
-
- it("defaults to current directory when path is undefined", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "hi");
- const result = await tool.execute({});
- expect(result).toContain("hello.txt");
- });
-
- it("blocks path traversal", async () => {
- const tool = createListFilesTool(workDir);
- const result = await tool.execute({ path: "../" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, relPath))` — when relPath
- // is absolute, `join` does NOT short-circuit. The old code silently
- // rewrote `/some/path` to `<workdir>/some/path` and either returned an
- // ENOENT-style error or, worse, listed an unrelated path. After the fix,
- // absolute paths resolve to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("lists an absolute path that lives under the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "alpha.txt"), "a");
- await writeFile(join(workDir, "beta.txt"), "b");
- const result = await tool.execute({ path: workDir });
- expect(result).toContain("alpha.txt");
- expect(result).toContain("beta.txt");
- // "Error listing files" would indicate the path was mangled into a
- // non-existent location.
- expect(result).not.toMatch(/error listing/i);
- });
-
- it("rejects absolute paths outside the workdir with the workdir error (not a generic ENOENT)", async () => {
- const tool = createListFilesTool(workDir);
- // Use a tmpdir path that's definitely not under workDir. Under the
- // bug, this got rewritten to `<workdir>/tmp/...` and produced an
- // `Error listing files` ENOENT message instead of the workdir error.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}`);
- const result = await tool.execute({ path: evilPath });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // A directory symlink inside the workdir pointing to an external
- // directory is the classic escape vector for a `ls` style tool.
- // `canonicalize` must resolve the symlink so the listing is denied.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- await writeFile(join(externalDir, "secret.txt"), "secret");
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks listing through a symlinked directory that escapes the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await symlink(externalDir, join(workDir, "peek"));
- const result = await tool.execute({ path: "peek" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("secret.txt");
- });
- });
-});
diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts
deleted file mode 100644
index 7f26522..0000000
--- a/packages/core/tests/tools/lsp-tool.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import type { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js";
-
-const SERVER: ResolvedLspServer = {
- id: "luau-lsp",
- extensions: [".luau"],
- spawn: () => ({ process: {} as never }),
-};
-
-function makeManager(overrides: Partial<LspManager> = {}): LspManager {
- return {
- hasServerForFile: vi.fn(() => true),
- touchFile: vi.fn(async () => {}),
- getDiagnostics: vi.fn(() => ({})),
- request: vi.fn(async () => []),
- getClients: vi.fn(async () => []),
- shutdownAll: vi.fn(async () => {}),
- ...overrides,
- } as unknown as LspManager;
-}
-
-function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext {
- return () => ({ manager, workingDirectory: "/work", servers });
-}
-
-describe("createLspTool", () => {
- it("exposes the expected schema/name", () => {
- const tool = createLspTool(ctx(makeManager()));
- expect(tool.name).toBe("lsp");
- expect(tool.description).toMatch(/luau-lsp/i);
- });
-
- it("errors when no servers are configured", async () => {
- const tool = createLspTool(ctx(makeManager(), []));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/no LSP servers are configured/i);
- });
-
- it("errors when no server matches the file", async () => {
- const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.ts" });
- expect(out).toMatch(/no configured LSP server matches/i);
- });
-
- it("diagnostics: touches the file then reports errors", async () => {
- const touchFile = vi.fn(async () => {});
- const getDiagnostics = vi.fn(() => ({
- "/work/a.luau": [
- {
- range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } },
- severity: 1,
- message: "bad type",
- },
- ],
- }));
- const manager = makeManager({
- touchFile: touchFile as never,
- getDiagnostics: getDiagnostics as never,
- });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(touchFile).toHaveBeenCalledOnce();
- expect(out).toContain("ERROR [3:2] bad type");
- });
-
- it("diagnostics: reports clean when no errors", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/No errors reported/i);
- });
-
- it("hover: requires line and character", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "hover", path: "a.luau" });
- expect(out).toMatch(/requires both 'line' and 'character'/i);
- });
-
- it("hover: converts 1-based coords to 0-based on the wire", async () => {
- const request = vi.fn(async () => [{ contents: "hi" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 });
- expect(request).toHaveBeenCalledOnce();
- const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } };
- expect(arg.method).toBe("textDocument/hover");
- expect(arg.params.position).toEqual({ line: 4, character: 2 });
- });
-
- it("references: includes declaration context", async () => {
- const request = vi.fn(async () => []);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 });
- const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } };
- expect(arg.params.context).toEqual({ includeDeclaration: true });
- });
-
- it("documentSymbol: does not require a position", async () => {
- const request = vi.fn(async () => [{ name: "foo" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" });
- const arg = request.mock.calls[0]?.[0] as { method: string };
- expect(arg.method).toBe("textDocument/documentSymbol");
- expect(out).toContain("foo");
- });
-});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
deleted file mode 100644
index 90165d8..0000000
--- a/packages/core/tests/tools/read-file.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { randomBytes } from "node:crypto";
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createReadFileTool } from "../../src/tools/read-file.js";
-import { SPILL_ROOT } from "../../src/tools/truncate.js";
-
-describe("read_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("reads an existing file", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "Hello, world!");
- const result = await tool.execute({ path: "hello.txt" });
- expect(result).toContain("Hello, world!");
- expect(result).toContain("[file: hello.txt — lines 1-1 of 1]");
- });
-
- it("returns error for non-existent file", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "missing.txt" });
- expect(result).toMatch(/not found/i);
- });
-
- it("blocks path traversal", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "../etc/passwd" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- it("respects offset and limit", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "multi.txt"), "line1\nline2\nline3\nline4\nline5");
- const result = await tool.execute({ path: "multi.txt", offset: 2, limit: 2 });
- expect(result).toContain("line2");
- expect(result).toContain("line3");
- expect(result).not.toContain("line1");
- expect(result).not.toContain("line4");
- expect(result).toContain("[file: multi.txt — lines 2-3 of 5]");
- });
-
- it("truncates long lines and points to read_file_slice", async () => {
- const tool = createReadFileTool(workDir);
- const longLine = "x".repeat(3000);
- await writeFile(join(workDir, "wide.txt"), longLine);
- const result = await tool.execute({ path: "wide.txt" });
- expect(result).toContain("[line 1 truncated, total 3,000 chars");
- expect(result).toContain("use read_file_slice");
- });
-
- // The universal truncator writes oversized tool output to
- // `${SPILL_ROOT}/<tabId>/<callId>.txt` and the truncation notice tells
- // the AI to read that absolute path back. A previous implementation
- // used `resolve(join(workingDirectory, filePath))` which silently
- // concatenated the absolute spill path *under* the workdir, producing
- // a non-existent path and ENOENT — breaking the entire spill-and-resume
- // flow. These tests guard that contract.
- describe("absolute path handling (spill-file regression)", () => {
- let spillSubdir: string;
-
- beforeEach(async () => {
- spillSubdir = join(SPILL_ROOT, `test-${Date.now()}-${randomBytes(4).toString("hex")}`);
- await mkdir(spillSubdir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(spillSubdir, { recursive: true, force: true });
- });
-
- it("reads a spill file via its absolute path", async () => {
- const tool = createReadFileTool(workDir);
- const spillFile = join(spillSubdir, "call-abc.txt");
- const payload = "spilled output line 1\nspilled output line 2";
- await writeFile(spillFile, payload);
-
- const result = await tool.execute({ path: spillFile });
-
- expect(result).toContain("spilled output line 1");
- expect(result).toContain("spilled output line 2");
- expect(result).not.toMatch(/not found/i);
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("still rejects absolute paths that are neither in the workdir nor the spill root", async () => {
- const tool = createReadFileTool(workDir);
- // Path check happens before file read, so /etc/hostname existing
- // (or not) is irrelevant — we just need an absolute path outside
- // both the workdir and SPILL_ROOT.
- const result = await tool.execute({ path: "/etc/hostname" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlinks must resolve consistently across the agent permission gate
- // and the tool itself. The containment check operates on the canonical
- // path — so a symlink-in-workdir that points outside is treated as
- // "outside" and gated like any other external path. Lexical-only
- // checks would let these slip through silently.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("follows symlinks that stay inside the workdir", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "real.txt"), "real content");
- await symlink(join(workDir, "real.txt"), join(workDir, "link.txt"));
- const result = await tool.execute({ path: "link.txt" });
- expect(result).toContain("real content");
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("blocks symlinks that escape the workdir", async () => {
- const tool = createReadFileTool(workDir);
- const secret = join(externalDir, "secret.txt");
- await writeFile(secret, "leaked secret");
- // Create a symlink *inside* workDir pointing to a file *outside*
- // workDir. Lexical-only path validation would see "workdir/trap.txt"
- // (under workdir) and allow it. Canonical resolution sees the
- // symlink's target and correctly rejects.
- await symlink(secret, join(workDir, "trap.txt"));
- const result = await tool.execute({ path: "trap.txt" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("leaked secret");
- });
- });
-});
diff --git a/packages/core/tests/tools/read-tab.test.ts b/packages/core/tests/tools/read-tab.test.ts
deleted file mode 100644
index 71e419c..0000000
--- a/packages/core/tests/tools/read-tab.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createReadTabTool, type ReadTabCallbacks } from "../../src/tools/read-tab.js";
-import type { TabResolution } from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<ReadTabCallbacks> = {}): ReadTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- getLastResponse: () => ({ text: "the answer is 42", status: "idle" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- ...overrides,
- };
-}
-
-describe("createReadTabTool — schema & description", () => {
- it("is a non-blocking snapshot read", () => {
- const tool = createReadTabTool(makeCallbacks());
- expect(tool.name).toBe("read_tab");
- expect(tool.description).toContain("SNAPSHOT");
- expect(tool.description.toLowerCase()).toContain("does not block");
- });
-});
-
-describe("createReadTabTool — execute()", () => {
- it("returns the last assistant response wrapped in a tab_response tag", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("<tab_response");
- expect(out).toContain('tab="targ"');
- expect(out).toContain('status="idle"');
- expect(out).toContain("the answer is 42");
- expect(out).toContain("</tab_response>");
- });
-
- it("notes that a running tab's response is its previous completed turn", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: "older turn", status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("still running");
- expect(out).toContain("older turn");
- });
-
- it("explains when a tab has no completed response yet (idle)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "idle" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("no assistant responses yet");
- });
-
- it("explains when a tab is still on its first turn (running, no prior text)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("still working on its first turn");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("returns a helpful error when the id is unknown", async () => {
- const tool = createReadTabTool(makeCallbacks({ resolveShortId: () => ({ status: "none" }) }));
- const out = await tool.execute({ tab_id: "zzzz" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- }),
- );
- const out = await tool.execute({ tab_id: "abcd" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- });
-});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
deleted file mode 100644
index cad75d2..0000000
--- a/packages/core/tests/tools/registry.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { z } from "zod";
-import { createToolRegistry } from "../../src/tools/registry.js";
-import type { ToolDefinition } from "../../src/types/index.js";
-
-const mockTool: ToolDefinition = {
- name: "mock_tool",
- description: "A mock tool for testing",
- parameters: z.object({ input: z.string() }),
- execute: async (_args) => "mock result",
-};
-
-const anotherTool: ToolDefinition = {
- name: "another_tool",
- description: "Another mock tool",
- parameters: z.object({ value: z.number() }),
- execute: async (_args) => "another result",
-};
-
-/** A non-trivial tool that exercises nested objects, required fields, and enums. */
-const complexTool: ToolDefinition = {
- name: "complex_tool",
- description: "A tool with nested parameters",
- parameters: z.object({
- command: z.string().describe("Shell command to run"),
- options: z.object({
- timeout: z.number().optional().describe("Timeout in milliseconds"),
- shell: z.enum(["bash", "sh", "zsh"]).describe("Shell to use"),
- }),
- flags: z.array(z.string()).optional().describe("Additional flags"),
- }),
- execute: async (_args) => "complex result",
-};
-
-describe("createToolRegistry", () => {
- it("returns all tools via getTools()", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tools = registry.getTools();
- expect(tools).toHaveLength(2);
- expect(tools.map((t) => t.name)).toContain("mock_tool");
- expect(tools.map((t) => t.name)).toContain("another_tool");
- });
-
- it("retrieves specific tool by name", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tool = registry.getTool("mock_tool");
- expect(tool).toBeDefined();
- expect(tool?.name).toBe("mock_tool");
- });
-
- it("returns undefined for unknown tool", () => {
- const registry = createToolRegistry([mockTool]);
- expect(registry.getTool("nonexistent")).toBeUndefined();
- });
-
- describe("getAISDKTools", () => {
- it("returns correct keys for all tools", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools).toHaveProperty("mock_tool");
- expect(aiTools).toHaveProperty("another_tool");
- });
-
- it("AI SDK tools have description from ToolDefinition", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools.mock_tool.description).toBe("A mock tool for testing");
- });
-
- it("AI SDK tools surface schema via inputSchema, not parameters", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- // v6 uses inputSchema; v4 used parameters — this verifies the migration
- expect(aiTools.mock_tool).toHaveProperty("inputSchema");
- expect(aiTools.mock_tool).not.toHaveProperty("parameters");
- });
-
- it("AI SDK tools have no execute callback so the SDK does not auto-run", () => {
- const registry = createToolRegistry([mockTool, anotherTool, complexTool]);
- const aiTools = registry.getAISDKTools();
- for (const [name, sdkTool] of Object.entries(aiTools)) {
- expect(
- (sdkTool as Record<string, unknown>).execute,
- `Tool "${name}" should not have an execute callback`,
- ).toBeUndefined();
- }
- });
-
- it("inputSchema produces valid JSONSchema7 for a simple tool", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.mock_tool.inputSchema;
- // jsonSchema() wraps the raw JSONSchema7; it should expose the schema
- // as a `jsonSchema` property on the Schema object
- expect(schema).toBeDefined();
- // The wrapped schema object should carry the JSON Schema definition
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema).toBeDefined();
- expect(schemaObj.jsonSchema.type).toBe("object");
- const props = schemaObj.jsonSchema.properties as Record<string, unknown>;
- expect(props).toHaveProperty("input");
- });
-
- it("inputSchema produces correct JSONSchema7 for a non-trivial nested tool", () => {
- const registry = createToolRegistry([complexTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.complex_tool.inputSchema;
- expect(schema).toBeDefined();
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema.type).toBe("object");
-
- const props = schemaObj.jsonSchema.properties as Record<string, Record<string, unknown>>;
-
- // Top-level required field "command"
- expect(props).toHaveProperty("command");
- expect(props.command.type).toBe("string");
-
- // Nested object "options"
- expect(props).toHaveProperty("options");
- expect(props.options.type).toBe("object");
- const optProps = props.options.properties as Record<string, Record<string, unknown>>;
- expect(optProps).toHaveProperty("shell");
- expect(optProps.shell.enum).toEqual(["bash", "sh", "zsh"]);
-
- // Optional array "flags" present as a property
- expect(props).toHaveProperty("flags");
- expect(props.flags.type).toBe("array");
-
- // Required fields should include "command" and "options"
- const required = schemaObj.jsonSchema.required as string[];
- expect(required).toContain("command");
- expect(required).toContain("options");
- });
-
- it("getTool still returns the original ToolDefinition with execute", () => {
- const registry = createToolRegistry([mockTool]);
- const def = registry.getTool("mock_tool");
- expect(def).toBeDefined();
- expect(typeof def?.execute).toBe("function");
- expect(def?.name).toBe("mock_tool");
- });
- });
-});
diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts
deleted file mode 100644
index cb66d1c..0000000
--- a/packages/core/tests/tools/run-shell.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRunShellTool } from "../../src/tools/run-shell.js";
-
-describe("run_shell tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("executes a simple echo command", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo hello" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("hello");
- expect(result.exitCode).toBe(0);
- });
-
- it("returns non-zero exit code on failure", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "exit 42" });
- const result = JSON.parse(raw);
- expect(result.exitCode).toBe(42);
- });
-
- it("captures stderr", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo errormsg >&2" });
- const result = JSON.parse(raw);
- expect(result.stderr.trim()).toBe("errormsg");
- });
-
- it("handles timeout", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "sleep 10", timeout: 100 });
- const result = JSON.parse(raw);
- // Either times out (non-zero exit) or returns an error
- expect(result.exitCode !== 0 || result.error !== undefined).toBe(true);
- }, 5000);
-
- it("executes in the working directory", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "pwd" });
- const result = JSON.parse(raw);
- // On macOS /tmp is symlinked; use includes check
- expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, ""));
- });
-
- it("calls onOutput callback with stdout chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- const raw = await tool.execute({ command: "echo streaming" }, { onOutput });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("streaming");
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout");
- });
-
- it("calls onOutput callback with stderr chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- await tool.execute({ command: "echo errdata >&2" }, { onOutput });
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr");
- });
-
- it("works without context (backward compatible)", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo nocontext" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("nocontext");
- });
-});
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts
deleted file mode 100644
index c4e933c..0000000
--- a/packages/core/tests/tools/search-code.test.ts
+++ /dev/null
@@ -1,511 +0,0 @@
-import { spawnSync } from "node:child_process";
-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createSearchCodeTool } from "../../src/tools/search-code.js";
-
-// A tiny stub that impersonates `cs`: it ignores its args and prints whatever
-// JSON we put in the CS_STUB_OUTPUT env var. This makes JSON→text formatting
-// tests fully deterministic without needing a real cs binary in CI.
-function writeStub(dir: string, body: string): string {
- const stubPath = join(dir, "cs-stub.sh");
- writeFileSync(stubPath, body, { mode: 0o755 });
- chmodSync(stubPath, 0o755);
- return stubPath;
-}
-
-const ECHO_ENV_STUB = `#!/usr/bin/env bash
-printf '%s' "$CS_STUB_OUTPUT"
-`;
-
-// A stub that writes to stderr and exits non-zero, impersonating a cs failure
-// (bad flag, invalid regex, etc.).
-const FAIL_STUB = `#!/usr/bin/env bash
-echo "cs: simulated failure on stderr" >&2
-exit 3
-`;
-
-describe("search_code tool", () => {
- let workDir: string;
- const savedBin = process.env.DISPATCH_CS_BIN;
- const savedStubOut = process.env.CS_STUB_OUTPUT;
-
- beforeEach(async () => {
- workDir = await mkdtempP(join(tmpdir(), "dispatch-cs-test-"));
- });
-
- afterEach(async () => {
- await rmP(workDir, { recursive: true, force: true });
- if (savedBin === undefined) delete process.env.DISPATCH_CS_BIN;
- else process.env.DISPATCH_CS_BIN = savedBin;
- if (savedStubOut === undefined) delete process.env.CS_STUB_OUTPUT;
- else process.env.CS_STUB_OUTPUT = savedStubOut;
- });
-
- it("exposes the expected name and schema", () => {
- const tool = createSearchCodeTool(workDir);
- expect(tool.name).toBe("search_code");
- expect(tool.description).toContain("cs");
- // query is required; a representative set of optional knobs exist.
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect(shape.query).toBeDefined();
- expect(shape.path).toBeDefined();
- expect(shape.only).toBeDefined();
- expect(shape.result_limit).toBeDefined();
- });
-
- it("requires a non-empty query", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: " " });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("query is required");
- });
-
- it("does not crash when params are the wrong type (model hallucination)", async () => {
- const tool = createSearchCodeTool(workDir);
- // A non-string query must be rejected gracefully, not throw.
- const q = await tool.execute({ query: ["a", "b"] as unknown as string });
- expect(q).toMatch(/^Error:/);
- expect(q).toContain("query is required");
- // A non-string include_ext (array) must not throw "x.trim is not a function".
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const out = await tool.execute({
- query: "x",
- include_ext: ["ts", "go"] as unknown as string,
- exclude_pattern: { a: 1 } as unknown as string,
- });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("rejects a path outside the working directory", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything", path: "../../etc" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("outside the working directory");
- });
-
- it("rejects a path that points at a file, not a directory", async () => {
- await writeFileP(join(workDir, "a-file.ts"), "const x = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "a-file.ts" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("is a file, not a directory");
- });
-
- it("rejects a path that does not exist", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "no/such/dir" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("does not exist");
- });
-
- it("returns an actionable error when the cs binary is missing", async () => {
- process.env.DISPATCH_CS_BIN = "/nonexistent/path/to/cs-binary-xyz";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("requires the 'cs'");
- expect(out).toContain("DISPATCH_CS_BIN");
- });
-
- it("reports no matches when cs outputs null", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "nothinghere" });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("formats cs JSON results into readable per-file blocks", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const csJson = JSON.stringify([
- {
- filename: "web-search.ts",
- location: join(workDir, "packages/core/src/tools/web-search.ts"),
- score: 5.24,
- language: "TypeScript",
- total_lines: 106,
- lines: [
- { line_number: 7, content: "" },
- {
- line_number: 8,
- content: "export function createWebSearchTool(): ToolDefinition {",
- match_positions: [[16, 35]],
- },
- { line_number: 9, content: "\treturn {" },
- ],
- },
- {
- filename: "index.ts",
- location: join(workDir, "packages/core/src/index.ts"),
- score: 1.1,
- language: "TypeScript",
- lines: [{ line_number: 113, content: 'export { createWebSearchTool } from "./web.js";' }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
-
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "createWebSearchTool" });
-
- expect(out).toContain("Found matches in 2 files");
- // Paths are rendered relative to the workdir.
- expect(out).toContain("packages/core/src/tools/web-search.ts [TypeScript] (score 5.24)");
- expect(out).not.toContain(workDir);
- // Matched line is marked with '>'; line numbers + content present.
- expect(out).toContain("> 8: export function createWebSearchTool(): ToolDefinition {");
- expect(out).toContain(" 7: ");
- expect(out).toContain("packages/core/src/index.ts [TypeScript] (score 1.10)");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("renders cs 'content'-shape (prose) results instead of a bare header", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- // cs's snippet mode emits `content` + `matchlocations` and no `lines`.
- const csJson = JSON.stringify([
- {
- filename: "notes.md",
- location: join(workDir, "docs/notes.md"),
- score: 0.42,
- language: "Markdown",
- content: "Some heading\nthe orchestration paragraph that matched",
- matchlocations: [[13, 26]],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "orchestration" });
- expect(out).toContain("docs/notes.md [Markdown] (score 0.42)");
- // The snippet text must be present, not a bare header.
- expect(out).toContain("the orchestration paragraph that matched");
- expect(out).not.toContain("no snippet available");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("truncates an excessively long snippet line", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const longContent = `const x = "${"Z".repeat(5000)}";`;
- const csJson = JSON.stringify([
- {
- filename: "big.ts",
- location: join(workDir, "big.ts"),
- score: 1,
- language: "TypeScript",
- lines: [{ line_number: 1, content: longContent, match_positions: [[10, 14]] }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toContain("line truncated");
- // No single output line should approach the raw 5k length.
- const longest = Math.max(...out.split("\n").map((l) => l.length));
- expect(longest).toBeLessThan(700);
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("surfaces raw output when cs returns unparseable JSON", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "this is not json";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("could not parse cs output");
- expect(out).toContain("this is not json");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("reports an error (not 'No matches') when cs exits non-zero", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, FAIL_STUB);
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("exited with code 3");
- // stderr from cs is surfaced to the caller.
- expect(out).toContain("simulated failure on stderr");
- expect(out).not.toContain("No matches found");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- // ── Live integration: only runs when a real `cs` binary is available. ──
- const liveCsBin = findRealCs();
- describe.runIf(liveCsBin)("live cs binary", () => {
- it("finds a real match and ranks the defining file", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // Seed a small tree with a clear match.
- await writeFileP(
- join(workDir, "alpha.ts"),
- "export function findTheNeedle() {\n return 42;\n}\n",
- );
- await writeFileP(join(workDir, "beta.ts"), "const x = 1;\n// nothing relevant here\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "findTheNeedle" });
- expect(out).toContain("alpha.ts");
- expect(out).toContain("findTheNeedle");
- expect(out).not.toContain("Error:");
- });
-
- it("treats a dash-leading query as a search term, not a cs flag", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // A literal token beginning with '-' must not be parsed as a flag.
- await writeFileP(join(workDir, "dash.ts"), "const dashToken = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "-dashToken" });
- // Whether or not cs ranks a hit, it must NOT error out on flag parsing.
- expect(out).not.toContain("unknown shorthand flag");
- expect(out).not.toMatch(/^Error: cs exited/);
- });
-
- it("renders snippet lines for prose (markdown) matches", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(
- join(workDir, "doc.md"),
- "# Title\n\nThis paragraph mentions widgetronics in prose.\n",
- );
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "widgetronics" });
- expect(out).toContain("doc.md");
- // The matching prose text must be shown, not just a bare header.
- expect(out).toContain("widgetronics");
- expect(out).not.toContain("no snippet available");
- });
-
- it("widens the snippet window when context is given", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- const body = Array.from({ length: 21 }, (_, i) => `line ${i + 1}`);
- body[10] = "const findContextTarget = 1;";
- await writeFileP(join(workDir, "ctx.ts"), `${body.join("\n")}\n`);
- const tool = createSearchCodeTool(workDir);
- const countSnippetLines = (s: string) =>
- s.split("\n").filter((l) => /^\s+>?\s*\d+:/.test(l)).length;
- const narrow = await tool.execute({
- query: "findContextTarget",
- context: 0,
- result_limit: 1,
- });
- const wide = await tool.execute({
- query: "findContextTarget",
- context: 6,
- result_limit: 1,
- });
- expect(countSnippetLines(wide)).toBeGreaterThan(countSnippetLines(narrow));
- });
-
- it("returns 'No matches found.' for a query with no hits", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "alpha.ts"), "export const a = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" });
- expect(out).toBe("No matches found.");
- });
-
- it("tags .luau files as Luau", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "mod.luau"), "function Mod.doThing()\n\treturn 1\nend\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "doThing" });
- expect(out).toContain("mod.luau");
- expect(out).toContain("[Luau]");
- });
- });
-
- // ── Luau declaration detection: needs a cs built with the Luau patch
- // (docker/cs/luau-declarations.patch). Skipped on an unpatched/older cs. ──
- const luauCsBin = findLuauCapableCs(liveCsBin);
- describe.runIf(luauCsBin)("live cs binary (Luau declaration patch)", () => {
- // A small Luau module exercising every declaration form the patch adds.
- const LUAU_MODULE = [
- "local Mod = {}",
- "",
- "export type StuntResult = {",
- "\tscore: number,",
- "}",
- "",
- "type LaunchConfig = StuntResult",
- "",
- "function Mod.getDefaults(): LaunchConfig",
- "\treturn { score = 0 }",
- "end",
- "",
- "local function helperThing(x: number): number",
- "\treturn x + 1",
- "end",
- "",
- "Mod.live = Mod.getDefaults()",
- "local used = helperThing(1)",
- "",
- ].join("\n");
-
- beforeEach(async () => {
- process.env.DISPATCH_CS_BIN = luauCsBin as string;
- await writeFileP(join(workDir, "Mod.luau"), LUAU_MODULE);
- });
-
- it("detects `function Mod.x` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("function Mod.getDefaults");
- expect(out).not.toContain("No matches found");
- });
-
- it("detects `local function` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "helperThing", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("local function helperThing");
- });
-
- it("detects `type` / `export type` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const exportType = await tool.execute({ query: "StuntResult", only: "declarations" });
- expect(exportType).toContain("export type StuntResult");
- const aliasType = await tool.execute({ query: "LaunchConfig", only: "declarations" });
- expect(aliasType).toContain("type LaunchConfig");
- });
-
- it("excludes declaration lines when only=usages", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "usages" });
- // The call site is a usage; the `function Mod.getDefaults` definition is not.
- expect(out).toContain("Mod.live = Mod.getDefaults()");
- expect(out).not.toContain("function Mod.getDefaults");
- });
- });
-
- // ── Fuzzy mid-word matching: needs a cs built with the fuzzy patch
- // (docker/cs/fuzzy-distance.patch). Skipped on an unpatched/older cs. ──
- const fuzzyCsBin = findFuzzyCapableCs(liveCsBin);
- describe.runIf(fuzzyCsBin)("live cs binary (fuzzy edit-distance patch)", () => {
- beforeEach(() => {
- process.env.DISPATCH_CS_BIN = fuzzyCsBin as string;
- });
-
- it("matches a mid-word deletion within distance 1", async () => {
- await writeFileP(
- join(workDir, "phys.ts"),
- "export function computeSlipAngle() {\n\treturn 0;\n}\n",
- );
- const tool = createSearchCodeTool(workDir);
- // "computSlipAngle" drops the 'e' mid-word — edit distance 1.
- const out = await tool.execute({ query: "computSlipAngle~1" });
- expect(out).toContain("phys.ts");
- expect(out).toContain("computeSlipAngle");
- expect(out).not.toBe("No matches found.");
- });
-
- it("matches a mid-word insertion within distance 1", async () => {
- await writeFileP(join(workDir, "tire.ts"), "const tireFriction = 1;\n");
- const tool = createSearchCodeTool(workDir);
- // "tireFricction" has an extra 'c' — edit distance 1.
- const out = await tool.execute({ query: "tireFricction~1" });
- expect(out).toContain("tire.ts");
- expect(out).toContain("tireFriction");
- });
- });
-});
-
-/**
- * Locate a usable `cs` binary for live tests. Honors DISPATCH_CS_TEST_BIN, then
- * a `cs` on PATH. Returns null when none is runnable, so the live suite is
- * skipped rather than failing in environments without cs.
- */
-function findRealCs(): string | null {
- const candidates = [process.env.DISPATCH_CS_TEST_BIN, "cs"].filter(Boolean) as string[];
- for (const bin of candidates) {
- try {
- const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
- if (res.status === 0) return bin;
- } catch {
- // try next
- }
- }
- return null;
-}
-
-/**
- * Probe a `cs` binary against a throwaway corpus and return its trimmed stdout
- * (or "" on any failure). Used by the capability gates below so patch-dependent
- * live tests run only on a cs that actually has the patch — and skip (not fail)
- * on an unpatched/older binary.
- */
-function probeCs(bin: string, files: Record<string, string>, args: string[]): string {
- let dir: string | undefined;
- try {
- dir = mkdtempSync(join(tmpdir(), "dispatch-cs-probe-"));
- for (const [name, body] of Object.entries(files)) {
- writeFileSync(join(dir, name), body);
- }
- const res = spawnSync(bin, ["-f", "json", "--dir", dir, ...args], {
- encoding: "utf8",
- });
- if (res.status !== 0 || !res.stdout) return "";
- return res.stdout.trim();
- } catch {
- return "";
- } finally {
- if (dir) rmSync(dir, { recursive: true, force: true });
- }
-}
-
-/**
- * Return the cs binary only if it recognises Luau declarations (i.e. was built
- * with docker/cs/luau-declarations.patch): a `--only-declarations` search for a
- * top-level `function` in a .luau file yields a result. Otherwise null → skip.
- */
-function findLuauCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.luau": "function Probe.thing()\n\treturn 1\nend\n" }, [
- "--only-declarations",
- "--",
- "thing",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
-
-/**
- * Return the cs binary only if its fuzzy matcher honours mid-word edits (i.e.
- * was built with docker/cs/fuzzy-distance.patch): a distance-1 deletion matches.
- * Otherwise null → skip.
- */
-function findFuzzyCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.txt": "const x = computeSlipAngle;\n" }, [
- "--",
- "computSlipAngle~1",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
deleted file mode 100644
index 21d8032..0000000
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- createSendToTabTool,
- type SendToTabCallbacks,
- type TabResolution,
-} from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- deliver: () => ({ status: "started" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- self: { id: "self-id", handle: "self" },
- canReadTab: true,
- ...overrides,
- };
-}
-
-describe("createSendToTabTool — schema & description", () => {
- it("exposes tab_id and message params and a fire-and-forget description", () => {
- const tool = createSendToTabTool(makeCallbacks());
- expect(tool.name).toBe("send_to_tab");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description.toLowerCase()).toContain("queued");
- // Description must steer the model away from busy-waiting for a reply.
- expect(tool.description.toLowerCase()).toContain("do not sleep");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-
- it("mentions read_tab in the description only when canReadTab is true", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
- expect(tool.description).toContain("read_tab");
- });
-
- it("never mentions read_tab in the description when canReadTab is false", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
- expect(tool.description).not.toContain("read_tab");
- // Still tells the agent a reply will wake it + to end its turn.
- expect(tool.description.toLowerCase()).toContain("wake you with a new message");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-});
-
-describe("createSendToTabTool — execute()", () => {
- it("delivers to a resolved target and reports the started status", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "hello there" });
- expect(deliver).toHaveBeenCalledTimes(1);
- const [targetId, delivered] = deliver.mock.calls[0] ?? [];
- expect(targetId).toBe("target-id");
- // Provenance header names the sending tab's handle and marks it as a
- // peer agent (not the recipient's own user).
- expect(delivered).toContain("[message from tab self");
- expect(delivered).toContain("another agent");
- expect(delivered).toContain("hello there");
- // Reply contract: the recipient must answer via send_to_tab back to the
- // sender's handle, not as a plain text reply to its own user.
- expect(delivered).toContain('send_to_tab tool with tab_id "self"');
- expect(delivered).toContain("ONLY reply if");
- expect(out).toContain("idle");
- expect(out).toContain("targ");
- // Sender is steered away from busy-waiting and told to end its turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("points the sender at read_tab in the result only when canReadTab is true", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("read_tab");
- });
-
- it("omits read_tab from the result when canReadTab is false", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).not.toContain("read_tab");
- // Still steers away from busy-waiting and toward ending the turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("reports the queued status when the target is busy", async () => {
- const deliver = vi.fn(() => ({ status: "queued" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping" });
- expect(out.toLowerCase()).toContain("queued");
- expect(out.toLowerCase()).toContain("busy");
- });
-
- it("reports a HELD message when delivery is suppressed (auto-wake limit hit)", async () => {
- const deliver = vi.fn(() => ({ status: "suppressed" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping again" });
- expect(out).toContain("HELD");
- expect(out.toLowerCase()).toContain("limit");
- // It must steer the sender away from retrying in a loop.
- expect(out.toLowerCase()).toContain("do not keep resending");
- expect(out.toLowerCase()).toContain("human");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createSendToTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: " ", message: "hi" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("rejects an empty message", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: " " });
- expect(out).toContain("Error");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error and open-tab list when the id is unknown", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({ status: "none" }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "zzzz", message: "hi" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "abcd", message: "hi" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("refuses to send to its own tab", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ok",
- tab: { id: "self-id", title: "Me", handle: "self" },
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "self", message: "hi" });
- expect(out).toContain("cannot send a message to your own tab");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("surfaces a thrown delivery error instead of crashing", async () => {
- const tool = createSendToTabTool(
- makeCallbacks({
- deliver: () => {
- throw new Error("boom");
- },
- }),
- );
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("Error delivering message");
- expect(out).toContain("boom");
- });
-});
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
deleted file mode 100644
index 4885a94..0000000
--- a/packages/core/tests/tools/summon.test.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
-} from "../../src/tools/summon.js";
-
-const noopCallbacks: SummonCallbacks = {
- spawn: async () => "agent-id-stub",
- getResult: async () => ({ status: "done", result: "" }),
-};
-
-describe("createSummonTool — description content", () => {
- it("lists the agent directories so the LLM knows where to look", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents", "/tmp/work/.dispatch/agents"],
- );
- expect(tool.description).toContain("/home/u/.config/dispatch/agents");
- expect(tool.description).toContain("/tmp/work/.dispatch/agents");
- expect(tool.description).toContain("read_file");
- });
-
- it("includes available agent slugs+names in the description", () => {
- const agents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Implements code from a plan.",
- path: "/home/u/.config/dispatch/agents/programmer.toml",
- },
- {
- slug: "researcher",
- name: "Researcher",
- description: "Investigates topics.",
- path: "/home/u/.config/dispatch/agents/researcher.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- agents,
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("Programmer");
- expect(tool.description).toContain("Implements code from a plan");
- expect(tool.description).toContain("researcher");
- expect(tool.description).toContain("Investigates topics");
- });
-
- it("emits a 'no agents defined' notice when the catalog is empty", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("No agent definitions are currently defined");
- });
-
- it("shows two groups when userAgentEnabled is true", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- true,
- );
- expect(tool.description).toContain("Subagents (spawned as child tabs):");
- expect(tool.description).toContain(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("default");
- });
-
- it("hides user agents group when userAgentEnabled is false", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- false,
- );
- expect(tool.description).toContain("Available agents:");
- expect(tool.description).not.toContain("User agents");
- // "default" appears in generic description text, so check for the slug listing format
- expect(tool.description).not.toContain("- default: Default");
- });
-});
-
-describe("createSummonTool — execute() argument forwarding", () => {
- it("forwards agent slug through to callbacks.spawn", async () => {
- const spawn = vi.fn(async () => "tab-xyz");
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
- [],
- [],
- );
- await tool.execute({
- task: "do thing",
- agent: "programmer",
- background: true,
- });
- expect(spawn).toHaveBeenCalledTimes(1);
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({
- task: "do thing",
- agentSlug: "programmer",
- });
- });
-
- it("returns spawned agent_id when background=true (no blocking on result)", async () => {
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "should-not-see" }));
- const tool = createSummonTool("/tmp/work", { spawn: async () => "id-42", getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "test-agent", background: true });
- expect(out).toContain("id-42");
- // Background mode must not block on getResult
- expect(getResult).not.toHaveBeenCalled();
- });
-
- it("blocks on result and returns it when background=false (default)", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "done", result: "child-output" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- // Foreground summons prefix the blocked result with `agent_id: <id>` so
- // the frontend's ToolCallDisplay regex can surface the "Open Tab" button
- // (see summon.ts). Assert both the prefix and the child output survive.
- expect(out).toContain("agent_id: id-1");
- expect(out).toBe("agent_id: id-1\n\nchild-output");
- });
-
- it("surfaces child errors when blocking", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "error", error: "boom" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- expect(out).toContain("boom");
- });
-
- it("returns fire-and-forget message when top_level=true", async () => {
- const spawn = vi.fn(async () => "ua-tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- true, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true,
- });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-tab-1");
- expect(out).toContain("fire-and-forget");
- expect(getResult).not.toHaveBeenCalled();
-
- // Verify topLevel was forwarded to spawn
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true });
- });
-
- it("ignores top_level when userAgentEnabled is false", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "result" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- false, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true, // should be ignored
- });
- // Should behave as a normal foreground summon, not fire-and-forget
- expect(out).not.toContain("fire-and-forget");
- expect(getResult).toHaveBeenCalled();
- });
-});
-
-describe("createSummonTool — user-agent-only mode (perm_user_agent without perm_summon)", () => {
- // userAgentEnabled=true, subagentEnabled=false → the tool spawns ONLY
- // top-level user agents. `top_level` is implied (and forced), the
- // subagent/parallel-work prose is dropped, and only the user-agent
- // catalog group is shown.
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
-
- function userAgentOnlyTool(
- spawn = vi.fn(async () => "ua-1"),
- getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" })),
- ) {
- return {
- spawn,
- getResult,
- tool: createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- subagents,
- userAgents,
- ["/agents"],
- true, // userAgentEnabled
- false, // subagentEnabled
- ),
- };
- }
-
- it("describes spawning user agents and omits subagent/parallel-work prose", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("Spawn an independent top-level user agent");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description).not.toContain("Pattern for parallel work");
- expect(tool.description).not.toContain("Set background=true");
- });
-
- it("lists only the user-agent catalog group, not subagents", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("User agents (spawned as independent top-level tabs):");
- expect(tool.description).toContain("default");
- // Subagents must not be advertised in user-agent-only mode.
- expect(tool.description).not.toContain("Subagents (spawned as child tabs):");
- expect(tool.description).not.toContain("- programmer: Programmer");
- });
-
- it("only lists user-agent slugs in the 'agent' parameter description", () => {
- const { tool } = userAgentOnlyTool();
- const agentParam = (tool.parameters as unknown as { shape: { agent: { description: string } } })
- .shape.agent;
- expect(agentParam.description).toContain("default");
- expect(agentParam.description).not.toContain("programmer");
- });
-
- it("omits the top_level parameter (it is implied)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("top_level" in shape).toBe(false);
- });
-
- it("omits the background parameter (user agents are fire-and-forget)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("background" in shape).toBe(false);
- });
-
- it("forces topLevel=true on spawn even when top_level is not passed", async () => {
- const spawn = vi.fn(async () => "ua-99");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const { tool } = userAgentOnlyTool(spawn, getResult);
- const out = await tool.execute({ task: "do stuff", agent: "default" });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-99");
- expect(out).toContain("fire-and-forget");
- // Never blocks on a result for fire-and-forget user agents.
- expect(getResult).not.toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true, agentSlug: "default" });
- });
-});
-
-describe("createSummonTool — subagentEnabled defaults preserve legacy behavior", () => {
- it("defaults subagentEnabled=true so omitting it keeps subagent spawning", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "child" }));
- // No userAgentEnabled/subagentEnabled args → legacy subagent-only mode.
- const tool = createSummonTool("/tmp/work", { spawn, getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "programmer" });
- // Foreground subagent summon blocks and returns the child result.
- expect(out).toBe("agent_id: tab-1\n\nchild");
- expect(getResult).toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).not.toHaveProperty("topLevel");
- });
-});
diff --git a/packages/core/tests/tools/task-list.test.ts b/packages/core/tests/tools/task-list.test.ts
deleted file mode 100644
index 5903fec..0000000
--- a/packages/core/tests/tools/task-list.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createTaskListTool, TaskList } from "../../src/tools/task-list.js";
-import type { TaskItem } from "../../src/types/index.js";
-
-describe("TaskList (declarative store)", () => {
- it("starts empty", () => {
- const list = new TaskList();
- expect(list.getTasks()).toEqual([]);
- });
-
- it("setTasks replaces the whole list and assigns positional ids", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "first", status: "in_progress" },
- { content: "second", status: "pending" },
- ]);
- expect(result).toEqual([
- { id: "task-1", content: "first", status: "in_progress" },
- { id: "task-2", content: "second", status: "pending" },
- ]);
- expect(list.getTasks()).toEqual(result);
- });
-
- it("a second setTasks fully replaces the previous list (no append)", () => {
- const list = new TaskList();
- list.setTasks([
- { content: "a", status: "completed" },
- { content: "b", status: "completed" },
- { content: "c", status: "pending" },
- ]);
- const next = list.setTasks([{ content: "only", status: "in_progress" }]);
- expect(next).toEqual([{ id: "task-1", content: "only", status: "in_progress" }]);
- expect(list.getTasks()).toHaveLength(1);
- });
-
- it("preserves all four statuses", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "p", status: "pending" },
- { content: "i", status: "in_progress" },
- { content: "c", status: "completed" },
- { content: "x", status: "cancelled" },
- ]);
- expect(result.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
-
- it("defaults missing/invalid status to pending", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "no status" },
- { content: "bogus", status: "done" },
- { content: "junk", status: 42 },
- ]);
- expect(result.map((t) => t.status)).toEqual(["pending", "pending", "pending"]);
- });
-
- it("an empty array clears the list", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- expect(list.setTasks([])).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("getTasks returns copies (no external mutation leaks in)", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const snapshot = list.getTasks();
- snapshot[0].content = "mutated";
- expect(list.getTasks()[0].content).toBe("x");
- });
-
- it("onChange fires on every setTasks with the new snapshot", () => {
- const list = new TaskList();
- const seen: TaskItem[][] = [];
- const unsubscribe = list.onChange((tasks) => seen.push(tasks));
- list.setTasks([{ content: "a", status: "pending" }]);
- list.setTasks([{ content: "b", status: "completed" }]);
- expect(seen).toHaveLength(2);
- expect(seen[0]).toEqual([{ id: "task-1", content: "a", status: "pending" }]);
- expect(seen[1]).toEqual([{ id: "task-1", content: "b", status: "completed" }]);
- unsubscribe();
- list.setTasks([{ content: "c", status: "pending" }]);
- expect(seen).toHaveLength(2);
- });
-});
-
-describe("createTaskListTool", () => {
- it("exposes a single declarative `todos` parameter and the name `todo`", () => {
- const tool = createTaskListTool(new TaskList());
- expect(tool.name).toBe("todo");
- // One top-level param: the whole-list `todos` array.
- const shape = (tool.parameters as { shape: Record<string, unknown> }).shape;
- expect(Object.keys(shape)).toEqual(["todos"]);
- });
-
- it("execute updates the store and echoes the list WITHOUT ids", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({
- todos: [
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ],
- });
- expect(JSON.parse(out)).toEqual([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- // Store has ids; the echo does not.
- expect(list.getTasks()).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("execute fires onChange so the UI broadcast is wired", async () => {
- const list = new TaskList();
- const cb = vi.fn();
- list.onChange(cb);
- const tool = createTaskListTool(list);
- await tool.execute({ todos: [{ content: "x", status: "pending" }] });
- expect(cb).toHaveBeenCalledTimes(1);
- });
-
- it("execute with an empty array clears the store", async () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [] });
- expect(JSON.parse(out)).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("execute defaults invalid status to pending in both store and echo", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [{ content: "x", status: "done" }] });
- expect(JSON.parse(out)).toEqual([{ content: "x", status: "pending" }]);
- expect(list.getTasks()[0].status).toBe("pending");
- });
-
- it("execute rejects a non-array todos param", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: "nope" });
- expect(out).toMatch(/Error/);
- });
-
- it("execute rejects items missing a content string", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: [{ status: "pending" }] });
- expect(out).toMatch(/Error/);
- });
-});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
deleted file mode 100644
index 0dedbfc..0000000
--- a/packages/core/tests/tools/write-file.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { access, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createWriteFileTool } from "../../src/tools/write-file.js";
-
-describe("write_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("writes a new file", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "output.txt",
- content: "test content",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "output.txt"), "utf8");
- expect(written).toBe("test content");
- });
-
- it("creates parent directories", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "nested/dir/file.txt",
- content: "nested",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
- expect(written).toBe("nested");
- });
-
- it("blocks path traversal", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, filePath))` — when filePath
- // is absolute, `join` does NOT short-circuit, it concatenates. The old code
- // silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
- // writing to the wrong location. After the fix, absolute paths resolve
- // to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("writes an absolute path that lives under the workdir to the expected location", async () => {
- const tool = createWriteFileTool(workDir);
- const absoluteTarget = join(workDir, "abs.txt");
- const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
- expect(result).toMatch(/successfully wrote/i);
- // File must exist at exactly `absoluteTarget`, NOT at
- // `<workdir>/<workdir>/abs.txt` (the old mangled location).
- const written = await readFile(absoluteTarget, "utf8");
- expect(written).toBe("abs content");
- });
-
- it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
- const tool = createWriteFileTool(workDir);
- // Pick a path under tmpdir that's definitely not under workDir.
- // Under the bug, this got rewritten to `<workdir>/tmp/...` and the
- // write "succeeded" at the wrong location.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
- const result = await tool.execute({ path: evilPath, content: "should not land" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlink containment: even when the *leaf* doesn't exist yet (the
- // common case for write_file creating a new file), `canonicalize`
- // must walk up to the nearest existing ancestor and resolve symlinks
- // there. Otherwise, a directory symlink inside workdir pointing
- // outside lets a write escape the workspace.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
- const tool = createWriteFileTool(workDir);
- // `escape` is a symlink *inside* workdir to a directory *outside*.
- await symlink(externalDir, join(workDir, "escape"));
- const result = await tool.execute({
- path: "escape/payload.txt",
- content: "malicious payload",
- });
- expect(result).toMatch(/outside the working directory/i);
- // And the file must NOT exist in externalDir.
- await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
- // And externalDir should be empty (nothing leaked through).
- const entries = await readdir(externalDir);
- expect(entries).toEqual([]);
- });
- });
-
- describe("onAfterWrite hook", () => {
- it("appends the hook's returned string to a successful write", async () => {
- const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`);
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).toContain("DIAGNOSTICS for");
- expect(result).toContain(join(workDir, "a.luau"));
- });
-
- it("does not append when the hook returns empty string", async () => {
- const tool = createWriteFileTool(workDir, async () => "");
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/);
- });
-
- it("does not run the hook when the write is blocked (traversal)", async () => {
- let called = false;
- const tool = createWriteFileTool(workDir, async () => {
- called = true;
- return "should not appear";
- });
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- expect(called).toBe(false);
- });
-
- it("swallows hook errors so a throwing hook never fails the write", async () => {
- const tool = createWriteFileTool(workDir, async () => {
- throw new Error("lsp blew up");
- });
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).not.toContain("lsp blew up");
- });
-
- it("passes the canonical absolute path to the hook", async () => {
- let seen = "";
- const tool = createWriteFileTool(workDir, async (abs) => {
- seen = abs;
- return "";
- });
- await tool.execute({ path: "nested/b.luau", content: "x" });
- expect(seen).toBe(join(workDir, "nested/b.luau"));
- });
- });
-});
diff --git a/packages/core/tests/types/reasoning-effort.test.ts b/packages/core/tests/types/reasoning-effort.test.ts
deleted file mode 100644
index 97cd26c..0000000
--- a/packages/core/tests/types/reasoning-effort.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORT_LABELS,
- REASONING_EFFORTS,
-} from "../../src/types/index.js";
-
-describe("REASONING_EFFORTS — canonical effort list (single source of truth)", () => {
- it("is ordered least→most and includes xhigh between high and max", () => {
- expect(REASONING_EFFORTS).toEqual(["none", "low", "medium", "high", "xhigh", "max"]);
- const hi = REASONING_EFFORTS.indexOf("high");
- const xhi = REASONING_EFFORTS.indexOf("xhigh");
- const mx = REASONING_EFFORTS.indexOf("max");
- expect(hi).toBeLessThan(xhi);
- expect(xhi).toBeLessThan(mx);
- });
-
- it("has a human-readable label for every level (no gaps)", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(REASONING_EFFORT_LABELS[effort]).toBeTruthy();
- }
- expect(Object.keys(REASONING_EFFORT_LABELS).sort()).toEqual([...REASONING_EFFORTS].sort());
- });
-
- it("defaults to high", () => {
- expect(DEFAULT_REASONING_EFFORT).toBe("high");
- expect(REASONING_EFFORTS).toContain(DEFAULT_REASONING_EFFORT);
- });
-});
-
-describe("isReasoningEffort", () => {
- it("accepts every canonical level", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(isReasoningEffort(effort)).toBe(true);
- }
- });
-
- it("rejects unknown strings and non-strings", () => {
- expect(isReasoningEffort("turbo")).toBe(false);
- expect(isReasoningEffort("HIGH")).toBe(false);
- expect(isReasoningEffort("")).toBe(false);
- expect(isReasoningEffort(undefined)).toBe(false);
- expect(isReasoningEffort(null)).toBe(false);
- expect(isReasoningEffort(3)).toBe(false);
- expect(isReasoningEffort({})).toBe(false);
- });
-});