1691 lines · 58.3 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";import { createModelSelection } from "@t3tools/shared/model";import {  ApprovalRequestId,  CommandId,  DEFAULT_PROVIDER_INTERACTION_MODE,  EventId,  MessageId,  ProjectId,  ThreadId,  TurnId,} from "@t3tools/contracts";import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts";import { TextGenerationError } from "@t3tools/contracts";import { ProviderAdapterRequestError } from "../../provider/Errors.ts";import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";import {  ProviderService,  type ProviderServiceShape,} from "../../provider/Services/ProviderService.ts";import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";import {  GitStatusBroadcaster,  type GitStatusBroadcasterShape,} from "../../git/Services/GitStatusBroadcaster.ts";import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";import * as NodeServices from "@effect/platform-node/NodeServices";import { ServerSettingsService } from "../../serverSettings.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value);const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);const asMessageId = (value: string): MessageId => MessageId.make(value);const asTurnId = (value: string): TurnId => TurnId.make(value); const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) =>  Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); async function waitFor(  predicate: () => boolean | Promise<boolean>,  timeoutMs = 2000,): Promise<void> {  const deadline = Date.now() + timeoutMs;  const poll = async (): Promise<void> => {    if (await predicate()) {      return;    }    if (Date.now() >= deadline) {      throw new Error("Timed out waiting for expectation.");    }    await new Promise((resolve) => setTimeout(resolve, 10));    return poll();  };   return poll();} describe("ProviderCommandReactor", () => {  let runtime: ManagedRuntime.ManagedRuntime<    OrchestrationEngineService | ProviderCommandReactor,    unknown  > | null = null;  let scope: Scope.Closeable | null = null;  const createdStateDirs = new Set<string>();  const createdBaseDirs = new Set<string>();   afterEach(async () => {    if (scope) {      await Effect.runPromise(Scope.close(scope, Exit.void));    }    scope = null;    if (runtime) {      await runtime.dispose();    }    runtime = null;    for (const stateDir of createdStateDirs) {      fs.rmSync(stateDir, { recursive: true, force: true });    }    createdStateDirs.clear();    for (const baseDir of createdBaseDirs) {      fs.rmSync(baseDir, { recursive: true, force: true });    }    createdBaseDirs.clear();  });   async function createHarness(input?: {    readonly baseDir?: string;    readonly threadModelSelection?: ModelSelection;    readonly sessionModelSwitch?: "unsupported" | "in-session";  }) {    const now = new Date().toISOString();    const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));    createdBaseDirs.add(baseDir);    const { stateDir } = deriveServerPathsSync(baseDir, undefined);    createdStateDirs.add(stateDir);    const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());    let nextSessionIndex = 1;    const runtimeSessions: Array<ProviderSession> = [];    const modelSelection = input?.threadModelSelection ?? {      provider: "codex",      model: "gpt-5-codex",    };    const startSession = vi.fn((_: unknown, input: unknown) => {      const sessionIndex = nextSessionIndex++;      const resumeCursor =        typeof input === "object" && input !== null && "resumeCursor" in input          ? input.resumeCursor          : undefined;      const threadId =        typeof input === "object" &&        input !== null &&        "threadId" in input &&        typeof input.threadId === "string"          ? ThreadId.make(input.threadId)          : ThreadId.make(`thread-${sessionIndex}`);      const session: ProviderSession = {        provider: modelSelection.provider,        status: "ready" as const,        runtimeMode:          typeof input === "object" &&          input !== null &&          "runtimeMode" in input &&          (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")            ? input.runtimeMode            : "full-access",        ...(typeof input === "object" &&        input !== null &&        "cwd" in input &&        typeof input.cwd === "string"          ? { cwd: input.cwd }          : {}),        ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),        threadId,        resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },        createdAt: now,        updatedAt: now,      };      runtimeSessions.push(session);      return Effect.succeed(session);    });    const sendTurn = vi.fn((_: unknown) =>      Effect.succeed({        threadId: ThreadId.make("thread-1"),        turnId: asTurnId("turn-1"),      }),    );    const interruptTurn = vi.fn((_: unknown) => Effect.void);    const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);    const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);    const stopSession = vi.fn((input: unknown) =>      Effect.sync(() => {        const threadId =          typeof input === "object" && input !== null && "threadId" in input            ? (input as { threadId?: ThreadId }).threadId            : undefined;        if (!threadId) {          return;        }        const index = runtimeSessions.findIndex((session) => session.threadId === threadId);        if (index >= 0) {          runtimeSessions.splice(index, 1);        }      }),    );    const renameBranch = vi.fn((input: unknown) =>      Effect.succeed({        branch:          typeof input === "object" &&          input !== null &&          "newBranch" in input &&          typeof input.newBranch === "string"            ? input.newBranch            : "renamed-branch",      }),    );    const refreshStatus = vi.fn((_: string) =>      Effect.succeed({        isRepo: true,        hasOriginRemote: true,        isDefaultBranch: false,        branch: "renamed-branch",        hasWorkingTreeChanges: false,        workingTree: {          files: [],          insertions: 0,          deletions: 0,        },        hasUpstream: true,        aheadCount: 0,        behindCount: 0,        pr: null,      }),    );    const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>((_) =>      Effect.fail(        new TextGenerationError({          operation: "generateBranchName",          detail: "disabled in test harness",        }),      ),    );    const generateThreadTitle = vi.fn<TextGenerationShape["generateThreadTitle"]>((_) =>      Effect.fail(        new TextGenerationError({          operation: "generateThreadTitle",          detail: "disabled in test harness",        }),      ),    );     const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;    const service: ProviderServiceShape = {      startSession: startSession as ProviderServiceShape["startSession"],      sendTurn: sendTurn as ProviderServiceShape["sendTurn"],      interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],      respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],      respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],      stopSession: stopSession as ProviderServiceShape["stopSession"],      listSessions: () => Effect.succeed(runtimeSessions),      getCapabilities: (_provider) =>        Effect.succeed({          sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",        }),      rollbackConversation: () => unsupported(),      get streamEvents() {        return Stream.fromPubSub(runtimeEventPubSub);      },    };     const orchestrationLayer = OrchestrationEngineLive.pipe(      Layer.provide(OrchestrationProjectionSnapshotQueryLive),      Layer.provide(OrchestrationProjectionPipelineLive),      Layer.provide(OrchestrationEventStoreLive),      Layer.provide(OrchestrationCommandReceiptRepositoryLive),      Layer.provide(RepositoryIdentityResolverLive),      Layer.provide(SqlitePersistenceMemory),    );    const layer = ProviderCommandReactorLive.pipe(      Layer.provideMerge(orchestrationLayer),      Layer.provideMerge(Layer.succeed(ProviderService, service)),      Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),      Layer.provideMerge(        Layer.succeed(GitStatusBroadcaster, {          getStatus: () => Effect.die("getStatus should not be called in this test"),          refreshLocalStatus: () =>            Effect.die("refreshLocalStatus should not be called in this test"),          refreshStatus,          streamStatus: () => Stream.die("streamStatus should not be called in this test"),        } satisfies GitStatusBroadcasterShape),      ),      Layer.provideMerge(        Layer.mock(TextGeneration, {          generateBranchName,          generateThreadTitle,        }),      ),      Layer.provideMerge(ServerSettingsService.layerTest()),      Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),      Layer.provideMerge(NodeServices.layer),    );    runtime = ManagedRuntime.make(layer);     const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));    const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));    scope = await Effect.runPromise(Scope.make("sequential"));    await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));    const drain = () => Effect.runPromise(reactor.drain);     await Effect.runPromise(      engine.dispatch({        type: "project.create",        commandId: CommandId.make("cmd-project-create"),        projectId: asProjectId("project-1"),        title: "Provider Project",        workspaceRoot: "/tmp/provider-project",        defaultModelSelection: modelSelection,        createdAt: now,      }),    );    await Effect.runPromise(      engine.dispatch({        type: "thread.create",        commandId: CommandId.make("cmd-thread-create"),        threadId: ThreadId.make("thread-1"),        projectId: asProjectId("project-1"),        title: "Thread",        modelSelection: modelSelection,        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,        runtimeMode: "approval-required",        branch: null,        worktreePath: null,        createdAt: now,      }),    );     return {      engine,      startSession,      sendTurn,      interruptTurn,      respondToRequest,      respondToUserInput,      stopSession,      renameBranch,      refreshStatus,      generateBranchName,      generateThreadTitle,      stateDir,      drain,    };  }   it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {    const harness = await createHarness();    const now = new Date().toISOString();     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.turn.start",        commandId: CommandId.make("cmd-turn-start-1"),        threadId: ThreadId.make("thread-1"),        message: {          messageId: asMessageId("user-message-1"),          role: "user",          text: "hello reactor",          attachments: [],        },        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,        runtimeMode: "approval-required",        createdAt: now,      }),    );     await waitFor(() => harness.startSession.mock.calls.length === 1);    await waitFor(() => harness.sendTurn.mock.calls.length === 1);    expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1"));    expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({      cwd: "/tmp/provider-project",      modelSelection: {        provider: "codex",        model: "gpt-5-codex",      },      runtimeMode: "approval-required",    });     const readModel = await Effect.runPromise(harness.engine.getReadModel());    const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));    expect(thread?.session?.threadId).toBe("thread-1");    expect(thread?.session?.runtimeMode).toBe("approval-required");  });   it("generates a thread title on the first turn", async () => {    const harness = await createHarness();    const now = new Date().toISOString();    const seededTitle = "Please investigate reconnect failures after restar...";    harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" }));     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.meta.update",        commandId: CommandId.make("cmd-thread-title-seed"),        threadId: ThreadId.make("thread-1"),        title: seededTitle,      }),    );     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.turn.start",        commandId: CommandId.make("cmd-turn-start-title"),        threadId: ThreadId.make("thread-1"),        message: {          messageId: asMessageId("user-message-title"),          role: "user",          text: "Please investigate reconnect failures after restarting the session.",          attachments: [],        },        titleSeed: seededTitle,        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,        runtimeMode: "approval-required",        createdAt: now,      }),    );     await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);    expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({      message: "Please investigate reconnect failures after restarting the session.",    });     await waitFor(async () => {      const readModel = await Effect.runPromise(harness.engine.getReadModel());      return (        readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===        "Generated title"      );    });    const readModel = await Effect.runPromise(harness.engine.getReadModel());    const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));    expect(thread?.title).toBe("Generated title");  });   it("does not overwrite an existing custom thread title on the first turn", async () => {    const harness = await createHarness();    const now = new Date().toISOString();    const seededTitle = "Please investigate reconnect failures after restar...";     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.meta.update",        commandId: CommandId.make("cmd-thread-title-custom"),        threadId: ThreadId.make("thread-1"),        title: "Keep this custom title",      }),    );     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.turn.start",        commandId: CommandId.make("cmd-turn-start-title-preserve"),        threadId: ThreadId.make("thread-1"),        message: {          messageId: asMessageId("user-message-title-preserve"),          role: "user",          text: "Please investigate reconnect failures after restarting the session.",          attachments: [],        },        titleSeed: seededTitle,        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,        runtimeMode: "approval-required",        createdAt: now,      }),    );     await waitFor(() => harness.sendTurn.mock.calls.length === 1);    expect(harness.generateThreadTitle).not.toHaveBeenCalled();     const readModel = await Effect.runPromise(harness.engine.getReadModel());    const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));    expect(thread?.title).toBe("Keep this custom title");  });   it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => {    const harness = await createHarness();    const now = new Date().toISOString();    const seededTitle = "Fix reconnect spinner on resume";    harness.generateThreadTitle.mockReturnValue(      Effect.succeed({        title: "Reconnect spinner resume bug",      }),    );     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.meta.update",        commandId: CommandId.make("cmd-thread-title-formatted-seed"),        threadId: ThreadId.make("thread-1"),        title: seededTitle,      }),    );     await Effect.runPromise(      harness.engine.dispatch({        type: "thread.turn.start",        commandId: CommandId.make("cmd-turn-start-title-formatted"),        threadId: ThreadId.make("thread-1"),        message: {          messageId: asMessageId("user-message-title-formatted"),          role: "user",          text: "[effort:high]\\n\\nFix reconnect spinner on resume",          attachments: [],        },        titleSeed: seededTitle,        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,        runtimeMode: "approval-required",        createdAt: now,      }),    );     await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);    await waitFor(async () => {      const readModel = await Effect.runPromise(harness.engine.getReadModel());      return (        readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===        "Reconnect spinner resume bug"      );

Lines 1–500 of 1691.