Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.lazy.test.ts
Original file line number Diff line number Diff line change
@@ -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),
});
});
});
64 changes: 64 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void>((resolve) => {
markImportFinished = resolve;
}),
importStarted: new Promise<void>((resolve) => {
markImportStarted = resolve;
}),
markImportFinished: () => markImportFinished(),
markImportStarted: () => markImportStarted(),
query: vi.fn(),
releaseImport: () => releaseImport(),
waitForRelease: new Promise<void>((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<ClaudeAdapter, ClaudeAdapterShape>()(
"t3/provider/Layers/ClaudeAdapter.test/ClaudeAdapter",
Expand Down Expand Up @@ -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* () {
Expand Down
65 changes: 38 additions & 27 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<SDKUserMessage>;
readonly options: ClaudeQueryOptions;
}) =>
query({
prompt: input.prompt,
options: input.options,
}) as ClaudeQueryRuntime);
const createQuery = options?.createQuery;

const sessions = new Map<ThreadId, ClaudeSessionContext>();
const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading