2988 lines · 92.1 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,  ProviderItemId,  ProviderRuntimeEvent,  ThreadId,} from "@t3tools/contracts";import { assert, describe, it } from "@effect/vitest";import { Effect, Fiber, Layer, Random, Stream } from "effect"; import { attachmentRelativePath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";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 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;}) {  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).pipe(      Layer.provideMerge(        ServerConfig.layerTest(          config?.cwd ?? "/tmp/claude-adapter-test",          config?.baseDir ?? "/tmp",        ),      ),      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;  }  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.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.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: "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("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",        modelSelection: {          provider: "claudeAgent",          model: "claude-opus-4-6",          options: {            effort: "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("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",        modelSelection: {          provider: "claudeAgent",          model: "claude-sonnet-4-6",          options: {            effort: "max",          },        },        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("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",        modelSelection: {          provider: "claudeAgent",          model: "claude-haiku-4-5",          options: {            effort: "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: "claudeAgent",        modelSelection: {          provider: "claudeAgent",          model: "claude-haiku-4-5",          options: {            thinking: false,          },        },        runtimeMode: "full-access",      });       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",        modelSelection: {          provider: "claudeAgent",          model: "claude-sonnet-4-6",          options: {            thinking: false,          },        },        runtimeMode: "full-access",      });       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",        modelSelection: {          provider: "claudeAgent",          model: "claude-opus-4-6",          options: {            fastMode: true,          },        },        runtimeMode: "full-access",      });       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",        modelSelection: {          provider: "claudeAgent",          model: "claude-sonnet-4-6",          options: {            fastMode: true,          },        },        runtimeMode: "full-access",      });       const createInput = harness.getLastCreateQueryInput();      assert.equal(createInput?.options.settings, undefined);

Lines 1–500 of 2988.