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. Load more
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" );