From 8bf8aec997e5dd3aed4f088ab33cb2085203b873 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Wed, 15 Jul 2026 12:15:21 -0400 Subject: [PATCH] test(agent-mode): codify and apply Claude backend test conventions (#2687) --- AGENTS.md | 7 +- designdocs/agents/STYLE_GUIDE.md | 28 +- .../sdk/ClaudeSdkBackendProcess.test.ts | 1711 ++++++++--------- 3 files changed, 876 insertions(+), 870 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90720214..99489aea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,10 @@ Copilot for Obsidian is an AI-powered assistant plugin that integrates various L - **Always write generalizable solutions.** No hardcoded folder names, file patterns, or special-case logic (no "piano notes" / "daily notes" branches). Make varying behavior configurable, not hardcoded. - **Never modify AI prompt content** — system prompts, model adapter prompts, etc. — unless the user explicitly asks. - **Referential stability.** Never return a freshly-allocated `[]` / `{}` for an "empty" slice; return a frozen module-level constant (canonical examples: `EMPTY_PROVIDERS` / `EMPTY_CONFIGURED_MODELS` / `EMPTY_BACKENDS` in `src/settings/model.ts`). +- **Structure unit tests by module, class, and callable.** Use exactly one top-level `describe("moduleName", ...)` for the module under test; do not split the same subject across multiple top-level `describe` blocks. Within that module suite, wrap each class's tests in exactly one `describe("ClassName", ...)` so method ownership remains visible, then give each method exactly one nested `describe("methodName()", ...)` group. Keep module-level functions directly under the module suite, with exactly one `describe("functionName()", ...)` group per function. Merge cases that exercise the same callable. Separate same-callable groups only when a material test-lifecycle constraint makes merging misleading, and document that reason next to the groups. Write `it(...)` descriptions that state the observable behavior without requiring the reader to inspect the test body. +- **Pair every production TypeScript function and method with unit coverage.** Directly test exported and public callables; cover private and module-local helpers through their observable public contract unless direct isolation materially improves clarity. Test-only helper functions are exempt. +- **Document exported functions and public methods of exported classes when their purpose, contract, or parameters are not self-evident.** Simple functions and methods with unambiguous names and parameters may omit JSDoc. When JSDoc is needed, explain why the callable exists and the goal it serves without repeating its implementation, and add an `@param` entry for every parameter that explains its meaning without repeating its TypeScript type. +- **Document every exported class with JSDoc.** State what the class is responsible for managing and where its boundary ends so readers can understand its duty without reading the implementation. - **Never use `console.log`** — use `logInfo()` / `logWarn()` / `logError()` from `@/logger`. - **Comment the why, not the what;** minimal comments, no milestone/plan-step refs. → [`STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md) - **Never edit `styles.css`** (generated); edit `src/styles/tailwind.css`, no inline `style`, no arbitrary font sizes, wrap class strings in `cn()`. → [`STYLE_GUIDE.md`](./designdocs/agents/STYLE_GUIDE.md) @@ -113,11 +117,12 @@ gh api -X POST repos/OWNER/REPO/pulls/PR/comments/ROOT_COMMENT_ID/replies -f bod ``` Never open a review draft: `POST /pulls/PR/reviews` without an `event` (and the -UI's "Start a review") leaves the reply *pending*, which means invisible — it +UI's "Start a review") leaves the reply _pending_, which means invisible — it never reaches the reviewer, it is absent from the comments API, and its thread still reads as unanswered. Before finishing, confirm none exists: ```bash gh api repos/OWNER/REPO/pulls/PR/reviews --jq '[.[]|select(.state=="PENDING")]|length' # must be 0 ``` + diff --git a/designdocs/agents/STYLE_GUIDE.md b/designdocs/agents/STYLE_GUIDE.md index 602bb4bc..78aadbcd 100644 --- a/designdocs/agents/STYLE_GUIDE.md +++ b/designdocs/agents/STYLE_GUIDE.md @@ -15,6 +15,11 @@ language, comment, styling, and code-structure rules. - Custom hooks for reusable logic - Props interfaces defined above components +- Prefer `useSyncExternalStore` for mutable external sources that expose a + snapshot and subscription. Do not subscribe and increment dummy state solely + to force a render. Snapshots must remain referentially stable while their + semantic value is unchanged; continue to use `useState` for component-owned + UI state. ## Comments @@ -24,14 +29,21 @@ carry the **why** — the things a reader cannot recover by reading the code. - **Comment the why, not the what.** Document non-obvious constraints, invariants, gotchas, and "why this exists / why not the obvious alternative". If a comment only restates what the next line plainly says, delete it. -- **Default to minimal comments — JSDoc is not required on every function.** A - function with a clear name and signature needs no doc block. Add one only when - there's a why worth recording. When you do write a "what", keep it to one - short line. -- **Drop redundant `@param`/`@returns`.** Keep a tag only when it adds - information the type and name don't already convey (e.g. "`null` means the - agent is CLI-managed, so no key is stored"). Don't write a `@param` line that - just echoes the parameter name and type. +- **Document exported functions and public methods of exported classes when the + contract is not self-evident.** JSDoc is optional for a simple callable whose + purpose and parameters are already unambiguous. When JSDoc is needed, explain + why the callable exists and the goal it serves without narrating its concrete + implementation. Internal functions and non-public methods still default to + no doc block unless they carry a non-obvious constraint or invariant. +- **Document every parameter in an included JSDoc block by meaning, not type.** + Include one `@param` tag per parameter and explain its role or relevant + semantics. TypeScript owns the type information, so never repeat it in + JSDoc. Add `@returns` only when the return value has semantics the signature + cannot express. +- **Document every exported class with JSDoc.** Describe the state or lifecycle + the class owns, the responsibility it coordinates, and the boundary it does + not cross. The goal is to make the class's duty clear without requiring a + reader to inspect its methods or private fields. - **No milestone or plan-step references in code.** Never write `M1`/`M3`, `§4.3`, "step 3 of the plan", "after milestone X lands", or similar. These are scaffolding for whoever is _writing_ a branch and are meaningless to whoever _reviews or maintains_ the code later. diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts index 7c10e4f4..24889138 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -139,900 +139,889 @@ function getPromptQueryCalls(): unknown[][] { }); } -describe("promptInputToAnthropicContent", () => { - it("returns a plain string when the prompt is text-only", () => { - const result = promptInputToAnthropicContent({ - sessionId: "s1", - prompt: [ - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], +describe("ClaudeSdkBackendProcess", () => { + describe("promptInputToAnthropicContent()", () => { + it("returns a plain string when the prompt is text-only", () => { + const result = promptInputToAnthropicContent({ + sessionId: "s1", + prompt: [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ], + }); + expect(result).toBe("hello\nworld"); }); - expect(result).toBe("hello\nworld"); - }); - it("returns content blocks when an image is attached", () => { - const result = promptInputToAnthropicContent({ - sessionId: "s1", - prompt: [ + it("returns content blocks when an image is attached", () => { + const result = promptInputToAnthropicContent({ + sessionId: "s1", + prompt: [ + { type: "text", text: "describe" }, + { type: "image", mimeType: "image/png", data: "aGVsbG8=" }, + ], + }); + expect(result).toEqual([ { type: "text", text: "describe" }, - { type: "image", mimeType: "image/png", data: "aGVsbG8=" }, - ], + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" }, + }, + ]); }); - expect(result).toEqual([ - { type: "text", text: "describe" }, - { - type: "image", - source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" }, - }, - ]); - }); - it("normalizes jpg media types before sending image blocks", () => { - const result = promptInputToAnthropicContent({ - sessionId: "s1", - prompt: [ + it("normalizes jpg media types before sending image blocks", () => { + const result = promptInputToAnthropicContent({ + sessionId: "s1", + prompt: [ + { type: "text", text: "describe" }, + { type: "image", mimeType: "image/jpg", data: "aGVsbG8=" }, + ], + }); + expect(result).toEqual([ { type: "text", text: "describe" }, - { type: "image", mimeType: "image/jpg", data: "aGVsbG8=" }, - ], + { + type: "image", + source: { type: "base64", media_type: "image/jpeg", data: "aGVsbG8=" }, + }, + ]); }); - expect(result).toEqual([ - { type: "text", text: "describe" }, - { - type: "image", - source: { type: "base64", media_type: "image/jpeg", data: "aGVsbG8=" }, - }, - ]); - }); - it("omits image media types Anthropic does not accept", () => { - const result = promptInputToAnthropicContent({ - sessionId: "s1", - prompt: [ + it("omits image media types Anthropic does not accept", () => { + const result = promptInputToAnthropicContent({ + sessionId: "s1", + prompt: [ + { type: "text", text: "describe" }, + { type: "image", mimeType: "image/heic", data: "aGVsbG8=" }, + ], + }); + expect(result).toEqual([ { type: "text", text: "describe" }, - { type: "image", mimeType: "image/heic", data: "aGVsbG8=" }, - ], + { type: "text", text: "[Unsupported image attachment omitted: image/heic]" }, + ]); }); - expect(result).toEqual([ - { type: "text", text: "describe" }, - { type: "text", text: "[Unsupported image attachment omitted: image/heic]" }, - ]); - }); - it("represents resource_link as a defensive text reference", () => { - const result = promptInputToAnthropicContent({ - sessionId: "s1", - prompt: [ + it("represents resource_link as a defensive text reference", () => { + const result = promptInputToAnthropicContent({ + sessionId: "s1", + prompt: [ + { type: "text", text: "see the doc" }, + { type: "resource_link", uri: "vault://README.md", name: "README" }, + { type: "image", mimeType: "image/jpeg", data: "ZmFrZQ==" }, + ], + }); + expect(result).toEqual([ { type: "text", text: "see the doc" }, - { type: "resource_link", uri: "vault://README.md", name: "README" }, - { type: "image", mimeType: "image/jpeg", data: "ZmFrZQ==" }, - ], + { type: "text", text: "[Attached resource: README]" }, + { + type: "image", + source: { type: "base64", media_type: "image/jpeg", data: "ZmFrZQ==" }, + }, + ]); }); - expect(result).toEqual([ - { type: "text", text: "see the doc" }, - { type: "text", text: "[Attached resource: README]" }, - { - type: "image", - source: { type: "base64", media_type: "image/jpeg", data: "ZmFrZQ==" }, - }, - ]); - }); -}); - -describe("ClaudeSdkBackendProcess.prompt happy path", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); }); - it("translates SDK text deltas to agent_message_chunk and resolves with end_turn", async () => { - queryMock.mockImplementation(() => - makeQuery([ - streamEvent({ type: "message_start", message: {} }), - streamEvent({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "hello" }, - }), - resultMessage(), - ]) - ); + describe("prompt()", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", + it("translates SDK text deltas to agent_message_chunk and resolves with end_turn", async () => { + queryMock.mockImplementation(() => + makeQuery([ + streamEvent({ type: "message_start", message: {} }), + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "hello" }, + }), + resultMessage(), + ]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId, state } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(sessionId).toBeTruthy(); + expect(state.model?.current.baseModelId).toBe("claude-fake-pro"); + + const events: SessionEvent[] = []; + proc.registerSessionHandler(sessionId, (e) => events.push(e)); + + const resp = await proc.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + expect(resp.stopReason).toBe("end_turn"); + + const chunks = events.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); + expect(chunks).toHaveLength(1); + const chunk = chunks[0].update; + if (chunk.sessionUpdate === "agent_message_chunk" && chunk.content.type === "text") { + expect(chunk.content.text).toBe("hello"); + } else { + throw new Error("expected agent_message_chunk text update"); + } + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: Record }; + expect(call.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); + expect(Object.keys(call.options.mcpServers as object)).not.toContain("obsidian-vault"); + expect(call.options.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "LS"]); + expect(call.options.disallowedTools).toBeUndefined(); + // First turn → sessionId is seeded, no resume. + expect(call.options.sessionId).toBe(sessionId); + expect(call.options.resume).toBeUndefined(); + // No skill-creation directive opt passed → no systemPrompt override. + expect(call.options.systemPrompt).toBeUndefined(); + }); + + it("forwards the composed system prompt via systemPrompt append on the claude_code preset", async () => { + queryMock.mockImplementation(() => + makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getSystemPromptAppend: () => "DO THIS THING WITH SKILLS", + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const calls = getPromptQueryCalls(); + const opts = (calls[0][0] as { options: Record }).options; + expect(opts.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "DO THIS THING WITH SKILLS", + }); + }); + + it("captures the system prompt at newSession time and ignores later setting changes mid-session", async () => { + queryMock.mockImplementation(() => + makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) + ); + + let current = "FIRST DIRECTIVE"; + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getSystemPromptAppend: () => current, + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + // Mutate the "setting" after newSession → the session's first turn must + // still use the original prompt, proving capture-at-newSession semantics. + current = "SECOND DIRECTIVE"; + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const opts = (getPromptQueryCalls()[0][0] as { options: Record }).options; + expect(opts.systemPrompt).toEqual({ + type: "preset", + preset: "claude_code", + append: "FIRST DIRECTIVE", + }); + }); + + // The SDK adapter's contract is "forward `getSystemPromptAppend()` verbatim + // into `options.systemPrompt.append`", proven above. The Claude descriptor + // wires that callback to `buildAgentSystemPrompt`, whose composition — the + // Copilot base prompt, pill directive, user custom prompt, and the + // disable-builtin behavior — is unit-tested in + // `backends/shared/agentSystemPrompt.test.ts`. (The `sdk` layer can't import + // a `backend` module under `boundaries/dependencies`, so that assertion + // lives there, not here.) + + it("buffers events emitted before a session handler is registered and replays them", async () => { + queryMock.mockImplementation(() => + makeQuery([ + streamEvent({ type: "message_start", message: {} }), + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "buffered" }, + }), + resultMessage(), + ]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + // Kick off prompt without a handler — events are buffered. + const promptPromise = proc.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + + const seen: SessionEvent[] = []; + proc.registerSessionHandler(sessionId, (e) => seen.push(e)); + await promptPromise; + + const chunks = seen.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); + expect(chunks.length).toBeGreaterThan(0); + }); + + it("passes resume on the second prompt for the same session", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(2); + const second = promptCalls[1][0] as { options: Record }; + expect(second.options.resume).toBe(sessionId); + expect(second.options.sessionId).toBeUndefined(); + }); + + describe("authentication", () => { + function makeAuthCheckedProcess(checkAuth: jest.Mock) { + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + checkAuth, + }); + } + + it("rejects with AuthRequiredError and never spawns query when not signed in", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const checkAuth = jest.fn().mockResolvedValue(false); + const proc = makeAuthCheckedProcess(checkAuth); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + await expect( + proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }) + ).rejects.toBeInstanceOf(AuthRequiredError); + expect(getPromptQueryCalls()).toHaveLength(0); + }); + + it("checks auth only once across turns once signed in (cached)", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const checkAuth = jest.fn().mockResolvedValue(true); + const proc = makeAuthCheckedProcess(checkAuth); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); + + expect(checkAuth).toHaveBeenCalledTimes(1); + expect(getPromptQueryCalls()).toHaveLength(2); + }); + + it("re-checks auth on the next turn when a turn ends non-success with no errors", async () => { + const checkAuth = jest.fn().mockResolvedValue(true); + const proc = makeAuthCheckedProcess(checkAuth); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + // First turn ends with a non-success result carrying no error detail + // (the "saved login expired" shape) → cache is invalidated. + queryMock.mockImplementationOnce(() => makeQuery([errorResultMessage([])])); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); + expect(checkAuth).toHaveBeenCalledTimes(1); + + queryMock.mockImplementationOnce(() => makeQuery([resultMessage()])); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); + expect(checkAuth).toHaveBeenCalledTimes(2); + }); + }); + + describe("stream stall watchdog", () => { + function makeProcess() { + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + } + + /** + * A query whose stream emits a couple of mid-message deltas (arming the + * watchdog) and then goes silent forever — until the backend's abort + * controller fires, at which point the generator returns (the SDK "stops and + * cleans up"). Reproduces a dropped/half-open response with no terminal + * `result`, which would otherwise park `for await` and wedge the turn. + */ + function makeStallingQuery(arg: unknown) { + const { options } = arg as { options: { abortController: AbortController } }; + const { signal } = options.abortController; + const iter = (async function* () { + yield streamEvent({ type: "message_start", message: {} }); + yield streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Draf" }, + }); + await new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener("abort", () => resolve(), { once: true }); + }); + })(); + return Object.assign(iter, { + interrupt: jest.fn().mockResolvedValue(undefined), + setModel: jest.fn().mockResolvedValue(undefined), + setPermissionMode: jest.fn().mockResolvedValue(undefined), + }); + } + + it("aborts the turn and rejects when the stream stalls mid-message", async () => { + queryMock.mockImplementation((arg: unknown) => makeStallingQuery(arg)); + const proc = makeProcess(); + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + jest.useFakeTimers(); + try { + const turn = proc.prompt({ sessionId, prompt: [{ type: "text", text: "draft a plan" }] }); + // The thrown stall error is what `AgentSession` renders as the in-chat + // turn error via `markMessageError`. + const assertion = expect(turn).rejects.toThrow(/stalled/i); + // Past the idle window; advanceTimersByTimeAsync flushes microtasks so the + // two deltas are consumed and the watchdog timer fires. + await jest.advanceTimersByTimeAsync(61_000); + await assertion; + } finally { + jest.useRealTimers(); + } + // The query was aborted (not left dangling) so the turn can be retried. + const call = getPromptQueryCalls()[0][0] as { + options: { abortController: AbortController }; + }; + expect(call.options.abortController.signal.aborted).toBe(true); + }); + + it("passes an abort controller to query() and never fires while the stream is healthy", async () => { + queryMock.mockImplementation(() => + makeQuery([ + streamEvent({ type: "message_start", message: {} }), + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "ok" }, + }), + streamEvent({ type: "message_stop" }), + resultMessage(), + ]) + ); + const proc = makeProcess(); + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + + const resp = await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + expect(resp.stopReason).toBe("end_turn"); + const call = getPromptQueryCalls()[0][0] as { options: { abortController?: unknown } }; + expect(call.options.abortController).toBeInstanceOf(AbortController); + }); + }); + }); + + describe("newSession()", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + it("returns BackendState with current model + effort options from the cached catalog", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); + const ids = resp.state.model?.availableModels.map((m) => m.baseModelId); + expect(ids).toContain("claude-fake-pro"); + expect(ids).toContain("claude-fake-mini"); + const pro = resp.state.model?.availableModels.find( + (m) => m.baseModelId === "claude-fake-pro" + ); + expect(pro?.effortOptions.map((o) => o.value)).toEqual(["low", "medium", "high"]); + // The SDK's per-model `description` is carried into the entry (used as the + // capability second line in the picker + settings). + expect(pro?.description).toBe("test"); + }); + + it("honors persisted default model when it appears in the catalog", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getDefaultModelId: () => "claude-fake-mini", + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-mini"); + const miniEffort = resp.state.model?.availableModels.find( + (m) => m.baseModelId === "claude-fake-mini" + )?.effortOptions; + expect(miniEffort).toEqual([]); + }); + + it("falls back to catalog default when the default model is gone", async () => { + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getDefaultModelId: () => "claude-removed-by-cli-upgrade", + }); + + const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); + }); + + it("seeds session.model so prompt() sends options.model", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: { model?: string } }; + expect(call.options.model).toBe("claude-fake-pro"); + }); + + it("setSessionConfigOption('effort', …) clamps + persists the level on the session", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + const stateAfter = await proc.setSessionConfigOption({ + sessionId, + configId: "effort", + value: "high", + }); + expect(stateAfter.model?.current.effort).toBe("high"); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + const promptCalls = getPromptQueryCalls(); + expect(promptCalls).toHaveLength(1); + const call = promptCalls[0][0] as { options: { effort?: string } }; + expect(call.options.effort).toBe("high"); + }); + + it("disables thinking when the extended-thinking toggle is off", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnableThinking: () => false, + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const call = getPromptQueryCalls()[0][0] as { options: { thinking?: unknown } }; + expect(call.options.thinking).toEqual({ type: "disabled" }); + }); + + it("requests summarized adaptive thinking when the toggle is on", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnableThinking: () => true, + }); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const call = getPromptQueryCalls()[0][0] as { options: { thinking?: unknown } }; + expect(call.options.thinking).toEqual({ type: "adaptive", display: "summarized" }); + }); + + it("does not open a session when Claude Code is unsupported", async () => { + const checkCompatibility = jest + .fn() + .mockRejectedValue(new Error("Claude Code 2.1.205 is not supported")); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + checkCompatibility, + }); + + await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).rejects.toThrow( + "Claude Code 2.1.205 is not supported" + ); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("shares a successful compatibility check across sessions", async () => { + const checkCompatibility = jest.fn().mockResolvedValue(undefined); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + checkCompatibility, + }); + + await Promise.all([ + proc.newSession({ cwd: "/vault-a", mcpServers: [] }), + proc.newSession({ cwd: "/vault-b", mcpServers: [] }), + ]); + + expect(checkCompatibility).toHaveBeenCalledTimes(1); + }); + + it("retries compatibility after a failed check", async () => { + const checkCompatibility = jest + .fn() + .mockRejectedValueOnce(new Error("upgrade required")) + .mockResolvedValueOnce(undefined); + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + checkCompatibility, + }); + + await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).rejects.toThrow( + "upgrade required" + ); + await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).resolves.toBeDefined(); + expect(checkCompatibility).toHaveBeenCalledTimes(2); + }); + + it("threads the backend's env overrides into the probe on a cold cache", async () => { + // Cold module cache forces a real probe; the SDK reflects ANTHROPIC_MODEL + // into init.models itself, so the env just has to reach the probe. + (getCachedSdkCatalog as jest.Mock).mockReturnValue(undefined); + const initializationResult = jest.fn().mockResolvedValue({ models: FAKE_CATALOG }); + queryMock.mockReturnValue({ + initializationResult, + interrupt: jest.fn().mockResolvedValue(undefined), + }); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnvOverrides: () => ({ ANTHROPIC_MODEL: "claude-fable-5" }), + }); + + await proc.newSession({ cwd: "/vault", mcpServers: [] }); + + const probeCall = queryMock.mock.calls[0][0] as { + options: { pathToClaudeCodeExecutable: string; env?: Record }; + }; + expect(probeCall.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); + expect(probeCall.options.env?.ANTHROPIC_MODEL).toBe("claude-fable-5"); + // Child env is process.env plus the overrides, not a bare override map. + expect(probeCall.options.env?.PATH).toBe(process.env.PATH); + }); + }); + + function errorResultMessage(errors: string[]): SDKMessage { + return { + type: "result", + subtype: "error_during_execution", + duration_ms: 1, + duration_api_ms: 1, + is_error: true, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const { sessionId, state } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - expect(sessionId).toBeTruthy(); - expect(state.model?.current.baseModelId).toBe("claude-fake-pro"); - - const events: SessionEvent[] = []; - proc.registerSessionHandler(sessionId, (e) => events.push(e)); - - const resp = await proc.prompt({ - sessionId, - prompt: [{ type: "text", text: "hi" }], - }); - expect(resp.stopReason).toBe("end_turn"); - - const chunks = events.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); - expect(chunks).toHaveLength(1); - const chunk = chunks[0].update; - if (chunk.sessionUpdate === "agent_message_chunk" && chunk.content.type === "text") { - expect(chunk.content.text).toBe("hello"); - } else { - throw new Error("expected agent_message_chunk text update"); - } - - const promptCalls = getPromptQueryCalls(); - expect(promptCalls).toHaveLength(1); - const call = promptCalls[0][0] as { options: Record }; - expect(call.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); - expect(Object.keys(call.options.mcpServers as object)).not.toContain("obsidian-vault"); - expect(call.options.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "LS"]); - expect(call.options.disallowedTools).toBeUndefined(); - // First turn → sessionId is seeded, no resume. - expect(call.options.sessionId).toBe(sessionId); - expect(call.options.resume).toBeUndefined(); - // No skill-creation directive opt passed → no systemPrompt override. - expect(call.options.systemPrompt).toBeUndefined(); - }); - - it("forwards the composed system prompt via systemPrompt append on the claude_code preset", async () => { - queryMock.mockImplementation(() => - makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) - ); - - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getSystemPromptAppend: () => "DO THIS THING WITH SKILLS", - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const calls = getPromptQueryCalls(); - const opts = (calls[0][0] as { options: Record }).options; - expect(opts.systemPrompt).toEqual({ - type: "preset", - preset: "claude_code", - append: "DO THIS THING WITH SKILLS", - }); - }); - - it("captures the system prompt at newSession time and ignores later setting changes mid-session", async () => { - queryMock.mockImplementation(() => - makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) - ); - - let current = "FIRST DIRECTIVE"; - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getSystemPromptAppend: () => current, - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - // Mutate the "setting" after newSession → the session's first turn must - // still use the original prompt, proving capture-at-newSession semantics. - current = "SECOND DIRECTIVE"; - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const opts = (getPromptQueryCalls()[0][0] as { options: Record }).options; - expect(opts.systemPrompt).toEqual({ - type: "preset", - preset: "claude_code", - append: "FIRST DIRECTIVE", - }); - }); - - // The SDK adapter's contract is "forward `getSystemPromptAppend()` verbatim - // into `options.systemPrompt.append`", proven above. The Claude descriptor - // wires that callback to `buildAgentSystemPrompt`, whose composition — the - // Copilot base prompt, pill directive, user custom prompt, and the - // disable-builtin behavior — is unit-tested in - // `backends/shared/agentSystemPrompt.test.ts`. (The `sdk` layer can't import - // a `backend` module under `boundaries/dependencies`, so that assertion - // lives there, not here.) - - it("buffers events emitted before a session handler is registered and replays them", async () => { - queryMock.mockImplementation(() => - makeQuery([ - streamEvent({ type: "message_start", message: {} }), - streamEvent({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "buffered" }, - }), - resultMessage(), - ]) - ); - - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - // Kick off prompt without a handler — events are buffered. - const promptPromise = proc.prompt({ - sessionId, - prompt: [{ type: "text", text: "hi" }], - }); - - const seen: SessionEvent[] = []; - proc.registerSessionHandler(sessionId, (e) => seen.push(e)); - await promptPromise; - - const chunks = seen.filter((u) => u.update.sessionUpdate === "agent_message_chunk"); - expect(chunks.length).toBeGreaterThan(0); - }); - - it("passes resume on the second prompt for the same session", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); - - const promptCalls = getPromptQueryCalls(); - expect(promptCalls).toHaveLength(2); - const second = promptCalls[1][0] as { options: Record }; - expect(second.options.resume).toBe(sessionId); - expect(second.options.sessionId).toBeUndefined(); - }); -}); - -describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - it("returns BackendState with current model + effort options from the cached catalog", async () => { - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); - const ids = resp.state.model?.availableModels.map((m) => m.baseModelId); - expect(ids).toContain("claude-fake-pro"); - expect(ids).toContain("claude-fake-mini"); - const pro = resp.state.model?.availableModels.find((m) => m.baseModelId === "claude-fake-pro"); - expect(pro?.effortOptions.map((o) => o.value)).toEqual(["low", "medium", "high"]); - // The SDK's per-model `description` is carried into the entry (used as the - // capability second line in the picker + settings). - expect(pro?.description).toBe("test"); - }); - - it("honors persisted default model when it appears in the catalog", async () => { - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getDefaultModelId: () => "claude-fake-mini", - }); - - const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - expect(resp.state.model?.current.baseModelId).toBe("claude-fake-mini"); - const miniEffort = resp.state.model?.availableModels.find( - (m) => m.baseModelId === "claude-fake-mini" - )?.effortOptions; - expect(miniEffort).toEqual([]); - }); - - it("falls back to catalog default when the default model is gone", async () => { - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getDefaultModelId: () => "claude-removed-by-cli-upgrade", - }); - - const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro"); - }); - - it("seeds session.model so prompt() sends options.model", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const promptCalls = getPromptQueryCalls(); - expect(promptCalls).toHaveLength(1); - const call = promptCalls[0][0] as { options: { model?: string } }; - expect(call.options.model).toBe("claude-fake-pro"); - }); - - it("setSessionConfigOption('effort', …) clamps + persists the level on the session", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - const stateAfter = await proc.setSessionConfigOption({ - sessionId, - configId: "effort", - value: "high", - }); - expect(stateAfter.model?.current.effort).toBe("high"); - - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - const promptCalls = getPromptQueryCalls(); - expect(promptCalls).toHaveLength(1); - const call = promptCalls[0][0] as { options: { effort?: string } }; - expect(call.options.effort).toBe("high"); - }); - - it("disables thinking when the extended-thinking toggle is off", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getEnableThinking: () => false, - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const call = getPromptQueryCalls()[0][0] as { options: { thinking?: unknown } }; - expect(call.options.thinking).toEqual({ type: "disabled" }); - }); - - it("requests summarized adaptive thinking when the toggle is on", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getEnableThinking: () => true, - }); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const call = getPromptQueryCalls()[0][0] as { options: { thinking?: unknown } }; - expect(call.options.thinking).toEqual({ type: "adaptive", display: "summarized" }); - }); - - it("does not open a session when Claude Code is unsupported", async () => { - const checkCompatibility = jest - .fn() - .mockRejectedValue(new Error("Claude Code 2.1.205 is not supported")); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - checkCompatibility, - }); - - await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).rejects.toThrow( - "Claude Code 2.1.205 is not supported" - ); - expect(queryMock).not.toHaveBeenCalled(); - }); - - it("shares a successful compatibility check across sessions", async () => { - const checkCompatibility = jest.fn().mockResolvedValue(undefined); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - checkCompatibility, - }); - - await Promise.all([ - proc.newSession({ cwd: "/vault-a", mcpServers: [] }), - proc.newSession({ cwd: "/vault-b", mcpServers: [] }), - ]); - - expect(checkCompatibility).toHaveBeenCalledTimes(1); - }); - - it("retries compatibility after a failed check", async () => { - const checkCompatibility = jest - .fn() - .mockRejectedValueOnce(new Error("upgrade required")) - .mockResolvedValueOnce(undefined); - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - checkCompatibility, - }); - - await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).rejects.toThrow( - "upgrade required" - ); - await expect(proc.newSession({ cwd: "/vault", mcpServers: [] })).resolves.toBeDefined(); - expect(checkCompatibility).toHaveBeenCalledTimes(2); - }); -}); - -describe("ANTHROPIC_MODEL env override reaches the catalog probe", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - it("threads the backend's env overrides into the probe on a cold cache", async () => { - // Cold module cache forces a real probe; the SDK reflects ANTHROPIC_MODEL - // into init.models itself, so the env just has to reach the probe. - (getCachedSdkCatalog as jest.Mock).mockReturnValue(undefined); - const initializationResult = jest.fn().mockResolvedValue({ models: FAKE_CATALOG }); - queryMock.mockReturnValue({ - initializationResult, - interrupt: jest.fn().mockResolvedValue(undefined), - }); - - const proc = new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getEnvOverrides: () => ({ ANTHROPIC_MODEL: "claude-fable-5" }), - }); - - await proc.newSession({ cwd: "/vault", mcpServers: [] }); - - const probeCall = queryMock.mock.calls[0][0] as { - options: { pathToClaudeCodeExecutable: string; env?: Record }; + usage: {} as any, + modelUsage: {}, + permission_denials: [], + errors, + uuid: "uuid-e" as `${string}-${string}-${string}-${string}-${string}`, + session_id: "irrelevant", }; - expect(probeCall.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); - expect(probeCall.options.env?.ANTHROPIC_MODEL).toBe("claude-fable-5"); - // Child env is process.env plus the overrides, not a bare override map. - expect(probeCall.options.env?.PATH).toBe(process.env.PATH); - }); -}); - -function errorResultMessage(errors: string[]): SDKMessage { - return { - type: "result", - subtype: "error_during_execution", - duration_ms: 1, - duration_api_ms: 1, - is_error: true, - num_turns: 1, - stop_reason: null, - total_cost_usd: 0, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - usage: {} as any, - modelUsage: {}, - permission_denials: [], - errors, - uuid: "uuid-e" as `${string}-${string}-${string}-${string}-${string}`, - session_id: "irrelevant", - }; -} - -describe("ClaudeSdkBackendProcess project profile provider", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - // Echoes the resolved project instructions so a test can read back exactly - // what the SDK passed to `getSystemPromptAppend` per session. Mirrors the - // descriptor's real `(opts) => buildAgentSystemPrompt(opts)`. - const echoAppend = (opts?: { projectInstructions?: string }): string => { - const parts = ["BASE"]; - if (opts?.projectInstructions) parts.push(opts.projectInstructions); - return parts.join("\n\n"); - }; - - function makeProc(getSystemPromptAppend: (opts?: { projectInstructions?: string }) => string) { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getSystemPromptAppend, - }); } - function appendOf(callIndex: number): unknown { - const opts = (getPromptQueryCalls()[callIndex][0] as { options: Record }) - .options; - return (opts.systemPrompt as { append?: unknown } | undefined)?.append; - } - - it("global parity: an unset provider resolves the byte-identical no-project append", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - - // No provider wired, no projectId → same as today's global path. - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE"); - }); - - it("global parity: GLOBAL_SCOPE / unknown project (provider returns undefined) yields the global append", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider(() => undefined); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "__global__", - }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE"); - }); - - it("appends the owning project's instructions when the provider resolves a profile", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "Cite only #verified notes." } : undefined - ); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "proj-1", - }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE\n\nCite only #verified notes."); - }); - - it("resolves each session's own project on one process (per-session, not process-global)", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => { - if (id === "proj-A") return { id, systemPrompt: "A rules" }; - if (id === "proj-B") return { id, systemPrompt: "B rules" }; - return undefined; + describe("setProjectProfileProvider()", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); }); - const a = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-A" }); - const b = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-B" }); - proc.registerSessionHandler(a.sessionId, () => {}); - proc.registerSessionHandler(b.sessionId, () => {}); + // Echoes the resolved project instructions so a test can read back exactly + // what the SDK passed to `getSystemPromptAppend` per session. Mirrors the + // descriptor's real `(opts) => buildAgentSystemPrompt(opts)`. + const echoAppend = (opts?: { projectInstructions?: string }): string => { + const parts = ["BASE"]; + if (opts?.projectInstructions) parts.push(opts.projectInstructions); + return parts.join("\n\n"); + }; - await proc.prompt({ sessionId: a.sessionId, prompt: [{ type: "text", text: "hi" }] }); - await proc.prompt({ sessionId: b.sessionId, prompt: [{ type: "text", text: "hi" }] }); - - // The two prompt calls carry each session's own project instructions. - expect(appendOf(0)).toBe("BASE\n\nA rules"); - expect(appendOf(1)).toBe("BASE\n\nB rules"); - }); - - it("captures project instructions at newSession, ignoring later provider swaps mid-session", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(echoAppend); - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "ORIGINAL" } : undefined - ); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - projectId: "proj-1", - }); - proc.registerSessionHandler(sessionId, () => {}); - // Swap the provider after the session exists; the captured append must win. - proc.setProjectProfileProvider((id) => - id === "proj-1" ? { id, systemPrompt: "CHANGED" } : undefined - ); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - expect(appendOf(0)).toBe("BASE\n\nORIGINAL"); - }); -}); - -describe("ClaudeSdkBackendProcess additional directories", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - function makeProc() { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - } - - it("reports support for additionalDirectories (stable SDK option)", () => { - expect(makeProc().supportsAdditionalDirectories()).toBe(true); - }); - - it("forwards captured additionalDirectories into options on every turn", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(); - - const { sessionId } = await proc.newSession({ - cwd: "/vault", - mcpServers: [], - additionalDirectories: ["/abs/context-a", "/abs/context-b"], - }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } }; - expect(call.options.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]); - }); - - it("omits additionalDirectories from options when none were captured", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const proc = makeProc(); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - - const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } }; - expect(call.options.additionalDirectories).toBeUndefined(); - }); -}); - -describe("ClaudeSdkBackendProcess.prompt auth gate", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - function makeProc(checkAuth: jest.Mock) { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - checkAuth, - }); - } - - it("rejects with AuthRequiredError and never spawns query when not signed in", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const checkAuth = jest.fn().mockResolvedValue(false); - const proc = makeProc(checkAuth); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - - await expect( - proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }) - ).rejects.toBeInstanceOf(AuthRequiredError); - expect(getPromptQueryCalls()).toHaveLength(0); - }); - - it("checks auth only once across turns once signed in (cached)", async () => { - queryMock.mockImplementation(() => makeQuery([resultMessage()])); - const checkAuth = jest.fn().mockResolvedValue(true); - const proc = makeProc(checkAuth); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); - - expect(checkAuth).toHaveBeenCalledTimes(1); - expect(getPromptQueryCalls()).toHaveLength(2); - }); - - it("re-checks auth on the next turn when a turn ends non-success with no errors", async () => { - const checkAuth = jest.fn().mockResolvedValue(true); - const proc = makeProc(checkAuth); - - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - - // First turn ends with a non-success result carrying no error detail - // (the "saved login expired" shape) → cache is invalidated. - queryMock.mockImplementationOnce(() => makeQuery([errorResultMessage([])])); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] }); - expect(checkAuth).toHaveBeenCalledTimes(1); - - queryMock.mockImplementationOnce(() => makeQuery([resultMessage()])); - await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] }); - expect(checkAuth).toHaveBeenCalledTimes(2); - }); -}); - -describe("ClaudeSdkBackendProcess.prompt stream-stall watchdog", () => { - beforeEach(() => { - queryMock.mockReset(); - createSdkMcpServerMock.mockClear(); - }); - - function makeProc() { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - }); - } - - /** - * A query whose stream emits a couple of mid-message deltas (arming the - * watchdog) and then goes silent forever — until the backend's abort - * controller fires, at which point the generator returns (the SDK "stops and - * cleans up"). Reproduces a dropped/half-open response with no terminal - * `result`, which would otherwise park `for await` and wedge the turn. - */ - function makeStallingQuery(arg: unknown) { - const { options } = arg as { options: { abortController: AbortController } }; - const { signal } = options.abortController; - const iter = (async function* () { - yield streamEvent({ type: "message_start", message: {} }); - yield streamEvent({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "Draf" }, + function makeProc(getSystemPromptAppend: (opts?: { projectInstructions?: string }) => string) { + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getSystemPromptAppend, }); - await new Promise((resolve) => { - if (signal.aborted) resolve(); - else signal.addEventListener("abort", () => resolve(), { once: true }); - }); - })(); - return Object.assign(iter, { - interrupt: jest.fn().mockResolvedValue(undefined), - setModel: jest.fn().mockResolvedValue(undefined), - setPermissionMode: jest.fn().mockResolvedValue(undefined), - }); - } - - it("aborts the turn and rejects when the stream stalls mid-message", async () => { - queryMock.mockImplementation((arg: unknown) => makeStallingQuery(arg)); - const proc = makeProc(); - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); - - jest.useFakeTimers(); - try { - const turn = proc.prompt({ sessionId, prompt: [{ type: "text", text: "draft a plan" }] }); - // The thrown stall error is what `AgentSession` renders as the in-chat - // turn error via `markMessageError`. - const assertion = expect(turn).rejects.toThrow(/stalled/i); - // Past the idle window; advanceTimersByTimeAsync flushes microtasks so the - // two deltas are consumed and the watchdog timer fires. - await jest.advanceTimersByTimeAsync(61_000); - await assertion; - } finally { - jest.useRealTimers(); } - // The query was aborted (not left dangling) so the turn can be retried. - const call = getPromptQueryCalls()[0][0] as { options: { abortController: AbortController } }; - expect(call.options.abortController.signal.aborted).toBe(true); - }); - it("passes an abort controller to query() and never fires while the stream is healthy", async () => { - queryMock.mockImplementation(() => - makeQuery([ - streamEvent({ type: "message_start", message: {} }), - streamEvent({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "ok" }, - }), - streamEvent({ type: "message_stop" }), - resultMessage(), - ]) - ); - const proc = makeProc(); - const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); - proc.registerSessionHandler(sessionId, () => {}); + function appendOf(callIndex: number): unknown { + const opts = (getPromptQueryCalls()[callIndex][0] as { options: Record }) + .options; + return (opts.systemPrompt as { append?: unknown } | undefined)?.append; + } - const resp = await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - expect(resp.stopReason).toBe("end_turn"); - const call = getPromptQueryCalls()[0][0] as { options: { abortController?: unknown } }; - expect(call.options.abortController).toBeInstanceOf(AbortController); - }); -}); + it("global parity: an unset provider resolves the byte-identical no-project append", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(echoAppend); -describe("ClaudeSdkBackendProcess.sessionExistsLocally", () => { - const cwd = "/vault"; - // Mirrors the CLI's project-dir encoding: non-alphanumerics → "-". - const projectDir = cwd.replace(/[^a-zA-Z0-9]/g, "-"); - let configDir: string; + // No provider wired, no projectId → same as today's global path. + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); - beforeEach(async () => { - configDir = await mkdtemp(path.join(os.tmpdir(), "claude-config-")); - }); - afterEach(async () => { - await rm(configDir, { recursive: true, force: true }); - }); - - function makeProc(): ClaudeSdkBackendProcess { - return new ClaudeSdkBackendProcess({ - pathToClaudeCodeExecutable: "/usr/local/bin/claude", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app: { vault: {} } as any, - clientVersion: "1.2.3", - descriptor: fakeDescriptor(), - getEnvOverrides: () => ({ CLAUDE_CONFIG_DIR: configDir }), + expect(appendOf(0)).toBe("BASE"); }); - } - it("returns true when this device has the session transcript on disk", async () => { - const sessionId = "11111111-2222-3333-4444-555555555555"; - const dir = path.join(configDir, "projects", projectDir); - await mkdir(dir, { recursive: true }); - await writeFile(path.join(dir, `${sessionId}.jsonl`), "{}\n"); + it("global parity: GLOBAL_SCOPE / unknown project (provider returns undefined) yields the global append", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(echoAppend); + proc.setProjectProfileProvider(() => undefined); - await expect(makeProc().sessionExistsLocally({ sessionId, cwd })).resolves.toBe(true); + const { sessionId } = await proc.newSession({ + cwd: "/vault", + mcpServers: [], + projectId: "__global__", + }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + expect(appendOf(0)).toBe("BASE"); + }); + + it("appends the owning project's instructions when the provider resolves a profile", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(echoAppend); + proc.setProjectProfileProvider((id) => + id === "proj-1" ? { id, systemPrompt: "Cite only #verified notes." } : undefined + ); + + const { sessionId } = await proc.newSession({ + cwd: "/vault", + mcpServers: [], + projectId: "proj-1", + }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + expect(appendOf(0)).toBe("BASE\n\nCite only #verified notes."); + }); + + it("resolves each session's own project on one process (per-session, not process-global)", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(echoAppend); + proc.setProjectProfileProvider((id) => { + if (id === "proj-A") return { id, systemPrompt: "A rules" }; + if (id === "proj-B") return { id, systemPrompt: "B rules" }; + return undefined; + }); + + const a = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-A" }); + const b = await proc.newSession({ cwd: "/vault", mcpServers: [], projectId: "proj-B" }); + proc.registerSessionHandler(a.sessionId, () => {}); + proc.registerSessionHandler(b.sessionId, () => {}); + + await proc.prompt({ sessionId: a.sessionId, prompt: [{ type: "text", text: "hi" }] }); + await proc.prompt({ sessionId: b.sessionId, prompt: [{ type: "text", text: "hi" }] }); + + // The two prompt calls carry each session's own project instructions. + expect(appendOf(0)).toBe("BASE\n\nA rules"); + expect(appendOf(1)).toBe("BASE\n\nB rules"); + }); + + it("captures project instructions at newSession, ignoring later provider swaps mid-session", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(echoAppend); + proc.setProjectProfileProvider((id) => + id === "proj-1" ? { id, systemPrompt: "ORIGINAL" } : undefined + ); + + const { sessionId } = await proc.newSession({ + cwd: "/vault", + mcpServers: [], + projectId: "proj-1", + }); + proc.registerSessionHandler(sessionId, () => {}); + // Swap the provider after the session exists; the captured append must win. + proc.setProjectProfileProvider((id) => + id === "proj-1" ? { id, systemPrompt: "CHANGED" } : undefined + ); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + expect(appendOf(0)).toBe("BASE\n\nORIGINAL"); + }); }); - it("returns false for a session whose transcript never synced to this device", async () => { - await expect( - makeProc().sessionExistsLocally({ sessionId: "absent-session-id", cwd }) - ).resolves.toBe(false); + describe("supportsAdditionalDirectories()", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + function makeProc() { + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + } + + it("reports support for additionalDirectories (stable SDK option)", () => { + expect(makeProc().supportsAdditionalDirectories()).toBe(true); + }); + + it("forwards captured additionalDirectories into options on every turn", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(); + + const { sessionId } = await proc.newSession({ + cwd: "/vault", + mcpServers: [], + additionalDirectories: ["/abs/context-a", "/abs/context-b"], + }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } }; + expect(call.options.additionalDirectories).toEqual(["/abs/context-a", "/abs/context-b"]); + }); + + it("omits additionalDirectories from options when none were captured", async () => { + queryMock.mockImplementation(() => makeQuery([resultMessage()])); + const proc = makeProc(); + + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + proc.registerSessionHandler(sessionId, () => {}); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] }); + + const call = getPromptQueryCalls()[0][0] as { options: { additionalDirectories?: string[] } }; + expect(call.options.additionalDirectories).toBeUndefined(); + }); + }); + + describe("sessionExistsLocally()", () => { + const cwd = "/vault"; + // Mirrors the CLI's project-dir encoding: non-alphanumerics → "-". + const projectDir = cwd.replace(/[^a-zA-Z0-9]/g, "-"); + let configDir: string; + + beforeEach(async () => { + configDir = await mkdtemp(path.join(os.tmpdir(), "claude-config-")); + }); + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }); + }); + + function makeProc(): ClaudeSdkBackendProcess { + return new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnvOverrides: () => ({ CLAUDE_CONFIG_DIR: configDir }), + }); + } + + it("returns true when this device has the session transcript on disk", async () => { + const sessionId = "11111111-2222-3333-4444-555555555555"; + const dir = path.join(configDir, "projects", projectDir); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, `${sessionId}.jsonl`), "{}\n"); + + await expect(makeProc().sessionExistsLocally({ sessionId, cwd })).resolves.toBe(true); + }); + + it("returns false for a session whose transcript never synced to this device", async () => { + await expect( + makeProc().sessionExistsLocally({ sessionId: "absent-session-id", cwd }) + ).resolves.toBe(false); + }); }); });