4613 lines · 150.0 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * as NodeFS from "node:fs";import * as NodeOS from "node:os";import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices";import type {  Options as ClaudeQueryOptions,  PermissionMode,  PermissionResult,  SDKMessage,  SDKUserMessage,} from "@anthropic-ai/claude-agent-sdk";import {  ApprovalRequestId,  ClaudeSettings,  ProviderDriverKind,  ProviderItemId,  ProviderRuntimeEvent,  type RuntimeMode,  ThreadId,  ProviderInstanceId,} from "@t3tools/contracts";import { createModelSelection } from "@t3tools/shared/model";import { assert, describe, it } from "@effect/vitest";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Random from "effect/Random";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import { ServerSettingsService } from "../../serverSettings.ts";import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts";import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); // 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",) {} class FakeClaudeQuery implements AsyncIterable<SDKMessage> {  private readonly queue: Array<SDKMessage> = [];  private readonly waiters: Array<{    readonly resolve: (value: IteratorResult<SDKMessage>) => void;    readonly reject: (reason: unknown) => void;  }> = [];  private done = false;  private failure: unknown | undefined;   public readonly interruptCalls: Array<void> = [];  public readonly stopTaskCalls: Array<string> = [];  public readonly setModelCalls: Array<string | undefined> = [];  public readonly setPermissionModeCalls: Array<string> = [];  public readonly setMaxThinkingTokensCalls: Array<number | null> = [];  public closeCalls = 0;   emit(message: SDKMessage): void {    if (this.done) {      return;    }    const waiter = this.waiters.shift();    if (waiter) {      waiter.resolve({ done: false, value: message });      return;    }    this.queue.push(message);  }   fail(cause: unknown): void {    if (this.done) {      return;    }    this.done = true;    this.failure = cause;    for (const waiter of this.waiters.splice(0)) {      waiter.reject(cause);    }  }   finish(): void {    if (this.done) {      return;    }    this.done = true;    this.failure = undefined;    for (const waiter of this.waiters.splice(0)) {      waiter.resolve({ done: true, value: undefined });    }  }   readonly interrupt = async (): Promise<void> => {    this.interruptCalls.push(undefined);  };   readonly stopTask = async (taskId: string): Promise<void> => {    this.stopTaskCalls.push(taskId);  };   readonly setModel = async (model?: string): Promise<void> => {    this.setModelCalls.push(model);  };   readonly setPermissionMode = async (mode: PermissionMode): Promise<void> => {    this.setPermissionModeCalls.push(mode);  };   readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise<void> => {    this.setMaxThinkingTokensCalls.push(maxThinkingTokens);  };   readonly close = (): void => {    this.closeCalls += 1;    this.finish();  };   [Symbol.asyncIterator](): AsyncIterator<SDKMessage> {    return {      next: () => {        if (this.queue.length > 0) {          const value = this.queue.shift();          if (value) {            return Promise.resolve({              done: false,              value,            });          }        }        if (this.failure !== undefined) {          const failure = this.failure;          this.failure = undefined;          return Promise.reject(failure);        }        if (this.done) {          return Promise.resolve({            done: true,            value: undefined,          });        }        return new Promise((resolve, reject) => {          this.waiters.push({            resolve,            reject,          });        });      },    };  }} function makeHarness(config?: {  readonly nativeEventLogPath?: string;  readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];  readonly cwd?: string;  readonly baseDir?: string;  readonly claudeConfig?: Partial<ClaudeSettings>;  readonly instanceId?: ProviderInstanceId;}) {  const query = new FakeClaudeQuery();  let createInput:    | {        readonly prompt: AsyncIterable<SDKUserMessage>;        readonly options: ClaudeQueryOptions;      }    | undefined;   const adapterOptions: ClaudeAdapterLiveOptions = {    ...(config?.instanceId ? { instanceId: config.instanceId } : {}),    createQuery: (input) => {      createInput = input;      return query;    },    ...(config?.nativeEventLogger      ? {          nativeEventLogger: config.nativeEventLogger,        }      : {}),    ...(config?.nativeEventLogPath      ? {          nativeEventLogPath: config.nativeEventLogPath,        }      : {}),  };   return {    layer: Layer.effect(      ClaudeAdapter,      Effect.gen(function* () {        const claudeConfig = decodeClaudeSettings(config?.claudeConfig ?? {});        return yield* makeClaudeAdapter(claudeConfig, adapterOptions);      }),    ).pipe(      Layer.provideMerge(        ServerConfig.layerTest(          config?.cwd ?? "/tmp/claude-adapter-test",          config?.baseDir ?? "/tmp",        ),      ),      Layer.provideMerge(ServerSettingsService.layerTest()),      Layer.provideMerge(NodeServices.layer),    ),    query,    getLastCreateQueryInput: () => createInput,  };} function makeDeterministicRandomService(seed = 0x1234_5678): {  nextIntUnsafe: () => number;  nextDoubleUnsafe: () => number;} {  let state = seed >>> 0;  const nextIntUnsafe = (): number => {    state = (Math.imul(1_664_525, state) + 1_013_904_223) >>> 0;    return state;  };   return {    nextIntUnsafe,    nextDoubleUnsafe: () => nextIntUnsafe() / 0x1_0000_0000,  };} async function readFirstPromptText(  input:    | {        readonly prompt: AsyncIterable<SDKUserMessage>;      }    | undefined,): Promise<string | undefined> {  const iterator = input?.prompt[Symbol.asyncIterator]();  if (!iterator) {    return undefined;  }  const next = await iterator.next();  if (next.done) {    return undefined;  }  if (typeof next.value.message.content === "string") {    return next.value.message.content;  }  const content = next.value.message.content[0];  if (!content || content.type !== "text") {    return undefined;  }  return content.text;} async function readFirstPromptMessage(  input:    | {        readonly prompt: AsyncIterable<SDKUserMessage>;      }    | undefined,): Promise<SDKUserMessage | undefined> {  const iterator = input?.prompt[Symbol.asyncIterator]();  if (!iterator) {    return undefined;  }  const next = await iterator.next();  if (next.done) {    return undefined;  }  return next.value;} const THREAD_ID = ThreadId.make("thread-claude-1");const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => {  it.effect("returns validation error for non-claude provider on startSession", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      const result = yield* adapter        .startSession({          threadId: THREAD_ID,          provider: ProviderDriverKind.make("codex"),          runtimeMode: "full-access",        })        .pipe(Effect.result);       assert.equal(result._tag, "Failure");      if (result._tag !== "Failure") {        return;      }      assert.deepEqual(        result.failure,        new ProviderAdapterValidationError({          provider: ProviderDriverKind.make("claudeAgent"),          operation: "startSession",          issue: "Expected provider 'claudeAgent' but received 'codex'.",        }),      );    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("retains Claude session startup causes without exposing their messages", () => {    const cause = new Error("credential material that must remain in the cause chain");    const layer = Layer.effect(      ClaudeAdapter,      Effect.gen(function* () {        const claudeConfig = decodeClaudeSettings({});        return yield* makeClaudeAdapter(claudeConfig, {          createQuery: () => {            throw cause;          },        });      }),    ).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 error = yield* adapter        .startSession({          threadId: THREAD_ID,          provider: ProviderDriverKind.make("claudeAgent"),          runtimeMode: "full-access",        })        .pipe(Effect.flip);       assert.instanceOf(error, ProviderAdapterProcessError);      assert.equal(error.detail, "Failed to start Claude runtime session.");      assert.strictEqual(error.cause, cause);      assert.notMatch(error.message, /credential material/u);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(layer),    );  });   it.effect("derives bypass permission mode from full-access runtime policy", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);      assert.equal(createInput?.options.permissionMode, "bypassPermissions");      assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("derives auto permission mode from auto runtime policy without skip flag", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        runtimeMode: "auto",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.permissionMode, "auto");      assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("loads Claude filesystem settings sources for SDK sessions", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        runtimeMode: "approval-required",      });       const createInput = harness.getLastCreateQueryInput();      assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);      assert.equal(createInput?.options.permissionMode, undefined);      assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("uses bypass permissions for full-access claude sessions", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.permissionMode, "bypassPermissions");      assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("forwards claude effort levels into query options", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        modelSelection: createModelSelection(          ProviderInstanceId.make("claudeAgent"),          "claude-opus-4-6",          [{ id: "effort", value: "max" }],        ),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, "max");    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => {    const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } });    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        modelSelection: createModelSelection(          ProviderInstanceId.make("claudeAgent"),          "claude-opus-4-6",        ),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(        createInput?.options.env?.CLAUDE_CONFIG_DIR,        NodePath.join(NodeOS.homedir(), ".claude-work"),      );    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        modelSelection: {          instanceId: ProviderInstanceId.make("claudeAgent"),          model: "claude-opus-4-7",        },        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, "max");    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: ProviderDriverKind.make("claudeAgent"),        modelSelection: createModelSelection(          ProviderInstanceId.make("claudeAgent"),

Lines 1–500 of 4613.