import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { ProviderApprovalDecision, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, } from "@t3tools/contracts"; import { ApprovalRequestId, EventId, ProviderSessionStartInput, ProviderSessionId, ProviderThreadId, ProviderTurnId, ThreadId, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProviderAdapterSessionNotFoundError, ProviderSessionNotFoundError, ProviderValidationError, ProviderUnsupportedError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); function makeFakeCodexAdapter() { const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSession = 1; const startSession = vi.fn((input: ProviderSessionStartInput) => Effect.sync(() => { const now = new Date().toISOString(); const next = nextSession; const session: ProviderSession = { sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), provider: "codex", status: "ready", threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, }; nextSession += 1; sessions.set(session.sessionId, session); return session; }), ); const sendTurn = vi.fn( ( input: ProviderSendTurnInput, ): Effect.Effect => { if (!sessions.has(input.sessionId)) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: "codex", sessionId: input.sessionId, }), ); } return Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turnId: ProviderTurnId.makeUnsafe("turn-1"), }); }, ); const interruptTurn = vi.fn( (_sessionId: string, _turnId?: string): Effect.Effect => Effect.void, ); const respondToRequest = vi.fn( ( _sessionId: string, _requestId: string, _decision: ProviderApprovalDecision, ): Effect.Effect => Effect.void, ); const stopSession = vi.fn( (sessionId: string): Effect.Effect => Effect.sync(() => { sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); }), ); const listSessions = vi.fn( (): Effect.Effect> => Effect.sync(() => Array.from(sessions.values())), ); const hasSession = vi.fn( (sessionId: string): Effect.Effect => Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), ); const readThread = vi.fn( ( _sessionId: ProviderSessionId, ): Effect.Effect< { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; }, ProviderAdapterError > => Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [{ id: asTurnId("turn-1"), items: [] }], }), ); const rollbackThread = vi.fn( ( _sessionId: ProviderSessionId, _numTurns: number, ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), ); const stopAll = vi.fn( (): Effect.Effect => Effect.sync(() => { sessions.clear(); }), ); const adapter: ProviderAdapterShape = { provider: "codex", startSession, sendTurn, interruptTurn, respondToRequest, stopSession, listSessions, hasSession, readThread, rollbackThread, stopAll, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; const emit = (event: ProviderRuntimeEvent): void => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; return { adapter, emit, startSession, sendTurn, interruptTurn, respondToRequest, stopSession, listSessions, hasSession, readThread, rollbackThread, stopAll, }; } const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), listProviders: () => Effect.succeed(["codex"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const layer = it.layer( Layer.mergeAll( makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), ), directoryLayer, runtimeRepositoryLayer, NodeServices.layer, ), ); return { codex, layer, }; } const routing = makeProviderServiceLayer(); it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), listProviders: () => Effect.succeed(["codex"]), }; const persistenceLayer = makeSqlitePersistenceLive(dbPath); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ sessionId: asSessionId("sess-stale"), provider: "codex", threadId: ThreadId.makeUnsafe("thread-stale"), }); }).pipe(Effect.provide(directoryLayer)); const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), ); yield* Effect.gen(function* () { yield* ProviderService; }).pipe(Effect.provide(providerLayer)); const persistedProvider = yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; return yield* directory.getProvider(asSessionId("sess-stale")); }).pipe(Effect.provide(directoryLayer)); assert.equal(persistedProvider, "codex"); const runtime = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(runtime), true); const legacyTableRows = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; return yield* sql<{ readonly name: string }>` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_sessions' `; }).pipe(Effect.provide(persistenceLayer)); assert.equal(legacyTableRows.length, 0); fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); const firstCodex = makeFakeCodexAdapter(); const firstRegistry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(firstCodex.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), listProviders: () => Effect.succeed(["codex"]), }; const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), ); const startedSession = yield* Effect.gen(function* () { const provider = yield* ProviderService; return yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", }); }).pipe(Effect.provide(firstProviderLayer)); yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.stopAll(); }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; return yield* repository.getBySessionId({ providerSessionId: startedSession.sessionId, }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { assert.equal(persistedAfterStopAll.value.status, "stopped"); assert.equal(persistedAfterStopAll.value.resumeCursor, null); } const secondCodex = makeFakeCodexAdapter(); const secondRegistry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(secondCodex.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), listProviders: () => Effect.succeed(["codex"]), }; const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), ); secondCodex.startSession.mockClear(); secondCodex.rollbackThread.mockClear(); yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.rollbackConversation({ sessionId: startedSession.sessionId, numTurns: 1, }); }).pipe(Effect.provide(secondProviderLayer)); assert.deepEqual(secondCodex.startSession.mock.calls, [ [ { provider: "codex", resumeThreadId: startedSession.threadId, }, ], ]); assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; assert.equal(typeof rollbackCall?.[0], "string"); assert.equal(rollbackCall?.[1], 1); fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", }); assert.equal(session.provider, "codex"); const sessions = yield* provider.listSessions(); assert.equal(sessions.length, 1); yield* provider.sendTurn({ sessionId: session.sessionId, input: "hello", attachments: [], }); assert.equal(routing.codex.sendTurn.mock.calls.length, 1); yield* provider.interruptTurn({ sessionId: session.sessionId }); assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]); yield* provider.respondToRequest({ sessionId: session.sessionId, requestId: asRequestId("req-1"), decision: "accept", }); assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ [session.sessionId, asRequestId("req-1"), "accept"], ]); yield* provider.rollbackConversation({ sessionId: session.sessionId, numTurns: 0, }); yield* provider.stopSession({ sessionId: session.sessionId }); const sendAfterStop = yield* Effect.result( provider.sendTurn({ sessionId: session.sessionId, input: "after-stop", attachments: [], }), ); assertFailure( sendAfterStop, new ProviderSessionNotFoundError({ sessionId: session.sessionId }), ); }), ); it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", }); yield* routing.codex.stopSession(initial.sessionId); routing.codex.startSession.mockClear(); routing.codex.rollbackThread.mockClear(); yield* provider.rollbackConversation({ sessionId: initial.sessionId, numTurns: 1, }); assert.deepEqual(routing.codex.startSession.mock.calls, [ [ { provider: "codex", resumeThreadId: initial.threadId, }, ], ]); assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; assert.equal(rollbackCall?.[1], 1); }), ); it.effect("stops all sessions and clears adapter runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", }); yield* provider.startSession(asThreadId("thread-2"), { provider: "codex", }); yield* provider.stopAll(); const remaining = yield* provider.listSessions(); assert.equal(remaining.length, 0); }), ); it.effect("persists runtime status transitions in provider_session_runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; const runtimeRepository = yield* ProviderSessionRuntimeRepository; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", }); yield* provider.sendTurn({ sessionId: session.sessionId, input: "hello", attachments: [], }); const runningRuntime = yield* runtimeRepository.getBySessionId({ providerSessionId: session.sessionId, }); assert.equal(Option.isSome(runningRuntime), true); if (Option.isSome(runningRuntime)) { assert.equal(runningRuntime.value.status, "running"); assert.equal(runningRuntime.value.resumeCursor, null); const payload = runningRuntime.value.runtimePayload; assert.equal(payload !== null && typeof payload === "object", true); if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { const runtimePayload = payload as { cwd: string; model: string | null; activeTurnId: string | null; lastError: string | null; lastRuntimeEvent: string | null; }; assert.equal(runtimePayload.cwd, process.cwd()); assert.equal(runtimePayload.model, null); assert.equal(runtimePayload.activeTurnId, "turn-1"); assert.equal(runtimePayload.lastError, null); assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } } yield* provider.stopAll(); const stoppedRuntime = yield* runtimeRepository.getBySessionId({ providerSessionId: session.sessionId, }); assert.equal(Option.isSome(stoppedRuntime), true); if (Option.isSome(stoppedRuntime)) { assert.equal(stoppedRuntime.value.status, "stopped"); } }), ); }); const fanout = makeProviderServiceLayer(); fanout.layer("ProviderServiceLive fanout", (it) => { it.effect("fans out adapter turn completion events", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", }); const eventsRef = yield* Ref.make>([]); const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => Ref.update(eventsRef, (current) => [...current, event]), ).pipe(Effect.forkChild); yield* sleep(20); const completedEvent: ProviderRuntimeEvent = { type: "turn.completed", eventId: asEventId("evt-1"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), status: "completed", }; fanout.codex.emit(completedEvent); yield* sleep(20); const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); assert.equal( events.some((entry) => entry.type === "turn.completed"), true, ); }), ); it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", }); const receivedByHealthy: string[] = []; const expectedEventIds = new Set(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]); const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( Stream.runForEach((event) => Effect.sync(() => { receivedByHealthy.push(event.eventId); }), ), Effect.forkChild, ); const failingFiber = yield* Stream.take(provider.streamEvents, 1).pipe( Stream.runForEach(() => Effect.fail("listener crash")), Effect.forkChild, ); yield* sleep(20); const events: ReadonlyArray = [ { type: "tool.completed", eventId: asEventId("evt-ordered-1"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", detail: "echo one", }, { type: "message.delta", eventId: asEventId("evt-ordered-2"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), delta: "hello", }, { type: "turn.completed", eventId: asEventId("evt-ordered-3"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), threadId: ThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), status: "completed", }, ]; for (const event of events) { fanout.codex.emit(event); } const failingResult = yield* Effect.result(Fiber.join(failingFiber)); assert.equal(failingResult._tag, "Failure"); yield* Fiber.join(healthyFiber); assert.deepEqual( receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], ); }), ); }); const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { it.effect("returns ProviderValidationError for invalid input payloads", () => Effect.gen(function* () { const provider = yield* ProviderService; const failure = yield* Effect.result( provider.startSession(asThreadId("thread-validation"), { provider: "invalid-provider", } as never), ); assert.equal(failure._tag, "Failure"); if (failure._tag !== "Failure") { return; } assert.equal(failure.failure._tag, "ProviderValidationError"); if (failure.failure._tag !== "ProviderValidationError") { return; } assert.equal(failure.failure.operation, "ProviderService.startSession"); assert.equal(failure.failure.issue.includes("invalid-provider"), true); }), ); it.effect("fails startSession when adapter returns no threadId", () => Effect.gen(function* () { const provider = yield* ProviderService; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { const now = new Date().toISOString(); return { sessionId: asSessionId("sess-missing-thread"), provider: "codex", status: "ready", cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, } satisfies ProviderSession; }), ); const failure = yield* Effect.result( provider.startSession(asThreadId("thread-missing"), { provider: "codex", cwd: "/tmp/project", }), ); assertFailure( failure, new ProviderValidationError({ operation: "ProviderService.startSession", issue: "Provider 'codex' returned a session without threadId.", }), ); }), ); });