3607 lines · 114.5 KB · TypeScript
RawDownload
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";import os from "node:os";import path 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 { Context, Effect, Fiber, Layer, Random, Schema, Stream } from "effect"; import { attachmentRelativePath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import { ServerSettingsService } from "../../serverSettings.ts";import { ProviderAdapterValidationError } from "../Errors.ts";import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`.class ClaudeAdapter extends Context.Service<ClaudeAdapter, ClaudeAdapterShape>()(  "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 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 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 = Schema.decodeSync(ClaudeSettings)(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("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("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 HOME", () => {    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?.HOME, path.join(os.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"),          "claude-opus-4-7",          [{ id: "effort", value: "xhigh" }],        ),        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("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => {    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-sonnet-4-6",          [{ id: "effort", value: "max" }],        ),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, "high");    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("ignores adaptive effort for Haiku 4.5", () => {    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-haiku-4-5",          [{ id: "effort", value: "high" }],        ),        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, undefined);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => {    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-haiku-4-5",          [{ id: "thinking", value: false }],        ),        runtimeMode: "full-access",      });

Lines 1–500 of 3607.