diff --git a/apps/server/src/provider/Drivers/ClaudeSdk.ts b/apps/server/src/provider/Drivers/ClaudeSdk.ts new file mode 100644 index 0000000000000..f48c6f1ed94ed --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSdk.ts @@ -0,0 +1,9 @@ +import type * as ClaudeAgentSdk from "@anthropic-ai/claude-agent-sdk"; + +const CLAUDE_AGENT_SDK_PACKAGE = ["@anthropic-ai", "claude-agent-sdk"].join( + String.fromCharCode(47), +); + +export function importClaudeAgentSdk(): Promise { + return import(/* @vite-ignore */ CLAUDE_AGENT_SDK_PACKAGE) as Promise; +} 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 0000000000000..be50143d93d36 --- /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 46a7c02665cd7..ba85402ef0ce6 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,35 @@ 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; + const query = vi.fn(); + const waitForRelease = new Promise((resolve) => { + releaseImport = resolve; + }); + return { + importFinished: new Promise((resolve) => { + markImportFinished = resolve; + }), + importStarted: new Promise((resolve) => { + markImportStarted = resolve; + }), + load: async () => { + markImportStarted(); + await waitForRelease; + markImportFinished(); + return { query }; + }, + markImportFinished: () => markImportFinished(), + markImportStarted: () => markImportStarted(), + query, + releaseImport: () => releaseImport(), + waitForRelease, + }; +}); + // 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", @@ -1714,6 +1744,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, { loadSdk: lazyClaudeSdk.load }); + }), + ).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 173b7a2b03554..f62d038f11216 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 { @@ -81,6 +80,7 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { importClaudeAgentSdk } from "../Drivers/ClaudeSdk.ts"; import { getClaudeModelCapabilities, isClaudeUltracodeEffort, @@ -332,6 +332,7 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; + readonly loadSdk?: () => Promise>; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -1716,16 +1717,8 @@ 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 loadSdk = options?.loadSdk ?? importClaudeAgentSdk; const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -4371,12 +4364,31 @@ 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 loadSdk()).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 f815ac75be345..fb29775433c96 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 { @@ -41,6 +40,7 @@ import { } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { importClaudeAgentSdk } from "../Drivers/ClaudeSdk.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -736,6 +736,7 @@ const probeClaudeCapabilities = ( claudeEnvironment, ); return yield* Effect.tryPromise(async () => { + const { query: claudeQuery } = await importClaudeAgentSdk(); const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. diff --git a/packages/provider-runtime/package.json b/packages/provider-runtime/package.json index 598cd468e9ba0..dd01304d7ec91 100644 --- a/packages/provider-runtime/package.json +++ b/packages/provider-runtime/package.json @@ -25,12 +25,12 @@ "test": "vp test run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "effect": "catalog:" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" diff --git a/packages/provider-runtime/src/package-artifact.test.ts b/packages/provider-runtime/src/package-artifact.test.ts new file mode 100644 index 0000000000000..ec07073afd8f5 --- /dev/null +++ b/packages/provider-runtime/src/package-artifact.test.ts @@ -0,0 +1,168 @@ +// @effect-diagnostics nodeBuiltinImport:off - This test verifies the packed Node package boundary. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +import { describe, expect, it } from "vite-plus/test"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +const packageDirectory = NodePath.resolve(import.meta.dirname, ".."); +const repositoryDirectory = NodePath.resolve(packageDirectory, "../.."); +const vp = NodePath.join(repositoryDirectory, "node_modules", ".bin", "vp"); + +async function run(command: string, args: ReadonlyArray, cwd: string) { + return execFile(command, args, { + cwd, + env: { ...process.env, COREPACK_ENABLE_PROJECT_SPEC: "0" }, + maxBuffer: 10 * 1024 * 1024, + }); +} + +interface PackageManifest { + readonly name: string; + readonly version: string; + readonly dependencies?: Readonly>; + readonly devDependencies?: Readonly>; + readonly peerDependencies?: Readonly>; + readonly [key: string]: unknown; +} + +async function readManifest(packageJsonPath: string): Promise { + return JSON.parse(await NodeFSP.readFile(packageJsonPath, "utf8")) as PackageManifest; +} + +function publishedManifest( + manifest: PackageManifest, + dependencies: Readonly>, + peerDependencies?: Readonly>, +) { + const { devDependencies: _devDependencies, scripts: _scripts, ...published } = manifest; + return { + ...published, + dependencies, + ...(peerDependencies ? { peerDependencies } : {}), + }; +} + +describe("provider-runtime package artifact", () => { + it( + "installs and starts a Codex-only consumer without the Claude SDK", + { timeout: 120_000 }, + async () => { + const temporaryDirectory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-provider-runtime-package-"), + ); + const consumerDirectory = NodePath.join(temporaryDirectory, "consumer"); + const contractsDirectory = NodePath.join(repositoryDirectory, "packages/contracts"); + const contractsPackageDirectory = NodePath.join(temporaryDirectory, "contracts-package"); + const providerRuntimePackageDirectory = NodePath.join( + temporaryDirectory, + "provider-runtime-package", + ); + + try { + const packageArtifacts = [ + [contractsDirectory, contractsPackageDirectory], + [packageDirectory, providerRuntimePackageDirectory], + ] satisfies ReadonlyArray; + await Promise.all( + packageArtifacts.map(([sourceDirectory, artifactDirectory]) => + run( + vp, + ["pack", "--out-dir", NodePath.join(artifactDirectory, "dist")], + sourceDirectory, + ), + ), + ); + + const bundle = await NodeFSP.readFile( + NodePath.join(providerRuntimePackageDirectory, "dist/index.mjs"), + "utf8", + ); + expect(bundle).not.toMatch( + /import\s*\(\s*(?:\/\*[\s\S]*?\*\/\s*)?["']@anthropic-ai\/claude-agent-sdk["']\s*\)/u, + ); + expect(bundle).not.toContain("@anthropic-ai/claude-agent-sdk"); + + const contractsManifest = await readManifest( + NodePath.join(contractsDirectory, "package.json"), + ); + const providerRuntimeManifest = await readManifest( + NodePath.join(packageDirectory, "package.json"), + ); + const effectManifest = await readManifest( + NodePath.join(packageDirectory, "node_modules/effect/package.json"), + ); + const platformNodeManifest = await readManifest( + NodePath.join(packageDirectory, "node_modules/@effect/platform-node/package.json"), + ); + expect(providerRuntimeManifest.dependencies).not.toHaveProperty( + "@anthropic-ai/claude-agent-sdk", + ); + expect(providerRuntimeManifest.devDependencies).toHaveProperty( + "@anthropic-ai/claude-agent-sdk", + ); + await Promise.all([ + NodeFSP.writeFile( + NodePath.join(contractsPackageDirectory, "package.json"), + JSON.stringify( + publishedManifest(contractsManifest, {}, { effect: effectManifest.version }), + ), + ), + NodeFSP.writeFile( + NodePath.join(providerRuntimePackageDirectory, "package.json"), + JSON.stringify( + publishedManifest(providerRuntimeManifest, { + "@effect/platform-node": platformNodeManifest.version, + "@t3tools/contracts": contractsManifest.version, + effect: effectManifest.version, + }), + ), + ), + ]); + + await NodeFSP.mkdir(consumerDirectory); + await NodeFSP.writeFile( + NodePath.join(consumerDirectory, "package.json"), + JSON.stringify({ + name: "provider-runtime-codex-consumer", + private: true, + type: "module", + dependencies: { + "@t3tools/contracts": "file:../contracts-package", + "@t3tools/provider-runtime": "file:../provider-runtime-package", + }, + }), + ); + await NodeFSP.writeFile( + NodePath.join(consumerDirectory, "pnpm-workspace.yaml"), + 'overrides:\n "@t3tools/contracts": "file:../contracts-package"\n', + ); + await NodeFSP.writeFile( + NodePath.join(consumerDirectory, "verify.mjs"), + [ + 'import { createProviderRuntime } from "@t3tools/provider-runtime";', + 'const runtime = await createProviderRuntime({ provider: "codex" });', + "await runtime.close();", + ].join("\n"), + ); + + await run( + "pnpm", + ["install", "--prefer-offline", "--ignore-scripts", "--config.ignore-workspace=true"], + consumerDirectory, + ); + await expect( + NodeFSP.access( + NodePath.join(consumerDirectory, "node_modules/@anthropic-ai/claude-agent-sdk"), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + await run(process.execPath, ["verify.mjs"], consumerDirectory); + } finally { + await NodeFSP.rm(temporaryDirectory, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffac836477c62..a1560eb9be37b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,9 +845,6 @@ importers: packages/provider-runtime: dependencies: - '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -858,6 +855,9 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.170 + version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/vitest': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index a7bb1b9b7c4c9..883000f2cbd61 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -560,6 +560,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { fffNodeVersion: "0.9.4", }), { + "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@ff-labs/fff-node": "0.9.4", "msgpackr-extract": "3.0.4", "node-pty": "1.1.0", diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 754cd646f17d2..d247f66f90874 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -69,25 +69,32 @@ describe("shouldBundleCliDependency", () => { }); describe("selectCliRuntimeExternalDependencies", () => { - it("keeps only runtime-external dependency roots for the Windows sidecar", () => { + it("keeps desktop runtime dependency roots for the Windows sidecar", () => { assert.deepStrictEqual( selectCliRuntimeExternalDependencies({ + "@anthropic-ai/claude-agent-sdk": "3.0.0", "@effect/platform-bun": "1.0.0", "@ff-labs/fff-node": "2.0.0", effect: "3.0.0", "node-pty": "4.0.0", }), { + "@anthropic-ai/claude-agent-sdk": "3.0.0", "@ff-labs/fff-node": "2.0.0", "node-pty": "4.0.0", }, ); }); - it("selects every external root declared by the server", () => { + it("selects every staged runtime root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"], + [ + "@anthropic-ai/claude-agent-sdk", + "@ff-labs/fff-node", + "msgpackr-extract", + "node-pty", + ], ); }); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index d7a89bc408a4e..aae4809cb783a 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -63,6 +63,12 @@ export const CLI_BUILD_ONLY_EXTERNAL_PREFIXES = [ "@effect/sql-sqlite-bun", ] as const; +// Computed imports do not reach the bundler's package hooks, but desktop +// artifacts still need their package roots in the staged production install. +const CLI_RUNTIME_STAGE_ONLY_DEPENDENCIES = [ + "@anthropic-ai/claude-agent-sdk", +] as const; + export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ ...CLI_RUNTIME_EXTERNAL_PREFIXES, ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, @@ -92,12 +98,16 @@ export function shouldBundleCliDependency(id: string): boolean { return !isExternalCliDependency(id); } -/** Select direct dependency roots whose runtime closure belongs in the sidecar. */ +/** Select direct dependency roots whose runtime closure belongs in desktop artifacts. */ export function selectCliRuntimeExternalDependencies( dependencies: Readonly>, ): Record { return Object.fromEntries( - Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)), + Object.entries(dependencies).filter( + ([name]) => + isRuntimeExternalCliDependency(name) || + CLI_RUNTIME_STAGE_ONLY_DEPENDENCIES.some((dependency) => dependency === name), + ), ); }