diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/tests | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/tests')
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); - }); -}); |
