diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.lazy.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.lazy.test.ts new file mode 100644 index 000000000000..be50143d93d3 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeAdapter.lazy.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => { + throw new Error("Claude SDK loaded eagerly."); +}); + +describe("ClaudeAdapter module loading", () => { + it("does not load the Claude SDK while importing the adapter", async () => { + await expect(import("./ClaudeAdapter.ts")).resolves.toMatchObject({ + makeClaudeAdapter: expect.any(Function), + }); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a45eae6faf3e..ea6410b8b01c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -40,6 +41,34 @@ import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +const lazyClaudeSdk = vi.hoisted(() => { + let markImportStarted!: () => void; + let markImportFinished!: () => void; + let releaseImport!: () => void; + return { + importFinished: new Promise((resolve) => { + markImportFinished = resolve; + }), + importStarted: new Promise((resolve) => { + markImportStarted = resolve; + }), + markImportFinished: () => markImportFinished(), + markImportStarted: () => markImportStarted(), + query: vi.fn(), + releaseImport: () => releaseImport(), + waitForRelease: new Promise((resolve) => { + releaseImport = resolve; + }), + }; +}); + +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + lazyClaudeSdk.markImportStarted(); + await lazyClaudeSdk.waitForRelease; + lazyClaudeSdk.markImportFinished(); + return { query: lazyClaudeSdk.query }; +}); + // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. class ClaudeAdapter extends Context.Service()( "t3/provider/Layers/ClaudeAdapter.test/ClaudeAdapter", @@ -1662,6 +1691,41 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not start a Claude runtime after lazy loading is interrupted", () => { + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const startFiber = yield* Effect.forkChild( + adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }), + ); + + yield* Effect.promise(() => lazyClaudeSdk.importStarted); + yield* Fiber.interrupt(startFiber); + lazyClaudeSdk.releaseImport(); + yield* Effect.promise(() => lazyClaudeSdk.importFinished); + + assert.equal(lazyClaudeSdk.query.mock.calls.length, 0); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("keeps the session available when process close fails", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d42049ebefc3..02b5ee43b9e7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -6,19 +6,18 @@ * * @module ClaudeAdapterLive */ -import { - type CanUseTool, - query, - type Options as ClaudeQueryOptions, - type PermissionMode, - type PermissionResult, - type PermissionUpdate, - type SDKMessage, - type SDKControlGetContextUsageResponse, - type SDKResultMessage, - type SettingSource, - type SDKUserMessage, - type ModelUsage, +import type { + CanUseTool, + Options as ClaudeQueryOptions, + PermissionMode, + PermissionResult, + PermissionUpdate, + SDKMessage, + SDKControlGetContextUsageResponse, + SDKResultMessage, + SettingSource, + SDKUserMessage, + ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { @@ -1706,16 +1705,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const createQuery = - options?.createQuery ?? - ((input: { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - }) => - query({ - prompt: input.prompt, - options: input.options, - }) as ClaudeQueryRuntime); + const createQuery = options?.createQuery; const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -4271,12 +4261,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.path_to_executable": claudeBinaryPath, }); - const queryRuntime = yield* Effect.try({ - try: () => - createQuery({ + const queryRuntime = yield* Effect.tryPromise({ + try: async (signal) => { + const query = + createQuery ?? + (await import(/* @vite-ignore */ "@anthropic-ai/claude-agent-sdk")).query; + signal.throwIfAborted(); + const runtime = query({ prompt, options: queryOptions, - }), + }) as ClaudeQueryRuntime; + let closed = false; + const close = () => { + if (closed) return; + closed = true; + try { + runtime.close(); + } catch { + // Interruption remains authoritative when late runtime cleanup fails. + } + }; + signal.addEventListener("abort", close, { once: true }); + if (signal.aborted) { + close(); + signal.throwIfAborted(); + } + return runtime; + }, catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..5b6c7f54081a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -20,12 +20,11 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; -import { - query as claudeQuery, - type Options as ClaudeQueryOptions, - type SlashCommand as ClaudeSlashCommand, - type SDKUserMessage, - type SettingSource, +import type { + Options as ClaudeQueryOptions, + SlashCommand as ClaudeSlashCommand, + SDKUserMessage, + SettingSource, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -742,6 +741,9 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const { query: claudeQuery } = await import( + /* @vite-ignore */ "@anthropic-ai/claude-agent-sdk" + ); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API.