diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 26f89a91fac896a5250a2173857fe9524a239e27..a634e8882c6a6b09b658982ac04d7525d5908d25 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -196,257 +196,255 @@ export const makeOrchestrationIntegrationHarness = ( options?: MakeOrchestrationIntegrationHarnessOptions, ) => Effect.gen(function* () { - const sleep = (ms: number) => Effect.sleep(ms); - const provider = options?.provider ?? "codex"; - const useRealCodex = options?.realCodex === true; - const adapterHarness = useRealCodex - ? null - : yield* makeTestProviderAdapterHarness({ - provider, - }); - const fakeRegistry = adapterHarness - ? Layer.succeed(ProviderAdapterRegistry, { - getByProvider: (resolvedProvider) => - resolvedProvider === adapterHarness.provider - ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed([adapterHarness.provider]), - } as typeof ProviderAdapterRegistry.Service) - : null; - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); - const workspaceDir = path.join(rootDir, "workspace"); - const stateDir = path.join(rootDir, "state"); - const dbPath = path.join(stateDir, "state.sqlite"); - fs.mkdirSync(workspaceDir, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - initializeGitWorkspace(workspaceDir); + const sleep = (ms: number) => Effect.sleep(ms); + const provider = options?.provider ?? "codex"; + const useRealCodex = options?.realCodex === true; + const adapterHarness = useRealCodex + ? null + : yield* makeTestProviderAdapterHarness({ + provider, + }); + const fakeRegistry = adapterHarness + ? Layer.succeed(ProviderAdapterRegistry, { + getByProvider: (resolvedProvider) => + resolvedProvider === adapterHarness.provider + ? Effect.succeed(adapterHarness.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed([adapterHarness.provider]), + } as typeof ProviderAdapterRegistry.Service) + : null; + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); + const workspaceDir = path.join(rootDir, "workspace"); + const stateDir = path.join(rootDir, "state"); + const dbPath = path.join(stateDir, "state.sqlite"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + initializeGitWorkspace(workspaceDir); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - const realCodexRegistry = Layer.effect( - ProviderAdapterRegistry, - Effect.gen(function* () { - const codexAdapter = yield* CodexAdapter; - return { - getByProvider: (resolvedProvider) => - resolvedProvider === "codex" - ? Effect.succeed(codexAdapter) - : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), - listProviders: () => Effect.succeed(["codex"] as const), - } as typeof ProviderAdapterRegistry.Service; - }), - ).pipe( - Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(providerSessionDirectoryLayer), - ); - const providerLayer = useRealCodex - ? makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(realCodexRegistry), - ) - : makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(fakeRegistry!), - ); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + const realCodexRegistry = Layer.effect( + ProviderAdapterRegistry, + Effect.gen(function* () { + const codexAdapter = yield* CodexAdapter; + return { + getByProvider: (resolvedProvider) => + resolvedProvider === "codex" + ? Effect.succeed(codexAdapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed(["codex"] as const), + } as typeof ProviderAdapterRegistry.Service; + }), + ).pipe( + Layer.provide(makeCodexAdapterLive()), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerSessionDirectoryLayer), + ); + const providerLayer = useRealCodex + ? makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(realCodexRegistry), + ) + : makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(fakeRegistry!), + ); - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - ProjectionCheckpointRepositoryLive, - ProjectionPendingApprovalRepositoryLive, - CheckpointStoreLive, - providerLayer, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => - Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); - const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: null }), - } as unknown as TextGenerationShape); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), - Layer.provideMerge(textGenerationLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - const layer = orchestrationReactorLayer.pipe( - Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), - Layer.provideMerge(NodeServices.layer), - ); + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + ProjectionCheckpointRepositoryLive, + ProjectionPendingApprovalRepositoryLive, + CheckpointStoreLive, + providerLayer, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const gitCoreLayer = Layer.succeed(GitCore, { + renameBranch: (input: Parameters[0]) => + Effect.succeed({ branch: input.newBranch }), + } as unknown as GitCoreShape); + const textGenerationLayer = Layer.succeed(TextGeneration, { + generateBranchName: () => Effect.succeed({ branch: null }), + } as unknown as TextGenerationShape); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + const layer = orchestrationReactorLayer.pipe( + Layer.provide(persistenceLayer), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(NodeServices.layer), + ); - const runtime = ManagedRuntime.make(layer); - const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => - runtime.runPromise(Effect.service(OrchestrationEngineService)), - ).pipe(Effect.orDie); - const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => - runtime.runPromise(Effect.service(OrchestrationReactor)), - ).pipe(Effect.orDie); - const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => - runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), - ).pipe(Effect.orDie); - const providerService = yield* tryRuntimePromise("load ProviderService service", () => - runtime.runPromise(Effect.service(ProviderService)), - ).pipe(Effect.orDie); - const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => - runtime.runPromise(Effect.service(CheckpointStore)), - ).pipe(Effect.orDie); - const checkpointRepository = yield* tryRuntimePromise( - "load ProjectionCheckpointRepository service", - () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), - ).pipe(Effect.orDie); - const pendingApprovalRepository = yield* tryRuntimePromise( - "load ProjectionPendingApprovalRepository service", - () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), - ).pipe(Effect.orDie); + const runtime = ManagedRuntime.make(layer); + const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => + runtime.runPromise(Effect.service(OrchestrationEngineService)), + ).pipe(Effect.orDie); + const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => + runtime.runPromise(Effect.service(OrchestrationReactor)), + ).pipe(Effect.orDie); + const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => + runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), + ).pipe(Effect.orDie); + const providerService = yield* tryRuntimePromise("load ProviderService service", () => + runtime.runPromise(Effect.service(ProviderService)), + ).pipe(Effect.orDie); + const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => + runtime.runPromise(Effect.service(CheckpointStore)), + ).pipe(Effect.orDie); + const checkpointRepository = yield* tryRuntimePromise( + "load ProjectionCheckpointRepository service", + () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), + ).pipe(Effect.orDie); + const pendingApprovalRepository = yield* tryRuntimePromise( + "load ProjectionPendingApprovalRepository service", + () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), + ).pipe(Effect.orDie); - const scope = yield* Scope.make("sequential"); - yield* tryRuntimePromise("start OrchestrationReactor", () => - runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), - ).pipe(Effect.orDie); - yield* sleep(10); + const scope = yield* Scope.make("sequential"); + yield* tryRuntimePromise("start OrchestrationReactor", () => + runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), + ).pipe(Effect.orDie); + yield* sleep(10); - const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( - threadId, - predicate, - timeoutMs, - ) => - waitFor( - snapshotQuery - .getSnapshot() - .pipe( - Effect.map( - (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, + const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( + threadId, + predicate, + timeoutMs, + ) => + waitFor( + snapshotQuery + .getSnapshot() + .pipe( + Effect.map( + (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, + ), ), + (thread): thread is OrchestrationThread => thread !== null && predicate(thread), + `projected thread '${threadId}'`, + timeoutMs, + ) as Effect.Effect; + + const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( + predicate, + timeoutMs, + ) => + waitFor( + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk): ReadonlyArray => Array.from(chunk)), ), - (thread): thread is OrchestrationThread => thread !== null && predicate(thread), - `projected thread '${threadId}'`, - timeoutMs, - ) as Effect.Effect; + (events) => events.some(predicate), + "domain event", + timeoutMs, + ); - const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( - predicate, - timeoutMs, - ) => - waitFor( - Stream.runCollect(engine.readEvents(0)).pipe( - Effect.map((chunk): ReadonlyArray => Array.from(chunk)), - ), - (events) => events.some(predicate), - "domain event", + const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( + requestId, + predicate, timeoutMs, - ); - - const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( - requestId, - predicate, - timeoutMs, - ) => - waitFor( - pendingApprovalRepository - .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) - .pipe( - Effect.map((row) => - Option.match(row, { - onNone: () => null, - onSome: (value) => ({ - status: value.status, - decision: value.decision, - resolvedAt: value.resolvedAt, + ) => + waitFor( + pendingApprovalRepository + .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) + .pipe( + Effect.map((row) => + Option.match(row, { + onNone: () => null, + onSome: (value) => ({ + status: value.status, + decision: value.decision, + resolvedAt: value.resolvedAt, + }), }), - }), + ), ), - ), - ( - row, - ): row is { - readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; - readonly resolvedAt: string | null; - } => row !== null && predicate(row), - `pending approval '${requestId}'`, - timeoutMs, - ) as Effect.Effect< - { - readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; - readonly resolvedAt: string | null; - }, - never - >; + ( + row, + ): row is { + readonly status: "pending" | "resolved"; + readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly resolvedAt: string | null; + } => row !== null && predicate(row), + `pending approval '${requestId}'`, + timeoutMs, + ) as Effect.Effect< + { + readonly status: "pending" | "resolved"; + readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly resolvedAt: string | null; + }, + never + >; - let disposed = false; - const dispose = Effect.gen(function* () { - if (disposed) { - return; - } - disposed = true; - - const shutdown = Effect.gen(function* () { - const stopAllExit = yield* Effect.exit( - Effect.promise(() => runtime.runPromise(providerService.stopAll())), - ); - const closeScopeExit = yield* Effect.exit( - Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))), - ); - const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); - - const failureCause = Exit.isFailure(stopAllExit) - ? stopAllExit.cause - : Exit.isFailure(closeScopeExit) - ? closeScopeExit.cause - : Exit.isFailure(disposeRuntimeExit) - ? disposeRuntimeExit.cause - : null; - - if (failureCause) { - return yield* Effect.failCause(failureCause); + let disposed = false; + const dispose = Effect.gen(function* () { + if (disposed) { + return; } + disposed = true; + + const shutdown = Effect.gen(function* () { + const stopAllExit = yield* Effect.exit( + Effect.promise(() => runtime.runPromise(providerService.stopAll())), + ); + const closeScopeExit = yield* Effect.exit(Scope.close(scope, Exit.void)); + const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); + + const failureCause = Exit.isFailure(stopAllExit) + ? stopAllExit.cause + : Exit.isFailure(closeScopeExit) + ? closeScopeExit.cause + : Exit.isFailure(disposeRuntimeExit) + ? disposeRuntimeExit.cause + : null; + + if (failureCause) { + return yield* Effect.failCause(failureCause); + } + }); + + yield* shutdown.pipe( + Effect.ensuring( + Effect.sync(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }), + ), + ); }); - yield* shutdown.pipe( - Effect.ensuring( - Effect.sync(() => { - fs.rmSync(rootDir, { recursive: true, force: true }); - }), - ), - ); - }); - - return { - rootDir, - workspaceDir, + return { + rootDir, + workspaceDir, dbPath, adapterHarness: adapterHarness as TestProviderAdapterHarness, - engine, - snapshotQuery, - providerService, - checkpointStore, - checkpointRepository, - pendingApprovalRepository, - waitForThread, - waitForDomainEvent, - waitForPendingApproval, - dispose, - } satisfies OrchestrationIntegrationHarness; -}); + engine, + snapshotQuery, + providerService, + checkpointStore, + checkpointRepository, + pendingApprovalRepository, + waitForThread, + waitForDomainEvent, + waitForPendingApproval, + dispose, + } satisfies OrchestrationIntegrationHarness; + }); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 6f14a9e1553cf00c057c11a056be38239faac3a6..5fb140b60157a6f2622a0e0099e1272711882fcf 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -7,9 +7,6 @@ import { ProviderRuntimeEvent, RuntimeSessionId, ProviderSession, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ProviderTurnStartResult, ThreadId, TurnId, @@ -39,9 +36,8 @@ export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; readonly provider: "codex" | "claudeCode" | "cursor"; - readonly sessionId: string; readonly createdAt: string; - readonly threadId?: string | undefined; + readonly threadId: string; readonly turnId?: string | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; @@ -184,18 +180,18 @@ export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( - sessionId: string, + threadId: ThreadId, response: TestTurnResponse, ) => Effect.Effect; readonly queueTurnResponseForNextSession: ( response: TestTurnResponse, ) => Effect.Effect; readonly getStartCount: () => number; - readonly getRollbackCalls: (sessionId: string) => ReadonlyArray; - readonly getInterruptCalls: (sessionId: string) => ReadonlyArray; - readonly listActiveSessionIds: () => ReadonlyArray; - readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{ - readonly sessionId: ProviderSessionId; + readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray; + readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray; + readonly listActiveSessionIds: () => ReadonlyArray; + readonly getApprovalResponses: (threadId: ThreadId) => ReadonlyArray<{ + readonly threadId: ThreadId; readonly requestId: ApprovalRequestId; readonly decision: ProviderApprovalDecision; }>; @@ -211,19 +207,19 @@ function nowIso(): string { function sessionNotFound( provider: "codex" | "claudeCode", - sessionId: string, + threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ provider, - sessionId, + threadId: String(threadId), }); } function missingSessionEffect( provider: "codex" | "claudeCode", - sessionId: string, + threadId: ThreadId, ): Effect.Effect { - return Effect.fail(sessionNotFound(provider, sessionId)); + return Effect.fail(sessionNotFound(provider, threadId)); } export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => @@ -231,13 +227,13 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const provider = options?.provider ?? "codex"; const runtimeEvents = yield* Queue.unbounded(); let sessionCount = 0; - const sessions = new Map(); + const sessions = new Map(); const queuedResponsesForNextSession: TestTurnResponse[] = []; - const interruptCallsBySession = new Map>(); + const interruptCallsBySession = new Map>(); const approvalResponsesBySession = new Map< - string, + ThreadId, Array<{ - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly requestId: ApprovalRequestId; readonly decision: ProviderApprovalDecision; }> @@ -256,23 +252,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter } sessionCount += 1; - const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); - const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); + const threadId = input.threadId; const createdAt = nowIso(); const session: ProviderSession = { - sessionId, provider, status: "ready", runtimeMode: input.runtimeMode, threadId, cwd: input.cwd, - resumeCursor: input.resumeCursor ?? { sessionId }, + resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, createdAt, updatedAt: createdAt, }; - sessions.set(sessionId, { + sessions.set(threadId, { session, snapshot: { threadId, @@ -288,21 +282,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const state = sessions.get(input.sessionId); + const state = sessions.get(input.threadId); if (!state) { - return yield* missingSessionEffect(provider, input.sessionId); + return yield* missingSessionEffect(provider, input.threadId); } state.turnCount += 1; const turnCount = state.turnCount; - const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); + const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); const response = state.queuedResponses.shift(); if (!response) { return yield* new ProviderAdapterValidationError({ provider, operation: "sendTurn", - issue: `No queued turn response for session ${input.sessionId}.`, + issue: `No queued turn response for thread ${input.threadId}.`, }); } @@ -313,14 +307,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter ...(fixtureEvent as Record), eventId: randomUUID(), provider, - sessionId: RuntimeSessionId.makeUnsafe(input.sessionId), + sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), createdAt: nowIso(), }; - if (Object.hasOwn(rawEvent, "threadId")) { - rawEvent.threadId = ThreadId.makeUnsafe(state.snapshot.threadId); - } + rawEvent.threadId = state.snapshot.threadId; if (Object.hasOwn(rawEvent, "turnId")) { - rawEvent.turnId = TurnId.makeUnsafe(turnId); + rawEvent.turnId = turnId; } const runtimeEvent = normalizeFixtureEvent(rawEvent); @@ -373,10 +365,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter type: "turn.completed", eventId: EventId.makeUnsafe(randomUUID()), provider, - sessionId: RuntimeSessionId.makeUnsafe(input.sessionId), createdAt: nowIso(), - threadId: ThreadId.makeUnsafe(state.snapshot.threadId), - turnId: TurnId.makeUnsafe(turnId), + threadId: state.snapshot.threadId, + turnId, payload: { state: "completed", }, @@ -394,60 +385,60 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }); const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( - sessionId, + threadId, turnId, ) => - sessions.has(sessionId) + sessions.has(threadId) ? Effect.sync(() => { - const existing = interruptCallsBySession.get(sessionId) ?? []; + const existing = interruptCallsBySession.get(threadId) ?? []; existing.push(turnId); - interruptCallsBySession.set(sessionId, existing); + interruptCallsBySession.set(threadId, existing); }) - : missingSessionEffect(provider, sessionId); + : missingSessionEffect(provider, threadId); const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => - sessions.has(sessionId) + sessions.has(threadId) ? Effect.sync(() => { - const existing = approvalResponsesBySession.get(sessionId) ?? []; + const existing = approvalResponsesBySession.get(threadId) ?? []; existing.push({ - sessionId, + threadId, requestId, decision, }); - approvalResponsesBySession.set(sessionId, existing); + approvalResponsesBySession.set(threadId, existing); }) - : missingSessionEffect(provider, sessionId); + : missingSessionEffect(provider, threadId); - const stopSession: ProviderAdapterShape["stopSession"] = (sessionId) => + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { - sessions.delete(sessionId); + sessions.delete(threadId); }); const listSessions: ProviderAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); - const hasSession: ProviderAdapterShape["hasSession"] = (sessionId) => - Effect.succeed(sessions.has(sessionId)); + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.succeed(sessions.has(threadId)); - const readThread: ProviderAdapterShape["readThread"] = (sessionId) => { - const state = sessions.get(sessionId); + const readThread: ProviderAdapterShape["readThread"] = (threadId) => { + const state = sessions.get(threadId); if (!state) { - return missingSessionEffect(provider, sessionId); + return missingSessionEffect(provider, threadId); } return Effect.succeed(state.snapshot); }; const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( - sessionId, + threadId, numTurns, ) => { - const state = sessions.get(sessionId); + const state = sessions.get(threadId); if (!state) { - return missingSessionEffect(provider, sessionId); + return missingSessionEffect(provider, threadId); } if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { return Effect.fail( @@ -494,16 +485,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter }; const queueTurnResponse = ( - sessionId: string, + threadId: ThreadId, response: TestTurnResponse, ): Effect.Effect => - Effect.sync(() => sessions.get(sessionId)).pipe( + Effect.sync(() => sessions.get(threadId)).pipe( Effect.flatMap((state) => state ? Effect.sync(() => { state.queuedResponses.push(response); }) - : Effect.fail(sessionNotFound(provider, sessionId)), + : Effect.fail(sessionNotFound(provider, threadId)), ), ); @@ -514,8 +505,8 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter queuedResponsesForNextSession.push(response); }); - const getRollbackCalls = (sessionId: string): ReadonlyArray => { - const state = sessions.get(sessionId); + const getRollbackCalls = (threadId: ThreadId): ReadonlyArray => { + const state = sessions.get(threadId); if (!state) { return []; } @@ -524,25 +515,25 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const getStartCount = (): number => sessionCount; - const getInterruptCalls = (sessionId: string): ReadonlyArray => { - const calls = interruptCallsBySession.get(sessionId); + const getInterruptCalls = (threadId: ThreadId): ReadonlyArray => { + const calls = interruptCallsBySession.get(threadId); if (!calls) { return []; } return [...calls]; }; - const listActiveSessionIds = (): ReadonlyArray => - Array.from(sessions.values(), (state) => state.session.sessionId); + const listActiveSessionIds = (): ReadonlyArray => + Array.from(sessions.values(), (state) => state.session.threadId); const getApprovalResponses = ( - sessionId: string, + threadId: ThreadId, ): ReadonlyArray<{ - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly requestId: ApprovalRequestId; readonly decision: ProviderApprovalDecision; }> => { - const responses = approvalResponsesBySession.get(sessionId); + const responses = approvalResponsesBySession.get(threadId); if (!responses) { return []; } diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 15b0ca8a9feeab90f93385e1d72df0f2ec0b45ab..4657fdaad868eb026924380fff7d63a10c8049b7 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -7,9 +7,6 @@ import { EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; @@ -27,18 +24,12 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); -const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); -const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); -const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); +const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); type IntegrationProvider = "codex" | "claudeCode"; @@ -81,7 +72,6 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr return { eventId: asEventId(eventId), provider, - sessionId: FIXTURE_SESSION_ID, createdAt, }; } @@ -167,20 +157,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + { type: "turn.started", ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Single turn response.\n", }, { type: "turn.completed", ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -276,15 +266,12 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( (entry) => entry.session?.status === "ready" && entry.session.providerName === "codex" && - entry.session.providerThreadId !== null && entry.messages.some( (message) => message.role === "assistant" && message.streaming === false, ), 180_000, ); - - const originalProviderThreadId = firstThread.session?.providerThreadId; - assert.isNotNull(originalProviderThreadId); + assert.equal(firstThread.session?.threadId, "thread-1"); yield* harness.engine.dispatch({ type: "thread.turn.start", @@ -305,15 +292,13 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( (entry) => entry.session?.status === "ready" && entry.session.providerName === "codex" && - entry.session.providerThreadId !== null && entry.session.runtimeMode === "approval-required" && entry.messages.some( (message) => message.role === "assistant" && message.text.includes("BETA"), ), 180_000, ); - - assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId); + assert.equal(secondThread.session?.threadId, "thread-1"); }), ), ); @@ -328,13 +313,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "turn.started", ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -343,7 +328,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "tool.completed", ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -352,14 +337,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => { type: "message.delta", ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v2.\n", }, { type: "turn.completed", ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -377,35 +362,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => text: "Make first edit", }); - const firstTurnThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null, + (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", ); - const sessionId = firstTurnThread.session?.providerSessionId; - assert.equal(sessionId !== null, true); - if (!sessionId) { - return; - } - yield* harness.adapterHarness.queueTurnResponse(sessionId, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v3.\n", }, { type: "turn.completed", ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -492,13 +472,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons { type: "turn.started", ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, requestKind: "command", @@ -507,7 +487,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons { type: "turn.completed", ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -551,9 +531,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons assert.equal(resolvedRow.status, "resolved"); assert.equal(resolvedRow.decision, "accept"); - const providerSessionId = thread.session?.providerSessionId ?? "test-session-1"; const approvalResponses = yield* waitForSync( - () => harness.adapterHarness.getApprovalResponses(providerSessionId), + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), (responses) => responses.length === 1, "provider approval response", ); @@ -574,13 +553,13 @@ it.live("records failed turn runtime state and checkpoint status as error", () = { type: "turn.started", ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "content.delta", ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, payload: { streamKind: "assistant_text", @@ -590,7 +569,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () = { type: "runtime.error", ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, payload: { message: "Sandbox command failed.", @@ -599,7 +578,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () = { type: "turn.completed", ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, payload: { state: "failed", @@ -653,13 +632,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "turn.started", ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -668,7 +647,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "tool.completed", ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -677,14 +656,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "message.delta", ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v2.\n", }, { type: "turn.completed", ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -701,28 +680,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git text: "First edit", }); - const firstTurnThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1, + (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, ); - const sessionId = firstTurnThread.session?.providerSessionId; - assert.equal(sessionId !== null, true); - if (!sessionId) { - return; - } - yield* harness.adapterHarness.queueTurnResponse(sessionId, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "tool.started", ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -731,7 +705,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "tool.completed", ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, toolKind: "command", title: "Edit file", @@ -740,14 +714,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git { type: "message.delta", ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Updated README to v3.\n", }, { type: "turn.completed", ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -816,7 +790,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, ); - assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ threadId: THREAD_ID, @@ -874,20 +848,20 @@ it.live("starts a claudeCode session on first turn when provider is requested", { type: "turn.started", ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Claude first turn.\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -928,20 +902,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res { type: "turn.started", ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn before restart.\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -956,16 +930,10 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res provider: "claudeCode", }); - const firstThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", ); - const staleSessionId = firstThread.session?.providerSessionId; - assert.equal(staleSessionId !== null, true); - if (!staleSessionId) { - return; - } yield* harness.providerService.stopAll(); yield* waitForSync( @@ -979,20 +947,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res { type: "turn.started", ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Turn after restart.\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -1021,7 +989,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), ); assert.equal(recoveredThread.session?.providerName, "claudeCode"); - assert.equal(recoveredThread.session?.providerSessionId, staleSessionId); + assert.equal(recoveredThread.session?.threadId, "thread-1"); }), "claudeCode", ), @@ -1038,13 +1006,13 @@ it.live("forwards claudeCode approval responses to the provider session", () => { type: "turn.started", ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "approval.requested", ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, requestId: APPROVAL_REQUEST_ID, requestKind: "command", @@ -1053,7 +1021,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => { type: "turn.completed", ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -1071,11 +1039,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => const thread = yield* harness.waitForThread(THREAD_ID, (entry) => entry.activities.some((activity) => activity.kind === "approval.requested"), ); - const providerSessionId = thread.session?.providerSessionId; - assert.equal(providerSessionId !== null, true); - if (!providerSessionId) { - return; - } + assert.equal(thread.session?.threadId, "thread-1"); yield* harness.engine.dispatch({ type: "thread.approval.respond", @@ -1092,7 +1056,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => ); const approvalResponses = yield* waitForSync( - () => harness.adapterHarness.getApprovalResponses(providerSessionId), + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), (responses) => responses.length === 1, "claude provider approval response", ); @@ -1113,20 +1077,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => { type: "turn.started", ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "Long running output.\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -1143,13 +1107,9 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => const thread = yield* harness.waitForThread( THREAD_ID, - (entry) => entry.session?.providerSessionId !== null, + (entry) => entry.session?.threadId === "thread-1", ); - const providerSessionId = thread.session?.providerSessionId; - assert.equal(providerSessionId !== null, true); - if (!providerSessionId) { - return; - } + assert.equal(thread.session?.threadId, "thread-1"); yield* harness.engine.dispatch({ type: "thread.turn.interrupt", @@ -1160,7 +1120,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested"); const interruptCalls = yield* waitForSync( - () => harness.adapterHarness.getInterruptCalls(providerSessionId), + () => harness.adapterHarness.getInterruptCalls(THREAD_ID), (calls) => calls.length === 1, "claude provider interrupt call", ); @@ -1181,20 +1141,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( { type: "turn.started", ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v2\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -1213,35 +1173,30 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( provider: "claudeCode", }); - const firstThread = yield* harness.waitForThread( + yield* harness.waitForThread( THREAD_ID, - (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", ); - const sessionId = firstThread.session?.providerSessionId; - assert.equal(sessionId !== null, true); - if (!sessionId) { - return; - } - yield* harness.adapterHarness.queueTurnResponse(sessionId, { + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { events: [ { type: "turn.started", ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, }, { type: "message.delta", ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, delta: "README -> v3\n", }, { type: "turn.completed", ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), - threadId: FIXTURE_THREAD_ID, + threadId: THREAD_ID, turnId: FIXTURE_TURN_ID, status: "completed", }, @@ -1289,7 +1244,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), false, ); - assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); }), "claudeCode", ), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 23f1345cfaa7b670fcf4f3f4ba73e14b0ce020ce..5c1362f231a51e9072595b0460d9f35ce7ba4045 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,4 +1,4 @@ -import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; @@ -93,18 +93,18 @@ const collectEventsDuring = ( const runTurn = (input: { readonly provider: ProviderServiceShape; readonly harness: TestProviderAdapterHarness; - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly userText: string; readonly response: TestTurnResponse; }) => Effect.gen(function* () { - yield* input.harness.queueTurnResponse(input.sessionId, input.response); + yield* input.harness.queueTurnResponse(input.threadId, input.response); return yield* collectEventsDuring( input.provider.streamEvents, input.response.events.length, input.provider.sendTurn({ - sessionId: input.sessionId, + threadId: input.threadId, input: input.userText, attachments: [], }), @@ -120,6 +120,7 @@ it.effect("replays typed runtime fixture events", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-typed"), { + threadId: ThreadId.makeUnsafe("thread-integration-typed"), provider: "codex", cwd: fixture.cwd, runtimeMode: "full-access", @@ -130,7 +131,7 @@ it.effect("replays typed runtime fixture events", () => const observedEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "hello", response: { events: codexTurnTextFixture }, }); @@ -154,6 +155,7 @@ it.effect("replays file-changing fixture turn events", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-tools"), { + threadId: ThreadId.makeUnsafe("thread-integration-tools"), provider: "codex", cwd: fixture.cwd, runtimeMode: "full-access", @@ -164,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () => const observedEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "make a small change", response: { events: codexTurnToolFixture, @@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-multi"), { + threadId: ThreadId.makeUnsafe("thread-integration-multi"), provider: "codex", cwd: fixture.cwd, runtimeMode: "full-access", @@ -202,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () => const firstTurnEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 1", response: { events: codexTurnToolFixture, @@ -218,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () => const secondTurnEvents = yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 2 approval", response: { events: codexTurnApprovalFixture, @@ -245,6 +248,7 @@ it.effect("rolls back provider conversation state only", () => const session = yield* provider.startSession( ThreadId.makeUnsafe("thread-integration-rollback"), { + threadId: ThreadId.makeUnsafe("thread-integration-rollback"), provider: "codex", cwd: fixture.cwd, runtimeMode: "full-access", @@ -255,7 +259,7 @@ it.effect("rolls back provider conversation state only", () => yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 1", response: { events: codexTurnToolFixture, @@ -267,7 +271,7 @@ it.effect("rolls back provider conversation state only", () => yield* runTurn({ provider, harness: fixture.harness, - sessionId: session.sessionId, + threadId: session.threadId, userText: "turn 2 approval", response: { events: codexTurnApprovalFixture, @@ -277,11 +281,11 @@ it.effect("rolls back provider conversation state only", () => }); yield* provider.rollbackConversation({ - sessionId: session.sessionId, + threadId: session.threadId, numTurns: 1, }); - const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); + const rollbackCalls = fixture.harness.getRollbackCalls(session.threadId); assert.deepEqual(rollbackCalls, [1]); const readme = yield* readFileString(join(fixture.cwd, "README.md")); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 30835cc6c8e2cec7d2bf8f8875b85073d417f50b..2f6c6d5ab7200849a5c594b20877411433b8c777 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { ProviderSessionId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import { CodexAppServerManager, @@ -12,7 +12,7 @@ import { normalizeCodexModelSlug, } from "./codexAppServerManager"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); function createSendTurnHarness() { const manager = new CodexAppServerManager(); @@ -168,6 +168,7 @@ describe("startSession", () => { try { await expect( manager.startSession({ + threadId: asThreadId("thread-1"), provider: "codex", runtimeMode: "full-access", }), @@ -191,7 +192,7 @@ describe("sendTurn", () => { createSendTurnHarness(); const result = await manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), input: "Inspect this image", attachments: [ { @@ -207,7 +208,7 @@ describe("sendTurn", () => { threadId: "thread_1", turnId: "turn_1", }); - expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(requireSession).toHaveBeenCalledWith("thread_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ @@ -234,7 +235,7 @@ describe("sendTurn", () => { const { manager, context, sendRequest } = createSendTurnHarness(); await manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), attachments: [ { type: "image", @@ -259,7 +260,7 @@ describe("sendTurn", () => { await expect( manager.sendTurn({ - sessionId: asSessionId("sess_1"), + threadId: asThreadId("thread_1"), }), ).rejects.toThrow("Turn input must include text or attachments."); }); @@ -280,9 +281,9 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.readThread(asSessionId("sess_1")); + const result = await manager.readThread(asThreadId("thread_1")); - expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(requireSession).toHaveBeenCalledWith("thread_1"); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", includeTurns: true, @@ -310,7 +311,7 @@ describe("thread checkpoint control", () => { ], }); - const result = await manager.readThread(asSessionId("sess_1")); + const result = await manager.readThread(asThreadId("thread_1")); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", @@ -336,7 +337,7 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.rollbackThread(asSessionId("sess_1"), 2); + const result = await manager.rollbackThread(asThreadId("thread_1"), 2); expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { threadId: "thread_1", @@ -364,6 +365,7 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" try { const firstSession = await manager.startSession({ + threadId: asThreadId("thread-live"), provider: "codex", cwd: workspaceDir, runtimeMode: "full-access", @@ -378,24 +380,25 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" }); const firstTurn = await manager.sendTurn({ - sessionId: firstSession.sessionId, + threadId: firstSession.threadId, input: `Reply with exactly the word ALPHA ${randomUUID()}`, }); expect(firstTurn.threadId).toBe(firstSession.threadId); await vi.waitFor(async () => { - const snapshot = await manager.readThread(firstSession.sessionId); + const snapshot = await manager.readThread(firstSession.threadId); expect(snapshot.turns.length).toBeGreaterThan(0); }, { timeout: 120_000, interval: 1_000 }); - const firstSnapshot = await manager.readThread(firstSession.sessionId); + const firstSnapshot = await manager.readThread(firstSession.threadId); const originalThreadId = firstSnapshot.threadId; const originalTurnCount = firstSnapshot.turns.length; - manager.stopSession(firstSession.sessionId); + manager.stopSession(firstSession.threadId); const resumedSession = await manager.startSession({ + threadId: firstSession.threadId, provider: "codex", cwd: workspaceDir, runtimeMode: "approval-required", @@ -412,17 +415,17 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" expect(resumedSession.threadId).toBe(originalThreadId); - const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId); + const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); await manager.sendTurn({ - sessionId: resumedSession.sessionId, + threadId: resumedSession.threadId, input: `Reply with exactly the word BETA ${randomUUID()}`, }); await vi.waitFor(async () => { - const snapshot = await manager.readThread(resumedSession.sessionId); + const snapshot = await manager.readThread(resumedSession.threadId); expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); }, { timeout: 120_000, interval: 1_000 }); } finally { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index ba7844a2e9aac435f284f3c483cbfdee4d1fc239..b159b2a53a379c31591d10a6067024f25f612906 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -8,9 +8,8 @@ import { EventId, ProviderItemId, ProviderRequestKind, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, + ThreadId, + TurnId, normalizeModelSlug, type ProviderApprovalDecision, type ProviderEvent, @@ -18,7 +17,7 @@ import { type ProviderSessionStartInput, type ProviderTurnStartResult, } from "@t3tools/contracts"; -import { Effect } from "effect"; +import { Effect, ServiceMap } from "effect"; type PendingRequestKey = string; @@ -34,8 +33,8 @@ interface PendingApprovalRequest { jsonRpcId: string | number; method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; - threadId?: ProviderThreadId; - turnId?: ProviderTurnId; + threadId: ThreadId; + turnId?: TurnId; itemId?: ProviderItemId; } @@ -72,7 +71,7 @@ interface JsonRpcNotification { } export interface CodexAppServerSendTurnInput { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; @@ -80,12 +79,12 @@ export interface CodexAppServerSendTurnInput { } export interface CodexThreadTurnSnapshot { - id: ProviderTurnId; + id: TurnId; items: unknown[]; } export interface CodexThreadSnapshot { - threadId: ProviderThreadId; + threadId: string; turns: CodexThreadTurnSnapshot[]; } @@ -191,10 +190,16 @@ export interface CodexAppServerManagerEvents { } export class CodexAppServerManager extends EventEmitter { - private readonly sessions = new Map(); + private readonly sessions = new Map(); + + private runPromise: (effect: Effect.Effect) => Promise; + constructor(services?: ServiceMap.ServiceMap) { + super(); + this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise; + } async startSession(input: ProviderSessionStartInput): Promise { - const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); + const threadId = input.threadId; const now = new Date().toISOString(); let context: CodexSessionContext | undefined; @@ -202,12 +207,12 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(input.sessionId); - if (!context.session.threadId) { - throw new Error("Session is missing a thread id."); - } + const context = this.requireSession(input.threadId); const turnInput: Array< { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } @@ -403,15 +408,23 @@ export class CodexAppServerManager extends EventEmitter; model?: string; effort?: string; } = { - threadId: context.session.threadId, + threadId: providerThreadId, input: turnInput, }; const normalizedModel = normalizeCodexModelSlug(input.model); @@ -429,7 +442,7 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(sessionId); + async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { + const context = this.requireSession(threadId); const effectiveTurnId = turnId ?? context.session.activeTurnId; - if (!effectiveTurnId || !context.session.threadId) { + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!effectiveTurnId || !providerThreadId) { return; } await this.sendRequest(context, "turn/interrupt", { - threadId: context.session.threadId, + threadId: providerThreadId, turnId: effectiveTurnId, }); } - async readThread(sessionId: ProviderSessionId): Promise { - const context = this.requireSession(sessionId); - const threadId = context.session.threadId; - if (!threadId) { - throw new Error("Session is missing a thread id."); + async readThread(threadId: ThreadId): Promise { + const context = this.requireSession(threadId); + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!providerThreadId) { + throw new Error("Session is missing a provider resume thread id."); } const response = await this.sendRequest(context, "thread/read", { - threadId, + threadId: providerThreadId, includeTurns: true, }); return this.parseThreadSnapshot("thread/read", response); } - async rollbackThread( - sessionId: ProviderSessionId, - numTurns: number, - ): Promise { - const context = this.requireSession(sessionId); - const threadId = context.session.threadId; - if (!threadId) { - throw new Error("Session is missing a thread id."); + async rollbackThread(threadId: ThreadId, numTurns: number): Promise { + const context = this.requireSession(threadId); + const providerThreadId = readResumeThreadId({ + threadId: context.session.threadId, + runtimeMode: context.session.runtimeMode, + resumeCursor: context.session.resumeCursor, + }); + if (!providerThreadId) { + throw new Error("Session is missing a provider resume thread id."); } if (!Number.isInteger(numTurns) || numTurns < 1) { throw new Error("numTurns must be an integer >= 1."); } const response = await this.sendRequest(context, "thread/rollback", { - threadId, + threadId: providerThreadId, numTurns, }); this.updateSession(context, { @@ -501,11 +524,11 @@ export class CodexAppServerManager extends EventEmitter { - const context = this.requireSession(sessionId); + const context = this.requireSession(threadId); const pendingRequest = context.pendingApprovals.get(requestId); if (!pendingRequest) { throw new Error(`Unknown pending approval request: ${requestId}`); @@ -523,10 +546,9 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; - const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); + const turnId = TurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { id: turnId, @@ -939,7 +959,7 @@ export class CodexAppServerManager extends EventEmitter( return normalized?.length ? maker(normalized) : undefined; } -function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { - return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); +function normalizeProviderThreadId(value: string | undefined): string | undefined { + return brandIfNonEmpty(value, (normalized) => normalized); } function readCodexProviderOptions(input: ProviderSessionStartInput): { @@ -1083,20 +1093,20 @@ function readCodexProviderOptions(input: ProviderSessionStartInput): { }; } -function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined { +function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { return undefined; } const rawThreadId = (resumeCursor as Record).threadId; - return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined; + return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; } -function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { +function readResumeThreadId(input: ProviderSessionStartInput): string | undefined { return readResumeCursorThreadId(input.resumeCursor); } -function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { - return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); +function toTurnId(value: string | undefined): TurnId | undefined { + return brandIfNonEmpty(value, TurnId.makeUnsafe); } function toProviderItemId(value: string | undefined): ProviderItemId | undefined { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 9607507d6ae400f676fb87284b1bfe34acf01299..8c66887820d4c1a0ab70bfd7ba22dbca37bdcb76 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -9,9 +9,6 @@ import { EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -40,18 +37,14 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; readonly provider: "codex" | "claudeCode" | "cursor"; - readonly sessionId: string; readonly createdAt: string; - readonly threadId?: string | undefined; + readonly threadId: ThreadId; readonly turnId?: string | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; @@ -68,7 +61,7 @@ function createProviderServiceHarness( const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const rollbackConversation = vi.fn( - (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void, + (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, ); const unsupported = () => @@ -77,11 +70,10 @@ function createProviderServiceHarness( hasSession ? Effect.succeed([ { - sessionId: asSessionId("sess-1"), provider: providerName, status: "ready", runtimeMode: "full-access", - threadId: asProviderThreadId("provider-thread-1"), + threadId: ThreadId.makeUnsafe("thread-1"), cwd: sessionCwd, createdAt: now, updatedAt: now, @@ -344,8 +336,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -359,10 +349,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-1"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), }); await waitForGitRefExists( harness.cwd, @@ -374,10 +364,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-1"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), payload: { state: "completed" }, }); @@ -422,8 +412,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: asTurnId("turn-main"), lastError: null, @@ -437,10 +425,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-main"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-main"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), }); await waitForGitRefExists( harness.cwd, @@ -453,10 +441,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-aux"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"), - turnId: asProviderTurnId("turn-aux"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-aux"), payload: { state: "completed" }, }); @@ -471,10 +459,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-main"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-main"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-main"), payload: { state: "completed" }, }); @@ -501,8 +489,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "claudeCode", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -516,10 +502,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), provider: "claudeCode", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), }); await waitForGitRefExists( harness.cwd, @@ -531,10 +517,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), provider: "claudeCode", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), payload: { state: "completed" }, }); @@ -563,8 +549,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -578,10 +562,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-missing-baseline"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-baseline"), payload: { state: "completed" }, }); @@ -652,8 +636,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-missing"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), runtimeMode: "approval-required", activeTurnId: asTurnId("turn-missing-cwd"), lastError: null, @@ -668,10 +650,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), provider: "codex", - sessionId: asSessionId("sess-missing"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), - turnId: asProviderTurnId("turn-missing-cwd"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-missing-cwd"), payload: { state: "completed" }, }); @@ -701,8 +683,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -716,10 +696,10 @@ describe("CheckpointReactor", () => { type: "checkpoint.captured", eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-3"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-3"), turnCount: 3, status: "completed", }); @@ -753,8 +733,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -768,10 +746,10 @@ describe("CheckpointReactor", () => { type: "turn.completed", eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-runtime-failure"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-runtime-failure"), payload: { state: "completed" }, }); @@ -779,10 +757,10 @@ describe("CheckpointReactor", () => { type: "turn.started", eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("turn-after-runtime-failure"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-after-runtime-failure"), }); await waitForGitRefExists( @@ -807,8 +785,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -865,7 +841,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - sessionId: asSessionId("sess-1"), + numTurns: 1, }); expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); @@ -887,8 +863,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "claudeCode", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -940,7 +914,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - sessionId: asSessionId("sess-1"), + numTurns: 1, }); }); @@ -958,8 +932,6 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -1032,11 +1004,11 @@ describe("CheckpointReactor", () => { expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ - sessionId: asSessionId("sess-1"), + numTurns: 1, }); expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ - sessionId: asSessionId("sess-1"), + numTurns: 1, }); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index deb246fb6b00eeff492f3bfd457e908018344f78..07ffac32531319c29a13cfed7048f932fb859a48 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -2,13 +2,10 @@ import { CommandId, EventId, MessageId, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, type OrchestrationEvent, type ProviderRuntimeEvent, - RuntimeSessionId, } from "@t3tools/contracts"; import { Cause, Effect, Layer, Option, Queue, Stream } from "effect"; @@ -38,14 +35,6 @@ function toTurnId(value: string | undefined): TurnId | null { return value === undefined ? null : TurnId.makeUnsafe(String(value)); } -function toProviderThreadId(value: string | undefined): ProviderThreadId | null { - return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value)); -} - -function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId { - return ProviderSessionId.makeUnsafe(String(value)); -} - function sameId(left: string | null | undefined, right: string | null | undefined): boolean { if (left === null || left === undefined || right === null || right === undefined) { return false; @@ -100,107 +89,75 @@ const make = Effect.gen(function* () { }); const appendCaptureFailureActivity = (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly turnId: TurnId | null; readonly detail: string; readonly createdAt: string; }) => - Effect.gen(function* () { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === input.sessionId, - ); - if (!thread) { - return; - } - yield* orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: serverCommandId("checkpoint-capture-failure"), - threadId: thread.id, - activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), - tone: "error", - kind: "checkpoint.capture.failed", - summary: "Checkpoint capture failed", - payload: { - detail: input.detail, - }, - turnId: input.turnId, - createdAt: input.createdAt, + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("checkpoint-capture-failure"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: { + detail: input.detail, }, + turnId: input.turnId, createdAt: input.createdAt, - }); + }, + createdAt: input.createdAt, }); const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( threadId: ThreadId, ): Effect.fn.Return< - Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> + Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> > { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); - const projectedSessionId = thread?.session?.providerSessionId ?? null; - const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined; const sessions = yield* providerService.listSessions(); const findSessionWithCwd = ( session: (typeof sessions)[number] | undefined, - ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { + ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => { if (!session?.cwd) { return Option.none(); } - return Option.some({ sessionId: session.sessionId, cwd: session.cwd }); + return Option.some({ threadId: session.threadId, cwd: session.cwd }); }; - if (projectedSessionId !== null) { - const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId); + if (thread) { + const projectedSession = sessions.find( + (session) => session.threadId === thread.id, + ); const fromProjected = findSessionWithCwd(projectedSession); if (Option.isSome(fromProjected)) { return fromProjected; } } - if (projectedProviderThreadId) { - const matchedSession = sessions.find( - (session) => session.threadId === projectedProviderThreadId, - ); - const fromProviderThread = findSessionWithCwd(matchedSession); - if (Option.isSome(fromProviderThread)) { - return fromProviderThread; - } - } - return Option.none(); }); const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( event: Extract, ) { - const providerSessionId = toProviderSessionId(event.sessionId); const turnId = toTurnId(event.turnId); if (!turnId) { return; } const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === providerSessionId, - ); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) { return; } - const projectedProviderThreadId = thread.session?.providerThreadId ?? null; - const eventProviderThreadId = toProviderThreadId(event.threadId); - if ( - projectedProviderThreadId !== null && - eventProviderThreadId !== null && - !sameId(projectedProviderThreadId, eventProviderThreadId) - ) { - return; - } - // When a primary turn is active, only that turn may produce completion checkpoints. if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { return; @@ -272,7 +229,7 @@ const make = Effect.gen(function* () { ), Effect.tapError((error) => appendCaptureFailureActivity({ - sessionId: providerSessionId, + threadId: thread.id, turnId, detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, createdAt: event.createdAt, @@ -332,7 +289,6 @@ const make = Effect.gen(function* () { const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* ( event: Extract, ) { - const providerSessionId = toProviderSessionId(event.sessionId); const turnId = toTurnId(event.turnId); if (!turnId) { return; @@ -340,7 +296,7 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === providerSessionId, + (entry) => entry.id === event.threadId, ); if (!thread) { return; @@ -520,7 +476,7 @@ const make = Effect.gen(function* () { const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { yield* providerService.rollbackConversation({ - sessionId: sessionRuntime.value.sessionId, + threadId: sessionRuntime.value.threadId, numTurns: rolledBackTurns, }); } @@ -588,7 +544,7 @@ const make = Effect.gen(function* () { yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => appendCaptureFailureActivity({ - sessionId: toProviderSessionId(event.sessionId), + threadId: event.threadId, turnId, detail: error.message, createdAt: new Date().toISOString(), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 063eee4e4ae7be9410775460e8054191391e1a50..0531c1c8b149dba585e2bc9e43fd5454774180d4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4,8 +4,6 @@ import { CorrelationId, EventId, MessageId, - ProviderSessionId, - ProviderThreadId, ProjectId, ThreadId, TurnId, @@ -1771,18 +1769,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta commandId: CommandId.makeUnsafe("cmd-restart-2"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), - metadata: { - providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), - }, + metadata: {}, payload: { threadId, session: { threadId, status: "running", providerName: "codex", - providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), runtimeMode: "approval-required", activeTurnId: turnId, lastError: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 72998e080741969dfe9bce1b31e1a5ca0fd239be..8daaeb3285e25738b1481bcc0630bb8c6ab23bd6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -665,8 +665,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, status: event.payload.session.status, providerName: event.payload.session.providerName, - providerSessionId: event.payload.session.providerSessionId, - providerThreadId: event.payload.session.providerThreadId, runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 40601092e7535a41c01c8b003dfac53cbff49d0d..ab6e3c15c1849c9562acc9640eaa1fedcecb7251 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -3,8 +3,6 @@ import { EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), @@ -290,8 +285,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asProviderSessionId("provider-session-1"), - providerThreadId: asProviderThreadId("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d2b3dae21bd2292c3dcb6f2090aad875a51334c7..772e543f7e67b5e4a97e9decd1df04b261991b00 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -461,8 +461,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadId: row.threadId, status: row.status, providerName: row.providerName, - providerSessionId: row.providerSessionId, - providerThreadId: row.providerThreadId, runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9d3c4a21309ec0da52de194ed2c8e186965f5c33..5498d4fc246d790637c8a1582571a06981ee0e87 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -9,9 +9,6 @@ import { EventId, MessageId, ProjectId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -38,8 +35,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); @@ -110,8 +105,14 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "model" in input && typeof input.model === "string" ? input.model : undefined; + const threadId = + typeof input === "object" && + input !== null && + "threadId" in input && + typeof input.threadId === "string" + ? ThreadId.makeUnsafe(input.threadId) + : ThreadId.makeUnsafe(`thread-${sessionIndex}`); const session: ProviderSession = { - sessionId: asSessionId(`sess-${sessionIndex}`), provider, status: "ready" as const, runtimeMode: @@ -122,7 +123,7 @@ describe("ProviderCommandReactor", () => { ? input.runtimeMode : "full-access", ...(model !== undefined ? { model } : {}), - threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), + threadId, resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, createdAt: now, updatedAt: now, @@ -132,22 +133,22 @@ describe("ProviderCommandReactor", () => { }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - turnId: asProviderTurnId("provider-turn-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), }), ); const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn(() => Effect.void); const stopSession = vi.fn((input: unknown) => Effect.sync(() => { - const sessionId = - typeof input === "object" && input !== null && "sessionId" in input - ? (input as { sessionId?: ProviderSessionId }).sessionId + const threadId = + typeof input === "object" && input !== null && "threadId" in input + ? (input as { threadId?: ThreadId }).threadId : undefined; - if (!sessionId) { + if (!threadId) { return; } - const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId); + const index = runtimeSessions.findIndex((session) => session.threadId === threadId); if (index >= 0) { runtimeSessions.splice(index, 1); } @@ -284,7 +285,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-1"); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -321,7 +322,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.session?.providerName).toBe("claudeCode"); - expect(thread?.session?.providerSessionId).toBe("sess-1"); + expect(thread?.session?.threadId).toBe("thread-1"); }); it("starts first turn with cursor provider when provider is specified", async () => { @@ -357,7 +358,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.session?.providerName).toBe("cursor"); - expect(thread?.session?.providerSessionId).toBe("sess-1"); + expect(thread?.session?.threadId).toBe("thread-1"); }); it("reuses the same provider session when runtime mode is unchanged", async () => { @@ -500,7 +501,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - sessionId: asSessionId("sess-1"), + threadId: ThreadId.makeUnsafe("thread-1"), model: "gpt-5.3-codex", }); }); @@ -554,8 +555,6 @@ describe("ProviderCommandReactor", () => { return thread?.runtimeMode === "approval-required"; }); await waitFor(() => harness.startSession.mock.calls.length === 2); - await waitFor(() => harness.stopSession.mock.calls.length === 1); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -574,18 +573,19 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); + expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), resumeCursor: { opaque: "cursor-1" }, runtimeMode: "approval-required", }); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - sessionId: asSessionId("sess-2"), + threadId: ThreadId.makeUnsafe("thread-1"), }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-2"); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -629,12 +629,12 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.stopSession.mock.calls.length === 1); await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); + expect(harness.stopSession.mock.calls.length).toBe(0); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), provider: "claudeCode", runtimeMode: "approval-required", }); @@ -642,7 +642,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-2"); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.providerName).toBe("claudeCode"); expect(thread?.session?.runtimeMode).toBe("approval-required"); }); @@ -707,7 +707,7 @@ describe("ProviderCommandReactor", () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session?.providerSessionId).toBe("sess-1"); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.runtimeMode).toBe("full-access"); }); @@ -724,8 +724,6 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, @@ -747,7 +745,8 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.interruptTurn.mock.calls.length === 1); expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ - sessionId: "sess-1", + threadId: "thread-1", + turnId: "turn-1", }); }); @@ -764,8 +763,6 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -788,7 +785,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.respondToRequest.mock.calls.length === 1); expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ - sessionId: "sess-1", + threadId: "thread-1", requestId: "approval-request-1", decision: "accept", }); @@ -816,8 +813,6 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", providerName: "cursor", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -901,8 +896,6 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -926,8 +919,7 @@ describe("ProviderCommandReactor", () => { const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.session).not.toBeNull(); expect(thread?.session?.status).toBe("stopped"); - expect(thread?.session?.providerSessionId).toBeNull(); - expect(thread?.session?.providerThreadId).toBeNull(); + expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.activeTurnId).toBeNull(); }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 196dd7c9e7aab2dd4c60c0c9a2d535cda534013f..cc4e5f20f273880efef7b8285cabf0dd9597aeb7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,9 +4,8 @@ import { EventId, type OrchestrationEvent, type ProviderKind, - type ProviderSessionId, type OrchestrationSession, - type ThreadId, + ThreadId, type ProviderSession, type RuntimeMode, type TurnId, @@ -220,9 +219,9 @@ const make = Effect.gen(function* () { projects: readModel.projects, }); - const resolveActiveSession = (sessionId: ProviderSessionId) => + const resolveActiveSession = (threadId: ThreadId) => providerService.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), + Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)), ); const startProviderSession = (input?: { @@ -230,6 +229,7 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; }) => providerService.startSession(threadId, { + threadId, ...(input?.provider ?? preferredProvider ? { provider: input?.provider ?? preferredProvider } : {}), @@ -246,8 +246,6 @@ const make = Effect.gen(function* () { threadId, status: mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, - providerSessionId: session.sessionId, - providerThreadId: session.threadId ?? null, runtimeMode: desiredRuntimeMode, // Provider turn ids are not orchestration turn ids. activeTurnId: null, @@ -257,11 +255,12 @@ const make = Effect.gen(function* () { createdAt, }); - const existingSessionId = thread.session?.providerSessionId; - if (existingSessionId) { + const existingSessionThreadId = + thread.session && thread.session.status !== "stopped" ? thread.id : null; + if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionId); + const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -271,12 +270,8 @@ const make = Effect.gen(function* () { const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; - if ( - !runtimeModeChanged && - !providerChanged && - !shouldRestartForModelChange - ) { - return existingSessionId; + if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { + return existingSessionThreadId; } const resumeCursor = @@ -285,7 +280,7 @@ const make = Effect.gen(function* () { : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, - existingSessionId, + existingSessionThreadId, currentProvider, desiredProvider: options?.provider ?? currentProvider, currentRuntimeMode: thread.session?.runtimeMode, @@ -302,30 +297,20 @@ const make = Effect.gen(function* () { }); yield* Effect.logInfo("provider command reactor restarted provider session", { threadId, - previousSessionId: existingSessionId, - restartedSessionId: restartedSession.sessionId, + previousSessionId: existingSessionThreadId, + restartedSessionThreadId: restartedSession.threadId, provider: restartedSession.provider, - providerThreadId: restartedSession.threadId, runtimeMode: restartedSession.runtimeMode, }); yield* bindSessionToThread(restartedSession); - yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to stop superseded provider session", { - threadId, - sessionId: existingSessionId, - cause: Cause.pretty(cause), - }), - ), - ); - return restartedSession.sessionId; + return restartedSession.threadId; } const startedSession = yield* startProviderSession( options?.provider !== undefined ? { provider: options.provider } : undefined, ); yield* bindSessionToThread(startedSession); - return startedSession.sessionId; + return startedSession.threadId; }); const sendTurnForThread = Effect.fnUntraced(function* (input: { @@ -341,14 +326,14 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { + yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), + Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), ); const sessionModelSwitch = activeSession === undefined @@ -358,7 +343,7 @@ const make = Effect.gen(function* () { sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; yield* providerService.sendTurn({ - sessionId, + threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), @@ -487,8 +472,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const sessionId = thread.session?.providerSessionId; - if (!sessionId) { + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -500,7 +485,7 @@ const make = Effect.gen(function* () { } // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ sessionId }); + yield* providerService.interruptTurn({ threadId: event.payload.threadId }); }); const processApprovalResponseRequested = Effect.fnUntraced(function* ( @@ -510,8 +495,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const sessionId = thread.session?.providerSessionId; - if (!sessionId) { + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.approval.respond.failed", @@ -525,7 +510,7 @@ const make = Effect.gen(function* () { yield* providerService .respondToRequest({ - sessionId, + threadId: event.payload.threadId, requestId: event.payload.requestId, decision: event.payload.decision, }) @@ -559,10 +544,8 @@ const make = Effect.gen(function* () { } const now = event.payload.createdAt; - const sessionId = thread.session?.providerSessionId; - - if (sessionId) { - yield* providerService.stopSession({ sessionId }); + if (thread.session && thread.session.status !== "stopped") { + yield* providerService.stopSession({ threadId: thread.id }); } yield* setThreadSession({ @@ -571,8 +554,6 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "stopped", providerName: thread.session?.providerName ?? null, - providerSessionId: null, - providerThreadId: null, runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e723313026efd12ceb106329d3a1a8ae8c52757d..569907de7ea626104522b2d6cdc2cc3afbddf257 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -6,10 +6,8 @@ import { MessageId, ProjectId, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it } from "vitest"; @@ -33,20 +31,18 @@ import { ServerConfig } from "../../config.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; readonly provider: "codex" | "claudeCode" | "cursor"; - readonly sessionId: string; readonly createdAt: string; - readonly threadId?: string | undefined; + readonly threadId: ThreadId; readonly turnId?: string | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; @@ -182,8 +178,6 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", - providerSessionId: asSessionId("sess-1"), - providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), runtimeMode: "approval-required", activeTurnId: null, updatedAt: createdAt, @@ -207,9 +201,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: now, - turnId: asProviderTurnId("turn-1"), + turnId: asTurnId("turn-1"), }); await waitForThread( @@ -221,9 +215,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-1"), + turnId: asTurnId("turn-1"), payload: { state: "failed", errorMessage: "turn failed", @@ -249,9 +243,8 @@ describe("ProviderRuntimeIngestion", () => { type: "session.state.changed", eventId: asEventId("evt-session-state-waiting"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: waitingAt, - threadId: asProviderThreadId("provider-thread-1"), payload: { state: "waiting", reason: "awaiting approval", @@ -269,9 +262,8 @@ describe("ProviderRuntimeIngestion", () => { type: "session.state.changed", eventId: asEventId("evt-session-state-error"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), payload: { state: "error", reason: "provider crashed", @@ -292,9 +284,8 @@ describe("ProviderRuntimeIngestion", () => { type: "session.state.changed", eventId: asEventId("evt-session-state-stopped"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), payload: { state: "stopped", }, @@ -314,9 +305,8 @@ describe("ProviderRuntimeIngestion", () => { type: "session.state.changed", eventId: asEventId("evt-session-state-ready"), provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), payload: { state: "ready", }, @@ -341,10 +331,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-midturn-lifecycle"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-midturn-lifecycle"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-midturn-lifecycle"), }); await waitForThread( @@ -358,17 +347,15 @@ describe("ProviderRuntimeIngestion", () => { type: "thread.started", eventId: asEventId("evt-thread-started-midturn-lifecycle"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), + threadId: asThreadId("thread-1"), }); harness.emit({ type: "session.started", eventId: asEventId("evt-session-started-midturn-lifecycle"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), + threadId: asThreadId("thread-1"), }); await Effect.runPromise(Effect.sleep("40 millis")); @@ -381,10 +368,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-midturn-lifecycle"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-midturn-lifecycle"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-midturn-lifecycle"), status: "completed", }); @@ -402,10 +388,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-primary"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-primary"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-primary"), }); await waitForThread( @@ -418,10 +403,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-aux"), - turnId: asProviderTurnId("turn-aux"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-aux"), status: "completed", }); @@ -437,10 +421,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-primary"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("turn-primary"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-primary"), status: "completed", }); @@ -463,8 +446,6 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "claudeCode", - providerSessionId: asSessionId("sess-1"), - providerThreadId: asProviderThreadId("claude-thread-placeholder"), runtimeMode: "approval-required", activeTurnId: null, updatedAt: seededAt, @@ -478,10 +459,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-claude-placeholder"), provider: "claudeCode", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-real"), - turnId: asProviderTurnId("turn-claude-placeholder"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), }); await waitForThread( @@ -495,10 +475,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-claude-placeholder"), provider: "claudeCode", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - threadId: asProviderThreadId("provider-thread-real"), - turnId: asProviderTurnId("turn-claude-placeholder"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), status: "completed", }); @@ -516,9 +495,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-guarded"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-guarded-main"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-main"), }); await waitForThread( @@ -532,9 +511,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-other"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-guarded-other"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-other"), status: "completed", }); @@ -550,9 +529,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-guarded-main"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-guarded-main"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-guarded-main"), status: "completed", }); @@ -570,9 +549,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-1"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), payload: { streamKind: "assistant_text", @@ -583,9 +562,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-2"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), payload: { streamKind: "assistant_text", @@ -596,9 +575,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-message-completed"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), itemId: asItemId("item-1"), payload: { itemType: "assistant_message", @@ -627,9 +606,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-assistant-item-completed-no-delta"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-no-delta"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-no-delta"), itemId: asItemId("item-no-delta"), payload: { itemType: "assistant_message", @@ -659,9 +638,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), }); await waitForThread( harness.engine, @@ -673,9 +652,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), itemId: asItemId("item-buffered"), payload: { streamKind: "assistant_text", @@ -698,9 +677,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-message-completed-buffered"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffered"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered"), itemId: asItemId("item-buffered"), payload: { itemType: "assistant_message", @@ -747,9 +726,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), }); await waitForThread( harness.engine, @@ -762,9 +741,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), itemId: asItemId("item-streaming-mode"), payload: { streamKind: "assistant_text", @@ -789,9 +768,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-message-completed-streaming-mode"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-streaming-mode"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-mode"), itemId: asItemId("item-streaming-mode"), payload: { itemType: "assistant_message", @@ -821,9 +800,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), }); await waitForThread( harness.engine, @@ -836,9 +815,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), itemId: asItemId("item-buffer-spill"), payload: { streamKind: "assistant_text", @@ -849,9 +828,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-message-completed-buffer-spill"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-buffer-spill"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffer-spill"), itemId: asItemId("item-buffer-spill"), payload: { itemType: "assistant_message", @@ -881,9 +860,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.started", eventId: asEventId("evt-turn-started-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), }); await waitForThread( @@ -897,9 +876,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-message-delta-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), itemId: asItemId("item-complete-dedup"), payload: { streamKind: "assistant_text", @@ -910,9 +889,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.completed", eventId: asEventId("evt-message-completed-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), itemId: asItemId("item-complete-dedup"), payload: { itemType: "assistant_message", @@ -923,9 +902,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.completed", eventId: asEventId("evt-turn-completed-for-complete-dedup"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-complete-dedup"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-complete-dedup"), payload: { state: "completed", }, @@ -967,8 +946,8 @@ describe("ProviderRuntimeIngestion", () => { type: "request.opened", eventId: asEventId("evt-request-opened"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, + threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.makeUnsafe("req-open"), payload: { requestType: "command_execution_approval", @@ -980,8 +959,8 @@ describe("ProviderRuntimeIngestion", () => { type: "request.resolved", eventId: asEventId("evt-request-resolved"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, + threadId: asThreadId("thread-1"), requestId: ApprovalRequestId.makeUnsafe("req-open"), payload: { requestType: "command_execution_approval", @@ -1033,9 +1012,9 @@ describe("ProviderRuntimeIngestion", () => { type: "runtime.error", eventId: asEventId("evt-runtime-error"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-3"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-3"), payload: { message: "runtime exploded", }, @@ -1060,26 +1039,24 @@ describe("ProviderRuntimeIngestion", () => { type: "session.started", eventId: asEventId("evt-session-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + threadId: asThreadId("thread-1"), message: "session started", }); harness.emit({ type: "thread.started", eventId: asEventId("evt-thread-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), + threadId: asThreadId("thread-1"), }); harness.emit({ type: "item.started", eventId: asEventId("evt-tool-started"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-9"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-9"), payload: { itemType: "command_execution", status: "in_progress", @@ -1114,9 +1091,8 @@ describe("ProviderRuntimeIngestion", () => { type: "thread.metadata.updated", eventId: asEventId("evt-thread-metadata-updated"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - threadId: asProviderThreadId("provider-thread-1"), + threadId: asThreadId("thread-1"), payload: { name: "Renamed by provider", metadata: { source: "provider" }, @@ -1127,9 +1103,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.plan.updated", eventId: asEventId("evt-turn-plan-updated"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-p1"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), payload: { explanation: "Working through the plan", plan: [ @@ -1143,9 +1119,9 @@ describe("ProviderRuntimeIngestion", () => { type: "item.updated", eventId: asEventId("evt-item-updated"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-p1"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), itemId: asItemId("item-p1-tool"), payload: { itemType: "command_execution", @@ -1160,9 +1136,9 @@ describe("ProviderRuntimeIngestion", () => { type: "runtime.warning", eventId: asEventId("evt-runtime-warning"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-p1"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), payload: { message: "Provider got slow", detail: { latencyMs: 1500 }, @@ -1173,9 +1149,9 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.diff.updated", eventId: asEventId("evt-turn-diff-updated"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-p1"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), itemId: asItemId("item-p1-assistant"), payload: { unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n", @@ -1249,9 +1225,9 @@ describe("ProviderRuntimeIngestion", () => { type: "content.delta", eventId: asEventId("evt-invalid-delta"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: now, - turnId: asProviderTurnId("turn-invalid"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-invalid"), itemId: asItemId("item-invalid"), payload: { streamKind: "assistant_text", @@ -1263,9 +1239,9 @@ describe("ProviderRuntimeIngestion", () => { type: "runtime.error", eventId: asEventId("evt-runtime-error-after-failure"), provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-after-failure"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-after-failure"), payload: { message: "runtime still processed", }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d2719189bf4f846b5f1de3b073e1dd5b0387208e..f78e7d1e99a9687d31c2fc164b3f61ed1a28d1d2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -5,13 +5,10 @@ import { MessageId, type OrchestrationEvent, CheckpointRef, - ProviderThreadId, - type ThreadId, + ThreadId, TurnId, type OrchestrationThreadActivity, type ProviderRuntimeEvent, - ProviderSessionId, - RuntimeSessionId, } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; @@ -22,7 +19,7 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; -const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; +const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); @@ -53,14 +50,6 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); } -function toProviderThreadId(value: string | undefined): ProviderThreadId | null { - return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value)); -} - -function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId { - return ProviderSessionId.makeUnsafe(String(value)); -} - function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value); } @@ -72,13 +61,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine return left === right; } -function isSyntheticClaudeThreadId( - provider: ProviderRuntimeEvent["provider"], - threadId: ProviderThreadId | null, -): boolean { - return provider === "claudeCode" && threadId !== null && threadId.startsWith("claude-thread-"); -} - function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } @@ -148,11 +130,13 @@ function orchestrationSessionStatusFromRuntimeState( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-change" | undefined { +): "command" | "file-read" | "file-change" | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": return "command"; + case "file_read_approval": + return "file-read"; case "file_change_approval": case "apply_patch_approval": return "file-change"; @@ -194,6 +178,8 @@ function runtimeEventToActivities( summary: requestKind === "command" ? "Command approval requested" + : requestKind === "file-read" + ? "File-read approval requested" : requestKind === "file-change" ? "File-change approval requested" : "Approval requested", @@ -380,15 +366,15 @@ const make = Effect.gen(function* () { }); const rememberAssistantMessageId = ( - sessionId: ProviderSessionId, + threadId: ThreadId, turnId: TurnId, messageId: MessageId, ) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Cache.set( turnMessageIdsByTurnKey, - providerTurnKey(sessionId, turnId), + providerTurnKey(threadId, turnId), Option.match(existingIds, { onNone: () => new Set([messageId]), onSome: (ids) => { @@ -402,11 +388,11 @@ const make = Effect.gen(function* () { ); const forgetAssistantMessageId = ( - sessionId: ProviderSessionId, + threadId: ThreadId, turnId: TurnId, messageId: MessageId, ) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.flatMap((existingIds) => Option.match(existingIds, { onNone: () => Effect.void, @@ -414,23 +400,23 @@ const make = Effect.gen(function* () { const nextIds = new Set(ids); nextIds.delete(messageId); if (nextIds.size === 0) { - return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); + return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); } - return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds); + return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds); }, }), ), ); - const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => - Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( + const getAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( Effect.map((existingIds) => Option.getOrElse(existingIds, (): Set => new Set()), ), ); - const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => - Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); + const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( @@ -505,9 +491,9 @@ const make = Effect.gen(function* () { yield* clearAssistantMessageState(input.messageId); }); - const clearTurnStateForSession = (sessionId: ProviderSessionId) => + const clearTurnStateForSession = (threadId: ThreadId) => Effect.gen(function* () { - const prefix = `${sessionId}:`; + const prefix = `${threadId}:`; const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); yield* Effect.forEach( turnKeys, @@ -532,29 +518,14 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { - const providerSessionId = toProviderSessionId(event.sessionId); const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find( - (entry) => entry.session?.providerSessionId === providerSessionId, - ); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); if (!thread) return; const now = event.createdAt; - const sessionProviderThreadId = thread.session?.providerThreadId ?? null; - const scopedSessionProviderThreadId = isSyntheticClaudeThreadId( - event.provider, - sessionProviderThreadId, - ) - ? null - : sessionProviderThreadId; - const eventProviderThreadId = toProviderThreadId(event.threadId); const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const matchesThreadScope = - eventProviderThreadId === null || - scopedSessionProviderThreadId === null || - sameId(eventProviderThreadId, scopedSessionProviderThreadId); const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; @@ -568,27 +539,10 @@ const make = Effect.gen(function* () { return true; case "session.started": case "thread.started": - if (!matchesThreadScope) { - return false; - } - // Never let auxiliary/provider-side spawned threads replace the primary thread binding. - if ( - eventProviderThreadId !== null && - scopedSessionProviderThreadId !== null && - !sameId(eventProviderThreadId, scopedSessionProviderThreadId) - ) { - return false; - } return true; case "turn.started": - if (!matchesThreadScope) { - return false; - } return !conflictsWithActiveTurn; case "turn.completed": - if (!matchesThreadScope) { - return false; - } if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -617,12 +571,6 @@ const make = Effect.gen(function* () { : event.type === "turn.completed" || event.type === "session.exited" ? null : activeTurnId; - const providerThreadIdFromEvent = - event.type === "thread.started" - ? toProviderThreadId(event.threadId) - : toProviderThreadId(event.threadId); - const providerThreadId = - providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null; const status = (() => { switch (event.type) { case "session.state.changed": @@ -658,8 +606,6 @@ const make = Effect.gen(function* () { threadId: thread.id, status, providerName: event.provider, - providerSessionId, - providerThreadId, runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, @@ -677,11 +623,11 @@ const make = Effect.gen(function* () { if (assistantDelta && assistantDelta.length > 0) { const assistantMessageId = MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); const turnId = toTurnId(event.turnId); if (turnId) { - yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId); + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); @@ -714,7 +660,7 @@ const make = Effect.gen(function* () { const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" ? { - messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`), + messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), fallbackText: event.payload.detail, } : undefined; @@ -723,7 +669,7 @@ const make = Effect.gen(function* () { const assistantMessageId = assistantCompletion.messageId; const turnId = toTurnId(event.turnId); if (turnId) { - yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId); + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } yield* finalizeAssistantMessage({ @@ -740,14 +686,14 @@ const make = Effect.gen(function* () { }); if (turnId) { - yield* forgetAssistantMessageId(providerSessionId, turnId, assistantMessageId); + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); } } if (event.type === "turn.completed") { const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(providerSessionId, turnId); + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => @@ -762,12 +708,12 @@ const make = Effect.gen(function* () { }), { concurrency: 1 }, ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(providerSessionId, turnId); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); } } if (event.type === "session.exited") { - yield* clearTurnStateForSession(providerSessionId); + yield* clearTurnStateForSession(thread.id); } if (event.type === "runtime.error") { @@ -775,15 +721,9 @@ const make = Effect.gen(function* () { const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true - : matchesThreadScope && - (activeTurnId === null || - eventTurnId === undefined || - sameId(activeTurnId, eventTurnId)); - - const providerThreadId = - event.threadId !== undefined - ? ProviderThreadId.makeUnsafe(event.threadId) - : (thread.session?.providerThreadId ?? null); + : activeTurnId === null || + eventTurnId === undefined || + sameId(activeTurnId, eventTurnId); if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ @@ -794,8 +734,6 @@ const make = Effect.gen(function* () { threadId: thread.id, status: "error", providerName: event.provider, - providerSessionId, - providerThreadId, runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, lastError: runtimeErrorMessage, @@ -819,7 +757,7 @@ const make = Effect.gen(function* () { const turnId = toTurnId(event.turnId); if (turnId) { const assistantMessageId = MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e9bcbdcd1e8755f4eadbb920b745699d1dd8ba1c..dd5a34882a4bbc98d88efc6e5f63e92cc46a50e0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -391,14 +391,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - metadata: { - ...(command.session.providerSessionId !== null - ? { providerSessionId: command.session.providerSessionId } - : {}), - ...(command.session.providerThreadId !== null - ? { providerThreadId: command.session.providerThreadId } - : {}), - }, + metadata: {}, }), type: "thread.session-set", payload: { diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 6f166417b5c9b288831961c9ab1d6e2bfa16743b..4d81cf5e8d7324579e5ba4b17f2cd23e9d4b2faf 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -74,8 +74,6 @@ function inferActorKind( return "server"; } if ( - event.metadata.providerSessionId !== undefined || - event.metadata.providerThreadId !== undefined || event.metadata.providerTurnId !== undefined || event.metadata.providerItemId !== undefined || event.metadata.adapterKey !== undefined diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index 77bb6ad622e2759b887619465f58a0b396db5871..2499eba19676664cc9d91ff21f78a5410b37f5de 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -23,8 +23,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id, status, provider_name, - provider_session_id, - provider_thread_id, runtime_mode, active_turn_id, last_error, @@ -34,8 +32,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.threadId}, ${row.status}, ${row.providerName}, - ${row.providerSessionId}, - ${row.providerThreadId}, ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, @@ -45,8 +41,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { DO UPDATE SET status = excluded.status, provider_name = excluded.provider_name, - provider_session_id = excluded.provider_session_id, - provider_thread_id = excluded.provider_thread_id, runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, @@ -63,8 +57,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { thread_id AS "threadId", status, provider_name AS "providerName", - provider_session_id AS "providerSessionId", - provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index a978a0a68a7ae9291902108eae72a0577ae821e0..da3e8bce90ae46ddc7eb7cee9660d47bdeb34520 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,4 +1,4 @@ -import { ProviderSessionId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema, Struct } from "effect"; @@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); const GetRuntimeRequestSchema = Schema.Struct({ - providerSessionId: ProviderSessionId, + threadId: ThreadId, }); const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; @@ -44,11 +44,9 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { execute: (runtime) => sql` INSERT INTO provider_session_runtime ( - provider_session_id, thread_id, provider_name, adapter_key, - provider_thread_id, runtime_mode, status, last_seen_at, @@ -56,23 +54,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { runtime_payload_json ) VALUES ( - ${runtime.providerSessionId}, ${runtime.threadId}, ${runtime.providerName}, ${runtime.adapterKey}, - ${runtime.providerThreadId}, ${runtime.runtimeMode}, ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, ${runtime.runtimePayload} ) - ON CONFLICT (provider_session_id) + ON CONFLICT (thread_id) DO UPDATE SET - thread_id = excluded.thread_id, provider_name = excluded.provider_name, adapter_key = excluded.adapter_key, - provider_thread_id = excluded.provider_thread_id, runtime_mode = excluded.runtime_mode, status = excluded.status, last_seen_at = excluded.last_seen_at, @@ -81,24 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { `, }); - const getRuntimeRowBySessionId = SqlSchema.findOneOption({ + const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, Result: ProviderSessionRuntimeDbRowSchema, - execute: ({ providerSessionId }) => + execute: ({ threadId }) => sql` SELECT - provider_session_id AS "providerSessionId", thread_id AS "threadId", provider_name AS "providerName", adapter_key AS "adapterKey", - provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime - WHERE provider_session_id = ${providerSessionId} + WHERE thread_id = ${threadId} `, }); @@ -108,27 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { execute: () => sql` SELECT - provider_session_id AS "providerSessionId", thread_id AS "threadId", provider_name AS "providerName", adapter_key AS "adapterKey", - provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime - ORDER BY last_seen_at ASC, provider_session_id ASC + ORDER BY last_seen_at ASC, thread_id ASC `, }); - const deleteRuntimeBySessionId = SqlSchema.void({ + const deleteRuntimeByThreadId = SqlSchema.void({ Request: DeleteRuntimeRequestSchema, - execute: ({ providerSessionId }) => + execute: ({ threadId }) => sql` DELETE FROM provider_session_runtime - WHERE provider_session_id = ${providerSessionId} + WHERE thread_id = ${threadId} `, }); @@ -142,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ); - const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) => - getRuntimeRowBySessionId(input).pipe( + const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => + getRuntimeRowByThreadId(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.getBySessionId:query", - "ProviderSessionRuntimeRepository.getBySessionId:decodeRow", + "ProviderSessionRuntimeRepository.getByThreadId:query", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", ), ), Effect.flatMap((runtimeRowOption) => @@ -157,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { decodeRuntime(row).pipe( Effect.mapError( toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime", + "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", ), ), Effect.map((runtime) => Option.some(runtime)), @@ -188,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ); - const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) => - deleteRuntimeBySessionId(input).pipe( + const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => + deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"), + toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), ), ); return { upsert, - getBySessionId, + getByThreadId, list, - deleteBySessionId, + deleteByThreadId, } satisfies ProviderSessionRuntimeRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts deleted file mode 100644 index ffdb6868b42eb18f9875d6e2284bc136c2963065..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; - -import { - ProviderSessionRepositoryPersistenceError, - ProviderSessionRepositoryValidationError, -} from "../Errors.ts"; -import { - ProviderSessionRepository, - type ProviderSessionEntry, - type ProviderSessionRepositoryShape, -} from "../Services/ProviderSessions.ts"; - -const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); - -const ProviderSessionRowSchema = Schema.Struct({ - sessionId: ProviderSessionId, - provider: ProviderKind, - threadId: Schema.NullOr(ThreadId), - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const SessionIdRequestSchema = Schema.Struct({ - sessionId: ProviderSessionId, -}); - -const UpsertSessionRequestSchema = Schema.Struct({ - sessionId: ProviderSessionId, - provider: ProviderKind, - threadId: Schema.NullOr(ThreadId), -}); - -function errorMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toValidationError( - operation: string, - cause: unknown, -): ProviderSessionRepositoryValidationError { - return new ProviderSessionRepositoryValidationError({ - operation, - issue: errorMessage(cause, "Invalid provider session repository input."), - cause, - }); -} - -function decodeInput(schema: S, input: unknown, operation: string) { - return Schema.decodeUnknownEffect(schema)(input).pipe( - Effect.mapError((cause) => toValidationError(operation, cause)), - ); -} - -function toPersistenceError( - operation: string, - cause: unknown, -): ProviderSessionRepositoryPersistenceError { - return new ProviderSessionRepositoryPersistenceError({ - operation, - detail: `Failed to execute ${operation}.`, - cause, - }); -} - -function toEntry(row: Schema.Schema.Type): ProviderSessionEntry { - return { - sessionId: row.sessionId, - provider: row.provider, - ...(row.threadId !== null ? { threadId: row.threadId } : {}), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -const makeProviderSessionRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertSessionRow = SqlSchema.findOne({ - Request: UpsertSessionRequestSchema, - Result: ProviderSessionRowSchema, - execute: (request) => - sql` - INSERT INTO provider_sessions ( - session_id, - provider, - thread_id, - created_at, - updated_at - ) - VALUES ( - ${request.sessionId}, - ${request.provider}, - ${request.threadId}, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - ) - ON CONFLICT (session_id) - DO UPDATE SET - provider = excluded.provider, - thread_id = excluded.thread_id, - updated_at = CURRENT_TIMESTAMP - RETURNING - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - `, - }); - - const findSessionRow = SqlSchema.findOneOption({ - Request: SessionIdRequestSchema, - Result: ProviderSessionRowSchema, - execute: ({ sessionId }) => - sql` - SELECT - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM provider_sessions - WHERE session_id = ${sessionId} - `, - }); - - const listSessionRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProviderSessionRowSchema, - execute: () => - sql` - SELECT - session_id AS "sessionId", - provider, - thread_id AS "threadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM provider_sessions - ORDER BY created_at ASC, session_id ASC - `, - }); - - const deleteSessionRow = SqlSchema.void({ - Request: SessionIdRequestSchema, - execute: ({ sessionId }) => sql`DELETE FROM provider_sessions WHERE session_id = ${sessionId}`, - }); - - const upsertSession: ProviderSessionRepositoryShape["upsertSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - UpsertSessionRequestSchema, - { - sessionId: input.sessionId, - provider: input.provider, - threadId: input.threadId ?? null, - }, - "ProviderSessionRepository.upsertSession", - ); - - yield* upsertSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.upsertSession:query", cause), - ), - Effect.asVoid, - ); - }); - - const getSession: ProviderSessionRepositoryShape["getSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - SessionIdRequestSchema, - { sessionId: input.sessionId }, - "ProviderSessionRepository.getSession", - ); - - const row = yield* findSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.getSession:query", cause), - ), - ); - - return Option.map(row, toEntry); - }); - - const listSessions: ProviderSessionRepositoryShape["listSessions"] = () => - listSessionRows(undefined).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.listSessions:query", cause), - ), - Effect.map((rows) => rows.map(toEntry)), - ); - - const deleteSession: ProviderSessionRepositoryShape["deleteSession"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - SessionIdRequestSchema, - { sessionId: input.sessionId }, - "ProviderSessionRepository.deleteSession", - ); - - yield* deleteSessionRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("ProviderSessionRepository.deleteSession:query", cause), - ), - ); - }); - - return { - upsertSession, - getSession, - listSessions, - deleteSession, - } satisfies ProviderSessionRepositoryShape; -}); - -export const ProviderSessionRepositoryLive = Layer.effect( - ProviderSessionRepository, - makeProviderSessionRepository, -); diff --git a/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts index a6adda0a870ff89be769a87fa3c6701a9bfc674c..f468ad49cf93b5cfdd6c6a22ba562660da382192 100644 --- a/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts @@ -6,11 +6,10 @@ export default Effect.gen(function* () { yield* sql` CREATE TABLE IF NOT EXISTS provider_session_runtime ( - provider_session_id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL, + thread_id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, adapter_key TEXT NOT NULL, - provider_thread_id TEXT, + runtime_mode TEXT NOT NULL DEFAULT 'full-access', status TEXT NOT NULL, last_seen_at TEXT NOT NULL, resume_cursor_json TEXT, @@ -18,11 +17,6 @@ export default Effect.gen(function* () { ) `; - yield* sql` - CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread - ON provider_session_runtime(thread_id) - `; - yield* sql` CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status ON provider_session_runtime(status) diff --git a/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts index 6a71d70759b384e391ef1fb811c964e63d8963be..3f7249e873fea0c65afe9c39a9aad479c4118c5c 100644 --- a/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts +++ b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts @@ -2,16 +2,5 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as Effect from "effect/Effect"; export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - ALTER TABLE provider_session_runtime - ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access' - `; - - yield* sql` - UPDATE provider_session_runtime - SET runtime_mode = 'full-access' - WHERE runtime_mode IS NULL - `; + yield* SqlClient.SqlClient; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 485ad42e54af054868d61acee1c10316788e7367..537ee10beef65cc1d29088647a2ff7a437e6abf8 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -10,8 +10,6 @@ import { RuntimeMode, IsoDateTime, OrchestrationSessionStatus, - ProviderSessionId, - ProviderThreadId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -24,8 +22,6 @@ export const ProjectionThreadSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), - providerSessionId: Schema.NullOr(ProviderSessionId), - providerThreadId: Schema.NullOr(ProviderThreadId), runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index 25ddc1af3075ade86f7fe8360c064053955dee83..885a9dd5f1b97991a0b015f68f72a3b9ea7b095a 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -7,9 +7,7 @@ */ import { IsoDateTime, - ProviderSessionId, ProviderSessionRuntimeStatus, - ProviderThreadId, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -19,11 +17,9 @@ import type { Effect } from "effect"; import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; export const ProviderSessionRuntime = Schema.Struct({ - providerSessionId: ProviderSessionId, threadId: ThreadId, providerName: Schema.String, adapterKey: Schema.String, - providerThreadId: Schema.NullOr(ProviderThreadId), runtimeMode: RuntimeMode, status: ProviderSessionRuntimeStatus, lastSeenAt: IsoDateTime, @@ -32,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({ }); export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; -export const GetProviderSessionRuntimeInput = Schema.Struct({ - providerSessionId: ProviderSessionId, -}); +export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; -export const DeleteProviderSessionRuntimeInput = Schema.Struct({ - providerSessionId: ProviderSessionId, -}); +export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; /** @@ -49,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape { /** * Insert or replace a provider runtime row. * - * Upserts by `providerSessionId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, including JSON payload/cursor fields. */ readonly upsert: ( runtime: ProviderSessionRuntime, ) => Effect.Effect; /** - * Read provider runtime state by provider session id. + * Read provider runtime state by canonical thread id. */ - readonly getBySessionId: ( + readonly getByThreadId: ( input: GetProviderSessionRuntimeInput, ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; @@ -73,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape { >; /** - * Delete provider runtime state by provider session id. + * Delete provider runtime state by canonical thread id. */ - readonly deleteBySessionId: ( + readonly deleteByThreadId: ( input: DeleteProviderSessionRuntimeInput, ) => Effect.Effect; } diff --git a/apps/server/src/persistence/Services/ProviderSessions.ts b/apps/server/src/persistence/Services/ProviderSessions.ts deleted file mode 100644 index 33336a53ac7e3112d3f2c852ffef00524bacffe7..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Services/ProviderSessions.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * ProviderSessionRepository - Repository interface for provider session lookup. - * - * Owns persistence operations that map internal sessions to provider kinds and - * optional thread ownership. - * - * @module ProviderSessionRepository - */ -import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import { Option, ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { ProviderSessionRepositoryError } from "../Errors.ts"; - -export interface ProviderSessionEntry { - readonly sessionId: ProviderSessionId; - readonly provider: ProviderKind; - readonly threadId?: ThreadId; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface UpsertProviderSessionInput { - readonly sessionId: ProviderSessionId; - readonly provider: ProviderKind; - readonly threadId?: ThreadId; -} - -export interface DeleteProviderSessionInput { - readonly sessionId: ProviderSessionId; -} - -export interface GetProviderSessionInput { - readonly sessionId: ProviderSessionId; -} - -/** - * ProviderSessionRepositoryShape - Service API for provider-session records. - */ -export interface ProviderSessionRepositoryShape { - /** - * Insert or replace a provider-session row. - * - * Upserts by `sessionId`. - */ - readonly upsertSession: ( - input: UpsertProviderSessionInput, - ) => Effect.Effect; - - /** - * Read a provider-session row by session id. - */ - readonly getSession: ( - input: GetProviderSessionInput, - ) => Effect.Effect, ProviderSessionRepositoryError>; - - /** - * List all provider-session rows. - * - * Returned in deterministic creation order. - */ - readonly listSessions: () => Effect.Effect< - ReadonlyArray, - ProviderSessionRepositoryError - >; - - /** - * Delete a provider-session row by session id. - */ - readonly deleteSession: ( - input: DeleteProviderSessionInput, - ) => Effect.Effect; -} - -/** - * ProviderSessionRepository - Service tag for provider-session persistence. - */ -export class ProviderSessionRepository extends ServiceMap.Service< - ProviderSessionRepository, - ProviderSessionRepositoryShape ->()("t3/persistence/Services/ProviderSessions/ProviderSessionRepository") {} diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index a3d29f77cc9d5f52482f37f59aca5f870b2e779e..e4e46d374865c405d6fcda322976a1f8632ee19c 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -26,12 +26,12 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass "ProviderAdapterSessionNotFoundError", { provider: Schema.String, - sessionId: Schema.String, + threadId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown ${this.provider} adapter session: ${this.sessionId}`; + return `Unknown ${this.provider} adapter thread: ${this.threadId}`; } } @@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass

()( "ProviderSessionNotFoundError", { - sessionId: Schema.String, + threadId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown provider session: ${this.sessionId}`; + return `Unknown provider thread: ${this.threadId}`; } } diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index a1e3604f3f5ed525b78b084b4b844ab5f7d1c24f..cd55c39837e4bd80e23a25b225f3cfd7f17780fa 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -5,7 +5,7 @@ import type { SDKMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import { ApprovalRequestId, type ProviderTurnId } from "@t3tools/contracts"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Random, Stream } from "effect"; @@ -161,13 +161,17 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { }; } + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); + describe("ClaudeCodeAdapterLive", () => { it.effect("returns validation error for non-claudeCode provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; const result = yield* adapter - .startSession({ provider: "codex", runtimeMode: "full-access" }) + .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) .pipe(Effect.result); assert.equal(result._tag, "Failure"); @@ -193,6 +197,7 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); @@ -211,6 +216,7 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", providerOptions: { @@ -240,13 +246,14 @@ describe("ClaudeCodeAdapterLive", () => { ); const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", model: "claude-sonnet-4-5", runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -376,12 +383,13 @@ describe("ClaudeCodeAdapterLive", () => { ); const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -464,12 +472,13 @@ describe("ClaudeCodeAdapterLive", () => { ); const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -533,13 +542,14 @@ describe("ClaudeCodeAdapterLive", () => { ); const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); assert.equal(session.threadId, undefined); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -602,6 +612,7 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "approval-required", }); @@ -647,7 +658,7 @@ describe("ClaudeCodeAdapterLive", () => { } yield* adapter.respondToRequest( - session.sessionId, + session.threadId, ApprovalRequestId.makeUnsafe(runtimeRequestId), "accept", ); @@ -678,6 +689,7 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, provider: "claudeCode", resumeCursor: { threadId: "resume-thread-1", @@ -711,6 +723,7 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); @@ -734,12 +747,13 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); const firstTurn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "first", attachments: [], }); @@ -765,7 +779,7 @@ describe("ClaudeCodeAdapterLive", () => { } const secondTurn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "second", attachments: [], }); @@ -790,16 +804,16 @@ describe("ClaudeCodeAdapterLive", () => { assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); } - const threadBeforeRollback = yield* adapter.readThread(session.sessionId); + const threadBeforeRollback = yield* adapter.readThread(session.threadId); assert.equal(threadBeforeRollback.turns.length, 2); - const rolledBack = yield* adapter.rollbackThread(session.sessionId, 1); + const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); assert.equal(rolledBack.turns.length, 1); - assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId as ProviderTurnId); + assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); - const threadAfterRollback = yield* adapter.readThread(session.sessionId); + const threadAfterRollback = yield* adapter.readThread(session.threadId); assert.equal(threadAfterRollback.turns.length, 1); - assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId as ProviderTurnId); + assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -812,11 +826,12 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", model: "claude-opus-4-6", attachments: [], @@ -834,7 +849,7 @@ describe("ClaudeCodeAdapterLive", () => { event?: { provider?: string; method?: string; - sessionId?: string; + threadId?: string; turnId?: string; }; }> = []; @@ -852,11 +867,12 @@ describe("ClaudeCodeAdapterLive", () => { const adapter = yield* ClaudeCodeAdapter; const session = yield* adapter.startSession({ + threadId: THREAD_ID, provider: "claudeCode", runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -895,7 +911,7 @@ describe("ClaudeCodeAdapterLive", () => { assert.equal(nativeEvents.length > 0, true); assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true); - assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); + assert.equal(nativeEvents.some((record) => String(record.event?.threadId) === String(session.threadId)), true); assert.equal( nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)), true, diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 36ea3686c35709cea176115c5cc7faed34d938be..31ea53739f20075ff3eca0d8d6989e74316a0f33 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -27,13 +27,9 @@ import { type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, - ProviderSessionId, type ProviderSession, - ProviderThreadId, - ProviderTurnId, RuntimeItemId, RuntimeRequestId, - RuntimeSessionId, RuntimeTaskId, ThreadId, TurnId, @@ -63,14 +59,14 @@ type PromptQueueItem = }; interface ClaudeResumeState { - readonly threadId?: ProviderThreadId; + readonly threadId?: ThreadId; readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; } interface ClaudeTurnState { - readonly turnId: ProviderTurnId; + readonly turnId: TurnId; readonly assistantItemId: string; readonly startedAt: string; readonly items: Array; @@ -102,13 +98,13 @@ interface ClaudeSessionContext { resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly turns: Array<{ - id: ReturnType; + id: TurnId; items: Array; }>; readonly inFlightTools: Map; turnState: ClaudeTurnState | undefined; lastAssistantUuid: string | undefined; - lastThreadStartedId: ProviderThreadId | undefined; + lastThreadStartedId: string | undefined; stopped: boolean; } @@ -144,22 +140,14 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId { - return RuntimeSessionId.makeUnsafe(value); -} - -function asRuntimeThreadId(value: ProviderThreadId): ThreadId { - return ThreadId.makeUnsafe(value); -} - -function asRuntimeTurnId(value: ProviderTurnId): TurnId { - return TurnId.makeUnsafe(value); -} - function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.makeUnsafe(value); } +function asCanonicalTurnId(value: TurnId): TurnId { + return value; +} + function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(value); } @@ -192,7 +180,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; const threadId = threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) - ? threadIdCandidate + ? ThreadId.makeUnsafe(threadIdCandidate) : undefined; const resumeCandidate = typeof cursor.resume === "string" @@ -206,7 +194,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; return { - ...(threadId ? { threadId: ProviderThreadId.makeUnsafe(threadId) } : {}), + ...(threadId ? { threadId } : {}), ...(resume ? { resume } : {}), ...(resumeSessionAt ? { resumeSessionAt } : {}), ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 @@ -243,6 +231,10 @@ function classifyToolItemType(toolName: string): CanonicalItemType { } function classifyRequestType(toolName: string): CanonicalRequestType { + const normalized = toolName.toLowerCase(); + if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { + return "file_read_approval"; + } return classifyToolItemType(toolName) === "command_execution" ? "command_execution_approval" : "file_change_approval"; @@ -324,6 +316,12 @@ function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoni return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; } +function providerThreadRef( + context: ClaudeSessionContext, +): { readonly providerThreadId: string } | {} { + return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; +} + function extractAssistantText(message: SDKMessage): string { if (message.type !== "assistant") { return ""; @@ -353,21 +351,21 @@ function extractAssistantText(message: SDKMessage): string { } function toSessionError( - sessionId: ReturnType, + threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("not found")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } if (normalized.includes("closed")) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } @@ -375,11 +373,11 @@ function toSessionError( } function toRequestError( - sessionId: ReturnType, + threadId: ThreadId, method: string, cause: unknown, ): ProviderAdapterError { - const sessionError = toSessionError(sessionId, cause); + const sessionError = toSessionError(threadId, cause); if (sessionError) { return sessionError; } @@ -469,7 +467,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { readonly options: ClaudeQueryOptions; }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); - const sessions = new Map(); + const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -502,11 +500,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { : crypto.randomUUID(), kind: "notification", provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: observedAt, method: sdkNativeMethod(message), - ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + ...(typeof message.session_id === "string" + ? { providerThreadId: message.session_id } + : {}), + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), payload: message, }, @@ -518,9 +517,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const snapshotThread = ( context: ClaudeSessionContext, ): Effect.Effect<{ - threadId: ReturnType; + threadId: ThreadId; turns: ReadonlyArray<{ - id: ReturnType; + id: TurnId; items: ReadonlyArray; }>; }, ProviderAdapterValidationError> => @@ -569,19 +568,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { if (typeof message.session_id !== "string" || message.session_id.length === 0) { return; } - const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id); + const nextThreadId = message.session_id; context.resumeSessionId = message.session_id; - const changed = context.session.threadId !== nextThreadId; - - if (changed) { - const updatedAt = yield* nowIso; - context.session = { - ...context.session, - threadId: nextThreadId, - updatedAt, - }; - yield* updateResumeCursor(context); - } + yield* updateResumeCursor(context); if (context.lastThreadStartedId !== nextThreadId) { context.lastThreadStartedId = nextThreadId; @@ -590,16 +579,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "thread.started", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - threadId: asRuntimeThreadId(nextThreadId), + threadId: context.session.threadId, payload: { providerThreadId: nextThreadId, }, - providerRefs: { - providerSessionId: context.session.sessionId, - providerThreadId: nextThreadId, - }, + providerRefs: {}, raw: { source: "claude.sdk.message", method: "claude/thread/started", @@ -626,19 +611,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "runtime.error", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}), + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { message, class: "provider_error", ...(cause !== undefined ? { detail: cause } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(turnState ? { providerTurnId: turnState.turnId } : {}), + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), }, }); }); @@ -655,18 +638,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "runtime.warning", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}), + threadId: context.session.threadId, + ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { message, ...(detail !== undefined ? { detail } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(turnState ? { providerTurnId: turnState.turnId } : {}), + ...providerThreadRef(context), + ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}), }, }); }); @@ -685,9 +666,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), + threadId: context.session.threadId, payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), @@ -698,10 +678,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { : {}), ...(errorMessage ? { errorMessage } : {}), }, - providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - }, + providerRefs: {}, }); return; } @@ -713,19 +690,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "content.delta", eventId: deltaStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: deltaStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnState.turnId), + threadId: context.session.threadId, + turnId: turnState.turnId, itemId: asRuntimeItemId(turnState.assistantItemId), payload: { streamKind: "assistant_text", delta: turnState.fallbackAssistantText, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - providerTurnId: turnState.turnId, + ...providerThreadRef(context), + providerTurnId: String(turnState.turnId), providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), }, }); @@ -736,19 +711,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "item.completed", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, itemId: asRuntimeItemId(turnState.assistantItemId), - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnState.turnId), + threadId: context.session.threadId, + turnId: turnState.turnId, payload: { itemType: "assistant_message", status: "completed", title: "Assistant message", }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), providerTurnId: turnState.turnId, providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), }, @@ -765,10 +738,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnState.turnId), + threadId: context.session.threadId, + turnId: turnState.turnId, payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), @@ -780,8 +752,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(errorMessage ? { errorMessage } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), providerTurnId: turnState.turnId, }, }); @@ -826,18 +797,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "content.delta", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(context.turnState.turnId), + threadId: context.session.threadId, + turnId: context.turnState.turnId, itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { streamKind: streamKindFromDeltaType(event.delta.type), delta: event.delta.text, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), providerTurnId: context.turnState.turnId, providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), }, @@ -884,10 +853,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "item.started", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), payload: { itemType: tool.itemType, @@ -900,9 +868,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerItemId: ProviderItemId.makeUnsafe(tool.itemId), }, raw: { @@ -927,10 +894,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "item.completed", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), payload: { itemType: tool.itemType, @@ -939,9 +905,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(tool.detail ? { detail: tool.detail } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerItemId: ProviderItemId.makeUnsafe(tool.itemId), }, raw: { @@ -980,10 +945,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "item.updated", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(context.turnState.turnId), + threadId: context.session.threadId, + turnId: context.turnState.turnId, itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { itemType: "assistant_message", @@ -992,8 +956,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { data: message.message, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), providerTurnId: context.turnState.turnId, providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), }, @@ -1041,13 +1004,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const base = { eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), }, raw: { @@ -1202,13 +1163,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const base = { eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...providerThreadRef(context), ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), }, raw: { @@ -1284,6 +1243,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { case "stream_event": yield* handleStreamEvent(context, message); return; + case "user": + return; case "assistant": yield* handleAssistantMessage(context, message); return; @@ -1341,19 +1302,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "request.resolved", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType: pending.requestType, decision: "cancel", }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...providerThreadRef(context), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: requestId, }, }); @@ -1382,32 +1341,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "session.exited", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), + createdAt: stamp.createdAt, + threadId: context.session.threadId, payload: { reason: "Session stopped", exitKind: "graceful", }, - providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - }, + providerRefs: {}, }); } - sessions.delete(context.session.sessionId); + sessions.delete(context.session.threadId); }); const requireSession = ( - sessionId: ReturnType, + threadId: ThreadId, ): Effect.Effect => { - const context = sessions.get(sessionId); + const context = sessions.get(threadId); if (!context) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, }), ); } @@ -1415,7 +1370,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return Effect.fail( new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, }), ); } @@ -1433,11 +1388,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } const startedAt = yield* nowIso; - const sessionId = ProviderSessionId.makeUnsafe( - `claude-session-${yield* Random.nextUUIDv4}`, - ); const resumeState = readClaudeResumeState(input.resumeCursor); - const threadId = resumeState?.threadId; + const threadId = input.threadId; const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( @@ -1488,10 +1440,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "request.opened", eventId: requestedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: requestedStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType, @@ -1503,11 +1454,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: requestId, }, raw: { @@ -1542,21 +1492,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "request.resolved", eventId: resolvedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: resolvedStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), payload: { requestType, decision, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: requestId, }, raw: { @@ -1623,14 +1571,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, - sessionId, + threadId, detail: toMessage(cause, "Failed to start Claude runtime session."), cause, }), }); const session: ProviderSession = { - sessionId, + threadId, provider: PROVIDER, status: "ready", runtimeMode: input.runtimeMode, @@ -1664,21 +1612,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { stopped: false, }; yield* Ref.set(contextRef, context); - sessions.set(sessionId, context); + sessions.set(threadId, context); const sessionStartedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.started", eventId: sessionStartedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: sessionStartedStamp.createdAt, - ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), + threadId, payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, - providerRefs: { - providerSessionId: sessionId, - ...(threadId ? { providerThreadId: threadId } : {}), - }, + providerRefs: {}, }); const configuredStamp = yield* makeEventStamp(); @@ -1686,9 +1630,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "session.configured", eventId: configuredStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: configuredStamp.createdAt, - ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), + threadId, payload: { config: { ...(input.model ? { model: input.model } : {}), @@ -1699,10 +1642,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { : {}), }, }, - providerRefs: { - providerSessionId: sessionId, - ...(threadId ? { providerThreadId: threadId } : {}), - }, + providerRefs: {}, }); const readyStamp = yield* makeEventStamp(); @@ -1710,16 +1650,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "session.state.changed", eventId: readyStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: readyStamp.createdAt, - ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), + threadId, payload: { state: "ready", }, - providerRefs: { - providerSessionId: sessionId, - ...(threadId ? { providerThreadId: threadId } : {}), - }, + providerRefs: {}, }); Effect.runFork(runSdkStream(context)); @@ -1731,24 +1667,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const context = yield* requireSession(input.sessionId); + const context = yield* requireSession(input.threadId); if (context.turnState) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", - issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, }); } if (input.model) { yield* Effect.tryPromise({ try: () => context.query.setModel(input.model), - catch: (cause) => toRequestError(input.sessionId, "turn/setModel", cause), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), }); } - const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); const turnState: ClaudeTurnState = { turnId, assistantItemId: yield* Random.nextUUIDv4, @@ -1773,18 +1709,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { type: "turn.started", eventId: turnStartedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: turnStartedStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnId), + threadId: context.session.threadId, + turnId, payload: { ...(input.model ? { model: input.model } : {}), ...(input.effort ? { effort: input.effort } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - providerTurnId: turnId, + providerTurnId: String(turnId), }, }); @@ -1793,10 +1726,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Queue.offer(context.promptQueue, { type: "message", message, - }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause))); + }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); return { - ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + threadId: context.session.threadId, turnId, ...(context.session.resumeCursor !== undefined ? { resumeCursor: context.session.resumeCursor } @@ -1804,24 +1737,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }; }); - const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, _turnId) => + const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); yield* Effect.tryPromise({ try: () => context.query.interrupt(), - catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), }); }); - const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) => + const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); return yield* snapshotThread(context); }); - const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); const nextLength = Math.max(0, context.turns.length - numTurns); context.turns.splice(nextLength); yield* updateResumeCursor(context); @@ -1829,12 +1762,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }); const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); const pending = context.pendingApprovals.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ @@ -1848,9 +1781,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Deferred.succeed(pending.decision, decision); }); - const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) => + const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); yield* stopSessionInternal(context, { emitExitEvent: true, }); @@ -1859,9 +1792,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) => + const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { - const context = sessions.get(sessionId); + const context = sessions.get(threadId); return context !== undefined && !context.stopped; }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index fdb812c41ccf101db2d15aa7c8658cf9bcc39477..e435af2afdde6165413abfbcdc12468d401bc8f4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -4,12 +4,11 @@ import { ProviderItemId, type ProviderApprovalDecision, type ProviderEvent, - ProviderSessionId, type ProviderSession, type ProviderSessionStartInput, - ProviderThreadId, - ProviderTurnId, type ProviderTurnStartResult, + ThreadId, + TurnId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterAll, assert, it, vi } from "@effect/vitest"; @@ -27,8 +26,8 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -37,11 +36,10 @@ class FakeCodexManager extends CodexAppServerManager { async (input: ProviderSessionStartInput): Promise => { const now = new Date().toISOString(); return { - sessionId: asSessionId("sess-1"), provider: "codex", status: "ready", runtimeMode: input.runtimeMode, - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId: input.threadId, cwd: input.cwd, createdAt: now, updatedAt: now, @@ -51,28 +49,28 @@ class FakeCodexManager extends CodexAppServerManager { public sendTurnImpl = vi.fn( async (_input: CodexAppServerSendTurnInput): Promise => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), }), ); public interruptTurnImpl = vi.fn( - async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise => undefined, + async (_threadId: ThreadId, _turnId?: TurnId): Promise => undefined, ); - public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({ + threadId: asThreadId("thread-1"), turns: [], })); - public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({ + threadId: asThreadId("thread-1"), turns: [], })); public respondToRequestImpl = vi.fn( async ( - _sessionId: ProviderSessionId, + _threadId: ThreadId, _requestId: ApprovalRequestId, _decision: ProviderApprovalDecision, ): Promise => undefined, @@ -88,33 +86,33 @@ class FakeCodexManager extends CodexAppServerManager { return this.sendTurnImpl(input); } - override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { - return this.interruptTurnImpl(sessionId, turnId); + override interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise { + return this.interruptTurnImpl(threadId, turnId); } - override readThread(sessionId: ProviderSessionId) { - return this.readThreadImpl(sessionId); + override readThread(threadId: ThreadId) { + return this.readThreadImpl(threadId); } - override rollbackThread(sessionId: ProviderSessionId, numTurns: number) { - return this.rollbackThreadImpl(sessionId, numTurns); + override rollbackThread(threadId: ThreadId, numTurns: number) { + return this.rollbackThreadImpl(threadId, numTurns); } override respondToRequest( - sessionId: ProviderSessionId, + threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ): Promise { - return this.respondToRequestImpl(sessionId, requestId, decision); + return this.respondToRequestImpl(threadId, requestId, decision); } - override stopSession(_sessionId: ProviderSessionId): void {} + override stopSession(_threadId: ThreadId): void {} override listSessions(): ProviderSession[] { return []; } - override hasSession(_sessionId: ProviderSessionId): boolean { + override hasSession(_threadId: ThreadId): boolean { return false; } @@ -128,9 +126,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - getThreadId: () => Effect.succeed(Option.none()), remove: () => Effect.void, - listSessionIds: () => Effect.succeed([]), + listThreadIds: () => Effect.succeed([]), }); const validationManager = new FakeCodexManager(); @@ -149,6 +146,7 @@ validationLayer("CodexAdapterLive validation", (it) => { const result = yield* adapter .startSession({ provider: "claudeCode", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }) .pipe(Effect.result); @@ -184,7 +182,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .sendTurn({ - sessionId: asSessionId("sess-missing"), + threadId: asThreadId("sess-missing"), input: "hello", attachments: [], }) @@ -200,7 +198,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { return; } assert.equal(result.failure.provider, "codex"); - assert.equal(result.failure.sessionId, "sess-missing"); + assert.equal(result.failure.threadId, "sess-missing"); assert.instanceOf(result.failure.cause, Error); }), ); @@ -225,10 +223,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-msg-complete"), kind: "notification", provider: "codex", - sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), method: "item/completed", - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId: asThreadId("thread-1"), turnId: asTurnId("turn-1"), itemId: asItemId("msg_1"), payload: { @@ -265,7 +262,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-session-closed"), kind: "session", provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "session/closed", message: "Session stopped", @@ -282,7 +279,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (firstEvent.value.type !== "session.exited") { return; } - assert.equal(firstEvent.value.sessionId, "sess-1"); + assert.equal(firstEvent.value.threadId, "thread-1"); assert.equal(firstEvent.value.payload.reason, "Session stopped"); }), ); @@ -296,7 +293,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-request-resolved"), kind: "notification", provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "serverRequest/resolved", requestId: ApprovalRequestId.makeUnsafe("req-1"), @@ -334,7 +331,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { id: asEventId("evt-windows-sandbox-failed"), kind: "notification", provider: "codex", - sessionId: asSessionId("sess-1"), + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), method: "windowsSandbox/setupCompleted", message: "Sandbox setup failed", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0aa91f8b18a8765ae2b256f200dc904fc934c1da..bc1367434ead465d7f6d68fc0f32ce37df54ba85 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -13,16 +13,11 @@ import { type ProviderRuntimeEvent, RuntimeItemId, RuntimeRequestId, - RuntimeSessionId, ProviderApprovalDecision, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, - TurnId, } from "@t3tools/contracts"; -import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -33,7 +28,6 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { CodexAppServerManager } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -43,7 +37,7 @@ const PROVIDER = "codex" as const; export interface CodexAdapterLiveOptions { readonly manager?: CodexAppServerManager; - readonly makeManager?: () => CodexAppServerManager; + readonly makeManager?: (services?: ServiceMap.ServiceMap) => CodexAppServerManager; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -56,33 +50,29 @@ function toMessage(cause: unknown, fallback: string): string { } function toSessionError( - sessionId: ProviderSessionId, + threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } if (normalized.includes("session is closed")) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } return undefined; } -function toRequestError( - sessionId: ProviderSessionId, - method: string, - cause: unknown, -): ProviderAdapterError { - const sessionError = toSessionError(sessionId, cause); +function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); if (sessionError) { return sessionError; } @@ -139,7 +129,8 @@ function toCanonicalItemType(raw: unknown): CanonicalItemType { if (type.includes("reasoning") || type.includes("thought")) return "reasoning"; if (type.includes("plan") || type.includes("todo")) return "plan"; if (type.includes("command")) return "command_execution"; - if (type.includes("file change") || type.includes("patch") || type.includes("edit")) return "file_change"; + if (type.includes("file change") || type.includes("patch") || type.includes("edit")) + return "file_change"; if (type.includes("mcp")) return "mcp_tool_call"; if (type.includes("dynamic tool")) return "dynamic_tool_call"; if (type.includes("collab")) return "collab_agent_tool_call"; @@ -211,6 +202,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { switch (method) { case "item/commandExecution/requestApproval": return "command_execution_approval"; + case "item/fileRead/requestApproval": + return "file_read_approval"; case "item/fileChange/requestApproval": return "file_change_approval"; case "applyPatchApproval": @@ -232,6 +225,8 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { switch (kind) { case "command": return "command_execution_approval"; + case "file-read": + return "file_read_approval"; case "file-change": return "file_change_approval"; default: @@ -239,7 +234,9 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { } } -function toRequestTypeFromResolvedPayload(payload: Record | undefined): CanonicalRequestType { +function toRequestTypeFromResolvedPayload( + payload: Record | undefined, +): CanonicalRequestType { const request = asObject(payload?.request); const method = asString(request?.method) ?? asString(payload?.method); if (method) { @@ -252,13 +249,9 @@ function toRequestTypeFromResolvedPayload(payload: Record | und return "unknown"; } -function toThreadState(value: unknown): - | "active" - | "idle" - | "archived" - | "closed" - | "compacted" - | "error" { +function toThreadState( + value: unknown, +): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { switch (value) { case "idle": return "idle"; @@ -278,7 +271,13 @@ function toThreadState(value: unknown): function contentStreamKindFromMethod( method: string, -): "assistant_text" | "reasoning_text" | "reasoning_summary_text" | "plan_text" | "command_output" | "file_change_output" { +): + | "assistant_text" + | "reasoning_text" + | "reasoning_summary_text" + | "plan_text" + | "command_output" + | "file_change_output" { switch (method) { case "item/agentMessage/delta": return "assistant_text"; @@ -297,18 +296,6 @@ function contentStreamKindFromMethod( } } -function asRuntimeSessionId(sessionId: ProviderSessionId): RuntimeSessionId { - return RuntimeSessionId.makeUnsafe(sessionId); -} - -function asRuntimeThreadId(threadId: ProviderThreadId): ThreadId { - return ThreadId.makeUnsafe(threadId); -} - -function asRuntimeTurnId(turnId: ProviderTurnId): TurnId { - return TurnId.makeUnsafe(turnId); -} - function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { return RuntimeItemId.makeUnsafe(itemId); } @@ -321,29 +308,28 @@ function eventRawSource(event: ProviderEvent): NonNullable = { - providerSessionId: event.sessionId, - }; - if (event.threadId) refs.providerThreadId = event.threadId; +function providerRefsFromEvent( + event: ProviderEvent, +): ProviderRuntimeEvent["providerRefs"] | undefined { + const refs: Record = {}; if (event.turnId) refs.providerTurnId = event.turnId; if (event.itemId) refs.providerItemId = event.itemId; if (event.requestId) refs.providerRequestId = event.requestId; - return Object.keys(refs).length > 0 - ? (refs as ProviderRuntimeEvent["providerRefs"]) - : undefined; + return Object.keys(refs).length > 0 ? (refs as ProviderRuntimeEvent["providerRefs"]) : undefined; } -function runtimeEventBase(event: ProviderEvent): Omit { +function runtimeEventBase( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): Omit { const refs = providerRefsFromEvent(event); return { eventId: event.id, provider: event.provider, - sessionId: asRuntimeSessionId(event.sessionId), + threadId: canonicalThreadId, createdAt: event.createdAt, - ...(event.threadId ? { threadId: asRuntimeThreadId(event.threadId) } : {}), - ...(event.turnId ? { turnId: asRuntimeTurnId(event.turnId) } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}), ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}), ...(refs ? { providerRefs: refs } : {}), @@ -357,6 +343,7 @@ function runtimeEventBase(event: ProviderEvent): Omit { +function mapToRuntimeEvents( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { const payload = asObject(event.payload); const turn = asObject(payload?.turn); @@ -402,7 +392,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray asObject(entry)) .filter((entry): entry is Record => entry !== undefined) @@ -648,7 +636,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* Effect.service(ServerConfig); - const directory = yield* ProviderSessionDirectory; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -964,14 +955,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : undefined); const manager = yield* Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { if (options?.manager) { return options.manager; } - if (options?.makeManager) { - return options.makeManager(); - } - return new CodexAppServerManager(); + const services = yield* Effect.services(); + return options?.makeManager?.(services) ?? new CodexAppServerManager(services); }), (manager) => Effect.sync(() => { @@ -999,11 +988,13 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, - sessionId: "pending", + threadId: input.threadId, detail: toMessage(cause, "Failed to start Codex adapter session."), cause, }), - }); + }).pipe( + Effect.map((session) => session), + ); }; const sendTurn: CodexAdapterShape["sendTurn"] = (input) => @@ -1018,7 +1009,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }); if (!attachmentPath) { return yield* toRequestError( - input.sessionId, + input.threadId, "turn/start", new Error(`Invalid attachment id '${attachment.id}'.`), ); @@ -1026,7 +1017,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const bytes = yield* fileSystem .readFile(attachmentPath) .pipe( - Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), + Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)), ); return { type: "image" as const, @@ -1039,7 +1030,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => return yield* Effect.tryPromise({ try: () => { const managerInput = { - sessionId: input.sessionId, + threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), ...(input.effort !== undefined ? { effort: input.effort } : {}), @@ -1047,23 +1038,33 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }; return manager.sendTurn(managerInput); }, - catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), - }); + catch: (cause) => toRequestError(input.threadId, "turn/start", cause), + }).pipe( + Effect.map((result) => ({ + ...result, + threadId: input.threadId, + })), + ); }); - const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => + const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => Effect.tryPromise({ - try: () => manager.interruptTurn(sessionId, turnId), - catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), + try: () => manager.interruptTurn(threadId, turnId), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), }); - const readThread: CodexAdapterShape["readThread"] = (sessionId) => + const readThread: CodexAdapterShape["readThread"] = (threadId) => Effect.tryPromise({ - try: () => manager.readThread(sessionId), - catch: (cause) => toRequestError(sessionId, "thread/read", cause), - }); + try: () => manager.readThread(threadId), + catch: (cause) => toRequestError(threadId, "thread/read", cause), + }).pipe( + Effect.map((snapshot) => ({ + threadId, + turns: snapshot.turns, + })), + ); - const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => { + const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { if (!Number.isInteger(numTurns) || numTurns < 1) { return Effect.fail( new ProviderAdapterValidationError({ @@ -1075,31 +1076,36 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => } return Effect.tryPromise({ - try: () => manager.rollbackThread(sessionId, numTurns), - catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), - }); + try: () => manager.rollbackThread(threadId, numTurns), + catch: (cause) => toRequestError(threadId, "thread/rollback", cause), + }).pipe( + Effect.map((snapshot) => ({ + threadId, + turns: snapshot.turns, + })), + ); }; const respondToRequest: CodexAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => Effect.tryPromise({ - try: () => manager.respondToRequest(sessionId, requestId, decision), - catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), + try: () => manager.respondToRequest(threadId, requestId, decision), + catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause), }); - const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => + const stopSession: CodexAdapterShape["stopSession"] = (threadId) => Effect.sync(() => { - manager.stopSession(sessionId); + manager.stopSession(threadId); }); const listSessions: CodexAdapterShape["listSessions"] = () => Effect.sync(() => manager.listSessions()); - const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => - Effect.sync(() => manager.hasSession(sessionId)); + const hasSession: CodexAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => manager.hasSession(threadId)); const stopAll: CodexAdapterShape["stopAll"] = () => Effect.sync(() => { @@ -1115,33 +1121,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => if (!nativeEventLogger) { return; } - const orchestrationThreadId = yield* Effect.catch( - directory.getThreadId(event.sessionId).pipe( - Effect.map((threadIdOption) => - Option.isSome(threadIdOption) ? threadIdOption.value : null, - ), - ), - () => Effect.succeed(null), - ); - yield* nativeEventLogger.write(event, orchestrationThreadId); + yield* nativeEventLogger.write(event, event.threadId); }); const services = yield* Effect.services(); const listener = (event: ProviderEvent) => Effect.gen(function* () { yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event); - if (runtimeEvents.length === 0) { - yield* Effect.logDebug("ignoring unhandled Codex provider event", { - method: event.method, - sessionId: event.sessionId, - threadId: event.threadId, - turnId: event.turnId, - itemId: event.itemId, - }); - return; - } - yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); + const runtimeEvents = mapToRuntimeEvents(event, event.threadId); + if (runtimeEvents.length === 0) { + yield* Effect.logDebug("ignoring unhandled Codex provider event", { + method: event.method, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.itemId, + }); + return; + } + yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }).pipe(Effect.runPromiseWith(services)); manager.on("event", listener); return listener; diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c4583c25f76a69b78db08626a625c81dea205c15..76a46d106e7beb77b82f37096d7d732447a28cfc 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import readline from "node:readline"; -import { ApprovalRequestId } from "@t3tools/contracts"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Stream } from "effect"; @@ -10,6 +10,10 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; +const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); +const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy"); + class FakeCursorAcpProcess extends EventEmitter { readonly stdin = new PassThrough(); readonly stdout = new PassThrough(); @@ -256,7 +260,11 @@ describe("CursorAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* CursorAdapter; const result = yield* adapter - .startSession({ provider: "codex", runtimeMode: "full-access" }) + .startSession({ + provider: "codex", + threadId: THREAD_ID, + runtimeMode: "full-access", + }) .pipe(Effect.result); assert.equal(result._tag, "Failure"); @@ -290,12 +298,13 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, cwd: "/tmp/project", runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); @@ -355,6 +364,7 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, model: "composer-1.5", runtimeMode: "full-access", }); @@ -364,13 +374,13 @@ describe("CursorAdapterLive", () => { }); it.effect("writes provider-native observability records when enabled", () => { - const nativeEvents: Array<{ - event?: { - provider?: string; - method?: string; - sessionId?: string; - }; - }> = []; + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + }; + }> = []; const fake = new FakeCursorAcpProcess(); const layer = makeCursorAdapterLive({ createProcess: () => fake as never, @@ -388,13 +398,14 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; const session = yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, cwd: "/tmp/project", runtimeMode: "full-access", }); assert.equal(nativeEvents.length > 0, true); assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); - assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); + assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true); assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); }).pipe(Effect.provide(layer)); }); @@ -409,6 +420,7 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; const session = yield* adapter.startSession({ provider: "cursor", + threadId: RESUME_THREAD_ID, cwd: "/tmp/project", resumeCursor: { acpSessionId: "acp-session-resume", @@ -426,7 +438,7 @@ describe("CursorAdapterLive", () => { cwd: "/tmp/project", mcpServers: [], }); - assert.equal(session.threadId, "acp-session-resume"); + assert.equal(session.threadId, RESUME_THREAD_ID); assert.deepEqual(session.resumeCursor, { acpSessionId: "acp-session-resume", }); @@ -443,6 +455,7 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; const session = yield* adapter.startSession({ provider: "cursor", + threadId: LEGACY_RESUME_THREAD_ID, cwd: "/tmp/project", resumeCursor: { sessionId: "acp-session-legacy", @@ -456,7 +469,7 @@ describe("CursorAdapterLive", () => { cwd: "/tmp/project", mcpServers: [], }); - assert.equal(session.threadId, "acp-session-legacy"); + assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID); assert.deepEqual(session.resumeCursor, { acpSessionId: "acp-session-legacy", }); @@ -474,6 +487,7 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, runtimeMode: "approval-required", }); @@ -498,7 +512,7 @@ describe("CursorAdapterLive", () => { } yield* adapter.respondToRequest( - session.sessionId, + session.threadId, ApprovalRequestId.makeUnsafe(runtimeRequestId), "acceptForSession", ); @@ -528,6 +542,7 @@ describe("CursorAdapterLive", () => { yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, runtimeMode: "full-access", }); @@ -562,6 +577,7 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; const session = yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, runtimeMode: "full-access", }); @@ -569,7 +585,7 @@ describe("CursorAdapterLive", () => { const result = yield* adapter .sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: " ", attachments: [], }) @@ -608,11 +624,12 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", + threadId: THREAD_ID, runtimeMode: "full-access", }); yield* adapter.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 15814957ef5121c85b910b477e4428dba27f1b8f..7d462744e77332d00faa01242c3c3e351c320650 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -16,16 +16,12 @@ import { type CanonicalItemType, type CanonicalRequestType, ProviderItemId, - ProviderSessionId, type ProviderRuntimeEvent, type ProviderSession, - ProviderThreadId, - ProviderTurnId, type ProviderTurnStartResult, type RuntimeMode, RuntimeItemId, RuntimeRequestId, - RuntimeSessionId, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -72,7 +68,7 @@ interface PendingPermission { } interface CursorTurnState { - readonly turnId: ReturnType; + readonly turnId: TurnId; readonly assistantItemId: ReturnType; readonly startedToolCalls: Set; readonly toolCalls: Map; @@ -87,7 +83,7 @@ interface CursorSessionContext { readonly pending: Map; readonly pendingPermissions: Map; readonly turns: Array<{ - id: ReturnType; + id: TurnId; items: Array; }>; turnState: CursorTurnState | undefined; @@ -114,18 +110,6 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId { - return RuntimeSessionId.makeUnsafe(value); -} - -function asRuntimeThreadId(value: ProviderThreadId): ThreadId { - return ThreadId.makeUnsafe(value); -} - -function asRuntimeTurnId(value: ProviderTurnId): TurnId { - return TurnId.makeUnsafe(value); -} - function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.makeUnsafe(value); } @@ -139,21 +123,21 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { } function toSessionError( - sessionId: ReturnType, + threadId: ThreadId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); if (normalized.includes("unknown session") || normalized.includes("not found")) { return new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } if (normalized.includes("closed")) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, cause, }); } @@ -161,11 +145,11 @@ function toSessionError( } function toRequestError( - sessionId: ReturnType, + threadId: ThreadId, method: string, cause: unknown, ): ProviderAdapterError { - const sessionError = toSessionError(sessionId, cause); + const sessionError = toSessionError(threadId, cause); if (sessionError) { return sessionError; } @@ -336,7 +320,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - const sessions = new Map(); + const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -431,19 +415,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "runtime.warning", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), payload: { message, ...(detail !== undefined ? { detail } : {}), }, - providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), - }, + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), }); }); @@ -464,10 +445,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "item.completed", eventId: itemStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: itemStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnState.turnId), + threadId: context.session.threadId, + turnId: turnState.turnId, itemId: asRuntimeItemId(turnState.assistantItemId), payload: { itemType: "assistant_message", @@ -475,9 +455,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { title: "Assistant message", }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - providerTurnId: turnState.turnId, + providerTurnId: String(turnState.turnId), providerItemId: turnState.assistantItemId, }, }); @@ -492,19 +470,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "turn.completed", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnState.turnId), + threadId: context.session.threadId, + turnId: turnState.turnId, payload: { state, ...(stopReason ? { stopReason } : {}), ...(errorMessage ? { errorMessage } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - providerTurnId: turnState.turnId, + providerTurnId: String(turnState.turnId), }, }); @@ -572,10 +547,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "request.resolved", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), requestId: asRuntimeRequestId(requestId), payload: { requestType, @@ -586,9 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: String(decoded.id), }, raw: { @@ -614,10 +586,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "request.opened", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), requestId: asRuntimeRequestId(requestId), payload: { requestType, @@ -625,9 +596,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { args: decoded.params, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: String(decoded.id), }, raw: { @@ -659,14 +628,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const base = { provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), - providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), - }, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), raw: { source: "cursor.acp.notification" as const, method: decoded.method, @@ -701,7 +667,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "content.delta", eventId: stamp.eventId, createdAt: stamp.createdAt, - turnId: asRuntimeTurnId(context.turnState.turnId), + turnId: context.turnState.turnId, itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { streamKind: "reasoning_text", @@ -720,7 +686,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "content.delta", eventId: stamp.eventId, createdAt: stamp.createdAt, - turnId: asRuntimeTurnId(context.turnState.turnId), + turnId: context.turnState.turnId, itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { streamKind: "assistant_text", @@ -746,7 +712,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "item.started", eventId: stamp.eventId, createdAt: stamp.createdAt, - turnId: asRuntimeTurnId(context.turnState.turnId), + turnId: context.turnState.turnId, itemId: asRuntimeItemId(update.toolCallId), payload: { itemType, @@ -765,7 +731,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "item.updated", eventId: stamp.eventId, createdAt: stamp.createdAt, - turnId: asRuntimeTurnId(context.turnState.turnId), + turnId: context.turnState.turnId, itemId: asRuntimeItemId(update.toolCallId), payload: { itemType, @@ -791,7 +757,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: eventType, eventId: stamp.eventId, createdAt: stamp.createdAt, - turnId: asRuntimeTurnId(context.turnState.turnId), + turnId: context.turnState.turnId, itemId: asRuntimeItemId(update.toolCallId), payload: { itemType, @@ -857,10 +823,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { id: EventId.makeUnsafe(randomUUID()), kind: nativeKind, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: new Date().toISOString(), method: nativeMethod, - ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + threadId: context.session.threadId, ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), payload: message, }, @@ -956,36 +921,31 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { if (options?.emitExitEvent !== false) { const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.exited", - eventId: stamp.eventId, - provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), - createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - payload: { - reason: "Session stopped", - exitKind: "graceful", - }, - providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - }, - }); - } + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + }); + } - sessions.delete(context.session.sessionId); + sessions.delete(context.session.threadId); }); const requireSession = ( - sessionId: ReturnType, + threadId: ThreadId, ): Effect.Effect => { - const context = sessions.get(sessionId); + const context = sessions.get(threadId); if (!context) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, - sessionId, + threadId, }), ); } @@ -993,7 +953,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return Effect.fail( new ProviderAdapterSessionClosedError({ provider: PROVIDER, - sessionId, + threadId, }), ); } @@ -1011,7 +971,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } const startedAt = yield* nowIso; - const sessionId = ProviderSessionId.makeUnsafe(yield* Random.nextUUIDv4); const cwd = input.cwd ?? process.cwd(); const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; const binaryPath = cursorOptions?.binaryPath ?? "agent"; @@ -1028,7 +987,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, - sessionId, + threadId: input.threadId, detail: toMessage(cause, "Failed to spawn Cursor ACP process."), cause, }), @@ -1037,7 +996,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const output = readline.createInterface({ input: child.stdout }); const session: ProviderSession = { - sessionId, + threadId: input.threadId, provider: PROVIDER, status: "connecting", runtimeMode: input.runtimeMode, @@ -1085,10 +1044,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "runtime.error", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), payload: { message: error.message || "Cursor ACP process error.", class: "transport_error", @@ -1118,9 +1076,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "session.exited", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), + threadId: context.session.threadId, payload: { reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, exitKind: code === 0 ? "graceful" : "error", @@ -1128,19 +1085,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }); - sessions.delete(context.session.sessionId); + sessions.delete(context.session.threadId); }), ); }); - sessions.set(sessionId, context); + sessions.set(input.threadId, context); const initializeResult = yield* Effect.tryPromise({ try: async () => sendRequest(context, "initialize", { protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, }), - catch: (cause) => toRequestError(sessionId, "initialize", cause), + catch: (cause) => toRequestError(input.threadId, "initialize", cause), }); const decodedInitialize = yield* Effect.try({ try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult), @@ -1158,14 +1115,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "session.configured", eventId: initStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: initStamp.createdAt, + threadId: input.threadId, payload: { config: decodedInitialize, }, - providerRefs: { - providerSessionId: sessionId, - }, raw: { source: "cursor.acp.response", method: "initialize", @@ -1179,14 +1133,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "auth.status", eventId: authStartStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: authStartStamp.createdAt, + threadId: input.threadId, payload: { isAuthenticating: true, }, - providerRefs: { - providerSessionId: sessionId, - }, raw: { source: "cursor.acp.request", method: "authenticate", @@ -1196,7 +1147,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const authenticateResult = yield* Effect.tryPromise({ try: async () => sendRequest(context, "authenticate", authenticateRequest), - catch: (cause) => toRequestError(sessionId, "authenticate", cause), + catch: (cause) => toRequestError(input.threadId, "authenticate", cause), }).pipe( Effect.match({ onFailure: (error) => ({ ok: false as const, error }), @@ -1209,15 +1160,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "auth.status", eventId: authEndStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: authEndStamp.createdAt, + threadId: input.threadId, payload: { isAuthenticating: false, error: toMessage(authenticateResult.error, "Cursor authentication failed."), }, - providerRefs: { - providerSessionId: sessionId, - }, raw: { source: "cursor.acp.response", method: "authenticate", @@ -1231,14 +1179,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "auth.status", eventId: authEndStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: authEndStamp.createdAt, + threadId: input.threadId, payload: { isAuthenticating: false, }, - providerRefs: { - providerSessionId: sessionId, - }, raw: { source: "cursor.acp.response", method: "authenticate", @@ -1273,15 +1218,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); return decoded.sessionId; }, - catch: (cause) => toRequestError(sessionId, "session/new|session/load", cause), + catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause), }); - const threadId = ProviderThreadId.makeUnsafe(acpSessionId); context.acpSessionId = acpSessionId; context.session = { ...context.session, status: "ready", - threadId, resumeCursor: { acpSessionId, }, @@ -1293,14 +1236,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "session.started", eventId: sessionStartedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: sessionStartedStamp.createdAt, - threadId: asRuntimeThreadId(threadId), + threadId: input.threadId, payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, - providerRefs: { - providerSessionId: sessionId, - providerThreadId: threadId, - }, }); const threadStartedStamp = yield* makeEventStamp(); @@ -1308,15 +1246,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "thread.started", eventId: threadStartedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: threadStartedStamp.createdAt, - threadId: asRuntimeThreadId(threadId), + threadId: input.threadId, payload: { - providerThreadId: threadId, - }, - providerRefs: { - providerSessionId: sessionId, - providerThreadId: threadId, + providerThreadId: acpSessionId, }, }); @@ -1325,16 +1258,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "session.state.changed", eventId: readyStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(sessionId), createdAt: readyStamp.createdAt, - threadId: asRuntimeThreadId(threadId), + threadId: input.threadId, payload: { state: "ready", }, - providerRefs: { - providerSessionId: sessionId, - providerThreadId: threadId, - }, }); return { @@ -1344,13 +1272,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const context = yield* requireSession(input.sessionId); + const context = yield* requireSession(input.threadId); if (context.turnState) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", - issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, }); } @@ -1363,7 +1291,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } - const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); const turnState: CursorTurnState = { turnId, assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), @@ -1385,18 +1313,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "turn.started", eventId: startedStamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: startedStamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - turnId: asRuntimeTurnId(turnId), + threadId: context.session.threadId, + turnId, payload: { ...(input.model ? { model: input.model } : {}), ...(input.effort ? { effort: input.effort } : {}), }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - providerTurnId: turnId, + providerTurnId: String(turnId), }, }); @@ -1406,7 +1331,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { sessionId: context.acpSessionId, prompt: [{ type: "text", text: promptText }], }), - catch: (cause) => toRequestError(input.sessionId, "session/prompt", cause), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), }); const promptResult = yield* Effect.try({ @@ -1438,7 +1363,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }; return { - ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + threadId: context.session.threadId, turnId, ...(context.session.resumeCursor !== undefined ? { resumeCursor: context.session.resumeCursor } @@ -1446,9 +1371,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } satisfies ProviderTurnStartResult; }); - const interruptTurn: CursorAdapterShape["interruptTurn"] = (sessionId, _turnId) => + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); if (!context.turnState) { return; } @@ -1456,7 +1381,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const cancelResult = yield* Effect.tryPromise({ try: async () => sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), - catch: (cause) => toRequestError(sessionId, "session/cancel", cause), + catch: (cause) => toRequestError(threadId, "session/cancel", cause), }).pipe( Effect.match({ onFailure: (error) => ({ ok: false as const, error }), @@ -1475,11 +1400,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); }); - const readThread: CursorAdapterShape["readThread"] = (sessionId) => + const readThread: CursorAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); return { - threadId: ProviderThreadId.makeUnsafe(context.acpSessionId), + threadId: context.session.threadId, turns: context.turns.map((turn) => ({ id: turn.id, items: [...turn.items], @@ -1487,22 +1412,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }; }); - const rollbackThread: CursorAdapterShape["rollbackThread"] = (sessionId, _numTurns) => + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) => Effect.fail( new ProviderAdapterRequestError({ provider: PROVIDER, method: "thread/rollback", - detail: `Cursor ACP does not support thread rollback for session '${sessionId}'.`, + detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`, }), ); const respondToRequest: CursorAdapterShape["respondToRequest"] = ( - sessionId, + threadId, requestId, decision, ) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); const pending = context.pendingPermissions.get(requestId); if (!pending) { return yield* new ProviderAdapterRequestError({ @@ -1540,10 +1465,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { type: "request.resolved", eventId: stamp.eventId, provider: PROVIDER, - sessionId: asRuntimeSessionId(context.session.sessionId), createdAt: stamp.createdAt, - ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), - ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})), + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), requestId: asRuntimeRequestId(requestId), payload: { requestType: pending.requestType, @@ -1553,9 +1477,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }, providerRefs: { - providerSessionId: context.session.sessionId, - ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), - ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), providerRequestId: String(pending.jsonRpcId), }, raw: { @@ -1568,9 +1490,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); }); - const stopSession: CursorAdapterShape["stopSession"] = (sessionId) => + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => Effect.gen(function* () { - const context = yield* requireSession(sessionId); + const context = yield* requireSession(threadId); yield* stopSessionInternal(context, { emitExitEvent: true, }); @@ -1579,9 +1501,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const listSessions: CursorAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - const hasSession: CursorAdapterShape["hasSession"] = (sessionId) => + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { - const context = sessions.get(sessionId); + const context = sessions.get(threadId); return context !== undefined && !context.stopping; }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 37b2e40d1202a9c427bf40ee7352f7cab6f420c7..3664e1db18f5cff1d73f6f9fc8f298e735065286 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -28,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter]; + : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e4c9491df56054d83b52a78c383e45d988ea13c4..81cf9d47f2ab26e9a5d31f02ceb74515c39e74f6 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,10 +14,8 @@ import { EventId, type ProviderKind, ProviderSessionStartInput, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; @@ -27,8 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProviderAdapterSessionNotFoundError, - ProviderSessionNotFoundError, ProviderUnsupportedError, + ProviderValidationError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -45,20 +43,17 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.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); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; readonly provider: "codex" | "claudeCode" | "cursor"; - readonly sessionId: string; readonly createdAt: string; - readonly threadId?: string | undefined; + readonly threadId: ThreadId; readonly turnId?: string | undefined; readonly itemId?: string | undefined; readonly requestId?: string | undefined; @@ -67,28 +62,23 @@ type LegacyProviderRuntimeEvent = { }; function makeFakeCodexAdapter(provider: ProviderKind = "codex") { - const sessions = new Map(); + 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 threadId = ProviderThreadId.makeUnsafe(`thread-${next}`); const session: ProviderSession = { - sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), provider, status: "ready", runtimeMode: input.runtimeMode, - threadId, - resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` }, + threadId: input.threadId, + resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, }; - nextSession += 1; - sessions.set(session.sessionId, session); + sessions.set(session.threadId, session); return session; }), ); @@ -97,39 +87,39 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ( input: ProviderSendTurnInput, ): Effect.Effect => { - if (!sessions.has(input.sessionId)) { + if (!sessions.has(input.threadId)) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider, - sessionId: input.sessionId, + threadId: input.threadId, }), ); } return Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), - turnId: ProviderTurnId.makeUnsafe("turn-1"), + threadId: input.threadId, + turnId: TurnId.makeUnsafe(`turn-${String(input.threadId)}`), }); }, ); const interruptTurn = vi.fn( - (_sessionId: string, _turnId?: string): Effect.Effect => + (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect => Effect.void, ); const respondToRequest = vi.fn( ( - _sessionId: string, + _threadId: ThreadId, _requestId: string, _decision: ProviderApprovalDecision, ): Effect.Effect => Effect.void, ); const stopSession = vi.fn( - (sessionId: string): Effect.Effect => + (threadId: ThreadId): Effect.Effect => Effect.sync(() => { - sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); + sessions.delete(threadId); }), ); @@ -139,32 +129,31 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { ); const hasSession = vi.fn( - (sessionId: string): Effect.Effect => - Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), + (threadId: ThreadId): Effect.Effect => Effect.succeed(sessions.has(threadId)), ); const readThread = vi.fn( ( - _sessionId: ProviderSessionId, + threadId: ThreadId, ): Effect.Effect< { - threadId: ProviderThreadId; - turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; + threadId: ThreadId; + turns: ReadonlyArray<{ id: TurnId; items: readonly [] }>; }, ProviderAdapterError > => Effect.succeed({ - threadId: ProviderThreadId.makeUnsafe("thread-1"), + threadId, turns: [{ id: asTurnId("turn-1"), items: [] }], }), ); const rollbackThread = vi.fn( ( - _sessionId: ProviderSessionId, + threadId: ThreadId, _numTurns: number, - ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => - Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), + ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> => + Effect.succeed({ threadId, turns: [] }), ); const stopAll = vi.fn( @@ -277,7 +266,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId: asSessionId("sess-stale"), provider: "codex", threadId: ThreadId.makeUnsafe("thread-stale"), }); @@ -294,13 +282,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const persistedProvider = yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - return yield* directory.getProvider(asSessionId("sess-stale")); + return yield* directory.getProvider(asThreadId("thread-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") }); + return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(runtime), true); @@ -348,10 +336,12 @@ it.effect( const startedSession = yield* Effect.gen(function* () { const provider = yield* ProviderService; - return yield* provider.startSession(asThreadId("thread-1"), { + const threadId = asThreadId("thread-1"); + return yield* provider.startSession(threadId, { provider: "codex", cwd: "/tmp/project", runtimeMode: "full-access", + threadId, }); }).pipe(Effect.provide(firstProviderLayer)); @@ -362,9 +352,7 @@ it.effect( const persistedAfterStopAll = yield* Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getBySessionId({ - providerSessionId: startedSession.sessionId, - }); + return yield* repository.getByThreadId({ threadId: startedSession.threadId }); }).pipe(Effect.provide(runtimeRepositoryLayer)); assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { @@ -394,7 +382,7 @@ it.effect( yield* Effect.gen(function* () { const provider = yield* ProviderService; yield* provider.rollbackConversation({ - sessionId: startedSession.sessionId, + threadId: startedSession.threadId, numTurns: 1, }); }).pipe(Effect.provide(secondProviderLayer)); @@ -430,6 +418,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", }); @@ -439,40 +428,43 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(sessions.length, 1); yield* provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, 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.interruptTurn({ threadId: session.threadId }); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); yield* provider.respondToRequest({ - sessionId: session.sessionId, + threadId: session.threadId, requestId: asRequestId("req-1"), decision: "accept", }); assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ - [session.sessionId, asRequestId("req-1"), "accept"], + [session.threadId, asRequestId("req-1"), "accept"], ]); yield* provider.rollbackConversation({ - sessionId: session.sessionId, + threadId: session.threadId, numTurns: 0, }); - yield* provider.stopSession({ sessionId: session.sessionId }); + yield* provider.stopSession({ threadId: session.threadId }); const sendAfterStop = yield* Effect.result( provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "after-stop", attachments: [], }), ); assertFailure( sendAfterStop, - new ProviderSessionNotFoundError({ sessionId: session.sessionId }), + new ProviderValidationError({ + operation: "ProviderService.sendTurn", + issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, + }), ); }), ); @@ -483,6 +475,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-claude"), { provider: "claudeCode", + threadId: asThreadId("thread-claude"), cwd: "/tmp/project-claude", runtimeMode: "full-access", }); @@ -505,15 +498,16 @@ routing.layer("ProviderServiceLive routing", (it) => { const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project", runtimeMode: "full-access", }); - yield* routing.codex.stopSession(initial.sessionId); + yield* routing.codex.stopSession(initial.threadId); routing.codex.startSession.mockClear(); routing.codex.rollbackThread.mockClear(); yield* provider.rollbackConversation({ - sessionId: initial.sessionId, + threadId: initial.threadId, numTurns: 1, }); @@ -544,6 +538,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), cwd: "/tmp/project-send-turn", runtimeMode: "full-access", }); @@ -553,7 +548,7 @@ routing.layer("ProviderServiceLive routing", (it) => { routing.codex.sendTurn.mockClear(); yield* provider.sendTurn({ - sessionId: initial.sessionId, + threadId: initial.threadId, input: "resume", attachments: [], }); @@ -583,10 +578,12 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); yield* provider.startSession(asThreadId("thread-2"), { provider: "codex", + threadId: asThreadId("thread-2"), runtimeMode: "full-access", }); @@ -604,16 +601,17 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); yield* provider.sendTurn({ - sessionId: session.sessionId, + threadId: session.threadId, input: "hello", attachments: [], }); - const runningRuntime = yield* runtimeRepository.getBySessionId({ - providerSessionId: session.sessionId, + const runningRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, }); assert.equal(Option.isSome(runningRuntime), true); if (Option.isSome(runningRuntime)) { @@ -638,8 +636,8 @@ routing.layer("ProviderServiceLive routing", (it) => { } yield* provider.stopAll(); - const stoppedRuntime = yield* runtimeRepository.getBySessionId({ - providerSessionId: session.sessionId, + const stoppedRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, }); assert.equal(Option.isSome(stoppedRuntime), true); if (Option.isSome(stoppedRuntime)) { @@ -656,6 +654,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -669,9 +668,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "turn.completed", eventId: asEventId("evt-1"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }; @@ -694,6 +692,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { provider: "codex", + threadId: asThreadId("thread-seq"), runtimeMode: "full-access", }); @@ -708,9 +707,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "tool.started", eventId: asEventId("evt-seq-1"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", @@ -719,9 +717,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "tool.completed", eventId: asEventId("evt-seq-2"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", @@ -730,9 +727,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "turn.completed", eventId: asEventId("evt-seq-3"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }); @@ -751,6 +747,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", }); @@ -775,9 +772,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "tool.completed", eventId: asEventId("evt-ordered-1"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", @@ -787,9 +783,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "message.delta", eventId: asEventId("evt-ordered-2"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), delta: "hello", }, @@ -797,9 +792,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { type: "turn.completed", eventId: asEventId("evt-ordered-3"), provider: "codex", - sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: asProviderThreadId("thread-1"), + threadId: session.threadId, turnId: asTurnId("turn-1"), status: "completed", }, @@ -854,32 +848,32 @@ validation.layer("ProviderServiceLive validation", (it) => { Effect.sync(() => { const now = new Date().toISOString(); return { - sessionId: asSessionId("sess-missing-thread"), provider: "codex", status: "ready", + threadId: input.threadId, runtimeMode: input.runtimeMode, - cwd: input.cwd ?? process.cwd(), - createdAt: now, - updatedAt: now, - } satisfies ProviderSession; - }), + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }), ); const session = yield* provider.startSession(asThreadId("thread-missing"), { provider: "codex", + threadId: asThreadId("thread-missing"), cwd: "/tmp/project", runtimeMode: "full-access", }); - assert.equal(session.sessionId, asSessionId("sess-missing-thread")); - assert.equal(session.threadId, undefined); + assert.equal(session.threadId, asThreadId("thread-missing")); - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: session.sessionId, + const runtime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, }); assert.equal(Option.isSome(runtime), true); if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerThreadId, null); + assert.equal(runtime.value.threadId, session.threadId); } }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 4f2976c83be824c8edd473a3a19aab98d7e1d43e..7025f8a2e1a050e6d563c36851b6dcf08b192ca9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -11,8 +11,6 @@ */ import { NonNegativeInt, - ProviderSessionId, - RuntimeSessionId, ThreadId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, @@ -22,14 +20,14 @@ import { type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect"; +import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; -import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory, - type ProviderSessionBinding, + type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -39,7 +37,7 @@ export interface ProviderServiceLiveOptions { } const ProviderRollbackConversationInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, numTurns: NonNegativeInt, }); @@ -96,7 +94,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record const directory = yield* ProviderSessionDirectory; const runtimeEventQueue = yield* Queue.unbounded(); const runtimeEventPubSub = yield* PubSub.unbounded(); - const routedSessionAliasesRef = yield* Ref.make>( - new Map(), - ); - const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId => - RuntimeSessionId.makeUnsafe(sessionId); - const asProviderSessionId = (sessionId: RuntimeSessionId): ProviderSessionId => - ProviderSessionId.makeUnsafe(sessionId); - - const canonicalizeRuntimeEventSession = ( - event: ProviderRuntimeEvent, - ): Effect.Effect => - Ref.get(routedSessionAliasesRef).pipe( - Effect.map((aliases) => { - for (const [staleSessionId, liveSessionId] of aliases) { - if (asRuntimeSessionId(liveSessionId) === event.sessionId) { - return { - ...event, - sessionId: asRuntimeSessionId(staleSessionId), - } satisfies ProviderRuntimeEvent; - } - } - return event; - }), - ); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - canonicalizeRuntimeEventSession(event).pipe( + Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => canonicalEventLogger - ? Effect.flatMap( - Effect.catch( - directory.getThreadId(asProviderSessionId(canonicalEvent.sessionId)).pipe( - Effect.map((threadIdOption) => - Option.isSome(threadIdOption) ? threadIdOption.value : null, - ), - ), - () => Effect.succeed(null), - ), - (orchestrationThreadId) => - canonicalEventLogger.write(canonicalEvent, orchestrationThreadId), - ) + ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), @@ -172,56 +135,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => session: ProviderSession, threadId: ThreadId, ) => - Effect.gen(function* () { - const providerThreadId = session.threadId; - - yield* directory.upsert({ - sessionId: session.sessionId, - provider: session.provider, - threadId, - ...(providerThreadId !== undefined ? { providerThreadId } : {}), - runtimeMode: session.runtimeMode, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session), - }); - - return providerThreadId; - }); - - const clearAliasKey = (staleSessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - if (!current.has(staleSessionId)) { - return current; - } - const next = new Map(current); - next.delete(staleSessionId); - return next; - }); - - const clearAliasesReferencing = (sessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - let changed = false; - const next = new Map(); - for (const [key, value] of current) { - if (key === sessionId || value === sessionId) { - changed = true; - continue; - } - next.set(key, value); - } - return changed ? next : current; - }); - - const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => - Ref.update(routedSessionAliasesRef, (current) => { - const existing = current.get(staleSessionId); - if (existing === liveSessionId) { - return current; - } - const next = new Map(current); - next.set(staleSessionId, liveSessionId); - return next; + directory.upsert({ + threadId, + provider: session.provider, + runtimeMode: session.runtimeMode, + status: toRuntimeStatus(session), + ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), + runtimePayload: toRuntimePayloadFromSession(session), }); const providers = yield* registry.listProviders(); @@ -244,166 +164,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ).pipe(Effect.asVoid); const recoverSessionForThread = (input: { - readonly staleSessionId: ProviderSessionId; - readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId }; + readonly binding: ProviderRuntimeBinding; readonly operation: string; }) => Effect.gen(function* () { const adapter = yield* registry.getByProvider(input.binding.provider); - const activeSessions = yield* adapter.listSessions(); - const resumeThreadId = input.binding.providerThreadId ?? undefined; const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; - const existing = - resumeThreadId === undefined - ? undefined - : activeSessions.find((session) => session.threadId === resumeThreadId); - if (existing) { - const existingProviderThreadId = yield* upsertSessionBinding( - existing, - input.binding.threadId, - ); - yield* directory.upsert({ - sessionId: input.staleSessionId, - provider: existing.provider, - threadId: input.binding.threadId, - ...(existingProviderThreadId !== undefined - ? { providerThreadId: existingProviderThreadId } - : {}), - ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}), - }); - if (existing.sessionId !== input.staleSessionId) { - yield* setAlias(input.staleSessionId, existing.sessionId); - } else { - yield* clearAliasKey(input.staleSessionId); + const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); + if (hasActiveSession) { + const activeSessions = yield* adapter.listSessions(); + const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); + if (existing) { + yield* upsertSessionBinding(existing, input.binding.threadId); + return { adapter, session: existing } as const; } - return { - adapter, - sessionId: existing.sessionId, - } as const; } if (!hasResumeCursor) { return yield* toValidationError( input.operation, - `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, + `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, ); } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ + threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(resumeThreadId ? { resumeThreadId } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { return yield* toValidationError( input.operation, - `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, + `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, ); } - const resumedProviderThreadId = yield* upsertSessionBinding( - resumed, - input.binding.threadId, - ); - - yield* directory.upsert({ - sessionId: input.staleSessionId, - provider: resumed.provider, - threadId: input.binding.threadId, - ...(resumedProviderThreadId !== undefined - ? { providerThreadId: resumedProviderThreadId } - : {}), - ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}), - }); - - if (resumed.sessionId !== input.staleSessionId) { - yield* setAlias(input.staleSessionId, resumed.sessionId); - } else { - yield* clearAliasKey(input.staleSessionId); - } - - return { - adapter, - sessionId: resumed.sessionId, - } as const; + yield* upsertSessionBinding(resumed, input.binding.threadId); + return { adapter, session: resumed } as const; }); const resolveRoutableSession = (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly operation: string; readonly allowRecovery: boolean; }) => Effect.gen(function* () { - const bindingOption = yield* directory.getBinding(input.sessionId); + const bindingOption = yield* directory.getBinding(input.threadId); const binding = Option.getOrUndefined(bindingOption); if (!binding) { - return yield* new ProviderSessionNotFoundError({ - sessionId: input.sessionId, - }); - } - if (!binding.threadId) { return yield* toValidationError( input.operation, - `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`, + `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, ); } - const bindingWithThreadId: ProviderSessionBinding & { - readonly threadId: ThreadId; - } = { - ...binding, - threadId: binding.threadId, - }; const adapter = yield* registry.getByProvider(binding.provider); - const hasRequestedSession = yield* adapter.hasSession(input.sessionId); + const hasRequestedSession = yield* adapter.hasSession(input.threadId); if (hasRequestedSession) { - yield* clearAliasKey(input.sessionId); - return { - adapter, - sessionId: input.sessionId, - isActive: true, - } as const; - } - - const alias = yield* Ref.get(routedSessionAliasesRef).pipe( - Effect.map((aliases) => aliases.get(input.sessionId)), - ); - if (alias) { - const aliasIsActive = yield* adapter.hasSession(alias); - if (aliasIsActive) { - return { - adapter, - sessionId: alias, - isActive: true, - } as const; - } - yield* clearAliasKey(input.sessionId); + return { adapter, threadId: input.threadId, isActive: true } as const; } if (!input.allowRecovery) { - return { - adapter, - sessionId: input.sessionId, - isActive: false, - } as const; + return { adapter, threadId: input.threadId, isActive: false } as const; } - const recovered = yield* recoverSessionForThread({ - staleSessionId: input.sessionId, - binding: bindingWithThreadId, - operation: input.operation, - }); - - return { - adapter: recovered.adapter, - sessionId: recovered.sessionId, - isActive: true, - } as const; + const recovered = yield* recoverSessionForThread({ binding, operation: input.operation }); + return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; }); const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) => @@ -416,6 +247,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const input = { ...parsed, + threadId, provider: parsed.provider ?? "codex", }; const adapter = yield* registry.getByProvider(input.provider); @@ -452,28 +284,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.sendTurn", allowRecovery: true, }); - const turn = yield* routed.adapter.sendTurn({ - ...input, - sessionId: routed.sessionId, - }); - const threadId = yield* directory - .getThreadId(input.sessionId) - .pipe(Effect.map(Option.getOrUndefined)); - if (!threadId) { - return yield* toValidationError( - "ProviderService.sendTurn", - `No thread id is tracked for provider session '${input.sessionId}'.`, - ); - } + const turn = yield* routed.adapter.sendTurn(input); yield* directory.upsert({ - sessionId: input.sessionId, + threadId: input.threadId, provider: routed.adapter.provider, - threadId, - ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}), status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { @@ -493,11 +311,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.interruptTurn", allowRecovery: true, }); - yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId); + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); }); const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => @@ -508,11 +326,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.respondToRequest", allowRecovery: true, }); - yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision); + yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); }); const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => @@ -523,19 +341,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => payload: rawInput, }); const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.stopSession", allowRecovery: false, }); if (routed.isActive) { - yield* routed.adapter.stopSession(routed.sessionId); + yield* routed.adapter.stopSession(routed.threadId); } - if (routed.sessionId !== input.sessionId) { - yield* directory.remove(routed.sessionId); - yield* clearAliasesReferencing(routed.sessionId); - } - yield* directory.remove(input.sessionId); - yield* clearAliasesReferencing(input.sessionId); + yield* directory.remove(input.threadId); }); const listSessions: ProviderServiceShape["listSessions"] = () => @@ -543,39 +356,36 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); const persistedBindings = yield* directory - .listSessionIds() + .listThreadIds() .pipe( - Effect.flatMap((sessionIds) => + Effect.flatMap((threadIds) => Effect.forEach( - sessionIds, - (sessionId) => - directory.getBinding(sessionId).pipe( - Effect.orElseSucceed(() => Option.none()), + threadIds, + (threadId) => + directory.getBinding(threadId).pipe( + Effect.orElseSucceed(() => Option.none()), ), { concurrency: "unbounded" }, ), ), - Effect.orElseSucceed(() => [] as Array>), + Effect.orElseSucceed(() => [] as Array>), ); - const bindingsBySessionId = new Map(); + const bindingsByThreadId = new Map(); for (const bindingOption of persistedBindings) { const binding = Option.getOrUndefined(bindingOption); if (binding) { - bindingsBySessionId.set(binding.sessionId, binding); + bindingsByThreadId.set(binding.threadId, binding); } } return activeSessions.map((session) => { - const binding = bindingsBySessionId.get(session.sessionId); + const binding = bindingsByThreadId.get(session.threadId); if (!binding) { return session; } return { ...session, - ...(session.threadId === undefined && binding.providerThreadId - ? { threadId: binding.providerThreadId } - : {}), ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}), @@ -598,22 +408,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return; } const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, + threadId: input.threadId, operation: "ProviderService.rollbackConversation", allowRecovery: true, }); - yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); + yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); }); const stopAll: ProviderServiceShape["stopAll"] = () => Effect.gen(function* () { - const sessionIds = yield* directory.listSessionIds(); + const threadIds = yield* directory.listThreadIds(); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); - yield* Effect.forEach(sessionIds, (sessionId) => - directory.getProvider(sessionId).pipe( + yield* Effect.forEach(threadIds, (threadId) => + directory.getProvider(threadId).pipe( Effect.flatMap((provider) => directory.upsert({ - sessionId, + threadId, provider, status: "stopped", runtimePayload: { @@ -625,9 +435,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ), ).pipe(Effect.asVoid); - // Keep persisted session bindings so stale sessions can be resumed after - // process restart via providerThreadId. - yield* Ref.set(routedSessionAliasesRef, new Map()); }); return { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 585b6ac089b2f0aada5ea5ede14d15a5d6097a73..22d415562241004df6b4f0f038acd9fadb3658d7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertFailure, assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; @@ -15,7 +15,7 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -31,74 +31,59 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts, reads, and removes session bindings", () => + it("upserts, reads, and removes thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const sessionId = ProviderSessionId.makeUnsafe("sess-1"); const initialThreadId = ThreadId.makeUnsafe("thread-1"); yield* directory.upsert({ - sessionId, provider: "codex", threadId: initialThreadId, }); - const provider = yield* directory.getProvider(sessionId); + const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); - const resolvedThreadId = yield* directory.getThreadId(sessionId); - assertSome(resolvedThreadId, initialThreadId); + const resolvedBinding = yield* directory.getBinding(initialThreadId); + assertSome(resolvedBinding, { + threadId: initialThreadId, + provider: "codex", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, initialThreadId); + } const nextThreadId = ThreadId.makeUnsafe("thread-2"); yield* directory.upsert({ - sessionId: sessionId, provider: "codex", threadId: nextThreadId, }); - const updatedThreadId = yield* directory.getThreadId(sessionId); - assertSome(updatedThreadId, nextThreadId); - const updatedBinding = yield* directory.getBinding(sessionId); + const updatedBinding = yield* directory.getBinding(nextThreadId); assert.equal(Option.isSome(updatedBinding), true); if (Option.isSome(updatedBinding)) { assert.equal(updatedBinding.value.threadId, nextThreadId); - assert.equal(updatedBinding.value.providerThreadId, null); } - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); + const runtime = yield* runtimeRepository.getByThreadId({ threadId: nextThreadId }); assert.equal(Option.isSome(runtime), true); if (Option.isSome(runtime)) { assert.equal(runtime.value.threadId, nextThreadId); - assert.equal(runtime.value.providerThreadId, null); assert.equal(runtime.value.status, "running"); - assert.equal(runtime.value.adapterKey, "codex"); + assert.equal(runtime.value.providerName, "codex"); } - const sessionIds = yield* directory.listSessionIds(); - assert.deepEqual(sessionIds, [sessionId]); + const threadIds = yield* directory.listThreadIds(); + assert.deepEqual(threadIds, [nextThreadId]); - yield* directory.remove(sessionId); - const missingProvider = yield* directory.getProvider(sessionId).pipe(Effect.result); - assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); - })); - - it("fails upsert when thread id is unavailable", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const result = yield* Effect.result( - directory.upsert({ - sessionId: ProviderSessionId.makeUnsafe("sess-no-thread"), - provider: "codex", - }), - ); + yield* directory.remove(nextThreadId); + const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); assertFailure( - result, - new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", + missingProvider, + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getProvider", + detail: `No persisted provider binding found for thread '${nextThreadId}'.`, }), ); })); @@ -108,15 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const sessionId = ProviderSessionId.makeUnsafe("sess-runtime"); const threadId = ThreadId.makeUnsafe("thread-runtime"); - const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime"); yield* directory.upsert({ - sessionId, provider: "codex", threadId, - providerThreadId, status: "starting", resumeCursor: { threadId: "provider-thread-runtime", @@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }); yield* directory.upsert({ - sessionId, provider: "codex", + threadId, status: "running", runtimePayload: { activeTurnId: "turn-1", }, }); - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); assert.equal(Option.isSome(runtime), true); if (Option.isSome(runtime)) { assert.equal(runtime.value.threadId, threadId); - assert.equal(runtime.value.providerThreadId, providerThreadId); assert.equal(runtime.value.status, "running"); assert.deepEqual(runtime.value.resumeCursor, { - threadId: providerThreadId, + threadId: "provider-thread-runtime", }); assert.deepEqual(runtime.value.runtimePayload, { cwd: "/tmp/project", @@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); - it("clears providerThreadId when explicitly set to null", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; - - const sessionId = ProviderSessionId.makeUnsafe("sess-clear-provider-thread-id"); - const threadId = ThreadId.makeUnsafe("thread-clear-provider-thread-id"); - const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-to-clear"); - - yield* directory.upsert({ - sessionId, - provider: "codex", - threadId, - adapterKey: "custom-adapter", - providerThreadId, - }); - - yield* directory.upsert({ - sessionId, - provider: "codex", - providerThreadId: null, - }); - - const runtime = yield* runtimeRepository.getBySessionId({ - providerSessionId: sessionId, - }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerThreadId, null); - assert.equal(runtime.value.adapterKey, "custom-adapter"); - } - })); - it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); - const sessionId = ProviderSessionId.makeUnsafe("sess-restart"); const threadId = ThreadId.makeUnsafe("thread-restart"); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId, provider: "codex", threadId, }); @@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const sql = yield* SqlClient.SqlClient; - const provider = yield* directory.getProvider(sessionId); + const provider = yield* directory.getProvider(threadId); assert.equal(provider, "codex"); - const resolvedThreadId = yield* directory.getThreadId(sessionId); - assertSome(resolvedThreadId, threadId); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "codex", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } const legacyTableRows = yield* sql<{ readonly name: string }>` SELECT name @@ -229,18 +178,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("accepts cursor provider bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const sessionId = ProviderSessionId.makeUnsafe("sess-cursor"); const threadId = ThreadId.makeUnsafe("thread-cursor"); yield* directory.upsert({ - sessionId, provider: "cursor", threadId, }); - const provider = yield* directory.getProvider(sessionId); + const provider = yield* directory.getProvider(threadId); assert.equal(provider, "cursor"); - const resolvedThreadId = yield* directory.getThreadId(sessionId); - assertSome(resolvedThreadId, threadId); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "cursor", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index a3dc31134bb29d79171e9fd8410719e0d888e9c5..a45cd87decf19a439aa394d0462e4dfddcdc03b9 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,15 +1,14 @@ -import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, - ProviderSessionNotFoundError, ProviderValidationError, } from "../Errors.ts"; import { ProviderSessionDirectory, - type ProviderSessionBinding, + type ProviderRuntimeBinding, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; @@ -49,10 +48,7 @@ function mergeRuntimePayload( return existing ?? null; } if (isRecord(existing) && isRecord(next)) { - return { - ...existing, - ...next, - }; + return { ...existing, ...next }; } return next; } @@ -60,21 +56,19 @@ function mergeRuntimePayload( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - const getBinding = (sessionId: ProviderSessionId) => - repository.getBySessionId({ providerSessionId: sessionId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), + const getBinding = (threadId: ThreadId) => + repository.getByThreadId({ threadId }).pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")), Effect.flatMap((runtime) => Option.match(runtime, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeed(Option.none()), onSome: (value) => decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( Effect.map((provider) => Option.some({ - sessionId: value.providerSessionId, - provider, threadId: value.threadId, + provider, adapterKey: value.adapterKey, - providerThreadId: value.providerThreadId, runtimeMode: value.runtimeMode, status: value.status, resumeCursor: value.resumeCursor, @@ -88,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { const existing = yield* repository - .getBySessionId({ - providerSessionId: binding.sessionId, - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId"))); + .getByThreadId({ threadId: binding.threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); const existingRuntime = Option.getOrUndefined(existing); const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; @@ -105,14 +97,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { const now = new Date().toISOString(); yield* repository .upsert({ - providerSessionId: binding.sessionId, threadId: resolvedThreadId, providerName: binding.provider, adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, - providerThreadId: - binding.providerThreadId !== undefined - ? binding.providerThreadId - : (existingRuntime?.providerThreadId ?? null), runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", status: binding.status ?? existingRuntime?.status ?? "running", lastSeenAt: now, @@ -128,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () { .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); - const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => - getBinding(sessionId).pipe( + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => + getBinding(threadId).pipe( Effect.flatMap((binding) => Option.match(binding, { onSome: (value) => Effect.succeed(value.provider), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + onNone: () => + Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getProvider", + detail: `No persisted provider binding found for thread '${threadId}'.`, + }), + ), }), ), ); - const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) => - getBinding(sessionId); - - const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => - getBinding(sessionId).pipe( - Effect.flatMap((binding) => - Option.match(binding, { - onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - }), - ), - ); - - const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => + const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => repository - .deleteBySessionId({ providerSessionId: sessionId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), - ); + .deleteByThreadId({ threadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId"))); - const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( - Effect.map((provider) => ({ - sessionId: row.providerSessionId, - provider, - threadId: row.threadId, - })), - ), - { concurrency: "unbounded" }, - ), - ), - Effect.map((bindings) => bindings.map((binding) => binding.sessionId)), + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), + Effect.map((rows) => rows.map((row) => row.threadId)), ); return { upsert, getProvider, - getBinding: getBindingBySessionId, - getThreadId, + getBinding, remove, - listSessionIds, + listThreadIds, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index f6bd4a7ac95a70139393e567bed336cb09735ed8..631621e3e7d57dfbc4791e918dd29740ea559a45 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -14,11 +14,10 @@ import type { ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, - ProviderSessionId, ProviderSessionStartInput, - ProviderThreadId, - ProviderTurnId, + ThreadId, ProviderTurnStartResult, + TurnId, } from "@t3tools/contracts"; import type { Effect } from "effect"; import type { Stream } from "effect"; @@ -33,12 +32,12 @@ export interface ProviderAdapterCapabilities { } export interface ProviderThreadTurnSnapshot { - readonly id: ProviderTurnId; + readonly id: TurnId; readonly items: ReadonlyArray; } export interface ProviderThreadSnapshot { - readonly threadId: ProviderThreadId; + readonly threadId: ThreadId; readonly turns: ReadonlyArray; } @@ -67,15 +66,15 @@ export interface ProviderAdapterShape { * Interrupt an active turn. */ readonly interruptTurn: ( - sessionId: ProviderSessionId, - turnId?: ProviderTurnId, + threadId: ThreadId, + turnId?: TurnId, ) => Effect.Effect; /** * Respond to an interactive approval request. */ readonly respondToRequest: ( - sessionId: ProviderSessionId, + threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Effect.Effect; @@ -83,7 +82,7 @@ export interface ProviderAdapterShape { /** * Stop one provider session. */ - readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect; + readonly stopSession: (threadId: ThreadId) => Effect.Effect; /** * List currently active provider sessions for this adapter. @@ -93,20 +92,20 @@ export interface ProviderAdapterShape { /** * Check whether this adapter owns an active session id. */ - readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect; + readonly hasSession: (threadId: ThreadId) => Effect.Effect; /** * Read a provider thread snapshot. */ readonly readThread: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; /** * Roll back a provider thread by N turns. */ readonly rollbackThread: ( - sessionId: ProviderSessionId, + threadId: ThreadId, numTurns: number, ) => Effect.Effect; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 07be22f57135ba0ac98ed462b30bc6b235851270..048f9b41a6f8885533ad7d727a38b5759d1b92f4 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -18,7 +18,6 @@ import type { ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, - ProviderSessionId, ProviderSessionStartInput, ProviderStopSessionInput, ThreadId, @@ -88,7 +87,7 @@ export interface ProviderServiceShape { * Roll back provider conversation state by a number of turns. */ readonly rollbackConversation: (input: { - readonly sessionId: ProviderSessionId; + readonly threadId: ThreadId; readonly numTurns: number; }) => Effect.Effect; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 96bdcc23a5d2dd4feac9de1920e16c0afe25499f..3a374976b0797fb8efe86ed1010278fc6ca389b6 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,17 +1,6 @@ -/** - * ProviderSessionDirectory - Session ownership index across provider adapters. - * - * Tracks which provider owns each `sessionId` so `ProviderService` can route - * session-scoped calls to the correct adapter. It is metadata only and does not - * perform provider RPC or checkpoint operations. - * - * @module ProviderSessionDirectory - */ import type { ProviderKind, - ProviderSessionId, ProviderSessionRuntimeStatus, - ProviderThreadId, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -20,84 +9,48 @@ import type { Effect } from "effect"; import type { ProviderSessionDirectoryPersistenceError, - ProviderSessionNotFoundError, ProviderValidationError, } from "../Errors.ts"; -export interface ProviderSessionBinding { - readonly sessionId: ProviderSessionId; +export interface ProviderRuntimeBinding { + readonly threadId: ThreadId; readonly provider: ProviderKind; - readonly threadId?: ThreadId; readonly adapterKey?: string; - readonly providerThreadId?: ProviderThreadId | null; readonly status?: ProviderSessionRuntimeStatus; readonly resumeCursor?: unknown | null; readonly runtimePayload?: unknown | null; readonly runtimeMode?: RuntimeMode; } -export type ProviderSessionDirectoryReadError = - | ProviderSessionNotFoundError - | ProviderSessionDirectoryPersistenceError; +export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; -/** - * ProviderSessionDirectoryShape - Service API for provider session ownership metadata. - */ export interface ProviderSessionDirectoryShape { - /** - * Record or update ownership for one provider session. - * - * Preserves existing persisted fields when omitted and shallow-merges - * runtime payload objects. - */ readonly upsert: ( - binding: ProviderSessionBinding, + binding: ProviderRuntimeBinding, ) => Effect.Effect; - /** - * Resolve the provider owner for a session id. - */ readonly getProvider: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; - /** - * Resolve the full tracked binding for a session id. - */ readonly getBinding: ( - sessionId: ProviderSessionId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; + threadId: ThreadId, + ) => Effect.Effect, ProviderSessionDirectoryReadError>; - /** - * Resolve the tracked thread id for a session, if known. - */ - readonly getThreadId: ( - sessionId: ProviderSessionId, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; - - /** - * Remove a session binding. - */ readonly remove: ( - sessionId: ProviderSessionId, + threadId: ThreadId, ) => Effect.Effect; - /** - * List tracked session ids. - */ - readonly listSessionIds: () => Effect.Effect< - ReadonlyArray, + readonly listThreadIds: () => Effect.Effect< + ReadonlyArray, ProviderSessionDirectoryPersistenceError >; } -/** - * ProviderSessionDirectory - Service tag for session ownership lookup. - */ export class ProviderSessionDirectory extends ServiceMap.Service< ProviderSessionDirectory, ProviderSessionDirectoryShape diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index dfbf38cff7d11c51bfa49c4d9a22b4cfb56b44be..ae5249df894652df026d7b8945f04ee08df6e453 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -58,14 +58,16 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); - const claudeAdapterLayer = makeClaudeCodeAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), - }); - const cursorAdapterLayer = makeCursorAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), - }); + const claudeAdapterLayer = makeClaudeCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const cursorAdapterLayer = makeCursorAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer, cursorAdapterLayer)), + Layer.provide(codexAdapterLayer), + Layer.provide(claudeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e368df9e4fd936e9fc23d005f7adb633d5ced439..f10edc474ad99a336ce57eb39751c54ee93e0827 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -18,9 +18,8 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, + ThreadId, + TurnId, WS_CHANNELS, WS_METHODS, type WebSocketResponse, @@ -60,11 +59,9 @@ interface PendingMessages { const pendingBySocket = new WeakMap(); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => - ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); -const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const defaultOpenService: OpenShape = { openBrowser: () => Effect.void, @@ -1146,26 +1143,24 @@ describe("WebSocket Server", () => { it("keeps orchestration domain push behavior for provider runtime events", async () => { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const sessionId = asProviderSessionId("sess-test"); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const providerService: ProviderServiceShape = { - startSession: () => + startSession: (threadId) => Effect.succeed({ - sessionId, provider: "codex", status: "ready", runtimeMode: "full-access", - threadId: asProviderThreadId("provider-thread-1"), + threadId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), - sendTurn: () => + sendTurn: ({ threadId }) => Effect.succeed({ - threadId: asProviderThreadId("provider-thread-1"), - turnId: asProviderTurnId("provider-turn-1"), + threadId, + turnId: asTurnId("provider-turn-1"), }), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), @@ -1239,9 +1234,9 @@ describe("WebSocket Server", () => { type: "content.delta", eventId: asEventId("evt-ws-runtime-message-delta"), provider: "codex", - sessionId, + threadId: asThreadId("thread-1"), createdAt: new Date().toISOString(), - turnId: asProviderTurnId("turn-1"), + turnId: asTurnId("turn-1"), itemId: asProviderItemId("item-1"), payload: { streamKind: "assistant_text", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1859d125a1cb97bcb746334f53fb4f83337a12c6..d1c68d321dffc002ffcdfac0c603c5c93586ea2d 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -52,8 +52,7 @@ export default function BranchToolbar({ const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. - const sessionId = serverThread?.session?.sessionId; - if (sessionId && worktreePath !== activeWorktreePath && api) { + if (serverThread?.session && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -89,7 +88,7 @@ export default function BranchToolbar({ }, [ activeThreadId, - serverThread?.session?.sessionId, + serverThread?.session, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cd1bbca6e141670f3df19431a0661bbc15674481..80eede8fce269a3c9f1dbc50e02c807b54c265c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3324,6 +3324,8 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ {approval.requestKind === "command" ? "Command approval requested" + : approval.requestKind === "file-read" + ? "File-read approval requested" : "File-change approval requested"} { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "claudeCode", - providerSessionId: null, - providerThreadId: null, runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -171,8 +169,6 @@ describe("store read model sync", () => { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "cursor", - providerSessionId: null, - providerThreadId: null, runtimeMode: "approval-required", activeTurnId: null, lastError: null, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6ec049761e5ca71bf975de848c084570e5249f2b..da848cfacf193dfaa27196e523f82d49a5d15261 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -4,7 +4,6 @@ import { getModelOptions, normalizeModelSlug, type ProviderKind, - ProviderSessionId, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, @@ -247,7 +246,7 @@ export function syncServerReadModel( const existing = existingThreadById.get(thread.id); return { id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, + codexThreadId: null, projectId: thread.projectId, title: thread.title, model: resolveModelSlugForProvider( @@ -260,13 +259,9 @@ export function syncServerReadModel( runtimeMode: thread.runtimeMode, session: thread.session ? { - sessionId: - thread.session.providerSessionId ?? - ProviderSessionId.makeUnsafe(`thread:${thread.id}`), provider: toLegacyProvider(thread.session.providerName), status: toLegacySessionStatus(thread.session.status), orchestrationStatus: thread.session.status, - threadId: thread.session.providerThreadId, activeTurnId: thread.session.activeTurnId ?? undefined, createdAt: thread.session.updatedAt, updatedAt: thread.session.updatedAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 85362f0a028332daaee38e2fcdc17ed2e5e56248..dca818cfb8087e6b2dd2250825c2c75d62d5a4e7 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -8,8 +8,6 @@ import type { TurnId, MessageId, CheckpointRef, - ProviderThreadId, - ProviderSessionId, ProviderKind, } from "@t3tools/contracts"; @@ -75,7 +73,7 @@ export interface Project { export interface Thread { id: ThreadId; - codexThreadId: ProviderThreadId | null; + codexThreadId: string | null; projectId: ProjectId; title: string; model: string; @@ -93,10 +91,8 @@ export interface Thread { } export interface ThreadSession { - sessionId: ProviderSessionId; provider: ProviderKind; status: SessionPhase | "error" | "closed"; - threadId: ProviderThreadId | null; activeTurnId?: TurnId | undefined; createdAt: string; updatedAt: string; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 48a5e471160f7089b9c2f0b62644a051d412c84d..24962aed69f0ac6c4d41ae658dcf558827c9e5f8 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -28,12 +28,6 @@ export type MessageId = typeof MessageId.Type; export const TurnId = makeEntityId("TurnId"); export type TurnId = typeof TurnId.Type; -export const ProviderSessionId = makeEntityId("ProviderSessionId"); -export type ProviderSessionId = typeof ProviderSessionId.Type; -export const ProviderThreadId = makeEntityId("ProviderThreadId"); -export type ProviderThreadId = typeof ProviderThreadId.Type; -export const ProviderTurnId = makeEntityId("ProviderTurnId"); -export type ProviderTurnId = typeof ProviderTurnId.Type; export const ProviderItemId = makeEntityId("ProviderItemId"); export type ProviderItemId = typeof ProviderItemId.Type; export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e6dd616ea3b24ba67a744dd4fab73ce8592a15bd..33fa23244d20ae57ef0c007c70ffaf8fb85d6652 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -9,9 +9,6 @@ import { NonNegativeInt, ProjectId, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -35,7 +32,7 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); +export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; @@ -149,8 +146,6 @@ export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(TrimmedNonEmptyString), - providerSessionId: Schema.NullOr(ProviderSessionId), - providerThreadId: Schema.NullOr(ProviderThreadId), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), @@ -637,9 +632,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ }); export const OrchestrationEventMetadata = Schema.Struct({ - providerSessionId: Schema.optional(ProviderSessionId), - providerThreadId: Schema.optional(ProviderThreadId), - providerTurnId: Schema.optional(ProviderTurnId), + providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index fcbbc79338b8b958ec782bf0d83977aae27e3c08..c9d35497ebce86ac7ad1534aed3558f969f38380 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -6,10 +6,8 @@ import { IsoDateTime, NonNegativeInt, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, ThreadId, + TurnId, } from "./baseSchemas"; import { ChatAttachment, @@ -34,15 +32,14 @@ export const ProviderSessionStatus = Schema.Literals([ export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; export const ProviderSession = Schema.Struct({ - sessionId: ProviderSessionId, provider: ProviderKind, status: ProviderSessionStatus, runtimeMode: RuntimeMode, cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), - threadId: Schema.optional(ProviderThreadId), + threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), - activeTurnId: Schema.optional(ProviderTurnId), + activeTurnId: Schema.optional(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, lastError: Schema.optional(TrimmedNonEmptyStringSchema), @@ -75,6 +72,7 @@ export const ProviderStartOptions = Schema.Struct({ export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const ProviderSessionStartInput = Schema.Struct({ + threadId: ThreadId, provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), @@ -85,7 +83,7 @@ export const ProviderSessionStartInput = Schema.Struct({ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, input: Schema.optional( TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), @@ -98,25 +96,25 @@ export const ProviderSendTurnInput = Schema.Struct({ export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; export const ProviderTurnStartResult = Schema.Struct({ - threadId: Schema.optional(ProviderThreadId), - turnId: ProviderTurnId, + threadId: ThreadId, + turnId: TurnId, resumeCursor: Schema.optional(Schema.Unknown), }); export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; export const ProviderInterruptTurnInput = Schema.Struct({ - sessionId: ProviderSessionId, - turnId: Schema.optional(ProviderTurnId), + threadId: ThreadId, + turnId: Schema.optional(TurnId), }); export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; export const ProviderStopSessionInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, }); export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; export const ProviderListCheckpointsInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, }); export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; @@ -137,7 +135,7 @@ export const ProviderListCheckpointsResult = Schema.Struct({ export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; export const ProviderRevertToCheckpointInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, turnCount: NonNegativeInt, }); export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; @@ -152,7 +150,7 @@ export const ProviderRevertToCheckpointResult = Schema.Struct({ export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; export const ProviderGetCheckpointDiffInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, ...TurnCountRange.fields, }); export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; @@ -165,7 +163,7 @@ export const ProviderGetCheckpointDiffResult = Schema.Struct({ export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; export const ProviderRespondToRequestInput = Schema.Struct({ - sessionId: ProviderSessionId, + threadId: ThreadId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, }); @@ -178,12 +176,11 @@ export const ProviderEvent = Schema.Struct({ id: EventId, kind: ProviderEventKind, provider: ProviderKind, - sessionId: ProviderSessionId, + threadId: ThreadId, createdAt: IsoDateTime, method: TrimmedNonEmptyStringSchema, message: Schema.optional(TrimmedNonEmptyStringSchema), - threadId: Schema.optional(ProviderThreadId), - turnId: Schema.optional(ProviderTurnId), + turnId: Schema.optional(TurnId), itemId: Schema.optional(ProviderItemId), requestId: Schema.optional(ApprovalRequestId), requestKind: Schema.optional(ProviderRequestKind), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 88c937b500e7c5f925b09aab5b1272629fde7bd3..c7ce78c5046f6e833b8e013315522d57b32f2af1 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -3,12 +3,8 @@ import { EventId, IsoDateTime, ProviderItemId, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, RuntimeItemId, RuntimeRequestId, - RuntimeSessionId, RuntimeTaskId, ThreadId, TrimmedNonEmptyString, @@ -44,9 +40,7 @@ export const ProviderRequestId = TrimmedNonEmptyStringSchema; export type ProviderRequestId = typeof ProviderRequestId.Type; export const ProviderRefs = Schema.Struct({ - providerSessionId: Schema.optional(ProviderSessionId), - providerThreadId: Schema.optional(ProviderThreadId), - providerTurnId: Schema.optional(ProviderTurnId), + providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema), providerItemId: Schema.optional(ProviderItemId), providerRequestId: Schema.optional(ProviderRequestId), }); @@ -131,6 +125,7 @@ export type CanonicalItemType = typeof CanonicalItemType.Type; export const CanonicalRequestType = Schema.Literals([ "command_execution_approval", + "file_read_approval", "file_change_approval", "apply_patch_approval", "exec_command_approval", @@ -239,9 +234,8 @@ export const RuntimeErrorType = Schema.Literal("runtime.error"); export const ProviderRuntimeEventBase = Schema.Struct({ eventId: EventId, provider: ProviderKind, - sessionId: RuntimeSessionId, + threadId: ThreadId, createdAt: IsoDateTime, - threadId: Schema.optional(ThreadId), turnId: Schema.optional(TurnId), itemId: Schema.optional(RuntimeItemId), requestId: Schema.optional(RuntimeRequestId), @@ -276,7 +270,7 @@ export const SessionExitedPayload = Schema.Struct({ export type SessionExitedPayload = typeof SessionExitedPayload.Type; export const ThreadStartedPayload = Schema.Struct({ - providerThreadId: Schema.optional(ProviderThreadId), + providerThreadId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; @@ -948,7 +942,12 @@ export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolv export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; // Legacy helper aliases retained for adapters/tests. -export const ProviderRuntimeToolKind = Schema.Literals(["command", "file-change", "other"]); +export const ProviderRuntimeToolKind = Schema.Literals([ + "command", + "file-read", + "file-change", + "other", +]); export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; export const ProviderRuntimeTurnStatus = RuntimeTurnState; diff --git a/provider-threadless-runtime-migration-plan.md b/provider-threadless-runtime-migration-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..7743794ec77dc804e7b45481df8a2862a123108a --- /dev/null +++ b/provider-threadless-runtime-migration-plan.md @@ -0,0 +1,281 @@ +# Provider Runtime Refactor Plan + +## Goal + +Remove `providerSessionId` and `providerThreadId` from the core application model and make +provider continuity depend only on: + +- canonical `threadId` +- provider kind +- runtime mode +- opaque `resumeCursor` + +Live provider session/process state becomes ephemeral and provider-private. + +## Desired Architecture + +### Durable state + +Persist only the data required to reconstruct a provider conversation after process restart: + +- `threadId` +- `provider` +- `runtimeMode` +- `resumeCursor` (opaque) +- `status` +- `activeTurnId` / `lastError` / `updatedAt` if needed for UX +- optional provider runtime payload only if it is required for resume or useful UX + +Do **not** persist: + +- `providerSessionId` +- `providerThreadId` +- live child-process/session handles +- adapter-private routing aliases + +### Ephemeral state + +Each adapter keeps an in-memory map keyed by canonical `threadId`: + +- `threadId -> live provider context` + +Examples: + +- Codex child process context +- Cursor ACP live session +- Claude Code live subprocess context + +These are disposable and rebuilt from persisted `resumeCursor`. + +### Observability + +Provider-native identifiers should live in logs/runtime events only, not in the canonical DB model. + +Examples: + +- Codex thread id +- Cursor ACP thread/session id +- Claude conversation/thread id + +## High-Level Refactor Sequence + +### 1. Contracts cleanup + +Remove provider identity fields from shared contracts: + +- `packages/contracts/src/provider.ts` +- `packages/contracts/src/orchestration.ts` +- `packages/contracts/src/providerRuntime.ts` +- related tests + +Planned changes: + +- remove `ProviderSessionId` from cross-layer service payloads where possible +- remove `providerSessionId` / `providerThreadId` from thread session snapshot contracts +- keep `resumeCursor` opaque + +### 2. Redefine thread runtime/session projection model + +Replace thread session projection as a provider-identity carrier with a pure UX/runtime status view. + +Possible projected shape: + +- `status` +- `providerName` +- `runtimeMode` +- `activeTurnId` +- `lastError` +- `updatedAt` + +No provider-native ids in the projection. + +Files likely affected: + +- `apps/server/src/persistence/Services/ProjectionThreadSessions.ts` +- `apps/server/src/persistence/Layers/ProjectionThreadSessions.ts` +- `apps/server/src/orchestration/projector.ts` +- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` +- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` +- `apps/web/src/store.ts` + +### 3. Make provider service thread-keyed + +Refactor provider service APIs to route by canonical `threadId`, not `providerSessionId`. + +Methods to change: + +- `startSession` +- `sendTurn` +- `interruptTurn` +- `respondToRequest` +- `rollbackConversation` +- `stopSession` / equivalent +- `listSessions` + +New mental model: + +- orchestration calls provider service with `threadId` +- provider service resolves/creates live adapter context for that canonical thread +- adapter handles provider-native resume semantics internally + +Files likely affected: + +- `apps/server/src/provider/Services/ProviderService.ts` +- `apps/server/src/provider/Layers/ProviderService.ts` +- `apps/server/src/provider/Services/ProviderAdapter.ts` +- all adapter implementations/tests + +### 4. Replace session directory with thread-keyed runtime directory + +Current `ProviderSessionDirectory` is centered on session ids. +Replace with a thread-keyed directory/repository. + +Persist per canonical thread: + +- `threadId` +- `provider` +- `runtimeMode` +- `resumeCursor` +- `status` +- optional runtime payload + +Likely replacement: + +- `ProviderThreadRuntimeDirectory` or similar + +Files likely affected: + +- `apps/server/src/provider/Services/ProviderSessionDirectory.ts` +- `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` +- `apps/server/src/persistence/Services/ProviderSessionRuntime.ts` +- `apps/server/src/persistence/Layers/ProviderSessionRuntime.ts` + +### 5. Database migration + +Perform a clean-slate schema migration to remove provider ids from persistence. + +Targets: + +- remove `provider_session_id` +- remove `provider_thread_id` +- make canonical thread id the primary runtime key where appropriate + +Likely tables impacted: + +- `provider_session_runtime` +- `projection_thread_sessions` + +Because SQLite column removal is awkward, and app is still early. A destructive migration is preferred. +Existing users will need to reset their db so migrations run from scratch. + +Migration should also preserve: + +- `resume_cursor_json` +- `runtime_mode` +- `runtime_payload_json` +- status metadata + +### 6. Orchestration refactor + +Remove provider-id assumptions from orchestration. + +Affected areas: + +- `ProviderCommandReactor` +- `ProviderRuntimeIngestion` +- `CheckpointReactor` +- decider/projector event payloads + +Rules: + +- orchestration should only know canonical `threadId` +- restart/reconcile should use provider service APIs keyed by canonical thread +- no orchestration logic should depend on provider-native ids + +### 7. Adapter refactor + +Each adapter should: + +- keep a `Map` (LiveContext is a provider-specific context object, typed independently for each adapter) +- use persisted opaque `resumeCursor` to recreate live state +- emit provider-native ids only in runtime/log events if desired + +Specific adapters: + +- `CodexAdapter` +- `CursorAdapter` +- `ClaudeCodeAdapter` + +### 8. Runtime events and logs + +Keep provider-native ids in logs and runtime events only when useful for debugging. + +Need to review: + +- `packages/contracts/src/providerRuntime.ts` +- `apps/server/src/provider/Layers/*Adapter.ts` +- log sinks / canonical event logging + +Goal: + +- provider-native ids optional and observational +- not required by orchestration/persistence correctness + +### 9. Web/client cleanup + +Web should consume only canonical thread status. + +Remove use of: + +- `providerSessionId` +- `providerThreadId` + +Likely files: + +- `apps/web/src/store.ts` +- `apps/web/src/types.ts` +- related tests + +### 10. Test migration + +Update and simplify tests across: + +- provider service tests +- orchestration tests +- integration harnesses +- checkpoint/runtime ingestion tests + +Tests should validate: + +- thread continuity across restart using persisted opaque `resumeCursor` +- runtime mode switches after reopen +- no correctness dependency on provider-native ids + +## Suggested Execution Order + +1. Add/adjust plan-approved contracts +2. Introduce new thread-keyed provider runtime persistence alongside old code temporarily in implementation +3. Refactor provider service/adapters to use canonical `threadId` +4. Switch orchestration to thread-keyed APIs +5. Switch projections/web contracts +6. Remove old provider-id fields and dead code +7. Run migration and clean tests + +## Key Invariants After Refactor + +- Canonical `threadId` is the only cross-layer routing key +- `resumeCursor` is the only persisted continuity primitive +- provider-native ids are never required for correctness +- live provider session/process state is always reconstructible and disposable +- restart/reopen behavior is first-class, not a stale-session special case + +## Open Questions + +1. Should `thread.session` remain in snapshots at all, or be folded into thread runtime fields? +2. Should provider runtime payload remain persisted if not required for resume? +3. Do we want one provider runtime row per canonical thread, or a more generic runtime-state table keyed by thread? + +## Recommendation + +Proceed with a full clean-slate migration rather than incremental compatibility shims. +The current model is carrying too much provider-native identity into layers that should only reason about canonical threads and opaque resume state.