486 lines · 15.6 KB · TypeScript
RawDownload
import assert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices";import { it } from "@effect/vitest";import { Effect, Layer, Option } from "effect";import { beforeEach, vi } from "vitest"; import { ThreadId } from "@t3tools/contracts";import { ServerConfig } from "../../config.ts";import { ServerSettingsService } from "../../serverSettings.ts";import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";import {  appendOpenCodeAssistantTextDelta,  makeOpenCodeAdapterLive,  mergeOpenCodeAssistantText,} from "./OpenCodeAdapter.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); const runtimeMock = vi.hoisted(() => {  type MessageEntry = {    info: {      id: string;      role: "user" | "assistant";    };    parts: Array<unknown>;  };   const state = {    startCalls: [] as string[],    sessionCreateUrls: [] as string[],    authHeaders: [] as Array<string | null>,    abortCalls: [] as string[],    closeCalls: [] as string[],    revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,    promptAsyncError: null as Error | null,    closeError: null as Error | null,    messages: [] as MessageEntry[],    subscribedEvents: [] as unknown[],  };   return {    state,    reset() {      state.startCalls.length = 0;      state.sessionCreateUrls.length = 0;      state.authHeaders.length = 0;      state.abortCalls.length = 0;      state.closeCalls.length = 0;      state.revertCalls.length = 0;      state.promptAsyncError = null;      state.closeError = null;      state.messages = [];      state.subscribedEvents = [];    },  };}); vi.mock("../opencodeRuntime.ts", async () => {  const actual =    await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts");   return {    ...actual,    startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {      runtimeMock.state.startCalls.push(binaryPath);      return {        url: "http://127.0.0.1:4301",        process: {          once() {},        },        close() {},      };    }),    connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({      url: serverUrl ?? "http://127.0.0.1:4301",      process: null,      external: Boolean(serverUrl),      close() {        runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301");        if (runtimeMock.state.closeError) {          throw runtimeMock.state.closeError;        }      },    })),    createOpenCodeSdkClient: vi.fn(      ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({        session: {          create: vi.fn(async () => {            runtimeMock.state.sessionCreateUrls.push(baseUrl);            runtimeMock.state.authHeaders.push(              serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,            );            return { data: { id: `${baseUrl}/session` } };          }),          abort: vi.fn(async ({ sessionID }: { sessionID: string }) => {            runtimeMock.state.abortCalls.push(sessionID);          }),          promptAsync: vi.fn(async () => {            if (runtimeMock.state.promptAsyncError) {              throw runtimeMock.state.promptAsyncError;            }          }),          messages: vi.fn(async () => ({ data: runtimeMock.state.messages })),          revert: vi.fn(            async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {              runtimeMock.state.revertCalls.push({                sessionID,                ...(messageID ? { messageID } : {}),              });              if (!messageID) {                runtimeMock.state.messages = [];                return;              }               const targetIndex = runtimeMock.state.messages.findIndex(                (entry) => entry.info.id === messageID,              );              runtimeMock.state.messages =                targetIndex >= 0                  ? runtimeMock.state.messages.slice(0, targetIndex + 1)                  : runtimeMock.state.messages;            },          ),        },        event: {          subscribe: vi.fn(async () => ({            stream: (async function* () {              for (const event of runtimeMock.state.subscribedEvents) {                yield event;              }            })(),          })),        },      }),    ),  };}); const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {  upsert: () => Effect.void,  getProvider: () =>    Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),  getBinding: () => Effect.succeed(Option.none()),  listThreadIds: () => Effect.succeed([]),  listBindings: () => Effect.succeed([]),}); const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe(  Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),  Layer.provideMerge(    ServerSettingsService.layerTest({      providers: {        opencode: {          binaryPath: "fake-opencode",          serverUrl: "http://127.0.0.1:9999",          serverPassword: "secret-password",        },      },    }),  ),  Layer.provideMerge(providerSessionDirectoryTestLayer),  Layer.provideMerge(NodeServices.layer),); beforeEach(() => {  runtimeMock.reset();}); const sleep = (ms: number) =>  Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {  it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () =>    Effect.gen(function* () {      const adapter = yield* OpenCodeAdapter;       const session = yield* adapter.startSession({        provider: "opencode",        threadId: asThreadId("thread-opencode"),        runtimeMode: "full-access",      });       assert.equal(session.provider, "opencode");      assert.equal(session.threadId, "thread-opencode");      assert.deepEqual(runtimeMock.state.startCalls, []);      assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]);      assert.deepEqual(runtimeMock.state.authHeaders, [        `Basic ${btoa("opencode:secret-password")}`,      ]);    }),  );   it.effect("stops a configured-server session without trying to own server lifecycle", () =>    Effect.gen(function* () {      const adapter = yield* OpenCodeAdapter;      yield* adapter.startSession({        provider: "opencode",        threadId: asThreadId("thread-opencode"),        runtimeMode: "full-access",      });       yield* adapter.stopSession(asThreadId("thread-opencode"));       assert.deepEqual(runtimeMock.state.startCalls, []);      assert.deepEqual(        runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"),        true,      );    }),  );   it.effect("clears session state when stopAll cleanup fails", () =>    Effect.gen(function* () {      const adapter = yield* OpenCodeAdapter;      yield* adapter.startSession({        provider: "opencode",        threadId: asThreadId("thread-stop-all-a"),        runtimeMode: "full-access",      });      yield* adapter.startSession({        provider: "opencode",        threadId: asThreadId("thread-stop-all-b"),        runtimeMode: "full-access",      });       runtimeMock.state.closeError = new Error("close failed");      const error = yield* adapter.stopAll().pipe(Effect.flip);      const sessions = yield* adapter.listSessions();       assert.equal(error._tag, "ProviderAdapterProcessError");      assert.equal(error.detail, "Failed to stop 2 OpenCode sessions.");      assert.deepEqual(runtimeMock.state.closeCalls, [        "http://127.0.0.1:9999",        "http://127.0.0.1:9999",      ]);      assert.deepEqual(sessions, []);    }),  );   it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () =>    Effect.gen(function* () {      const adapter = yield* OpenCodeAdapter;      yield* adapter.startSession({        provider: "opencode",        threadId: asThreadId("thread-send-turn-failure"),        runtimeMode: "full-access",      });       runtimeMock.state.promptAsyncError = new Error("prompt failed");      const error = yield* adapter        .sendTurn({          threadId: asThreadId("thread-send-turn-failure"),          input: "Fix it",          modelSelection: {            provider: "opencode",            model: "openai/gpt-5",          },        })        .pipe(Effect.flip);      const sessions = yield* adapter.listSessions();       assert.equal(error._tag, "ProviderAdapterRequestError");      if (error._tag !== "ProviderAdapterRequestError") {        throw new Error("Unexpected error type");      }      assert.equal(error.detail, "prompt failed");      assert.equal(        error.message,        "Provider adapter request failed (opencode) for session.promptAsync: prompt failed",      );      assert.equal(sessions.length, 1);      assert.equal(sessions[0]?.status, "ready");      assert.equal(sessions[0]?.activeTurnId, undefined);      assert.equal(sessions[0]?.lastError, "prompt failed");    }),  );   it.effect("reverts the full thread when rollback removes every assistant turn", () =>    Effect.gen(function* () {      const adapter = yield* OpenCodeAdapter;      const threadId = asThreadId("thread-rollback-all");      yield* adapter.startSession({        provider: "opencode",        threadId,        runtimeMode: "full-access",      });       runtimeMock.state.messages = [        {          info: { id: "assistant-1", role: "assistant" },          parts: [],        },        {          info: { id: "assistant-2", role: "assistant" },          parts: [],        },      ];       const snapshot = yield* adapter.rollbackThread(threadId, 2);       assert.deepEqual(runtimeMock.state.revertCalls, [        { sessionID: "http://127.0.0.1:9999/session" },      ]);      assert.deepEqual(snapshot.turns, []);    }),  );   it.effect("deduplicates overlapping assistant text deltas after part updates", () =>    Effect.sync(() => {      const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello");      const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world");      const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!");       assert.deepEqual(        [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit],        ["Hello", " world", "!"],      );      assert.equal(secondUpdate.latestText, "Hello world!");    }),  );   it.effect("writes provider-native observability records using the session thread id", () =>    Effect.gen(function* () {      const nativeEvents: Array<{        readonly event?: {          readonly provider?: string;          readonly threadId?: string;          readonly providerThreadId?: string;          readonly type?: string;        };      }> = [];      const nativeThreadIds: Array<string | null> = [];      runtimeMock.state.subscribedEvents = [        {          type: "message.updated",          properties: {            info: {              id: "msg-missing-session",              role: "assistant",            },          },        },        {          type: "message.updated",          properties: {            sessionID: "http://127.0.0.1:9999/other-session",            info: {              id: "msg-other-session",              role: "assistant",            },          },        },        {          type: "message.updated",          properties: {            sessionID: "http://127.0.0.1:9999/session",            info: {              id: "msg-native-log",              role: "assistant",            },          },        },      ];       const nativeEventLogger = {        filePath: "memory://opencode-native-events",        write: (event: unknown, threadId: ThreadId | null) => {          nativeEvents.push(event as (typeof nativeEvents)[number]);          nativeThreadIds.push(threadId ?? null);          return Effect.void;        },        close: () => Effect.void,      };       const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(        Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),        Layer.provideMerge(          ServerSettingsService.layerTest({            providers: {              opencode: {                binaryPath: "fake-opencode",                serverUrl: "http://127.0.0.1:9999",                serverPassword: "secret-password",              },            },          }),        ),        Layer.provideMerge(providerSessionDirectoryTestLayer),        Layer.provideMerge(NodeServices.layer),      );       const session = yield* Effect.gen(function* () {        const adapter = yield* OpenCodeAdapter;        const started = yield* adapter.startSession({          provider: "opencode",          threadId: asThreadId("thread-native-log"),          runtimeMode: "full-access",        });        yield* sleep(10);        return started;      }).pipe(Effect.provide(adapterLayer));       assert.equal(session.threadId, "thread-native-log");      assert.equal(nativeEvents.length, 1);      assert.equal(        nativeEvents.some((record) => record.event?.provider === "opencode"),        true,      );      assert.equal(        nativeEvents.some(          (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session",        ),        true,      );      assert.equal(        nativeEvents.some((record) => record.event?.threadId === "thread-native-log"),        true,      );      assert.equal(        nativeEvents.some((record) => record.event?.type === "message.updated"),        true,      );      assert.equal(        nativeThreadIds.every((threadId) => threadId === "thread-native-log"),        true,      );    }),  );   it.effect("keeps the event pump alive when native event logging fails", () =>    Effect.gen(function* () {      runtimeMock.state.subscribedEvents = [        {          type: "message.updated",          properties: {            sessionID: "http://127.0.0.1:9999/session",            info: {              id: "msg-native-log-failure",              role: "assistant",            },          },        },      ];       const nativeEventLogger = {        filePath: "memory://opencode-native-events",        write: () => Effect.die(new Error("native log write failed")),        close: () => Effect.void,      };       const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(        Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),        Layer.provideMerge(          ServerSettingsService.layerTest({            providers: {              opencode: {                binaryPath: "fake-opencode",                serverUrl: "http://127.0.0.1:9999",                serverPassword: "secret-password",              },            },          }),        ),        Layer.provideMerge(providerSessionDirectoryTestLayer),        Layer.provideMerge(NodeServices.layer),      );       const sessions = yield* Effect.gen(function* () {        const adapter = yield* OpenCodeAdapter;        yield* adapter.startSession({          provider: "opencode",          threadId: asThreadId("thread-native-log-failure"),          runtimeMode: "full-access",        });        yield* sleep(10);        return yield* adapter.listSessions();      }).pipe(Effect.provide(adapterLayer));       assert.equal(sessions.length, 1);      assert.equal(sessions[0]?.threadId, "thread-native-log-failure");      assert.deepEqual(runtimeMock.state.closeCalls, []);    }),  );});