2139 lines · 65.5 KB · TypeScript
RawDownload
import type {  Options as ClaudeQueryOptions,  PermissionMode,  PermissionResult,  SDKMessage,  SDKUserMessage,} from "@anthropic-ai/claude-agent-sdk";import { ApprovalRequestId, ProviderItemId, ThreadId } from "@t3tools/contracts";import { assert, describe, it } from "@effect/vitest";import { Effect, Fiber, Random, Stream } from "effect"; import { ProviderAdapterValidationError } from "../Errors.ts";import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; class FakeClaudeQuery implements AsyncIterable<SDKMessage> {  private readonly queue: Array<SDKMessage> = [];  private readonly resolvers: Array<(value: IteratorResult<SDKMessage>) => void> = [];  private done = false;   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 resolver = this.resolvers.shift();    if (resolver) {      resolver({ done: false, value: message });      return;    }    this.queue.push(message);  }   finish(): void {    if (this.done) {      return;    }    this.done = true;    for (const resolver of this.resolvers.splice(0)) {      resolver({ 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.done) {          return Promise.resolve({            done: true,            value: undefined,          });        }        return new Promise((resolve) => {          this.resolvers.push(resolve);        });      },    };  }} interface Harness {  readonly layer: ReturnType<typeof makeClaudeAdapterLive>;  readonly query: FakeClaudeQuery;  readonly getLastCreateQueryInput: () =>    | {        readonly prompt: AsyncIterable<SDKUserMessage>;        readonly options: ClaudeQueryOptions;      }    | undefined;} function makeHarness(config?: {  readonly nativeEventLogPath?: string;  readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];}): Harness {  const query = new FakeClaudeQuery();  let createInput:    | {        readonly prompt: AsyncIterable<SDKUserMessage>;        readonly options: ClaudeQueryOptions;      }    | undefined;   const adapterOptions: ClaudeAdapterLiveOptions = {    createQuery: (input) => {      createInput = input;      return query;    },    ...(config?.nativeEventLogger      ? {          nativeEventLogger: config.nativeEventLogger,        }      : {}),    ...(config?.nativeEventLogPath      ? {          nativeEventLogPath: config.nativeEventLogPath,        }      : {}),  };   return {    layer: makeClaudeAdapterLive(adapterOptions),    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;  }  const content = next.value.message.content[0];  if (!content || content.type !== "text") {    return undefined;  }  return content.text;} const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");const RESUME_THREAD_ID = ThreadId.makeUnsafe("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: "codex", runtimeMode: "full-access" })        .pipe(Effect.result);       assert.equal(result._tag, "Failure");      if (result._tag !== "Failure") {        return;      }      assert.deepEqual(        result.failure,        new ProviderAdapterValidationError({          provider: "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: "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("keeps explicit claude permission mode over runtime-derived defaults", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        runtimeMode: "full-access",        providerOptions: {          claudeAgent: {            permissionMode: "plan",          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.permissionMode, "plan");      assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined);    }).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: "claudeAgent",        model: "claude-opus-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            effort: "max",          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, "max");    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("ignores unsupported max effort for Sonnet 4.6", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-sonnet-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            effort: "max",          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, undefined);    }).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: "claudeAgent",        model: "claude-haiku-4-5",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            effort: "high",          },        },      });       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: "claudeAgent",        model: "claude-haiku-4-5",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            thinking: false,          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.deepEqual(createInput?.options.settings, {        alwaysThinkingEnabled: false,      });    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("ignores Claude thinking toggle for non-Haiku models", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-sonnet-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            thinking: false,          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.settings, undefined);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("forwards claude fast mode into SDK settings", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-opus-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            fastMode: true,          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.deepEqual(createInput?.options.settings, {        fastMode: true,      });    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("ignores claude fast mode for non-opus models", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-sonnet-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            fastMode: true,          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.settings, undefined);    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;      const session = yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-sonnet-4-6",        runtimeMode: "full-access",        modelOptions: {          claudeAgent: {            effort: "ultrathink",          },        },      });       yield* adapter.sendTurn({        threadId: session.threadId,        input: "Investigate the edge cases",        attachments: [],        model: "claude-sonnet-4-6",        modelOptions: {          claudeAgent: {            effort: "ultrathink",          },        },      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.effort, undefined);      const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));      assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");    }).pipe(      Effect.provideService(Random.Random, makeDeterministicRandomService()),      Effect.provide(harness.layer),    );  });   it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {    const harness = makeHarness();    return Effect.gen(function* () {      const adapter = yield* ClaudeAdapter;       const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe(        Stream.runCollect,        Effect.forkChild,      );       const session = yield* adapter.startSession({        threadId: THREAD_ID,        provider: "claudeAgent",        model: "claude-sonnet-4-5",        runtimeMode: "full-access",      });       const turn = yield* adapter.sendTurn({        threadId: session.threadId,        input: "hello",        attachments: [],      });       harness.query.emit({        type: "stream_event",        session_id: "sdk-session-1",        uuid: "stream-0",        parent_tool_use_id: null,        event: {          type: "content_block_start",          index: 0,          content_block: {            type: "text",            text: "",          },        },      } as unknown as SDKMessage);

Lines 1–500 of 2139.