Skip to content
Merged
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
9 changes: 9 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSdk.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ClaudeAgentSdk> {
return import(/* @vite-ignore */ CLAUDE_AGENT_SDK_PACKAGE) as Promise<typeof ClaudeAgentSdk>;
Comment thread
onmax marked this conversation as resolved.
Resolved
}
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),
});
});
});
65 changes: 65 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,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<void>((resolve) => {
releaseImport = resolve;
});
return {
importFinished: new Promise<void>((resolve) => {
markImportFinished = resolve;
}),
importStarted: new Promise<void>((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<ClaudeAdapter, ClaudeAdapterShape>()(
"t3/provider/Layers/ClaudeAdapter.test/ClaudeAdapter",
Expand Down Expand Up @@ -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* () {
Expand Down
66 changes: 39 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 @@ -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,
Expand Down Expand Up @@ -332,6 +332,7 @@ export interface ClaudeAdapterLiveOptions {
readonly prompt: AsyncIterable<SDKUserMessage>;
readonly options: ClaudeQueryOptions;
}) => ClaudeQueryRuntime;
readonly loadSdk?: () => Promise<Pick<typeof import("@anthropic-ai/claude-agent-sdk"), "query">>;
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
}
Expand Down Expand Up @@ -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<SDKUserMessage>;
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<ThreadId, ClaudeSessionContext>();
const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 7 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 All @@ -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({
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
Loading
Loading