authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 23:49:31-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log26f631b638205506d292c829feaf5d539e05804d
tree000a395457cb18a291b90b407b4f5b95ef5c318b
parent981cf405612932afa8a0b98b8c98474ef7e1015d

Migrate provider runtime/session flow to thread-scoped IDs

- Replace provider session-id centric plumbing with thread-id based runtime handling across server orchestration and provider layers - Update persistence repositories/migrations and contracts for threadless runtime semantics - Refresh integration/unit tests and web session/store types to match the new runtime model

54 files changed, 1865 insertions(+), 2665 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+233-235
...@@ -196,257 +196,255 @@ export const makeOrchestrationIntegrationHarness = (...@@ -196,257 +196,255 @@ export const makeOrchestrationIntegrationHarness = (
196 options?: MakeOrchestrationIntegrationHarnessOptions,196 options?: MakeOrchestrationIntegrationHarnessOptions,
197) =>197) =>
198 Effect.gen(function* () {198 Effect.gen(function* () {
199 const sleep = (ms: number) => Effect.sleep(ms);199 const sleep = (ms: number) => Effect.sleep(ms);
200 const provider = options?.provider ?? "codex";200 const provider = options?.provider ?? "codex";
201 const useRealCodex = options?.realCodex === true;201 const useRealCodex = options?.realCodex === true;
202 const adapterHarness = useRealCodex202 const adapterHarness = useRealCodex
203 ? null203 ? null
204 : yield* makeTestProviderAdapterHarness({204 : yield* makeTestProviderAdapterHarness({
205 provider,205 provider,
206 });206 });
207 const fakeRegistry = adapterHarness207 const fakeRegistry = adapterHarness
208 ? Layer.succeed(ProviderAdapterRegistry, {208 ? Layer.succeed(ProviderAdapterRegistry, {
209 getByProvider: (resolvedProvider) =>209 getByProvider: (resolvedProvider) =>
210 resolvedProvider === adapterHarness.provider210 resolvedProvider === adapterHarness.provider
211 ? Effect.succeed(adapterHarness.adapter)211 ? Effect.succeed(adapterHarness.adapter)
212 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),212 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
213 listProviders: () => Effect.succeed([adapterHarness.provider]),213 listProviders: () => Effect.succeed([adapterHarness.provider]),
214 } as typeof ProviderAdapterRegistry.Service)214 } as typeof ProviderAdapterRegistry.Service)
215 : null;215 : null;
216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
217 const workspaceDir = path.join(rootDir, "workspace");217 const workspaceDir = path.join(rootDir, "workspace");
218 const stateDir = path.join(rootDir, "state");218 const stateDir = path.join(rootDir, "state");
219 const dbPath = path.join(stateDir, "state.sqlite");219 const dbPath = path.join(stateDir, "state.sqlite");
220 fs.mkdirSync(workspaceDir, { recursive: true });220 fs.mkdirSync(workspaceDir, { recursive: true });
221 fs.mkdirSync(stateDir, { recursive: true });221 fs.mkdirSync(stateDir, { recursive: true });
222 initializeGitWorkspace(workspaceDir);222 initializeGitWorkspace(workspaceDir);
223223
224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
225 const orchestrationLayer = OrchestrationEngineLive.pipe(225 const orchestrationLayer = OrchestrationEngineLive.pipe(
226 Layer.provide(OrchestrationProjectionPipelineLive),226 Layer.provide(OrchestrationProjectionPipelineLive),
227 Layer.provide(OrchestrationEventStoreLive),227 Layer.provide(OrchestrationEventStoreLive),
228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
229 );229 );
230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
231 Layer.provide(ProviderSessionRuntimeRepositoryLive),231 Layer.provide(ProviderSessionRuntimeRepositoryLive),
232 );232 );
233 const realCodexRegistry = Layer.effect(233 const realCodexRegistry = Layer.effect(
234 ProviderAdapterRegistry,234 ProviderAdapterRegistry,
235 Effect.gen(function* () {235 Effect.gen(function* () {
236 const codexAdapter = yield* CodexAdapter;236 const codexAdapter = yield* CodexAdapter;
237 return {237 return {
238 getByProvider: (resolvedProvider) =>238 getByProvider: (resolvedProvider) =>
239 resolvedProvider === "codex"239 resolvedProvider === "codex"
240 ? Effect.succeed(codexAdapter)240 ? Effect.succeed(codexAdapter)
241 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),241 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
242 listProviders: () => Effect.succeed(["codex"] as const),242 listProviders: () => Effect.succeed(["codex"] as const),
243 } as typeof ProviderAdapterRegistry.Service;243 } as typeof ProviderAdapterRegistry.Service;
244 }),244 }),
245 ).pipe(245 ).pipe(
246 Layer.provide(makeCodexAdapterLive()),246 Layer.provide(makeCodexAdapterLive()),
247 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),247 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
248 Layer.provideMerge(NodeServices.layer),248 Layer.provideMerge(NodeServices.layer),
249 Layer.provideMerge(providerSessionDirectoryLayer),249 Layer.provideMerge(providerSessionDirectoryLayer),
250 );250 );
251 const providerLayer = useRealCodex251 const providerLayer = useRealCodex
252 ? makeProviderServiceLive().pipe(252 ? makeProviderServiceLive().pipe(
253 Layer.provide(providerSessionDirectoryLayer),253 Layer.provide(providerSessionDirectoryLayer),
254 Layer.provide(realCodexRegistry),254 Layer.provide(realCodexRegistry),
255 )255 )
256 : makeProviderServiceLive().pipe(256 : makeProviderServiceLive().pipe(
257 Layer.provide(providerSessionDirectoryLayer),257 Layer.provide(providerSessionDirectoryLayer),
258 Layer.provide(fakeRegistry!),258 Layer.provide(fakeRegistry!),
259 );259 );
260260
261 const runtimeServicesLayer = Layer.mergeAll(261 const runtimeServicesLayer = Layer.mergeAll(
262 orchestrationLayer,262 orchestrationLayer,
263 OrchestrationProjectionSnapshotQueryLive,263 OrchestrationProjectionSnapshotQueryLive,
264 ProjectionCheckpointRepositoryLive,264 ProjectionCheckpointRepositoryLive,
265 ProjectionPendingApprovalRepositoryLive,265 ProjectionPendingApprovalRepositoryLive,
266 CheckpointStoreLive,266 CheckpointStoreLive,
267 providerLayer,267 providerLayer,
268 );268 );
269 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(269 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
270 Layer.provideMerge(runtimeServicesLayer),270 Layer.provideMerge(runtimeServicesLayer),
271 );271 );
272 const gitCoreLayer = Layer.succeed(GitCore, {272 const gitCoreLayer = Layer.succeed(GitCore, {
273 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>273 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
274 Effect.succeed({ branch: input.newBranch }),274 Effect.succeed({ branch: input.newBranch }),
275 } as unknown as GitCoreShape);275 } as unknown as GitCoreShape);
276 const textGenerationLayer = Layer.succeed(TextGeneration, {276 const textGenerationLayer = Layer.succeed(TextGeneration, {
277 generateBranchName: () => Effect.succeed({ branch: null }),277 generateBranchName: () => Effect.succeed({ branch: null }),
278 } as unknown as TextGenerationShape);278 } as unknown as TextGenerationShape);
279 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(279 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
280 Layer.provideMerge(runtimeServicesLayer),280 Layer.provideMerge(runtimeServicesLayer),
281 Layer.provideMerge(gitCoreLayer),281 Layer.provideMerge(gitCoreLayer),
282 Layer.provideMerge(textGenerationLayer),282 Layer.provideMerge(textGenerationLayer),
283 );283 );
284 const checkpointReactorLayer = CheckpointReactorLive.pipe(284 const checkpointReactorLayer = CheckpointReactorLive.pipe(
285 Layer.provideMerge(runtimeServicesLayer),285 Layer.provideMerge(runtimeServicesLayer),
286 );286 );
287 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(287 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
288 Layer.provideMerge(runtimeIngestionLayer),288 Layer.provideMerge(runtimeIngestionLayer),
289 Layer.provideMerge(providerCommandReactorLayer),289 Layer.provideMerge(providerCommandReactorLayer),
290 Layer.provideMerge(checkpointReactorLayer),290 Layer.provideMerge(checkpointReactorLayer),
291 );291 );
292 const layer = orchestrationReactorLayer.pipe(292 const layer = orchestrationReactorLayer.pipe(
293 Layer.provide(persistenceLayer),293 Layer.provide(persistenceLayer),
294 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),294 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
295 Layer.provideMerge(NodeServices.layer),295 Layer.provideMerge(NodeServices.layer),
296 );296 );
297297
298 const runtime = ManagedRuntime.make(layer);298 const runtime = ManagedRuntime.make(layer);
299 const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () =>299 const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () =>
300 runtime.runPromise(Effect.service(OrchestrationEngineService)),300 runtime.runPromise(Effect.service(OrchestrationEngineService)),
301 ).pipe(Effect.orDie);301 ).pipe(Effect.orDie);
302 const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>302 const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>
303 runtime.runPromise(Effect.service(OrchestrationReactor)),303 runtime.runPromise(Effect.service(OrchestrationReactor)),
304 ).pipe(Effect.orDie);304 ).pipe(Effect.orDie);
305 const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>305 const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>
306 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),306 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
307 ).pipe(Effect.orDie);307 ).pipe(Effect.orDie);
308 const providerService = yield* tryRuntimePromise("load ProviderService service", () =>308 const providerService = yield* tryRuntimePromise("load ProviderService service", () =>
309 runtime.runPromise(Effect.service(ProviderService)),309 runtime.runPromise(Effect.service(ProviderService)),
310 ).pipe(Effect.orDie);310 ).pipe(Effect.orDie);
311 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>311 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
312 runtime.runPromise(Effect.service(CheckpointStore)),312 runtime.runPromise(Effect.service(CheckpointStore)),
313 ).pipe(Effect.orDie);313 ).pipe(Effect.orDie);
314 const checkpointRepository = yield* tryRuntimePromise(314 const checkpointRepository = yield* tryRuntimePromise(
315 "load ProjectionCheckpointRepository service",315 "load ProjectionCheckpointRepository service",
316 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),316 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
317 ).pipe(Effect.orDie);317 ).pipe(Effect.orDie);
318 const pendingApprovalRepository = yield* tryRuntimePromise(318 const pendingApprovalRepository = yield* tryRuntimePromise(
319 "load ProjectionPendingApprovalRepository service",319 "load ProjectionPendingApprovalRepository service",
320 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),320 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
321 ).pipe(Effect.orDie);321 ).pipe(Effect.orDie);
322322
323 const scope = yield* Scope.make("sequential");323 const scope = yield* Scope.make("sequential");
324 yield* tryRuntimePromise("start OrchestrationReactor", () =>324 yield* tryRuntimePromise("start OrchestrationReactor", () =>
325 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),325 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),
326 ).pipe(Effect.orDie);326 ).pipe(Effect.orDie);
327 yield* sleep(10);327 yield* sleep(10);
328328
329 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (329 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
330 threadId,330 threadId,
331 predicate,331 predicate,
332 timeoutMs,
333 ) =>
334 waitFor(
335 snapshotQuery
336 .getSnapshot()
337 .pipe(
338 Effect.map(
339 (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null,
340 ),
341 ),
342 (thread): thread is OrchestrationThread => thread !== null && predicate(thread),
343 `projected thread '${threadId}'`,
344 timeoutMs,332 timeoutMs,
345 ) as Effect.Effect<OrchestrationThread, never>;333 ) =>
334 waitFor(
335 snapshotQuery
336 .getSnapshot()
337 .pipe(
338 Effect.map(
339 (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null,
340 ),
341 ),
342 (thread): thread is OrchestrationThread => thread !== null && predicate(thread),
343 `projected thread '${threadId}'`,
344 timeoutMs,
345 ) as Effect.Effect<OrchestrationThread, never>;
346346
347 const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = (347 const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = (
348 predicate,348 predicate,
349 timeoutMs,
350 ) =>
351 waitFor(
352 Stream.runCollect(engine.readEvents(0)).pipe(
353 Effect.map((chunk): ReadonlyArray<OrchestrationEvent> => Array.from(chunk)),
354 ),
355 (events) => events.some(predicate),
356 "domain event",
357 timeoutMs,349 timeoutMs,
358 );350 ) =>
351 waitFor(
352 Stream.runCollect(engine.readEvents(0)).pipe(
353 Effect.map((chunk): ReadonlyArray<OrchestrationEvent> => Array.from(chunk)),
354 ),
355 (events) => events.some(predicate),
356 "domain event",
357 timeoutMs,
358 );
359359
360 const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = (360 const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = (
361 requestId,361 requestId,
362 predicate,362 predicate,
363 timeoutMs,363 timeoutMs,
364 ) =>364 ) =>
365 waitFor(365 waitFor(
366 pendingApprovalRepository366 pendingApprovalRepository
367 .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })367 .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })
368 .pipe(368 .pipe(
369 Effect.map((row) =>369 Effect.map((row) =>
370 Option.match(row, {370 Option.match(row, {
371 onNone: () => null,371 onNone: () => null,
372 onSome: (value) => ({372 onSome: (value) => ({
373 status: value.status,373 status: value.status,
374 decision: value.decision,374 decision: value.decision,
375 resolvedAt: value.resolvedAt,375 resolvedAt: value.resolvedAt,
376 }),
376 }),377 }),
377 }),378 ),
378 ),379 ),
379 ),380 (
380 (381 row,
381 row,382 ): row is {
382 ): row is {383 readonly status: "pending" | "resolved";
383 readonly status: "pending" | "resolved";384 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
384 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;385 readonly resolvedAt: string | null;
385 readonly resolvedAt: string | null;386 } => row !== null && predicate(row),
386 } => row !== null && predicate(row),387 `pending approval '${requestId}'`,
387 `pending approval '${requestId}'`,388 timeoutMs,
388 timeoutMs,389 ) as Effect.Effect<
389 ) as Effect.Effect<390 {
390 {391 readonly status: "pending" | "resolved";
391 readonly status: "pending" | "resolved";392 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
392 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;393 readonly resolvedAt: string | null;
393 readonly resolvedAt: string | null;394 },
394 },395 never
395 never396 >;
396 >;
397397
398 let disposed = false;398 let disposed = false;
399 const dispose = Effect.gen(function* () {399 const dispose = Effect.gen(function* () {
400 if (disposed) {400 if (disposed) {
401 return;401 return;
402 }402 }
403 disposed = true;403 disposed = true;
404404
405 const shutdown = Effect.gen(function* () {405 const shutdown = Effect.gen(function* () {
406 const stopAllExit = yield* Effect.exit(406 const stopAllExit = yield* Effect.exit(
407 Effect.promise(() => runtime.runPromise(providerService.stopAll())),407 Effect.promise(() => runtime.runPromise(providerService.stopAll())),
408 );408 );
409 const closeScopeExit = yield* Effect.exit(409 const closeScopeExit = yield* Effect.exit(Scope.close(scope, Exit.void));
410 Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))),410 const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose()));
411 );
412 const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose()));
413411
414 const failureCause = Exit.isFailure(stopAllExit)412 const failureCause = Exit.isFailure(stopAllExit)
415 ? stopAllExit.cause413 ? stopAllExit.cause
416 : Exit.isFailure(closeScopeExit)414 : Exit.isFailure(closeScopeExit)
417 ? closeScopeExit.cause415 ? closeScopeExit.cause
418 : Exit.isFailure(disposeRuntimeExit)416 : Exit.isFailure(disposeRuntimeExit)
419 ? disposeRuntimeExit.cause417 ? disposeRuntimeExit.cause
420 : null;418 : null;
421419
422 if (failureCause) {420 if (failureCause) {
423 return yield* Effect.failCause(failureCause);421 return yield* Effect.failCause(failureCause);
424 }422 }
425 });423 });
426424
427 yield* shutdown.pipe(425 yield* shutdown.pipe(
428 Effect.ensuring(426 Effect.ensuring(
429 Effect.sync(() => {427 Effect.sync(() => {
430 fs.rmSync(rootDir, { recursive: true, force: true });428 fs.rmSync(rootDir, { recursive: true, force: true });
431 }),429 }),
432 ),430 ),
433 );431 );
434 });432 });
435433
436 return {434 return {
437 rootDir,435 rootDir,
438 workspaceDir,436 workspaceDir,
439 dbPath,437 dbPath,
440 adapterHarness: adapterHarness as TestProviderAdapterHarness,438 adapterHarness: adapterHarness as TestProviderAdapterHarness,
441 engine,439 engine,
442 snapshotQuery,440 snapshotQuery,
443 providerService,441 providerService,
444 checkpointStore,442 checkpointStore,
445 checkpointRepository,443 checkpointRepository,
446 pendingApprovalRepository,444 pendingApprovalRepository,
447 waitForThread,445 waitForThread,
448 waitForDomainEvent,446 waitForDomainEvent,
449 waitForPendingApproval,447 waitForPendingApproval,
450 dispose,448 dispose,
451 } satisfies OrchestrationIntegrationHarness;449 } satisfies OrchestrationIntegrationHarness;
452});450 });
apps/server/integration/TestProviderAdapter.integration.ts+60-69
...@@ -7,9 +7,6 @@ import {...@@ -7,9 +7,6 @@ import {
7 ProviderRuntimeEvent,7 ProviderRuntimeEvent,
8 RuntimeSessionId,8 RuntimeSessionId,
9 ProviderSession,9 ProviderSession,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
13 ProviderTurnStartResult,10 ProviderTurnStartResult,
14 ThreadId,11 ThreadId,
15 TurnId,12 TurnId,
...@@ -39,9 +36,8 @@ export type FixtureProviderRuntimeEvent = {...@@ -39,9 +36,8 @@ export type FixtureProviderRuntimeEvent = {
39 readonly type: string;36 readonly type: string;
40 readonly eventId: EventId;37 readonly eventId: EventId;
41 readonly provider: "codex" | "claudeCode" | "cursor";38 readonly provider: "codex" | "claudeCode" | "cursor";
42 readonly sessionId: string;
43 readonly createdAt: string;39 readonly createdAt: string;
44 readonly threadId?: string | undefined;40 readonly threadId: string;
45 readonly turnId?: string | undefined;41 readonly turnId?: string | undefined;
46 readonly itemId?: string | undefined;42 readonly itemId?: string | undefined;
47 readonly requestId?: string | undefined;43 readonly requestId?: string | undefined;
...@@ -184,18 +180,18 @@ export interface TestProviderAdapterHarness {...@@ -184,18 +180,18 @@ export interface TestProviderAdapterHarness {
184 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
185 readonly provider: "codex" | "claudeCode";181 readonly provider: "codex" | "claudeCode";
186 readonly queueTurnResponse: (182 readonly queueTurnResponse: (
187 sessionId: string,183 threadId: ThreadId,
188 response: TestTurnResponse,184 response: TestTurnResponse,
189 ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>;185 ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>;
190 readonly queueTurnResponseForNextSession: (186 readonly queueTurnResponseForNextSession: (
191 response: TestTurnResponse,187 response: TestTurnResponse,
192 ) => Effect.Effect<void, never>;188 ) => Effect.Effect<void, never>;
193 readonly getStartCount: () => number;189 readonly getStartCount: () => number;
194 readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>;190 readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray<number>;
195 readonly getInterruptCalls: (sessionId: string) => ReadonlyArray<ProviderTurnId | undefined>;191 readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray<TurnId | undefined>;
196 readonly listActiveSessionIds: () => ReadonlyArray<ProviderSessionId>;192 readonly listActiveSessionIds: () => ReadonlyArray<ThreadId>;
197 readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{193 readonly getApprovalResponses: (threadId: ThreadId) => ReadonlyArray<{
198 readonly sessionId: ProviderSessionId;194 readonly threadId: ThreadId;
199 readonly requestId: ApprovalRequestId;195 readonly requestId: ApprovalRequestId;
200 readonly decision: ProviderApprovalDecision;196 readonly decision: ProviderApprovalDecision;
201 }>;197 }>;
...@@ -211,19 +207,19 @@ function nowIso(): string {...@@ -211,19 +207,19 @@ function nowIso(): string {
211207
212function sessionNotFound(208function sessionNotFound(
213 provider: "codex" | "claudeCode",209 provider: "codex" | "claudeCode",
214 sessionId: string,210 threadId: ThreadId,
215): ProviderAdapterSessionNotFoundError {211): ProviderAdapterSessionNotFoundError {
216 return new ProviderAdapterSessionNotFoundError({212 return new ProviderAdapterSessionNotFoundError({
217 provider,213 provider,
218 sessionId,214 threadId: String(threadId),
219 });215 });
220}216}
221217
222function missingSessionEffect(218function missingSessionEffect(
223 provider: "codex" | "claudeCode",219 provider: "codex" | "claudeCode",
224 sessionId: string,220 threadId: ThreadId,
225): Effect.Effect<never, ProviderAdapterError> {221): Effect.Effect<never, ProviderAdapterError> {
226 return Effect.fail(sessionNotFound(provider, sessionId));222 return Effect.fail(sessionNotFound(provider, threadId));
227}223}
228224
229export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>225export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
...@@ -231,13 +227,13 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -231,13 +227,13 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
231 const provider = options?.provider ?? "codex";227 const provider = options?.provider ?? "codex";
232 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();228 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
233 let sessionCount = 0;229 let sessionCount = 0;
234 const sessions = new Map<string, SessionState>();230 const sessions = new Map<ThreadId, SessionState>();
235 const queuedResponsesForNextSession: TestTurnResponse[] = [];231 const queuedResponsesForNextSession: TestTurnResponse[] = [];
236 const interruptCallsBySession = new Map<string, Array<ProviderTurnId | undefined>>();232 const interruptCallsBySession = new Map<ThreadId, Array<TurnId | undefined>>();
237 const approvalResponsesBySession = new Map<233 const approvalResponsesBySession = new Map<
238 string,234 ThreadId,
239 Array<{235 Array<{
240 readonly sessionId: ProviderSessionId;236 readonly threadId: ThreadId;
241 readonly requestId: ApprovalRequestId;237 readonly requestId: ApprovalRequestId;
242 readonly decision: ProviderApprovalDecision;238 readonly decision: ProviderApprovalDecision;
243 }>239 }>
...@@ -256,23 +252,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -256,23 +252,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
256 }252 }
257253
258 sessionCount += 1;254 sessionCount += 1;
259 const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`);255 const threadId = input.threadId;
260 const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`);
261 const createdAt = nowIso();256 const createdAt = nowIso();
262257
263 const session: ProviderSession = {258 const session: ProviderSession = {
264 sessionId,
265 provider,259 provider,
266 status: "ready",260 status: "ready",
267 runtimeMode: input.runtimeMode,261 runtimeMode: input.runtimeMode,
268 threadId,262 threadId,
269 cwd: input.cwd,263 cwd: input.cwd,
270 resumeCursor: input.resumeCursor ?? { sessionId },264 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
271 createdAt,265 createdAt,
272 updatedAt: createdAt,266 updatedAt: createdAt,
273 };267 };
274268
275 sessions.set(sessionId, {269 sessions.set(threadId, {
276 session,270 session,
277 snapshot: {271 snapshot: {
278 threadId,272 threadId,
...@@ -288,21 +282,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -288,21 +282,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
288282
289 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>283 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>
290 Effect.gen(function* () {284 Effect.gen(function* () {
291 const state = sessions.get(input.sessionId);285 const state = sessions.get(input.threadId);
292 if (!state) {286 if (!state) {
293 return yield* missingSessionEffect(provider, input.sessionId);287 return yield* missingSessionEffect(provider, input.threadId);
294 }288 }
295289
296 state.turnCount += 1;290 state.turnCount += 1;
297 const turnCount = state.turnCount;291 const turnCount = state.turnCount;
298 const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`);292 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
299293
300 const response = state.queuedResponses.shift();294 const response = state.queuedResponses.shift();
301 if (!response) {295 if (!response) {
302 return yield* new ProviderAdapterValidationError({296 return yield* new ProviderAdapterValidationError({
303 provider,297 provider,
304 operation: "sendTurn",298 operation: "sendTurn",
305 issue: `No queued turn response for session ${input.sessionId}.`,299 issue: `No queued turn response for thread ${input.threadId}.`,
306 });300 });
307 }301 }
308302
...@@ -313,14 +307,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -313,14 +307,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
313 ...(fixtureEvent as Record<string, unknown>),307 ...(fixtureEvent as Record<string, unknown>),
314 eventId: randomUUID(),308 eventId: randomUUID(),
315 provider,309 provider,
316 sessionId: RuntimeSessionId.makeUnsafe(input.sessionId),310 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
317 createdAt: nowIso(),311 createdAt: nowIso(),
318 };312 };
319 if (Object.hasOwn(rawEvent, "threadId")) {313 rawEvent.threadId = state.snapshot.threadId;
320 rawEvent.threadId = ThreadId.makeUnsafe(state.snapshot.threadId);
321 }
322 if (Object.hasOwn(rawEvent, "turnId")) {314 if (Object.hasOwn(rawEvent, "turnId")) {
323 rawEvent.turnId = TurnId.makeUnsafe(turnId);315 rawEvent.turnId = turnId;
324 }316 }
325317
326 const runtimeEvent = normalizeFixtureEvent(rawEvent);318 const runtimeEvent = normalizeFixtureEvent(rawEvent);
...@@ -373,10 +365,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -373,10 +365,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
373 type: "turn.completed",365 type: "turn.completed",
374 eventId: EventId.makeUnsafe(randomUUID()),366 eventId: EventId.makeUnsafe(randomUUID()),
375 provider,367 provider,
376 sessionId: RuntimeSessionId.makeUnsafe(input.sessionId),
377 createdAt: nowIso(),368 createdAt: nowIso(),
378 threadId: ThreadId.makeUnsafe(state.snapshot.threadId),369 threadId: state.snapshot.threadId,
379 turnId: TurnId.makeUnsafe(turnId),370 turnId,
380 payload: {371 payload: {
381 state: "completed",372 state: "completed",
382 },373 },
...@@ -394,60 +385,60 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -394,60 +385,60 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
394 });385 });
395386
396 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (387 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
397 sessionId,388 threadId,
398 turnId,389 turnId,
399 ) =>390 ) =>
400 sessions.has(sessionId)391 sessions.has(threadId)
401 ? Effect.sync(() => {392 ? Effect.sync(() => {
402 const existing = interruptCallsBySession.get(sessionId) ?? [];393 const existing = interruptCallsBySession.get(threadId) ?? [];
403 existing.push(turnId);394 existing.push(turnId);
404 interruptCallsBySession.set(sessionId, existing);395 interruptCallsBySession.set(threadId, existing);
405 })396 })
406 : missingSessionEffect(provider, sessionId);397 : missingSessionEffect(provider, threadId);
407398
408 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (399 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
409 sessionId,400 threadId,
410 requestId,401 requestId,
411 decision,402 decision,
412 ) =>403 ) =>
413 sessions.has(sessionId)404 sessions.has(threadId)
414 ? Effect.sync(() => {405 ? Effect.sync(() => {
415 const existing = approvalResponsesBySession.get(sessionId) ?? [];406 const existing = approvalResponsesBySession.get(threadId) ?? [];
416 existing.push({407 existing.push({
417 sessionId,408 threadId,
418 requestId,409 requestId,
419 decision,410 decision,
420 });411 });
421 approvalResponsesBySession.set(sessionId, existing);412 approvalResponsesBySession.set(threadId, existing);
422 })413 })
423 : missingSessionEffect(provider, sessionId);414 : missingSessionEffect(provider, threadId);
424415
425 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>416 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
426 Effect.sync(() => {417 Effect.sync(() => {
427 sessions.delete(sessionId);418 sessions.delete(threadId);
428 });419 });
429420
430 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>421 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>
431 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));422 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
432423
433 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (sessionId) =>424 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
434 Effect.succeed(sessions.has(sessionId));425 Effect.succeed(sessions.has(threadId));
435426
436 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => {427 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
437 const state = sessions.get(sessionId);428 const state = sessions.get(threadId);
438 if (!state) {429 if (!state) {
439 return missingSessionEffect(provider, sessionId);430 return missingSessionEffect(provider, threadId);
440 }431 }
441 return Effect.succeed(state.snapshot);432 return Effect.succeed(state.snapshot);
442 };433 };
443434
444 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (435 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
445 sessionId,436 threadId,
446 numTurns,437 numTurns,
447 ) => {438 ) => {
448 const state = sessions.get(sessionId);439 const state = sessions.get(threadId);
449 if (!state) {440 if (!state) {
450 return missingSessionEffect(provider, sessionId);441 return missingSessionEffect(provider, threadId);
451 }442 }
452 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {443 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
453 return Effect.fail(444 return Effect.fail(
...@@ -494,16 +485,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -494,16 +485,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
494 };485 };
495486
496 const queueTurnResponse = (487 const queueTurnResponse = (
497 sessionId: string,488 threadId: ThreadId,
498 response: TestTurnResponse,489 response: TestTurnResponse,
499 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>490 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>
500 Effect.sync(() => sessions.get(sessionId)).pipe(491 Effect.sync(() => sessions.get(threadId)).pipe(
501 Effect.flatMap((state) =>492 Effect.flatMap((state) =>
502 state493 state
503 ? Effect.sync(() => {494 ? Effect.sync(() => {
504 state.queuedResponses.push(response);495 state.queuedResponses.push(response);
505 })496 })
506 : Effect.fail(sessionNotFound(provider, sessionId)),497 : Effect.fail(sessionNotFound(provider, threadId)),
507 ),498 ),
508 );499 );
509500
...@@ -514,8 +505,8 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -514,8 +505,8 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
514 queuedResponsesForNextSession.push(response);505 queuedResponsesForNextSession.push(response);
515 });506 });
516507
517 const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => {508 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
518 const state = sessions.get(sessionId);509 const state = sessions.get(threadId);
519 if (!state) {510 if (!state) {
520 return [];511 return [];
521 }512 }
...@@ -524,25 +515,25 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -524,25 +515,25 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
524515
525 const getStartCount = (): number => sessionCount;516 const getStartCount = (): number => sessionCount;
526517
527 const getInterruptCalls = (sessionId: string): ReadonlyArray<ProviderTurnId | undefined> => {518 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
528 const calls = interruptCallsBySession.get(sessionId);519 const calls = interruptCallsBySession.get(threadId);
529 if (!calls) {520 if (!calls) {
530 return [];521 return [];
531 }522 }
532 return [...calls];523 return [...calls];
533 };524 };
534525
535 const listActiveSessionIds = (): ReadonlyArray<ProviderSessionId> =>526 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
536 Array.from(sessions.values(), (state) => state.session.sessionId);527 Array.from(sessions.values(), (state) => state.session.threadId);
537528
538 const getApprovalResponses = (529 const getApprovalResponses = (
539 sessionId: string,530 threadId: ThreadId,
540 ): ReadonlyArray<{531 ): ReadonlyArray<{
541 readonly sessionId: ProviderSessionId;532 readonly threadId: ThreadId;
542 readonly requestId: ApprovalRequestId;533 readonly requestId: ApprovalRequestId;
543 readonly decision: ProviderApprovalDecision;534 readonly decision: ProviderApprovalDecision;
544 }> => {535 }> => {
545 const responses = approvalResponsesBySession.get(sessionId);536 const responses = approvalResponsesBySession.get(threadId);
546 if (!responses) {537 if (!responses) {
547 return [];538 return [];
548 }539 }
apps/server/integration/orchestrationEngine.integration.test.ts+72-117
...@@ -7,9 +7,6 @@ import {...@@ -7,9 +7,6 @@ import {
7 EventId,7 EventId,
8 MessageId,8 MessageId,
9 ProjectId,9 ProjectId,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
13 ThreadId,10 ThreadId,
14} from "@t3tools/contracts";11} from "@t3tools/contracts";
15import { assert, it } from "@effect/vitest";12import { assert, it } from "@effect/vitest";
...@@ -27,18 +24,12 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";...@@ -27,18 +24,12 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";
27const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);24const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
28const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);25const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
29const asEventId = (value: string): EventId => EventId.makeUnsafe(value);26const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
30const asProviderSessionId = (value: string): ProviderSessionId =>
31 ProviderSessionId.makeUnsafe(value);
32const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
33const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
34const asApprovalRequestId = (value: string): ApprovalRequestId =>27const asApprovalRequestId = (value: string): ApprovalRequestId =>
35 ApprovalRequestId.makeUnsafe(value);28 ApprovalRequestId.makeUnsafe(value);
3629
37const PROJECT_ID = asProjectId("project-1");30const PROJECT_ID = asProjectId("project-1");
38const THREAD_ID = ThreadId.makeUnsafe("thread-1");31const THREAD_ID = ThreadId.makeUnsafe("thread-1");
39const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");32const FIXTURE_TURN_ID = "fixture-turn";
40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");33const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43type IntegrationProvider = "codex" | "claudeCode";34type IntegrationProvider = "codex" | "claudeCode";
4435
...@@ -81,7 +72,6 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr...@@ -81,7 +72,6 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr
81 return {72 return {
82 eventId: asEventId(eventId),73 eventId: asEventId(eventId),
83 provider,74 provider,
84 sessionId: FIXTURE_SESSION_ID,
85 createdAt,75 createdAt,
86 };76 };
87}77}
...@@ -167,20 +157,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +...@@ -167,20 +157,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
167 {157 {
168 type: "turn.started",158 type: "turn.started",
169 ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"),159 ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"),
170 threadId: FIXTURE_THREAD_ID,160 threadId: THREAD_ID,
171 turnId: FIXTURE_TURN_ID,161 turnId: FIXTURE_TURN_ID,
172 },162 },
173 {163 {
174 type: "message.delta",164 type: "message.delta",
175 ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"),165 ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"),
176 threadId: FIXTURE_THREAD_ID,166 threadId: THREAD_ID,
177 turnId: FIXTURE_TURN_ID,167 turnId: FIXTURE_TURN_ID,
178 delta: "Single turn response.\n",168 delta: "Single turn response.\n",
179 },169 },
180 {170 {
181 type: "turn.completed",171 type: "turn.completed",
182 ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"),172 ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"),
183 threadId: FIXTURE_THREAD_ID,173 threadId: THREAD_ID,
184 turnId: FIXTURE_TURN_ID,174 turnId: FIXTURE_TURN_ID,
185 status: "completed",175 status: "completed",
186 },176 },
...@@ -276,15 +266,12 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -276,15 +266,12 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
276 (entry) =>266 (entry) =>
277 entry.session?.status === "ready" &&267 entry.session?.status === "ready" &&
278 entry.session.providerName === "codex" &&268 entry.session.providerName === "codex" &&
279 entry.session.providerThreadId !== null &&
280 entry.messages.some(269 entry.messages.some(
281 (message) => message.role === "assistant" && message.streaming === false,270 (message) => message.role === "assistant" && message.streaming === false,
282 ),271 ),
283 180_000,272 180_000,
284 );273 );
285274 assert.equal(firstThread.session?.threadId, "thread-1");
286 const originalProviderThreadId = firstThread.session?.providerThreadId;
287 assert.isNotNull(originalProviderThreadId);
288275
289 yield* harness.engine.dispatch({276 yield* harness.engine.dispatch({
290 type: "thread.turn.start",277 type: "thread.turn.start",
...@@ -305,15 +292,13 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -305,15 +292,13 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
305 (entry) =>292 (entry) =>
306 entry.session?.status === "ready" &&293 entry.session?.status === "ready" &&
307 entry.session.providerName === "codex" &&294 entry.session.providerName === "codex" &&
308 entry.session.providerThreadId !== null &&
309 entry.session.runtimeMode === "approval-required" &&295 entry.session.runtimeMode === "approval-required" &&
310 entry.messages.some(296 entry.messages.some(
311 (message) => message.role === "assistant" && message.text.includes("BETA"),297 (message) => message.role === "assistant" && message.text.includes("BETA"),
312 ),298 ),
313 180_000,299 180_000,
314 );300 );
315301 assert.equal(secondThread.session?.threadId, "thread-1");
316 assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId);
317 }),302 }),
318 ),303 ),
319);304);
...@@ -328,13 +313,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -328,13 +313,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
328 {313 {
329 type: "turn.started",314 type: "turn.started",
330 ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"),315 ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"),
331 threadId: FIXTURE_THREAD_ID,316 threadId: THREAD_ID,
332 turnId: FIXTURE_TURN_ID,317 turnId: FIXTURE_TURN_ID,
333 },318 },
334 {319 {
335 type: "tool.started",320 type: "tool.started",
336 ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"),321 ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"),
337 threadId: FIXTURE_THREAD_ID,322 threadId: THREAD_ID,
338 turnId: FIXTURE_TURN_ID,323 turnId: FIXTURE_TURN_ID,
339 toolKind: "command",324 toolKind: "command",
340 title: "Edit file",325 title: "Edit file",
...@@ -343,7 +328,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -343,7 +328,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
343 {328 {
344 type: "tool.completed",329 type: "tool.completed",
345 ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"),330 ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"),
346 threadId: FIXTURE_THREAD_ID,331 threadId: THREAD_ID,
347 turnId: FIXTURE_TURN_ID,332 turnId: FIXTURE_TURN_ID,
348 toolKind: "command",333 toolKind: "command",
349 title: "Edit file",334 title: "Edit file",
...@@ -352,14 +337,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -352,14 +337,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
352 {337 {
353 type: "message.delta",338 type: "message.delta",
354 ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"),339 ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"),
355 threadId: FIXTURE_THREAD_ID,340 threadId: THREAD_ID,
356 turnId: FIXTURE_TURN_ID,341 turnId: FIXTURE_TURN_ID,
357 delta: "Updated README to v2.\n",342 delta: "Updated README to v2.\n",
358 },343 },
359 {344 {
360 type: "turn.completed",345 type: "turn.completed",
361 ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"),346 ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"),
362 threadId: FIXTURE_THREAD_ID,347 threadId: THREAD_ID,
363 turnId: FIXTURE_TURN_ID,348 turnId: FIXTURE_TURN_ID,
364 status: "completed",349 status: "completed",
365 },350 },
...@@ -377,35 +362,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -377,35 +362,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
377 text: "Make first edit",362 text: "Make first edit",
378 });363 });
379364
380 const firstTurnThread = yield* harness.waitForThread(365 yield* harness.waitForThread(
381 THREAD_ID,366 THREAD_ID,
382 (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null,367 (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1",
383 );368 );
384 const sessionId = firstTurnThread.session?.providerSessionId;
385 assert.equal(sessionId !== null, true);
386 if (!sessionId) {
387 return;
388 }
389369
390 yield* harness.adapterHarness.queueTurnResponse(sessionId, {370 yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, {
391 events: [371 events: [
392 {372 {
393 type: "turn.started",373 type: "turn.started",
394 ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"),374 ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"),
395 threadId: FIXTURE_THREAD_ID,375 threadId: THREAD_ID,
396 turnId: FIXTURE_TURN_ID,376 turnId: FIXTURE_TURN_ID,
397 },377 },
398 {378 {
399 type: "message.delta",379 type: "message.delta",
400 ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"),380 ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"),
401 threadId: FIXTURE_THREAD_ID,381 threadId: THREAD_ID,
402 turnId: FIXTURE_TURN_ID,382 turnId: FIXTURE_TURN_ID,
403 delta: "Updated README to v3.\n",383 delta: "Updated README to v3.\n",
404 },384 },
405 {385 {
406 type: "turn.completed",386 type: "turn.completed",
407 ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"),387 ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"),
408 threadId: FIXTURE_THREAD_ID,388 threadId: THREAD_ID,
409 turnId: FIXTURE_TURN_ID,389 turnId: FIXTURE_TURN_ID,
410 status: "completed",390 status: "completed",
411 },391 },
...@@ -492,13 +472,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons...@@ -492,13 +472,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons
492 {472 {
493 type: "turn.started",473 type: "turn.started",
494 ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"),474 ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"),
495 threadId: FIXTURE_THREAD_ID,475 threadId: THREAD_ID,
496 turnId: FIXTURE_TURN_ID,476 turnId: FIXTURE_TURN_ID,
497 },477 },
498 {478 {
499 type: "approval.requested",479 type: "approval.requested",
500 ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"),480 ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"),
501 threadId: FIXTURE_THREAD_ID,481 threadId: THREAD_ID,
502 turnId: FIXTURE_TURN_ID,482 turnId: FIXTURE_TURN_ID,
503 requestId: APPROVAL_REQUEST_ID,483 requestId: APPROVAL_REQUEST_ID,
504 requestKind: "command",484 requestKind: "command",
...@@ -507,7 +487,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons...@@ -507,7 +487,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons
507 {487 {
508 type: "turn.completed",488 type: "turn.completed",
509 ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"),489 ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"),
510 threadId: FIXTURE_THREAD_ID,490 threadId: THREAD_ID,
511 turnId: FIXTURE_TURN_ID,491 turnId: FIXTURE_TURN_ID,
512 status: "completed",492 status: "completed",
513 },493 },
...@@ -551,9 +531,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons...@@ -551,9 +531,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons
551 assert.equal(resolvedRow.status, "resolved");531 assert.equal(resolvedRow.status, "resolved");
552 assert.equal(resolvedRow.decision, "accept");532 assert.equal(resolvedRow.decision, "accept");
553533
554 const providerSessionId = thread.session?.providerSessionId ?? "test-session-1";
555 const approvalResponses = yield* waitForSync(534 const approvalResponses = yield* waitForSync(
556 () => harness.adapterHarness.getApprovalResponses(providerSessionId),535 () => harness.adapterHarness.getApprovalResponses(THREAD_ID),
557 (responses) => responses.length === 1,536 (responses) => responses.length === 1,
558 "provider approval response",537 "provider approval response",
559 );538 );
...@@ -574,13 +553,13 @@ it.live("records failed turn runtime state and checkpoint status as error", () =...@@ -574,13 +553,13 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
574 {553 {
575 type: "turn.started",554 type: "turn.started",
576 ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"),555 ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"),
577 threadId: FIXTURE_THREAD_ID,556 threadId: THREAD_ID,
578 turnId: FIXTURE_TURN_ID,557 turnId: FIXTURE_TURN_ID,
579 },558 },
580 {559 {
581 type: "content.delta",560 type: "content.delta",
582 ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"),561 ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"),
583 threadId: FIXTURE_THREAD_ID,562 threadId: THREAD_ID,
584 turnId: FIXTURE_TURN_ID,563 turnId: FIXTURE_TURN_ID,
585 payload: {564 payload: {
586 streamKind: "assistant_text",565 streamKind: "assistant_text",
...@@ -590,7 +569,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =...@@ -590,7 +569,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
590 {569 {
591 type: "runtime.error",570 type: "runtime.error",
592 ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"),571 ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"),
593 threadId: FIXTURE_THREAD_ID,572 threadId: THREAD_ID,
594 turnId: FIXTURE_TURN_ID,573 turnId: FIXTURE_TURN_ID,
595 payload: {574 payload: {
596 message: "Sandbox command failed.",575 message: "Sandbox command failed.",
...@@ -599,7 +578,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =...@@ -599,7 +578,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
599 {578 {
600 type: "turn.completed",579 type: "turn.completed",
601 ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"),580 ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"),
602 threadId: FIXTURE_THREAD_ID,581 threadId: THREAD_ID,
603 turnId: FIXTURE_TURN_ID,582 turnId: FIXTURE_TURN_ID,
604 payload: {583 payload: {
605 state: "failed",584 state: "failed",
...@@ -653,13 +632,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -653,13 +632,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
653 {632 {
654 type: "turn.started",633 type: "turn.started",
655 ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"),634 ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"),
656 threadId: FIXTURE_THREAD_ID,635 threadId: THREAD_ID,
657 turnId: FIXTURE_TURN_ID,636 turnId: FIXTURE_TURN_ID,
658 },637 },
659 {638 {
660 type: "tool.started",639 type: "tool.started",
661 ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"),640 ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"),
662 threadId: FIXTURE_THREAD_ID,641 threadId: THREAD_ID,
663 turnId: FIXTURE_TURN_ID,642 turnId: FIXTURE_TURN_ID,
664 toolKind: "command",643 toolKind: "command",
665 title: "Edit file",644 title: "Edit file",
...@@ -668,7 +647,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -668,7 +647,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
668 {647 {
669 type: "tool.completed",648 type: "tool.completed",
670 ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"),649 ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"),
671 threadId: FIXTURE_THREAD_ID,650 threadId: THREAD_ID,
672 turnId: FIXTURE_TURN_ID,651 turnId: FIXTURE_TURN_ID,
673 toolKind: "command",652 toolKind: "command",
674 title: "Edit file",653 title: "Edit file",
...@@ -677,14 +656,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -677,14 +656,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
677 {656 {
678 type: "message.delta",657 type: "message.delta",
679 ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"),658 ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"),
680 threadId: FIXTURE_THREAD_ID,659 threadId: THREAD_ID,
681 turnId: FIXTURE_TURN_ID,660 turnId: FIXTURE_TURN_ID,
682 delta: "Updated README to v2.\n",661 delta: "Updated README to v2.\n",
683 },662 },
684 {663 {
685 type: "turn.completed",664 type: "turn.completed",
686 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),665 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),
687 threadId: FIXTURE_THREAD_ID,666 threadId: THREAD_ID,
688 turnId: FIXTURE_TURN_ID,667 turnId: FIXTURE_TURN_ID,
689 status: "completed",668 status: "completed",
690 },669 },
...@@ -701,28 +680,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -701,28 +680,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
701 text: "First edit",680 text: "First edit",
702 });681 });
703682
704 const firstTurnThread = yield* harness.waitForThread(683 yield* harness.waitForThread(
705 THREAD_ID,684 THREAD_ID,
706 (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1,685 (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1,
707 );686 );
708 const sessionId = firstTurnThread.session?.providerSessionId;
709 assert.equal(sessionId !== null, true);
710 if (!sessionId) {
711 return;
712 }
713687
714 yield* harness.adapterHarness.queueTurnResponse(sessionId, {688 yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, {
715 events: [689 events: [
716 {690 {
717 type: "turn.started",691 type: "turn.started",
718 ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"),692 ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"),
719 threadId: FIXTURE_THREAD_ID,693 threadId: THREAD_ID,
720 turnId: FIXTURE_TURN_ID,694 turnId: FIXTURE_TURN_ID,
721 },695 },
722 {696 {
723 type: "tool.started",697 type: "tool.started",
724 ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"),698 ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"),
725 threadId: FIXTURE_THREAD_ID,699 threadId: THREAD_ID,
726 turnId: FIXTURE_TURN_ID,700 turnId: FIXTURE_TURN_ID,
727 toolKind: "command",701 toolKind: "command",
728 title: "Edit file",702 title: "Edit file",
...@@ -731,7 +705,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -731,7 +705,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
731 {705 {
732 type: "tool.completed",706 type: "tool.completed",
733 ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"),707 ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"),
734 threadId: FIXTURE_THREAD_ID,708 threadId: THREAD_ID,
735 turnId: FIXTURE_TURN_ID,709 turnId: FIXTURE_TURN_ID,
736 toolKind: "command",710 toolKind: "command",
737 title: "Edit file",711 title: "Edit file",
...@@ -740,14 +714,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -740,14 +714,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
740 {714 {
741 type: "message.delta",715 type: "message.delta",
742 ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"),716 ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"),
743 threadId: FIXTURE_THREAD_ID,717 threadId: THREAD_ID,
744 turnId: FIXTURE_TURN_ID,718 turnId: FIXTURE_TURN_ID,
745 delta: "Updated README to v3.\n",719 delta: "Updated README to v3.\n",
746 },720 },
747 {721 {
748 type: "turn.completed",722 type: "turn.completed",
749 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),723 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),
750 threadId: FIXTURE_THREAD_ID,724 threadId: THREAD_ID,
751 turnId: FIXTURE_TURN_ID,725 turnId: FIXTURE_TURN_ID,
752 status: "completed",726 status: "completed",
753 },727 },
...@@ -816,7 +790,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -816,7 +790,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
816 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),790 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
817 false,791 false,
818 );792 );
819 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);793 assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]);
820794
821 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({795 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
822 threadId: THREAD_ID,796 threadId: THREAD_ID,
...@@ -874,20 +848,20 @@ it.live("starts a claudeCode session on first turn when provider is requested",...@@ -874,20 +848,20 @@ it.live("starts a claudeCode session on first turn when provider is requested",
874 {848 {
875 type: "turn.started",849 type: "turn.started",
876 ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"),850 ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"),
877 threadId: FIXTURE_THREAD_ID,851 threadId: THREAD_ID,
878 turnId: FIXTURE_TURN_ID,852 turnId: FIXTURE_TURN_ID,
879 },853 },
880 {854 {
881 type: "message.delta",855 type: "message.delta",
882 ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"),856 ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"),
883 threadId: FIXTURE_THREAD_ID,857 threadId: THREAD_ID,
884 turnId: FIXTURE_TURN_ID,858 turnId: FIXTURE_TURN_ID,
885 delta: "Claude first turn.\n",859 delta: "Claude first turn.\n",
886 },860 },
887 {861 {
888 type: "turn.completed",862 type: "turn.completed",
889 ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"),863 ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"),
890 threadId: FIXTURE_THREAD_ID,864 threadId: THREAD_ID,
891 turnId: FIXTURE_TURN_ID,865 turnId: FIXTURE_TURN_ID,
892 status: "completed",866 status: "completed",
893 },867 },
...@@ -928,20 +902,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res...@@ -928,20 +902,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
928 {902 {
929 type: "turn.started",903 type: "turn.started",
930 ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"),904 ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"),
931 threadId: FIXTURE_THREAD_ID,905 threadId: THREAD_ID,
932 turnId: FIXTURE_TURN_ID,906 turnId: FIXTURE_TURN_ID,
933 },907 },
934 {908 {
935 type: "message.delta",909 type: "message.delta",
936 ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"),910 ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"),
937 threadId: FIXTURE_THREAD_ID,911 threadId: THREAD_ID,
938 turnId: FIXTURE_TURN_ID,912 turnId: FIXTURE_TURN_ID,
939 delta: "Turn before restart.\n",913 delta: "Turn before restart.\n",
940 },914 },
941 {915 {
942 type: "turn.completed",916 type: "turn.completed",
943 ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"),917 ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"),
944 threadId: FIXTURE_THREAD_ID,918 threadId: THREAD_ID,
945 turnId: FIXTURE_TURN_ID,919 turnId: FIXTURE_TURN_ID,
946 status: "completed",920 status: "completed",
947 },921 },
...@@ -956,16 +930,10 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res...@@ -956,16 +930,10 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
956 provider: "claudeCode",930 provider: "claudeCode",
957 });931 });
958932
959 const firstThread = yield* harness.waitForThread(933 yield* harness.waitForThread(
960 THREAD_ID,934 THREAD_ID,
961 (entry) =>935 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1",
962 entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null,
963 );936 );
964 const staleSessionId = firstThread.session?.providerSessionId;
965 assert.equal(staleSessionId !== null, true);
966 if (!staleSessionId) {
967 return;
968 }
969937
970 yield* harness.providerService.stopAll();938 yield* harness.providerService.stopAll();
971 yield* waitForSync(939 yield* waitForSync(
...@@ -979,20 +947,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res...@@ -979,20 +947,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
979 {947 {
980 type: "turn.started",948 type: "turn.started",
981 ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"),949 ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"),
982 threadId: FIXTURE_THREAD_ID,950 threadId: THREAD_ID,
983 turnId: FIXTURE_TURN_ID,951 turnId: FIXTURE_TURN_ID,
984 },952 },
985 {953 {
986 type: "message.delta",954 type: "message.delta",
987 ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"),955 ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"),
988 threadId: FIXTURE_THREAD_ID,956 threadId: THREAD_ID,
989 turnId: FIXTURE_TURN_ID,957 turnId: FIXTURE_TURN_ID,
990 delta: "Turn after restart.\n",958 delta: "Turn after restart.\n",
991 },959 },
992 {960 {
993 type: "turn.completed",961 type: "turn.completed",
994 ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"),962 ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"),
995 threadId: FIXTURE_THREAD_ID,963 threadId: THREAD_ID,
996 turnId: FIXTURE_TURN_ID,964 turnId: FIXTURE_TURN_ID,
997 status: "completed",965 status: "completed",
998 },966 },
...@@ -1021,7 +989,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res...@@ -1021,7 +989,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
1021 !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"),989 !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
1022 );990 );
1023 assert.equal(recoveredThread.session?.providerName, "claudeCode");991 assert.equal(recoveredThread.session?.providerName, "claudeCode");
1024 assert.equal(recoveredThread.session?.providerSessionId, staleSessionId);992 assert.equal(recoveredThread.session?.threadId, "thread-1");
1025 }),993 }),
1026 "claudeCode",994 "claudeCode",
1027 ),995 ),
...@@ -1038,13 +1006,13 @@ it.live("forwards claudeCode approval responses to the provider session", () =>...@@ -1038,13 +1006,13 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
1038 {1006 {
1039 type: "turn.started",1007 type: "turn.started",
1040 ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"),1008 ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"),
1041 threadId: FIXTURE_THREAD_ID,1009 threadId: THREAD_ID,
1042 turnId: FIXTURE_TURN_ID,1010 turnId: FIXTURE_TURN_ID,
1043 },1011 },
1044 {1012 {
1045 type: "approval.requested",1013 type: "approval.requested",
1046 ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"),1014 ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"),
1047 threadId: FIXTURE_THREAD_ID,1015 threadId: THREAD_ID,
1048 turnId: FIXTURE_TURN_ID,1016 turnId: FIXTURE_TURN_ID,
1049 requestId: APPROVAL_REQUEST_ID,1017 requestId: APPROVAL_REQUEST_ID,
1050 requestKind: "command",1018 requestKind: "command",
...@@ -1053,7 +1021,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>...@@ -1053,7 +1021,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
1053 {1021 {
1054 type: "turn.completed",1022 type: "turn.completed",
1055 ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"),1023 ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"),
1056 threadId: FIXTURE_THREAD_ID,1024 threadId: THREAD_ID,
1057 turnId: FIXTURE_TURN_ID,1025 turnId: FIXTURE_TURN_ID,
1058 status: "completed",1026 status: "completed",
1059 },1027 },
...@@ -1071,11 +1039,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>...@@ -1071,11 +1039,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
1071 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>1039 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
1072 entry.activities.some((activity) => activity.kind === "approval.requested"),1040 entry.activities.some((activity) => activity.kind === "approval.requested"),
1073 );1041 );
1074 const providerSessionId = thread.session?.providerSessionId;1042 assert.equal(thread.session?.threadId, "thread-1");
1075 assert.equal(providerSessionId !== null, true);
1076 if (!providerSessionId) {
1077 return;
1078 }
10791043
1080 yield* harness.engine.dispatch({1044 yield* harness.engine.dispatch({
1081 type: "thread.approval.respond",1045 type: "thread.approval.respond",
...@@ -1092,7 +1056,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>...@@ -1092,7 +1056,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
1092 );1056 );
10931057
1094 const approvalResponses = yield* waitForSync(1058 const approvalResponses = yield* waitForSync(
1095 () => harness.adapterHarness.getApprovalResponses(providerSessionId),1059 () => harness.adapterHarness.getApprovalResponses(THREAD_ID),
1096 (responses) => responses.length === 1,1060 (responses) => responses.length === 1,
1097 "claude provider approval response",1061 "claude provider approval response",
1098 );1062 );
...@@ -1113,20 +1077,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>...@@ -1113,20 +1077,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
1113 {1077 {
1114 type: "turn.started",1078 type: "turn.started",
1115 ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"),1079 ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"),
1116 threadId: FIXTURE_THREAD_ID,1080 threadId: THREAD_ID,
1117 turnId: FIXTURE_TURN_ID,1081 turnId: FIXTURE_TURN_ID,
1118 },1082 },
1119 {1083 {
1120 type: "message.delta",1084 type: "message.delta",
1121 ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"),1085 ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"),
1122 threadId: FIXTURE_THREAD_ID,1086 threadId: THREAD_ID,
1123 turnId: FIXTURE_TURN_ID,1087 turnId: FIXTURE_TURN_ID,
1124 delta: "Long running output.\n",1088 delta: "Long running output.\n",
1125 },1089 },
1126 {1090 {
1127 type: "turn.completed",1091 type: "turn.completed",
1128 ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"),1092 ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"),
1129 threadId: FIXTURE_THREAD_ID,1093 threadId: THREAD_ID,
1130 turnId: FIXTURE_TURN_ID,1094 turnId: FIXTURE_TURN_ID,
1131 status: "completed",1095 status: "completed",
1132 },1096 },
...@@ -1143,13 +1107,9 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>...@@ -1143,13 +1107,9 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
11431107
1144 const thread = yield* harness.waitForThread(1108 const thread = yield* harness.waitForThread(
1145 THREAD_ID,1109 THREAD_ID,
1146 (entry) => entry.session?.providerSessionId !== null,1110 (entry) => entry.session?.threadId === "thread-1",
1147 );1111 );
1148 const providerSessionId = thread.session?.providerSessionId;1112 assert.equal(thread.session?.threadId, "thread-1");
1149 assert.equal(providerSessionId !== null, true);
1150 if (!providerSessionId) {
1151 return;
1152 }
11531113
1154 yield* harness.engine.dispatch({1114 yield* harness.engine.dispatch({
1155 type: "thread.turn.interrupt",1115 type: "thread.turn.interrupt",
...@@ -1160,7 +1120,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>...@@ -1160,7 +1120,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
1160 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested");1120 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested");
11611121
1162 const interruptCalls = yield* waitForSync(1122 const interruptCalls = yield* waitForSync(
1163 () => harness.adapterHarness.getInterruptCalls(providerSessionId),1123 () => harness.adapterHarness.getInterruptCalls(THREAD_ID),
1164 (calls) => calls.length === 1,1124 (calls) => calls.length === 1,
1165 "claude provider interrupt call",1125 "claude provider interrupt call",
1166 );1126 );
...@@ -1181,20 +1141,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (...@@ -1181,20 +1141,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
1181 {1141 {
1182 type: "turn.started",1142 type: "turn.started",
1183 ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"),1143 ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"),
1184 threadId: FIXTURE_THREAD_ID,1144 threadId: THREAD_ID,
1185 turnId: FIXTURE_TURN_ID,1145 turnId: FIXTURE_TURN_ID,
1186 },1146 },
1187 {1147 {
1188 type: "message.delta",1148 type: "message.delta",
1189 ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"),1149 ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"),
1190 threadId: FIXTURE_THREAD_ID,1150 threadId: THREAD_ID,
1191 turnId: FIXTURE_TURN_ID,1151 turnId: FIXTURE_TURN_ID,
1192 delta: "README -> v2\n",1152 delta: "README -> v2\n",
1193 },1153 },
1194 {1154 {
1195 type: "turn.completed",1155 type: "turn.completed",
1196 ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"),1156 ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"),
1197 threadId: FIXTURE_THREAD_ID,1157 threadId: THREAD_ID,
1198 turnId: FIXTURE_TURN_ID,1158 turnId: FIXTURE_TURN_ID,
1199 status: "completed",1159 status: "completed",
1200 },1160 },
...@@ -1213,35 +1173,30 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (...@@ -1213,35 +1173,30 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
1213 provider: "claudeCode",1173 provider: "claudeCode",
1214 });1174 });
12151175
1216 const firstThread = yield* harness.waitForThread(1176 yield* harness.waitForThread(
1217 THREAD_ID,1177 THREAD_ID,
1218 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null,1178 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1",
1219 );1179 );
1220 const sessionId = firstThread.session?.providerSessionId;
1221 assert.equal(sessionId !== null, true);
1222 if (!sessionId) {
1223 return;
1224 }
12251180
1226 yield* harness.adapterHarness.queueTurnResponse(sessionId, {1181 yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, {
1227 events: [1182 events: [
1228 {1183 {
1229 type: "turn.started",1184 type: "turn.started",
1230 ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"),1185 ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"),
1231 threadId: FIXTURE_THREAD_ID,1186 threadId: THREAD_ID,
1232 turnId: FIXTURE_TURN_ID,1187 turnId: FIXTURE_TURN_ID,
1233 },1188 },
1234 {1189 {
1235 type: "message.delta",1190 type: "message.delta",
1236 ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"),1191 ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"),
1237 threadId: FIXTURE_THREAD_ID,1192 threadId: THREAD_ID,
1238 turnId: FIXTURE_TURN_ID,1193 turnId: FIXTURE_TURN_ID,
1239 delta: "README -> v3\n",1194 delta: "README -> v3\n",
1240 },1195 },
1241 {1196 {
1242 type: "turn.completed",1197 type: "turn.completed",
1243 ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"),1198 ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"),
1244 threadId: FIXTURE_THREAD_ID,1199 threadId: THREAD_ID,
1245 turnId: FIXTURE_TURN_ID,1200 turnId: FIXTURE_TURN_ID,
1246 status: "completed",1201 status: "completed",
1247 },1202 },
...@@ -1289,7 +1244,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (...@@ -1289,7 +1244,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
1289 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),1244 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
1290 false,1245 false,
1291 );1246 );
1292 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);1247 assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]);
1293 }),1248 }),
1294 "claudeCode",1249 "claudeCode",
1295 ),1250 ),
apps/server/integration/providerService.integration.test.ts+16-12
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it, assert } from "@effect/vitest";4import { it, assert } from "@effect/vitest";
...@@ -93,18 +93,18 @@ const collectEventsDuring = <A, E, R>(...@@ -93,18 +93,18 @@ const collectEventsDuring = <A, E, R>(
93const runTurn = (input: {93const runTurn = (input: {
94 readonly provider: ProviderServiceShape;94 readonly provider: ProviderServiceShape;
95 readonly harness: TestProviderAdapterHarness;95 readonly harness: TestProviderAdapterHarness;
96 readonly sessionId: ProviderSessionId;96 readonly threadId: ThreadId;
97 readonly userText: string;97 readonly userText: string;
98 readonly response: TestTurnResponse;98 readonly response: TestTurnResponse;
99}) =>99}) =>
100 Effect.gen(function* () {100 Effect.gen(function* () {
101 yield* input.harness.queueTurnResponse(input.sessionId, input.response);101 yield* input.harness.queueTurnResponse(input.threadId, input.response);
102102
103 return yield* collectEventsDuring(103 return yield* collectEventsDuring(
104 input.provider.streamEvents,104 input.provider.streamEvents,
105 input.response.events.length,105 input.response.events.length,
106 input.provider.sendTurn({106 input.provider.sendTurn({
107 sessionId: input.sessionId,107 threadId: input.threadId,
108 input: input.userText,108 input: input.userText,
109 attachments: [],109 attachments: [],
110 }),110 }),
...@@ -120,6 +120,7 @@ it.effect("replays typed runtime fixture events", () =>...@@ -120,6 +120,7 @@ it.effect("replays typed runtime fixture events", () =>
120 const session = yield* provider.startSession(120 const session = yield* provider.startSession(
121 ThreadId.makeUnsafe("thread-integration-typed"),121 ThreadId.makeUnsafe("thread-integration-typed"),
122 {122 {
123 threadId: ThreadId.makeUnsafe("thread-integration-typed"),
123 provider: "codex",124 provider: "codex",
124 cwd: fixture.cwd,125 cwd: fixture.cwd,
125 runtimeMode: "full-access",126 runtimeMode: "full-access",
...@@ -130,7 +131,7 @@ it.effect("replays typed runtime fixture events", () =>...@@ -130,7 +131,7 @@ it.effect("replays typed runtime fixture events", () =>
130 const observedEvents = yield* runTurn({131 const observedEvents = yield* runTurn({
131 provider,132 provider,
132 harness: fixture.harness,133 harness: fixture.harness,
133 sessionId: session.sessionId,134 threadId: session.threadId,
134 userText: "hello",135 userText: "hello",
135 response: { events: codexTurnTextFixture },136 response: { events: codexTurnTextFixture },
136 });137 });
...@@ -154,6 +155,7 @@ it.effect("replays file-changing fixture turn events", () =>...@@ -154,6 +155,7 @@ it.effect("replays file-changing fixture turn events", () =>
154 const session = yield* provider.startSession(155 const session = yield* provider.startSession(
155 ThreadId.makeUnsafe("thread-integration-tools"),156 ThreadId.makeUnsafe("thread-integration-tools"),
156 {157 {
158 threadId: ThreadId.makeUnsafe("thread-integration-tools"),
157 provider: "codex",159 provider: "codex",
158 cwd: fixture.cwd,160 cwd: fixture.cwd,
159 runtimeMode: "full-access",161 runtimeMode: "full-access",
...@@ -164,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () =>...@@ -164,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () =>
164 const observedEvents = yield* runTurn({166 const observedEvents = yield* runTurn({
165 provider,167 provider,
166 harness: fixture.harness,168 harness: fixture.harness,
167 sessionId: session.sessionId,169 threadId: session.threadId,
168 userText: "make a small change",170 userText: "make a small change",
169 response: {171 response: {
170 events: codexTurnToolFixture,172 events: codexTurnToolFixture,
...@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>...@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192 const session = yield* provider.startSession(194 const session = yield* provider.startSession(
193 ThreadId.makeUnsafe("thread-integration-multi"),195 ThreadId.makeUnsafe("thread-integration-multi"),
194 {196 {
197 threadId: ThreadId.makeUnsafe("thread-integration-multi"),
195 provider: "codex",198 provider: "codex",
196 cwd: fixture.cwd,199 cwd: fixture.cwd,
197 runtimeMode: "full-access",200 runtimeMode: "full-access",
...@@ -202,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () =>...@@ -202,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
202 const firstTurnEvents = yield* runTurn({205 const firstTurnEvents = yield* runTurn({
203 provider,206 provider,
204 harness: fixture.harness,207 harness: fixture.harness,
205 sessionId: session.sessionId,208 threadId: session.threadId,
206 userText: "turn 1",209 userText: "turn 1",
207 response: {210 response: {
208 events: codexTurnToolFixture,211 events: codexTurnToolFixture,
...@@ -218,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () =>...@@ -218,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
218 const secondTurnEvents = yield* runTurn({221 const secondTurnEvents = yield* runTurn({
219 provider,222 provider,
220 harness: fixture.harness,223 harness: fixture.harness,
221 sessionId: session.sessionId,224 threadId: session.threadId,
222 userText: "turn 2 approval",225 userText: "turn 2 approval",
223 response: {226 response: {
224 events: codexTurnApprovalFixture,227 events: codexTurnApprovalFixture,
...@@ -245,6 +248,7 @@ it.effect("rolls back provider conversation state only", () =>...@@ -245,6 +248,7 @@ it.effect("rolls back provider conversation state only", () =>
245 const session = yield* provider.startSession(248 const session = yield* provider.startSession(
246 ThreadId.makeUnsafe("thread-integration-rollback"),249 ThreadId.makeUnsafe("thread-integration-rollback"),
247 {250 {
251 threadId: ThreadId.makeUnsafe("thread-integration-rollback"),
248 provider: "codex",252 provider: "codex",
249 cwd: fixture.cwd,253 cwd: fixture.cwd,
250 runtimeMode: "full-access",254 runtimeMode: "full-access",
...@@ -255,7 +259,7 @@ it.effect("rolls back provider conversation state only", () =>...@@ -255,7 +259,7 @@ it.effect("rolls back provider conversation state only", () =>
255 yield* runTurn({259 yield* runTurn({
256 provider,260 provider,
257 harness: fixture.harness,261 harness: fixture.harness,
258 sessionId: session.sessionId,262 threadId: session.threadId,
259 userText: "turn 1",263 userText: "turn 1",
260 response: {264 response: {
261 events: codexTurnToolFixture,265 events: codexTurnToolFixture,
...@@ -267,7 +271,7 @@ it.effect("rolls back provider conversation state only", () =>...@@ -267,7 +271,7 @@ it.effect("rolls back provider conversation state only", () =>
267 yield* runTurn({271 yield* runTurn({
268 provider,272 provider,
269 harness: fixture.harness,273 harness: fixture.harness,
270 sessionId: session.sessionId,274 threadId: session.threadId,
271 userText: "turn 2 approval",275 userText: "turn 2 approval",
272 response: {276 response: {
273 events: codexTurnApprovalFixture,277 events: codexTurnApprovalFixture,
...@@ -277,11 +281,11 @@ it.effect("rolls back provider conversation state only", () =>...@@ -277,11 +281,11 @@ it.effect("rolls back provider conversation state only", () =>
277 });281 });
278282
279 yield* provider.rollbackConversation({283 yield* provider.rollbackConversation({
280 sessionId: session.sessionId,284 threadId: session.threadId,
281 numTurns: 1,285 numTurns: 1,
282 });286 });
283287
284 const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId);288 const rollbackCalls = fixture.harness.getRollbackCalls(session.threadId);
285 assert.deepEqual(rollbackCalls, [1]);289 assert.deepEqual(rollbackCalls, [1]);
286290
287 const readme = yield* readFileString(join(fixture.cwd, "README.md"));291 const readme = yield* readFileString(join(fixture.cwd, "README.md"));
apps/server/src/codexAppServerManager.test.ts+20-17
...@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";...@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4import os from "node:os";4import os from "node:os";
5import path from "node:path";5import path from "node:path";
6import { ProviderSessionId } from "@t3tools/contracts";6import { ThreadId } from "@t3tools/contracts";
77
8import {8import {
9 CodexAppServerManager,9 CodexAppServerManager,
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12 normalizeCodexModelSlug,12 normalizeCodexModelSlug,
13} from "./codexAppServerManager";13} from "./codexAppServerManager";
1414
15const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);15const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
1616
17function createSendTurnHarness() {17function createSendTurnHarness() {
18 const manager = new CodexAppServerManager();18 const manager = new CodexAppServerManager();
...@@ -168,6 +168,7 @@ describe("startSession", () => {...@@ -168,6 +168,7 @@ describe("startSession", () => {
168 try {168 try {
169 await expect(169 await expect(
170 manager.startSession({170 manager.startSession({
171 threadId: asThreadId("thread-1"),
171 provider: "codex",172 provider: "codex",
172 runtimeMode: "full-access",173 runtimeMode: "full-access",
173 }),174 }),
...@@ -191,7 +192,7 @@ describe("sendTurn", () => {...@@ -191,7 +192,7 @@ describe("sendTurn", () => {
191 createSendTurnHarness();192 createSendTurnHarness();
192193
193 const result = await manager.sendTurn({194 const result = await manager.sendTurn({
194 sessionId: asSessionId("sess_1"),195 threadId: asThreadId("thread_1"),
195 input: "Inspect this image",196 input: "Inspect this image",
196 attachments: [197 attachments: [
197 {198 {
...@@ -207,7 +208,7 @@ describe("sendTurn", () => {...@@ -207,7 +208,7 @@ describe("sendTurn", () => {
207 threadId: "thread_1",208 threadId: "thread_1",
208 turnId: "turn_1",209 turnId: "turn_1",
209 });210 });
210 expect(requireSession).toHaveBeenCalledWith("sess_1");211 expect(requireSession).toHaveBeenCalledWith("thread_1");
211 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
212 threadId: "thread_1",213 threadId: "thread_1",
213 input: [214 input: [
...@@ -234,7 +235,7 @@ describe("sendTurn", () => {...@@ -234,7 +235,7 @@ describe("sendTurn", () => {
234 const { manager, context, sendRequest } = createSendTurnHarness();235 const { manager, context, sendRequest } = createSendTurnHarness();
235236
236 await manager.sendTurn({237 await manager.sendTurn({
237 sessionId: asSessionId("sess_1"),238 threadId: asThreadId("thread_1"),
238 attachments: [239 attachments: [
239 {240 {
240 type: "image",241 type: "image",
...@@ -259,7 +260,7 @@ describe("sendTurn", () => {...@@ -259,7 +260,7 @@ describe("sendTurn", () => {
259260
260 await expect(261 await expect(
261 manager.sendTurn({262 manager.sendTurn({
262 sessionId: asSessionId("sess_1"),263 threadId: asThreadId("thread_1"),
263 }),264 }),
264 ).rejects.toThrow("Turn input must include text or attachments.");265 ).rejects.toThrow("Turn input must include text or attachments.");
265 });266 });
...@@ -280,9 +281,9 @@ describe("thread checkpoint control", () => {...@@ -280,9 +281,9 @@ describe("thread checkpoint control", () => {
280 },281 },
281 });282 });
282283
283 const result = await manager.readThread(asSessionId("sess_1"));284 const result = await manager.readThread(asThreadId("thread_1"));
284285
285 expect(requireSession).toHaveBeenCalledWith("sess_1");286 expect(requireSession).toHaveBeenCalledWith("thread_1");
286 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {287 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
287 threadId: "thread_1",288 threadId: "thread_1",
288 includeTurns: true,289 includeTurns: true,
...@@ -310,7 +311,7 @@ describe("thread checkpoint control", () => {...@@ -310,7 +311,7 @@ describe("thread checkpoint control", () => {
310 ],311 ],
311 });312 });
312313
313 const result = await manager.readThread(asSessionId("sess_1"));314 const result = await manager.readThread(asThreadId("thread_1"));
314315
315 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {316 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
316 threadId: "thread_1",317 threadId: "thread_1",
...@@ -336,7 +337,7 @@ describe("thread checkpoint control", () => {...@@ -336,7 +337,7 @@ describe("thread checkpoint control", () => {
336 },337 },
337 });338 });
338339
339 const result = await manager.rollbackThread(asSessionId("sess_1"), 2);340 const result = await manager.rollbackThread(asThreadId("thread_1"), 2);
340341
341 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {342 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
342 threadId: "thread_1",343 threadId: "thread_1",
...@@ -364,6 +365,7 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"...@@ -364,6 +365,7 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
364365
365 try {366 try {
366 const firstSession = await manager.startSession({367 const firstSession = await manager.startSession({
368 threadId: asThreadId("thread-live"),
367 provider: "codex",369 provider: "codex",
368 cwd: workspaceDir,370 cwd: workspaceDir,
369 runtimeMode: "full-access",371 runtimeMode: "full-access",
...@@ -378,24 +380,25 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"...@@ -378,24 +380,25 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
378 });380 });
379381
380 const firstTurn = await manager.sendTurn({382 const firstTurn = await manager.sendTurn({
381 sessionId: firstSession.sessionId,383 threadId: firstSession.threadId,
382 input: `Reply with exactly the word ALPHA ${randomUUID()}`,384 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383 });385 });
384386
385 expect(firstTurn.threadId).toBe(firstSession.threadId);387 expect(firstTurn.threadId).toBe(firstSession.threadId);
386388
387 await vi.waitFor(async () => {389 await vi.waitFor(async () => {
388 const snapshot = await manager.readThread(firstSession.sessionId);390 const snapshot = await manager.readThread(firstSession.threadId);
389 expect(snapshot.turns.length).toBeGreaterThan(0);391 expect(snapshot.turns.length).toBeGreaterThan(0);
390 }, { timeout: 120_000, interval: 1_000 });392 }, { timeout: 120_000, interval: 1_000 });
391393
392 const firstSnapshot = await manager.readThread(firstSession.sessionId);394 const firstSnapshot = await manager.readThread(firstSession.threadId);
393 const originalThreadId = firstSnapshot.threadId;395 const originalThreadId = firstSnapshot.threadId;
394 const originalTurnCount = firstSnapshot.turns.length;396 const originalTurnCount = firstSnapshot.turns.length;
395397
396 manager.stopSession(firstSession.sessionId);398 manager.stopSession(firstSession.threadId);
397399
398 const resumedSession = await manager.startSession({400 const resumedSession = await manager.startSession({
401 threadId: firstSession.threadId,
399 provider: "codex",402 provider: "codex",
400 cwd: workspaceDir,403 cwd: workspaceDir,
401 runtimeMode: "approval-required",404 runtimeMode: "approval-required",
...@@ -412,17 +415,17 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"...@@ -412,17 +415,17 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
412415
413 expect(resumedSession.threadId).toBe(originalThreadId);416 expect(resumedSession.threadId).toBe(originalThreadId);
414417
415 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId);418 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);
416 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);419 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);420 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418421
419 await manager.sendTurn({422 await manager.sendTurn({
420 sessionId: resumedSession.sessionId,423 threadId: resumedSession.threadId,
421 input: `Reply with exactly the word BETA ${randomUUID()}`,424 input: `Reply with exactly the word BETA ${randomUUID()}`,
422 });425 });
423426
424 await vi.waitFor(async () => {427 await vi.waitFor(async () => {
425 const snapshot = await manager.readThread(resumedSession.sessionId);428 const snapshot = await manager.readThread(resumedSession.threadId);
426 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);429 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427 }, { timeout: 120_000, interval: 1_000 });430 }, { timeout: 120_000, interval: 1_000 });
428 } finally {431 } finally {
apps/server/src/codexAppServerManager.ts+118-108
...@@ -8,9 +8,8 @@ import {...@@ -8,9 +8,8 @@ import {
8 EventId,8 EventId,
9 ProviderItemId,9 ProviderItemId,
10 ProviderRequestKind,10 ProviderRequestKind,
11 ProviderSessionId,11 ThreadId,
12 ProviderThreadId,12 TurnId,
13 ProviderTurnId,
14 normalizeModelSlug,13 normalizeModelSlug,
15 type ProviderApprovalDecision,14 type ProviderApprovalDecision,
16 type ProviderEvent,15 type ProviderEvent,
...@@ -18,7 +17,7 @@ import {...@@ -18,7 +17,7 @@ import {
18 type ProviderSessionStartInput,17 type ProviderSessionStartInput,
19 type ProviderTurnStartResult,18 type ProviderTurnStartResult,
20} from "@t3tools/contracts";19} from "@t3tools/contracts";
21import { Effect } from "effect";20import { Effect, ServiceMap } from "effect";
2221
23type PendingRequestKey = string;22type PendingRequestKey = string;
2423
...@@ -34,8 +33,8 @@ interface PendingApprovalRequest {...@@ -34,8 +33,8 @@ interface PendingApprovalRequest {
34 jsonRpcId: string | number;33 jsonRpcId: string | number;
35 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";34 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
36 requestKind: ProviderRequestKind;35 requestKind: ProviderRequestKind;
37 threadId?: ProviderThreadId;36 threadId: ThreadId;
38 turnId?: ProviderTurnId;37 turnId?: TurnId;
39 itemId?: ProviderItemId;38 itemId?: ProviderItemId;
40}39}
4140
...@@ -72,7 +71,7 @@ interface JsonRpcNotification {...@@ -72,7 +71,7 @@ interface JsonRpcNotification {
72}71}
7372
74export interface CodexAppServerSendTurnInput {73export interface CodexAppServerSendTurnInput {
75 readonly sessionId: ProviderSessionId;74 readonly threadId: ThreadId;
76 readonly input?: string;75 readonly input?: string;
77 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;76 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
78 readonly model?: string;77 readonly model?: string;
...@@ -80,12 +79,12 @@ export interface CodexAppServerSendTurnInput {...@@ -80,12 +79,12 @@ export interface CodexAppServerSendTurnInput {
80}79}
8180
82export interface CodexThreadTurnSnapshot {81export interface CodexThreadTurnSnapshot {
83 id: ProviderTurnId;82 id: TurnId;
84 items: unknown[];83 items: unknown[];
85}84}
8685
87export interface CodexThreadSnapshot {86export interface CodexThreadSnapshot {
88 threadId: ProviderThreadId;87 threadId: string;
89 turns: CodexThreadTurnSnapshot[];88 turns: CodexThreadTurnSnapshot[];
90}89}
9190
...@@ -191,10 +190,16 @@ export interface CodexAppServerManagerEvents {...@@ -191,10 +190,16 @@ export interface CodexAppServerManagerEvents {
191}190}
192191
193export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {192export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {
194 private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>();193 private readonly sessions = new Map<ThreadId, CodexSessionContext>();
194
195 private runPromise: (effect: Effect.Effect<unknown, never>) => Promise<unknown>;
196 constructor(services?: ServiceMap.ServiceMap<never>) {
197 super();
198 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;
199 }
195200
196 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {201 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
197 const sessionId = ProviderSessionId.makeUnsafe(randomUUID());202 const threadId = input.threadId;
198 const now = new Date().toISOString();203 const now = new Date().toISOString();
199 let context: CodexSessionContext | undefined;204 let context: CodexSessionContext | undefined;
200205
...@@ -202,12 +207,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -202,12 +207,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
202 const resolvedCwd = input.cwd ?? process.cwd();207 const resolvedCwd = input.cwd ?? process.cwd();
203208
204 const session: ProviderSession = {209 const session: ProviderSession = {
205 sessionId,
206 provider: "codex",210 provider: "codex",
207 status: "connecting",211 status: "connecting",
208 runtimeMode: input.runtimeMode,212 runtimeMode: input.runtimeMode,
209 model: normalizeCodexModelSlug(input.model),213 model: normalizeCodexModelSlug(input.model),
210 cwd: resolvedCwd,214 cwd: resolvedCwd,
215 threadId,
211 createdAt: now,216 createdAt: now,
212 updatedAt: now,217 updatedAt: now,
213 };218 };
...@@ -236,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -236,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
236 stopping: false,241 stopping: false,
237 };242 };
238243
239 this.sessions.set(sessionId, context);244 this.sessions.set(threadId, context);
240 this.attachProcessListeners(context);245 this.attachProcessListeners(context);
241246
242 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");247 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
...@@ -274,12 +279,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -274,12 +279,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
274 : "Starting a new Codex thread.",279 : "Starting a new Codex thread.",
275 );280 );
276 await Effect.logInfo("codex app-server opening thread", {281 await Effect.logInfo("codex app-server opening thread", {
277 sessionId,282 threadId,
278 requestedRuntimeMode: input.runtimeMode,283 requestedRuntimeMode: input.runtimeMode,
279 requestedModel: normalizedModel ?? null,284 requestedModel: normalizedModel ?? null,
280 requestedCwd: resolvedCwd,285 requestedCwd: resolvedCwd,
281 resumeThreadId: resumeThreadId ?? null,286 resumeThreadId: resumeThreadId ?? null,
282 }).pipe(Effect.runPromise);287 }).pipe(this.runPromise);
283288
284 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";289 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
285 let threadOpenResponse: unknown;290 let threadOpenResponse: unknown;
...@@ -298,12 +303,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -298,12 +303,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
298 error instanceof Error ? error.message : "Codex thread resume failed.",303 error instanceof Error ? error.message : "Codex thread resume failed.",
299 );304 );
300 await Effect.logWarning("codex app-server thread resume failed", {305 await Effect.logWarning("codex app-server thread resume failed", {
301 sessionId,306 threadId,
302 requestedRuntimeMode: input.runtimeMode,307 requestedRuntimeMode: input.runtimeMode,
303 resumeThreadId,308 resumeThreadId,
304 recoverable: false,309 recoverable: false,
305 cause: error instanceof Error ? error.message : String(error),310 cause: error instanceof Error ? error.message : String(error),
306 }).pipe(Effect.runPromise);311 }).pipe(this.runPromise);
307 throw error;312 throw error;
308 }313 }
309314
...@@ -314,12 +319,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -314,12 +319,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
314 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,319 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,
315 );320 );
316 await Effect.logWarning("codex app-server thread resume fell back to fresh start", {321 await Effect.logWarning("codex app-server thread resume fell back to fresh start", {
317 sessionId,322 threadId,
318 requestedRuntimeMode: input.runtimeMode,323 requestedRuntimeMode: input.runtimeMode,
319 resumeThreadId,324 resumeThreadId,
320 recoverable: true,325 recoverable: true,
321 cause: error instanceof Error ? error.message : String(error),326 cause: error instanceof Error ? error.message : String(error),
322 }).pipe(Effect.runPromise);327 }).pipe(this.runPromise);
323 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);328 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
324 }329 }
325 } else {330 } else {
...@@ -334,22 +339,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -334,22 +339,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
334 if (!threadIdRaw) {339 if (!threadIdRaw) {
335 throw new Error(`${threadOpenMethod} response did not include a thread id.`);340 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
336 }341 }
337 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);342 const providerThreadId = threadIdRaw;
338343
339 this.updateSession(context, {344 this.updateSession(context, {
340 status: "ready",345 status: "ready",
341 threadId,346 resumeCursor: { threadId: providerThreadId },
342 resumeCursor: { threadId },
343 });347 });
344 this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`);348 this.emitLifecycleEvent(
349 context,
350 "session/threadOpenResolved",
351 `Codex ${threadOpenMethod} resolved.`,
352 );
345 await Effect.logInfo("codex app-server thread open resolved", {353 await Effect.logInfo("codex app-server thread open resolved", {
346 sessionId,354 threadId,
347 threadOpenMethod,355 threadOpenMethod,
348 requestedResumeThreadId: resumeThreadId ?? null,356 requestedResumeThreadId: resumeThreadId ?? null,
349 resolvedThreadId: threadId,357 resolvedThreadId: providerThreadId,
350 requestedRuntimeMode: input.runtimeMode,358 requestedRuntimeMode: input.runtimeMode,
351 }).pipe(Effect.runPromise);359 }).pipe(this.runPromise);
352 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);360 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${providerThreadId}`);
353 return { ...context.session };361 return { ...context.session };
354 } catch (error) {362 } catch (error) {
355 const message = error instanceof Error ? error.message : "Failed to start Codex session.";363 const message = error instanceof Error ? error.message : "Failed to start Codex session.";
...@@ -359,13 +367,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -359,13 +367,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
359 lastError: message,367 lastError: message,
360 });368 });
361 this.emitErrorEvent(context, "session/startFailed", message);369 this.emitErrorEvent(context, "session/startFailed", message);
362 this.stopSession(sessionId);370 this.stopSession(threadId);
363 } else {371 } else {
364 this.emitEvent({372 this.emitEvent({
365 id: EventId.makeUnsafe(randomUUID()),373 id: EventId.makeUnsafe(randomUUID()),
366 kind: "error",374 kind: "error",
367 provider: "codex",375 provider: "codex",
368 sessionId,376 threadId,
369 createdAt: new Date().toISOString(),377 createdAt: new Date().toISOString(),
370 method: "session/startFailed",378 method: "session/startFailed",
371 message,379 message,
...@@ -376,10 +384,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -376,10 +384,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
376 }384 }
377385
378 async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {386 async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
379 const context = this.requireSession(input.sessionId);387 const context = this.requireSession(input.threadId);
380 if (!context.session.threadId) {
381 throw new Error("Session is missing a thread id.");
382 }
383388
384 const turnInput: Array<389 const turnInput: Array<
385 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }390 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
...@@ -403,15 +408,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -403,15 +408,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
403 throw new Error("Turn input must include text or attachments.");408 throw new Error("Turn input must include text or attachments.");
404 }409 }
405410
411 const providerThreadId = readResumeThreadId({
412 threadId: context.session.threadId,
413 runtimeMode: context.session.runtimeMode,
414 resumeCursor: context.session.resumeCursor,
415 });
416 if (!providerThreadId) {
417 throw new Error("Session is missing provider resume thread id.");
418 }
406 const turnStartParams: {419 const turnStartParams: {
407 threadId: ProviderThreadId;420 threadId: string;
408 input: Array<421 input: Array<
409 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }422 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
410 >;423 >;
411 model?: string;424 model?: string;
412 effort?: string;425 effort?: string;
413 } = {426 } = {
414 threadId: context.session.threadId,427 threadId: providerThreadId,
415 input: turnInput,428 input: turnInput,
416 };429 };
417 const normalizedModel = normalizeCodexModelSlug(input.model);430 const normalizedModel = normalizeCodexModelSlug(input.model);
...@@ -429,7 +442,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -429,7 +442,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
429 if (!turnIdRaw) {442 if (!turnIdRaw) {
430 throw new Error("turn/start response did not include a turn id.");443 throw new Error("turn/start response did not include a turn id.");
431 }444 }
432 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);445 const turnId = TurnId.makeUnsafe(turnIdRaw);
433446
434 this.updateSession(context, {447 this.updateSession(context, {
435 status: "running",448 status: "running",
...@@ -448,49 +461,59 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -448,49 +461,59 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
448 };461 };
449 }462 }
450463
451 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {464 async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise<void> {
452 const context = this.requireSession(sessionId);465 const context = this.requireSession(threadId);
453 const effectiveTurnId = turnId ?? context.session.activeTurnId;466 const effectiveTurnId = turnId ?? context.session.activeTurnId;
454467
455 if (!effectiveTurnId || !context.session.threadId) {468 const providerThreadId = readResumeThreadId({
469 threadId: context.session.threadId,
470 runtimeMode: context.session.runtimeMode,
471 resumeCursor: context.session.resumeCursor,
472 });
473 if (!effectiveTurnId || !providerThreadId) {
456 return;474 return;
457 }475 }
458476
459 await this.sendRequest(context, "turn/interrupt", {477 await this.sendRequest(context, "turn/interrupt", {
460 threadId: context.session.threadId,478 threadId: providerThreadId,
461 turnId: effectiveTurnId,479 turnId: effectiveTurnId,
462 });480 });
463 }481 }
464482
465 async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> {483 async readThread(threadId: ThreadId): Promise<CodexThreadSnapshot> {
466 const context = this.requireSession(sessionId);484 const context = this.requireSession(threadId);
467 const threadId = context.session.threadId;485 const providerThreadId = readResumeThreadId({
468 if (!threadId) {486 threadId: context.session.threadId,
469 throw new Error("Session is missing a thread id.");487 runtimeMode: context.session.runtimeMode,
488 resumeCursor: context.session.resumeCursor,
489 });
490 if (!providerThreadId) {
491 throw new Error("Session is missing a provider resume thread id.");
470 }492 }
471493
472 const response = await this.sendRequest(context, "thread/read", {494 const response = await this.sendRequest(context, "thread/read", {
473 threadId,495 threadId: providerThreadId,
474 includeTurns: true,496 includeTurns: true,
475 });497 });
476 return this.parseThreadSnapshot("thread/read", response);498 return this.parseThreadSnapshot("thread/read", response);
477 }499 }
478500
479 async rollbackThread(501 async rollbackThread(threadId: ThreadId, numTurns: number): Promise<CodexThreadSnapshot> {
480 sessionId: ProviderSessionId,502 const context = this.requireSession(threadId);
481 numTurns: number,503 const providerThreadId = readResumeThreadId({
482 ): Promise<CodexThreadSnapshot> {504 threadId: context.session.threadId,
483 const context = this.requireSession(sessionId);505 runtimeMode: context.session.runtimeMode,
484 const threadId = context.session.threadId;506 resumeCursor: context.session.resumeCursor,
485 if (!threadId) {507 });
486 throw new Error("Session is missing a thread id.");508 if (!providerThreadId) {
509 throw new Error("Session is missing a provider resume thread id.");
487 }510 }
488 if (!Number.isInteger(numTurns) || numTurns < 1) {511 if (!Number.isInteger(numTurns) || numTurns < 1) {
489 throw new Error("numTurns must be an integer >= 1.");512 throw new Error("numTurns must be an integer >= 1.");
490 }513 }
491514
492 const response = await this.sendRequest(context, "thread/rollback", {515 const response = await this.sendRequest(context, "thread/rollback", {
493 threadId,516 threadId: providerThreadId,
494 numTurns,517 numTurns,
495 });518 });
496 this.updateSession(context, {519 this.updateSession(context, {
...@@ -501,11 +524,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -501,11 +524,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
501 }524 }
502525
503 async respondToRequest(526 async respondToRequest(
504 sessionId: ProviderSessionId,527 threadId: ThreadId,
505 requestId: ApprovalRequestId,528 requestId: ApprovalRequestId,
506 decision: ProviderApprovalDecision,529 decision: ProviderApprovalDecision,
507 ): Promise<void> {530 ): Promise<void> {
508 const context = this.requireSession(sessionId);531 const context = this.requireSession(threadId);
509 const pendingRequest = context.pendingApprovals.get(requestId);532 const pendingRequest = context.pendingApprovals.get(requestId);
510 if (!pendingRequest) {533 if (!pendingRequest) {
511 throw new Error(`Unknown pending approval request: ${requestId}`);534 throw new Error(`Unknown pending approval request: ${requestId}`);
...@@ -523,10 +546,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -523,10 +546,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
523 id: EventId.makeUnsafe(randomUUID()),546 id: EventId.makeUnsafe(randomUUID()),
524 kind: "notification",547 kind: "notification",
525 provider: "codex",548 provider: "codex",
526 sessionId: context.session.sessionId,549 threadId: context.session.threadId,
527 createdAt: new Date().toISOString(),550 createdAt: new Date().toISOString(),
528 method: "item/requestApproval/decision",551 method: "item/requestApproval/decision",
529 threadId: pendingRequest.threadId,
530 turnId: pendingRequest.turnId,552 turnId: pendingRequest.turnId,
531 itemId: pendingRequest.itemId,553 itemId: pendingRequest.itemId,
532 requestId: pendingRequest.requestId,554 requestId: pendingRequest.requestId,
...@@ -539,8 +561,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -539,8 +561,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
539 });561 });
540 }562 }
541563
542 stopSession(sessionId: ProviderSessionId): void {564 stopSession(threadId: ThreadId): void {
543 const context = this.sessions.get(sessionId);565 const context = this.sessions.get(threadId);
544 if (!context) {566 if (!context) {
545 return;567 return;
546 }568 }
...@@ -565,7 +587,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -565,7 +587,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
565 activeTurnId: undefined,587 activeTurnId: undefined,
566 });588 });
567 this.emitLifecycleEvent(context, "session/closed", "Session stopped");589 this.emitLifecycleEvent(context, "session/closed", "Session stopped");
568 this.sessions.delete(sessionId);590 this.sessions.delete(threadId);
569 }591 }
570592
571 listSessions(): ProviderSession[] {593 listSessions(): ProviderSession[] {
...@@ -574,24 +596,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -574,24 +596,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
574 }));596 }));
575 }597 }
576598
577 hasSession(sessionId: ProviderSessionId): boolean {599 hasSession(threadId: ThreadId): boolean {
578 return this.sessions.has(sessionId);600 return this.sessions.has(threadId);
579 }601 }
580602
581 stopAll(): void {603 stopAll(): void {
582 for (const sessionId of this.sessions.keys()) {604 for (const threadId of this.sessions.keys()) {
583 this.stopSession(sessionId);605 this.stopSession(threadId);
584 }606 }
585 }607 }
586608
587 private requireSession(sessionId: ProviderSessionId): CodexSessionContext {609 private requireSession(threadId: ThreadId): CodexSessionContext {
588 const context = this.sessions.get(sessionId);610 const context = this.sessions.get(threadId);
589 if (!context) {611 if (!context) {
590 throw new Error(`Unknown session: ${sessionId}`);612 throw new Error(`Unknown session for thread: ${threadId}`);
591 }613 }
592614
593 if (context.session.status === "closed") {615 if (context.session.status === "closed") {
594 throw new Error(`Session is closed: ${sessionId}`);616 throw new Error(`Session is closed for thread: ${threadId}`);
595 }617 }
596618
597 return context;619 return context;
...@@ -636,7 +658,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -636,7 +658,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
636 lastError: code === 0 ? context.session.lastError : message,658 lastError: code === 0 ? context.session.lastError : message,
637 });659 });
638 this.emitLifecycleEvent(context, "session/exited", message);660 this.emitLifecycleEvent(context, "session/exited", message);
639 this.sessions.delete(context.session.sessionId);661 this.sessions.delete(context.session.threadId);
640 });662 });
641 }663 }
642664
...@@ -698,10 +720,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -698,10 +720,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
698 id: EventId.makeUnsafe(randomUUID()),720 id: EventId.makeUnsafe(randomUUID()),
699 kind: "notification",721 kind: "notification",
700 provider: "codex",722 provider: "codex",
701 sessionId: context.session.sessionId,723 threadId: context.session.threadId,
702 createdAt: new Date().toISOString(),724 createdAt: new Date().toISOString(),
703 method: notification.method,725 method: notification.method,
704 threadId: route.threadId,
705 turnId: route.turnId,726 turnId: route.turnId,
706 itemId: route.itemId,727 itemId: route.itemId,
707 textDelta,728 textDelta,
...@@ -709,19 +730,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -709,19 +730,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
709 });730 });
710731
711 if (notification.method === "thread/started") {732 if (notification.method === "thread/started") {
712 const threadId = toProviderThreadId(733 const providerThreadId = normalizeProviderThreadId(
713 this.readString(this.readObject(notification.params)?.thread, "id"),734 this.readString(this.readObject(notification.params)?.thread, "id"),
714 );735 );
715 if (threadId) {736 if (providerThreadId) {
716 this.updateSession(context, { threadId });737 this.updateSession(context, { resumeCursor: { threadId: providerThreadId } });
717 }738 }
718 return;739 return;
719 }740 }
720741
721 if (notification.method === "turn/started") {742 if (notification.method === "turn/started") {
722 const turnId = toProviderTurnId(743 const turnId = toTurnId(this.readString(this.readObject(notification.params)?.turn, "id"));
723 this.readString(this.readObject(notification.params)?.turn, "id"),
724 );
725 this.updateSession(context, {744 this.updateSession(context, {
726 status: "running",745 status: "running",
727 activeTurnId: turnId,746 activeTurnId: turnId,
...@@ -766,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -766,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
766 ? "item/commandExecution/requestApproval"785 ? "item/commandExecution/requestApproval"
767 : "item/fileChange/requestApproval",786 : "item/fileChange/requestApproval",
768 requestKind,787 requestKind,
769 ...(route.threadId ? { threadId: route.threadId } : {}),788 threadId: context.session.threadId,
770 ...(route.turnId ? { turnId: route.turnId } : {}),789 ...(route.turnId ? { turnId: route.turnId } : {}),
771 ...(route.itemId ? { itemId: route.itemId } : {}),790 ...(route.itemId ? { itemId: route.itemId } : {}),
772 };791 };
...@@ -777,10 +796,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -777,10 +796,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
777 id: EventId.makeUnsafe(randomUUID()),796 id: EventId.makeUnsafe(randomUUID()),
778 kind: "request",797 kind: "request",
779 provider: "codex",798 provider: "codex",
780 sessionId: context.session.sessionId,799 threadId: context.session.threadId,
781 createdAt: new Date().toISOString(),800 createdAt: new Date().toISOString(),
782 method: request.method,801 method: request.method,
783 threadId: route.threadId,
784 turnId: route.turnId,802 turnId: route.turnId,
785 itemId: route.itemId,803 itemId: route.itemId,
786 requestId,804 requestId,
...@@ -872,7 +890,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -872,7 +890,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
872 id: EventId.makeUnsafe(randomUUID()),890 id: EventId.makeUnsafe(randomUUID()),
873 kind: "session",891 kind: "session",
874 provider: "codex",892 provider: "codex",
875 sessionId: context.session.sessionId,893 threadId: context.session.threadId,
876 createdAt: new Date().toISOString(),894 createdAt: new Date().toISOString(),
877 method,895 method,
878 message,896 message,
...@@ -884,7 +902,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -884,7 +902,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
884 id: EventId.makeUnsafe(randomUUID()),902 id: EventId.makeUnsafe(randomUUID()),
885 kind: "error",903 kind: "error",
886 provider: "codex",904 provider: "codex",
887 sessionId: context.session.sessionId,905 threadId: context.session.threadId,
888 createdAt: new Date().toISOString(),906 createdAt: new Date().toISOString(),
889 method,907 method,
890 message,908 message,
...@@ -908,6 +926,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -908,6 +926,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
908 return "command";926 return "command";
909 }927 }
910928
929 if (method === "item/fileRead/requestApproval") {
930 return "file-read";
931 }
932
911 if (method === "item/fileChange/requestApproval") {933 if (method === "item/fileChange/requestApproval") {
912 return "file-change";934 return "file-change";
913 }935 }
...@@ -923,14 +945,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -923,14 +945,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
923 if (!threadIdRaw) {945 if (!threadIdRaw) {
924 throw new Error(`${method} response did not include a thread id.`);946 throw new Error(`${method} response did not include a thread id.`);
925 }947 }
926 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
927
928 const turnsRaw =948 const turnsRaw =
929 this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];949 this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
930 const turns = turnsRaw.map((turnValue, index) => {950 const turns = turnsRaw.map((turnValue, index) => {
931 const turn = this.readObject(turnValue);951 const turn = this.readObject(turnValue);
932 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;952 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;
933 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);953 const turnId = TurnId.makeUnsafe(turnIdRaw);
934 const items = this.readArray(turn, "items") ?? [];954 const items = this.readArray(turn, "items") ?? [];
935 return {955 return {
936 id: turnId,956 id: turnId,
...@@ -939,7 +959,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -939,7 +959,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
939 });959 });
940960
941 return {961 return {
942 threadId,962 threadId: threadIdRaw,
943 turns,963 turns,
944 };964 };
945 }965 }
...@@ -977,31 +997,21 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -977,31 +997,21 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
977 }997 }
978998
979 private readRouteFields(params: unknown): {999 private readRouteFields(params: unknown): {
980 threadId?: ProviderThreadId;1000 turnId?: TurnId;
981 turnId?: ProviderTurnId;
982 itemId?: ProviderItemId;1001 itemId?: ProviderItemId;
983 } {1002 } {
984 const route: {1003 const route: {
985 threadId?: ProviderThreadId;1004 turnId?: TurnId;
986 turnId?: ProviderTurnId;
987 itemId?: ProviderItemId;1005 itemId?: ProviderItemId;
988 } = {};1006 } = {};
9891007
990 const threadId = toProviderThreadId(1008 const turnId = toTurnId(
991 this.readString(params, "threadId") ??
992 this.readString(this.readObject(params, "thread"), "id"),
993 );
994 const turnId = toProviderTurnId(
995 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"),1009 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"),
996 );1010 );
997 const itemId = toProviderItemId(1011 const itemId = toProviderItemId(
998 this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"),1012 this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"),
999 );1013 );
10001014
1001 if (threadId) {
1002 route.threadId = threadId;
1003 }
1004
1005 if (turnId) {1015 if (turnId) {
1006 route.turnId = turnId;1016 route.turnId = turnId;
1007 }1017 }
...@@ -1065,8 +1075,8 @@ function brandIfNonEmpty<T extends string>(...@@ -1065,8 +1075,8 @@ function brandIfNonEmpty<T extends string>(
1065 return normalized?.length ? maker(normalized) : undefined;1075 return normalized?.length ? maker(normalized) : undefined;
1066}1076}
10671077
1068function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined {1078function normalizeProviderThreadId(value: string | undefined): string | undefined {
1069 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);1079 return brandIfNonEmpty(value, (normalized) => normalized);
1070}1080}
10711081
1072function readCodexProviderOptions(input: ProviderSessionStartInput): {1082function readCodexProviderOptions(input: ProviderSessionStartInput): {
...@@ -1083,20 +1093,20 @@ function readCodexProviderOptions(input: ProviderSessionStartInput): {...@@ -1083,20 +1093,20 @@ function readCodexProviderOptions(input: ProviderSessionStartInput): {
1083 };1093 };
1084}1094}
10851095
1086function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined {1096function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
1087 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {1097 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
1088 return undefined;1098 return undefined;
1089 }1099 }
1090 const rawThreadId = (resumeCursor as Record<string, unknown>).threadId;1100 const rawThreadId = (resumeCursor as Record<string, unknown>).threadId;
1091 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined;1101 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;
1092}1102}
10931103
1094function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {1104function readResumeThreadId(input: ProviderSessionStartInput): string | undefined {
1095 return readResumeCursorThreadId(input.resumeCursor);1105 return readResumeCursorThreadId(input.resumeCursor);
1096}1106}
10971107
1098function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {1108function toTurnId(value: string | undefined): TurnId | undefined {
1099 return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe);1109 return brandIfNonEmpty(value, TurnId.makeUnsafe);
1100}1110}
11011111
1102function toProviderItemId(value: string | undefined): ProviderItemId | undefined {1112function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+43-71
...@@ -9,9 +9,6 @@ import {...@@ -9,9 +9,6 @@ import {
9 EventId,9 EventId,
10 MessageId,10 MessageId,
11 ProjectId,11 ProjectId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,12 ThreadId,
16 TurnId,13 TurnId,
17} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -40,18 +37,14 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";...@@ -40,18 +37,14 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
40import { ServerConfig } from "../../config.ts";37import { ServerConfig } from "../../config.ts";
4138
42const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);39const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
43const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
44const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
45const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
46const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);40const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4741
48type LegacyProviderRuntimeEvent = {42type LegacyProviderRuntimeEvent = {
49 readonly type: string;43 readonly type: string;
50 readonly eventId: EventId;44 readonly eventId: EventId;
51 readonly provider: "codex" | "claudeCode" | "cursor";45 readonly provider: "codex" | "claudeCode" | "cursor";
52 readonly sessionId: string;
53 readonly createdAt: string;46 readonly createdAt: string;
54 readonly threadId?: string | undefined;47 readonly threadId: ThreadId;
55 readonly turnId?: string | undefined;48 readonly turnId?: string | undefined;
56 readonly itemId?: string | undefined;49 readonly itemId?: string | undefined;
57 readonly requestId?: string | undefined;50 readonly requestId?: string | undefined;
...@@ -68,7 +61,7 @@ function createProviderServiceHarness(...@@ -68,7 +61,7 @@ function createProviderServiceHarness(
68 const now = new Date().toISOString();61 const now = new Date().toISOString();
69 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());62 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
70 const rollbackConversation = vi.fn(63 const rollbackConversation = vi.fn(
71 (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void,64 (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void,
72 );65 );
7366
74 const unsupported = <A>() =>67 const unsupported = <A>() =>
...@@ -77,11 +70,10 @@ function createProviderServiceHarness(...@@ -77,11 +70,10 @@ function createProviderServiceHarness(
77 hasSession70 hasSession
78 ? Effect.succeed([71 ? Effect.succeed([
79 {72 {
80 sessionId: asSessionId("sess-1"),
81 provider: providerName,73 provider: providerName,
82 status: "ready",74 status: "ready",
83 runtimeMode: "full-access",75 runtimeMode: "full-access",
84 threadId: asProviderThreadId("provider-thread-1"),76 threadId: ThreadId.makeUnsafe("thread-1"),
85 cwd: sessionCwd,77 cwd: sessionCwd,
86 createdAt: now,78 createdAt: now,
87 updatedAt: now,79 updatedAt: now,
...@@ -344,8 +336,6 @@ describe("CheckpointReactor", () => {...@@ -344,8 +336,6 @@ describe("CheckpointReactor", () => {
344 threadId: ThreadId.makeUnsafe("thread-1"),336 threadId: ThreadId.makeUnsafe("thread-1"),
345 status: "ready",337 status: "ready",
346 providerName: "codex",338 providerName: "codex",
347 providerSessionId: asSessionId("sess-1"),
348 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
349 runtimeMode: "approval-required",339 runtimeMode: "approval-required",
350 activeTurnId: null,340 activeTurnId: null,
351 lastError: null,341 lastError: null,
...@@ -359,10 +349,10 @@ describe("CheckpointReactor", () => {...@@ -359,10 +349,10 @@ describe("CheckpointReactor", () => {
359 type: "turn.started",349 type: "turn.started",
360 eventId: EventId.makeUnsafe("evt-turn-started-1"),350 eventId: EventId.makeUnsafe("evt-turn-started-1"),
361 provider: "codex",351 provider: "codex",
362 sessionId: asSessionId("sess-1"),352
363 createdAt: new Date().toISOString(),353 createdAt: new Date().toISOString(),
364 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),354 threadId: ThreadId.makeUnsafe("thread-1"),
365 turnId: asProviderTurnId("turn-1"),355 turnId: asTurnId("turn-1"),
366 });356 });
367 await waitForGitRefExists(357 await waitForGitRefExists(
368 harness.cwd,358 harness.cwd,
...@@ -374,10 +364,10 @@ describe("CheckpointReactor", () => {...@@ -374,10 +364,10 @@ describe("CheckpointReactor", () => {
374 type: "turn.completed",364 type: "turn.completed",
375 eventId: EventId.makeUnsafe("evt-turn-completed-1"),365 eventId: EventId.makeUnsafe("evt-turn-completed-1"),
376 provider: "codex",366 provider: "codex",
377 sessionId: asSessionId("sess-1"),367
378 createdAt: new Date().toISOString(),368 createdAt: new Date().toISOString(),
379 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),369 threadId: ThreadId.makeUnsafe("thread-1"),
380 turnId: asProviderTurnId("turn-1"),370 turnId: asTurnId("turn-1"),
381 payload: { state: "completed" },371 payload: { state: "completed" },
382 });372 });
383373
...@@ -422,8 +412,6 @@ describe("CheckpointReactor", () => {...@@ -422,8 +412,6 @@ describe("CheckpointReactor", () => {
422 threadId: ThreadId.makeUnsafe("thread-1"),412 threadId: ThreadId.makeUnsafe("thread-1"),
423 status: "running",413 status: "running",
424 providerName: "codex",414 providerName: "codex",
425 providerSessionId: asSessionId("sess-1"),
426 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
427 runtimeMode: "approval-required",415 runtimeMode: "approval-required",
428 activeTurnId: asTurnId("turn-main"),416 activeTurnId: asTurnId("turn-main"),
429 lastError: null,417 lastError: null,
...@@ -437,10 +425,10 @@ describe("CheckpointReactor", () => {...@@ -437,10 +425,10 @@ describe("CheckpointReactor", () => {
437 type: "turn.started",425 type: "turn.started",
438 eventId: EventId.makeUnsafe("evt-turn-started-main"),426 eventId: EventId.makeUnsafe("evt-turn-started-main"),
439 provider: "codex",427 provider: "codex",
440 sessionId: asSessionId("sess-1"),428
441 createdAt: new Date().toISOString(),429 createdAt: new Date().toISOString(),
442 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),430 threadId: ThreadId.makeUnsafe("thread-1"),
443 turnId: asProviderTurnId("turn-main"),431 turnId: asTurnId("turn-main"),
444 });432 });
445 await waitForGitRefExists(433 await waitForGitRefExists(
446 harness.cwd,434 harness.cwd,
...@@ -453,10 +441,10 @@ describe("CheckpointReactor", () => {...@@ -453,10 +441,10 @@ describe("CheckpointReactor", () => {
453 type: "turn.completed",441 type: "turn.completed",
454 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),442 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),
455 provider: "codex",443 provider: "codex",
456 sessionId: asSessionId("sess-1"),444
457 createdAt: new Date().toISOString(),445 createdAt: new Date().toISOString(),
458 threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"),446 threadId: ThreadId.makeUnsafe("thread-1"),
459 turnId: asProviderTurnId("turn-aux"),447 turnId: asTurnId("turn-aux"),
460 payload: { state: "completed" },448 payload: { state: "completed" },
461 });449 });
462450
...@@ -471,10 +459,10 @@ describe("CheckpointReactor", () => {...@@ -471,10 +459,10 @@ describe("CheckpointReactor", () => {
471 type: "turn.completed",459 type: "turn.completed",
472 eventId: EventId.makeUnsafe("evt-turn-completed-main"),460 eventId: EventId.makeUnsafe("evt-turn-completed-main"),
473 provider: "codex",461 provider: "codex",
474 sessionId: asSessionId("sess-1"),462
475 createdAt: new Date().toISOString(),463 createdAt: new Date().toISOString(),
476 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),464 threadId: ThreadId.makeUnsafe("thread-1"),
477 turnId: asProviderTurnId("turn-main"),465 turnId: asTurnId("turn-main"),
478 payload: { state: "completed" },466 payload: { state: "completed" },
479 });467 });
480468
...@@ -501,8 +489,6 @@ describe("CheckpointReactor", () => {...@@ -501,8 +489,6 @@ describe("CheckpointReactor", () => {
501 threadId: ThreadId.makeUnsafe("thread-1"),489 threadId: ThreadId.makeUnsafe("thread-1"),
502 status: "ready",490 status: "ready",
503 providerName: "claudeCode",491 providerName: "claudeCode",
504 providerSessionId: asSessionId("sess-1"),
505 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
506 runtimeMode: "approval-required",492 runtimeMode: "approval-required",
507 activeTurnId: null,493 activeTurnId: null,
508 lastError: null,494 lastError: null,
...@@ -516,10 +502,10 @@ describe("CheckpointReactor", () => {...@@ -516,10 +502,10 @@ describe("CheckpointReactor", () => {
516 type: "turn.started",502 type: "turn.started",
517 eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),503 eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),
518 provider: "claudeCode",504 provider: "claudeCode",
519 sessionId: asSessionId("sess-1"),505
520 createdAt: new Date().toISOString(),506 createdAt: new Date().toISOString(),
521 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),507 threadId: ThreadId.makeUnsafe("thread-1"),
522 turnId: asProviderTurnId("turn-claude-1"),508 turnId: asTurnId("turn-claude-1"),
523 });509 });
524 await waitForGitRefExists(510 await waitForGitRefExists(
525 harness.cwd,511 harness.cwd,
...@@ -531,10 +517,10 @@ describe("CheckpointReactor", () => {...@@ -531,10 +517,10 @@ describe("CheckpointReactor", () => {
531 type: "turn.completed",517 type: "turn.completed",
532 eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),518 eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),
533 provider: "claudeCode",519 provider: "claudeCode",
534 sessionId: asSessionId("sess-1"),520
535 createdAt: new Date().toISOString(),521 createdAt: new Date().toISOString(),
536 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),522 threadId: ThreadId.makeUnsafe("thread-1"),
537 turnId: asProviderTurnId("turn-claude-1"),523 turnId: asTurnId("turn-claude-1"),
538 payload: { state: "completed" },524 payload: { state: "completed" },
539 });525 });
540526
...@@ -563,8 +549,6 @@ describe("CheckpointReactor", () => {...@@ -563,8 +549,6 @@ describe("CheckpointReactor", () => {
563 threadId: ThreadId.makeUnsafe("thread-1"),549 threadId: ThreadId.makeUnsafe("thread-1"),
564 status: "ready",550 status: "ready",
565 providerName: "codex",551 providerName: "codex",
566 providerSessionId: asSessionId("sess-1"),
567 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
568 runtimeMode: "approval-required",552 runtimeMode: "approval-required",
569 activeTurnId: null,553 activeTurnId: null,
570 lastError: null,554 lastError: null,
...@@ -578,10 +562,10 @@ describe("CheckpointReactor", () => {...@@ -578,10 +562,10 @@ describe("CheckpointReactor", () => {
578 type: "turn.completed",562 type: "turn.completed",
579 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),563 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),
580 provider: "codex",564 provider: "codex",
581 sessionId: asSessionId("sess-1"),565
582 createdAt: new Date().toISOString(),566 createdAt: new Date().toISOString(),
583 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),567 threadId: ThreadId.makeUnsafe("thread-1"),
584 turnId: asProviderTurnId("turn-missing-baseline"),568 turnId: asTurnId("turn-missing-baseline"),
585 payload: { state: "completed" },569 payload: { state: "completed" },
586 });570 });
587571
...@@ -652,8 +636,6 @@ describe("CheckpointReactor", () => {...@@ -652,8 +636,6 @@ describe("CheckpointReactor", () => {
652 threadId: ThreadId.makeUnsafe("thread-1"),636 threadId: ThreadId.makeUnsafe("thread-1"),
653 status: "running",637 status: "running",
654 providerName: "codex",638 providerName: "codex",
655 providerSessionId: asSessionId("sess-missing"),
656 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),
657 runtimeMode: "approval-required",639 runtimeMode: "approval-required",
658 activeTurnId: asTurnId("turn-missing-cwd"),640 activeTurnId: asTurnId("turn-missing-cwd"),
659 lastError: null,641 lastError: null,
...@@ -668,10 +650,10 @@ describe("CheckpointReactor", () => {...@@ -668,10 +650,10 @@ describe("CheckpointReactor", () => {
668 type: "turn.completed",650 type: "turn.completed",
669 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),651 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
670 provider: "codex",652 provider: "codex",
671 sessionId: asSessionId("sess-missing"),653
672 createdAt: new Date().toISOString(),654 createdAt: new Date().toISOString(),
673 threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),655 threadId: ThreadId.makeUnsafe("thread-1"),
674 turnId: asProviderTurnId("turn-missing-cwd"),656 turnId: asTurnId("turn-missing-cwd"),
675 payload: { state: "completed" },657 payload: { state: "completed" },
676 });658 });
677659
...@@ -701,8 +683,6 @@ describe("CheckpointReactor", () => {...@@ -701,8 +683,6 @@ describe("CheckpointReactor", () => {
701 threadId: ThreadId.makeUnsafe("thread-1"),683 threadId: ThreadId.makeUnsafe("thread-1"),
702 status: "ready",684 status: "ready",
703 providerName: "codex",685 providerName: "codex",
704 providerSessionId: asSessionId("sess-1"),
705 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
706 runtimeMode: "approval-required",686 runtimeMode: "approval-required",
707 activeTurnId: null,687 activeTurnId: null,
708 lastError: null,688 lastError: null,
...@@ -716,10 +696,10 @@ describe("CheckpointReactor", () => {...@@ -716,10 +696,10 @@ describe("CheckpointReactor", () => {
716 type: "checkpoint.captured",696 type: "checkpoint.captured",
717 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),697 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
718 provider: "codex",698 provider: "codex",
719 sessionId: asSessionId("sess-1"),699
720 createdAt: new Date().toISOString(),700 createdAt: new Date().toISOString(),
721 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),701 threadId: ThreadId.makeUnsafe("thread-1"),
722 turnId: asProviderTurnId("turn-3"),702 turnId: asTurnId("turn-3"),
723 turnCount: 3,703 turnCount: 3,
724 status: "completed",704 status: "completed",
725 });705 });
...@@ -753,8 +733,6 @@ describe("CheckpointReactor", () => {...@@ -753,8 +733,6 @@ describe("CheckpointReactor", () => {
753 threadId: ThreadId.makeUnsafe("thread-1"),733 threadId: ThreadId.makeUnsafe("thread-1"),
754 status: "ready",734 status: "ready",
755 providerName: "codex",735 providerName: "codex",
756 providerSessionId: asSessionId("sess-1"),
757 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
758 runtimeMode: "approval-required",736 runtimeMode: "approval-required",
759 activeTurnId: null,737 activeTurnId: null,
760 lastError: null,738 lastError: null,
...@@ -768,10 +746,10 @@ describe("CheckpointReactor", () => {...@@ -768,10 +746,10 @@ describe("CheckpointReactor", () => {
768 type: "turn.completed",746 type: "turn.completed",
769 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),747 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
770 provider: "codex",748 provider: "codex",
771 sessionId: asSessionId("sess-1"),749
772 createdAt: new Date().toISOString(),750 createdAt: new Date().toISOString(),
773 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),751 threadId: ThreadId.makeUnsafe("thread-1"),
774 turnId: asProviderTurnId("turn-runtime-failure"),752 turnId: asTurnId("turn-runtime-failure"),
775 payload: { state: "completed" },753 payload: { state: "completed" },
776 });754 });
777755
...@@ -779,10 +757,10 @@ describe("CheckpointReactor", () => {...@@ -779,10 +757,10 @@ describe("CheckpointReactor", () => {
779 type: "turn.started",757 type: "turn.started",
780 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),758 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
781 provider: "codex",759 provider: "codex",
782 sessionId: asSessionId("sess-1"),760
783 createdAt: new Date().toISOString(),761 createdAt: new Date().toISOString(),
784 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),762 threadId: ThreadId.makeUnsafe("thread-1"),
785 turnId: asProviderTurnId("turn-after-runtime-failure"),763 turnId: asTurnId("turn-after-runtime-failure"),
786 });764 });
787765
788 await waitForGitRefExists(766 await waitForGitRefExists(
...@@ -807,8 +785,6 @@ describe("CheckpointReactor", () => {...@@ -807,8 +785,6 @@ describe("CheckpointReactor", () => {
807 threadId: ThreadId.makeUnsafe("thread-1"),785 threadId: ThreadId.makeUnsafe("thread-1"),
808 status: "ready",786 status: "ready",
809 providerName: "codex",787 providerName: "codex",
810 providerSessionId: asSessionId("sess-1"),
811 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
812 runtimeMode: "approval-required",788 runtimeMode: "approval-required",
813 activeTurnId: null,789 activeTurnId: null,
814 lastError: null,790 lastError: null,
...@@ -865,7 +841,7 @@ describe("CheckpointReactor", () => {...@@ -865,7 +841,7 @@ describe("CheckpointReactor", () => {
865 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);841 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
866 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);842 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
867 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({843 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
868 sessionId: asSessionId("sess-1"),844
869 numTurns: 1,845 numTurns: 1,
870 });846 });
871 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");847 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
...@@ -887,8 +863,6 @@ describe("CheckpointReactor", () => {...@@ -887,8 +863,6 @@ describe("CheckpointReactor", () => {
887 threadId: ThreadId.makeUnsafe("thread-1"),863 threadId: ThreadId.makeUnsafe("thread-1"),
888 status: "ready",864 status: "ready",
889 providerName: "claudeCode",865 providerName: "claudeCode",
890 providerSessionId: asSessionId("sess-1"),
891 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
892 runtimeMode: "approval-required",866 runtimeMode: "approval-required",
893 activeTurnId: null,867 activeTurnId: null,
894 lastError: null,868 lastError: null,
...@@ -940,7 +914,7 @@ describe("CheckpointReactor", () => {...@@ -940,7 +914,7 @@ describe("CheckpointReactor", () => {
940 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");914 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
941 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);915 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
942 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({916 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
943 sessionId: asSessionId("sess-1"),917
944 numTurns: 1,918 numTurns: 1,
945 });919 });
946 });920 });
...@@ -958,8 +932,6 @@ describe("CheckpointReactor", () => {...@@ -958,8 +932,6 @@ describe("CheckpointReactor", () => {
958 threadId: ThreadId.makeUnsafe("thread-1"),932 threadId: ThreadId.makeUnsafe("thread-1"),
959 status: "ready",933 status: "ready",
960 providerName: "codex",934 providerName: "codex",
961 providerSessionId: asSessionId("sess-1"),
962 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
963 runtimeMode: "approval-required",935 runtimeMode: "approval-required",
964 activeTurnId: null,936 activeTurnId: null,
965 lastError: null,937 lastError: null,
...@@ -1032,11 +1004,11 @@ describe("CheckpointReactor", () => {...@@ -1032,11 +1004,11 @@ describe("CheckpointReactor", () => {
10321004
1033 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);1005 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
1034 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({1006 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
1035 sessionId: asSessionId("sess-1"),1007
1036 numTurns: 1,1008 numTurns: 1,
1037 });1009 });
1038 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({1010 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({
1039 sessionId: asSessionId("sess-1"),1011
1040 numTurns: 1,1012 numTurns: 1,
1041 });1013 });
1042 });1014 });
apps/server/src/orchestration/Layers/CheckpointReactor.ts+27-71
...@@ -2,13 +2,10 @@ import {...@@ -2,13 +2,10 @@ import {
2 CommandId,2 CommandId,
3 EventId,3 EventId,
4 MessageId,4 MessageId,
5 ProviderSessionId,
6 ProviderThreadId,
7 ThreadId,5 ThreadId,
8 TurnId,6 TurnId,
9 type OrchestrationEvent,7 type OrchestrationEvent,
10 type ProviderRuntimeEvent,8 type ProviderRuntimeEvent,
11 RuntimeSessionId,
12} from "@t3tools/contracts";9} from "@t3tools/contracts";
13import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";10import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
1411
...@@ -38,14 +35,6 @@ function toTurnId(value: string | undefined): TurnId | null {...@@ -38,14 +35,6 @@ function toTurnId(value: string | undefined): TurnId | null {
38 return value === undefined ? null : TurnId.makeUnsafe(String(value));35 return value === undefined ? null : TurnId.makeUnsafe(String(value));
39}36}
4037
41function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
42 return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value));
43}
44
45function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId {
46 return ProviderSessionId.makeUnsafe(String(value));
47}
48
49function sameId(left: string | null | undefined, right: string | null | undefined): boolean {38function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
50 if (left === null || left === undefined || right === null || right === undefined) {39 if (left === null || left === undefined || right === null || right === undefined) {
51 return false;40 return false;
...@@ -100,107 +89,75 @@ const make = Effect.gen(function* () {...@@ -100,107 +89,75 @@ const make = Effect.gen(function* () {
100 });89 });
10190
102 const appendCaptureFailureActivity = (input: {91 const appendCaptureFailureActivity = (input: {
103 readonly sessionId: ProviderSessionId;92 readonly threadId: ThreadId;
104 readonly turnId: TurnId | null;93 readonly turnId: TurnId | null;
105 readonly detail: string;94 readonly detail: string;
106 readonly createdAt: string;95 readonly createdAt: string;
107 }) =>96 }) =>
108 Effect.gen(function* () {97 orchestrationEngine.dispatch({
109 const readModel = yield* orchestrationEngine.getReadModel();98 type: "thread.activity.append",
110 const thread = readModel.threads.find(99 commandId: serverCommandId("checkpoint-capture-failure"),
111 (entry) => entry.session?.providerSessionId === input.sessionId,100 threadId: input.threadId,
112 );101 activity: {
113 if (!thread) {102 id: EventId.makeUnsafe(crypto.randomUUID()),
114 return;103 tone: "error",
115 }104 kind: "checkpoint.capture.failed",
116 yield* orchestrationEngine.dispatch({105 summary: "Checkpoint capture failed",
117 type: "thread.activity.append",106 payload: {
118 commandId: serverCommandId("checkpoint-capture-failure"),107 detail: input.detail,
119 threadId: thread.id,
120 activity: {
121 id: EventId.makeUnsafe(crypto.randomUUID()),
122 tone: "error",
123 kind: "checkpoint.capture.failed",
124 summary: "Checkpoint capture failed",
125 payload: {
126 detail: input.detail,
127 },
128 turnId: input.turnId,
129 createdAt: input.createdAt,
130 },108 },
109 turnId: input.turnId,
131 createdAt: input.createdAt,110 createdAt: input.createdAt,
132 });111 },
112 createdAt: input.createdAt,
133 });113 });
134114
135 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (115 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (
136 threadId: ThreadId,116 threadId: ThreadId,
137 ): Effect.fn.Return<117 ): Effect.fn.Return<
138 Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }>118 Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>
139 > {119 > {
140 const readModel = yield* orchestrationEngine.getReadModel();120 const readModel = yield* orchestrationEngine.getReadModel();
141 const thread = readModel.threads.find((entry) => entry.id === threadId);121 const thread = readModel.threads.find((entry) => entry.id === threadId);
142 const projectedSessionId = thread?.session?.providerSessionId ?? null;
143 const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined;
144122
145 const sessions = yield* providerService.listSessions();123 const sessions = yield* providerService.listSessions();
146124
147 const findSessionWithCwd = (125 const findSessionWithCwd = (
148 session: (typeof sessions)[number] | undefined,126 session: (typeof sessions)[number] | undefined,
149 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {127 ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => {
150 if (!session?.cwd) {128 if (!session?.cwd) {
151 return Option.none();129 return Option.none();
152 }130 }
153 return Option.some({ sessionId: session.sessionId, cwd: session.cwd });131 return Option.some({ threadId: session.threadId, cwd: session.cwd });
154 };132 };
155133
156 if (projectedSessionId !== null) {134 if (thread) {
157 const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId);135 const projectedSession = sessions.find(
136 (session) => session.threadId === thread.id,
137 );
158 const fromProjected = findSessionWithCwd(projectedSession);138 const fromProjected = findSessionWithCwd(projectedSession);
159 if (Option.isSome(fromProjected)) {139 if (Option.isSome(fromProjected)) {
160 return fromProjected;140 return fromProjected;
161 }141 }
162 }142 }
163143
164 if (projectedProviderThreadId) {
165 const matchedSession = sessions.find(
166 (session) => session.threadId === projectedProviderThreadId,
167 );
168 const fromProviderThread = findSessionWithCwd(matchedSession);
169 if (Option.isSome(fromProviderThread)) {
170 return fromProviderThread;
171 }
172 }
173
174 return Option.none();144 return Option.none();
175 });145 });
176146
177 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (147 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
178 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,148 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
179 ) {149 ) {
180 const providerSessionId = toProviderSessionId(event.sessionId);
181 const turnId = toTurnId(event.turnId);150 const turnId = toTurnId(event.turnId);
182 if (!turnId) {151 if (!turnId) {
183 return;152 return;
184 }153 }
185154
186 const readModel = yield* orchestrationEngine.getReadModel();155 const readModel = yield* orchestrationEngine.getReadModel();
187 const thread = readModel.threads.find(156 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
188 (entry) => entry.session?.providerSessionId === providerSessionId,
189 );
190 if (!thread) {157 if (!thread) {
191 return;158 return;
192 }159 }
193160
194 const projectedProviderThreadId = thread.session?.providerThreadId ?? null;
195 const eventProviderThreadId = toProviderThreadId(event.threadId);
196 if (
197 projectedProviderThreadId !== null &&
198 eventProviderThreadId !== null &&
199 !sameId(projectedProviderThreadId, eventProviderThreadId)
200 ) {
201 return;
202 }
203
204 // When a primary turn is active, only that turn may produce completion checkpoints.161 // When a primary turn is active, only that turn may produce completion checkpoints.
205 if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) {162 if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) {
206 return;163 return;
...@@ -272,7 +229,7 @@ const make = Effect.gen(function* () {...@@ -272,7 +229,7 @@ const make = Effect.gen(function* () {
272 ),229 ),
273 Effect.tapError((error) =>230 Effect.tapError((error) =>
274 appendCaptureFailureActivity({231 appendCaptureFailureActivity({
275 sessionId: providerSessionId,232 threadId: thread.id,
276 turnId,233 turnId,
277 detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`,234 detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`,
278 createdAt: event.createdAt,235 createdAt: event.createdAt,
...@@ -332,7 +289,6 @@ const make = Effect.gen(function* () {...@@ -332,7 +289,6 @@ const make = Effect.gen(function* () {
332 const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* (289 const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* (
333 event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>,290 event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>,
334 ) {291 ) {
335 const providerSessionId = toProviderSessionId(event.sessionId);
336 const turnId = toTurnId(event.turnId);292 const turnId = toTurnId(event.turnId);
337 if (!turnId) {293 if (!turnId) {
338 return;294 return;
...@@ -340,7 +296,7 @@ const make = Effect.gen(function* () {...@@ -340,7 +296,7 @@ const make = Effect.gen(function* () {
340296
341 const readModel = yield* orchestrationEngine.getReadModel();297 const readModel = yield* orchestrationEngine.getReadModel();
342 const thread = readModel.threads.find(298 const thread = readModel.threads.find(
343 (entry) => entry.session?.providerSessionId === providerSessionId,299 (entry) => entry.id === event.threadId,
344 );300 );
345 if (!thread) {301 if (!thread) {
346 return;302 return;
...@@ -520,7 +476,7 @@ const make = Effect.gen(function* () {...@@ -520,7 +476,7 @@ const make = Effect.gen(function* () {
520 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);476 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
521 if (rolledBackTurns > 0) {477 if (rolledBackTurns > 0) {
522 yield* providerService.rollbackConversation({478 yield* providerService.rollbackConversation({
523 sessionId: sessionRuntime.value.sessionId,479 threadId: sessionRuntime.value.threadId,
524 numTurns: rolledBackTurns,480 numTurns: rolledBackTurns,
525 });481 });
526 }482 }
...@@ -588,7 +544,7 @@ const make = Effect.gen(function* () {...@@ -588,7 +544,7 @@ const make = Effect.gen(function* () {
588 yield* captureCheckpointFromTurnCompletion(event).pipe(544 yield* captureCheckpointFromTurnCompletion(event).pipe(
589 Effect.catch((error) =>545 Effect.catch((error) =>
590 appendCaptureFailureActivity({546 appendCaptureFailureActivity({
591 sessionId: toProviderSessionId(event.sessionId),547 threadId: event.threadId,
592 turnId,548 turnId,
593 detail: error.message,549 detail: error.message,
594 createdAt: new Date().toISOString(),550 createdAt: new Date().toISOString(),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+1-8
...@@ -4,8 +4,6 @@ import {...@@ -4,8 +4,6 @@ import {
4 CorrelationId,4 CorrelationId,
5 EventId,5 EventId,
6 MessageId,6 MessageId,
7 ProviderSessionId,
8 ProviderThreadId,
9 ProjectId,7 ProjectId,
10 ThreadId,8 ThreadId,
11 TurnId,9 TurnId,
...@@ -1771,18 +1769,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta...@@ -1771,18 +1769,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
1771 commandId: CommandId.makeUnsafe("cmd-restart-2"),1769 commandId: CommandId.makeUnsafe("cmd-restart-2"),
1772 causationEventId: null,1770 causationEventId: null,
1773 correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),1771 correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),
1774 metadata: {1772 metadata: {},
1775 providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
1776 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
1777 },
1778 payload: {1773 payload: {
1779 threadId,1774 threadId,
1780 session: {1775 session: {
1781 threadId,1776 threadId,
1782 status: "running",1777 status: "running",
1783 providerName: "codex",1778 providerName: "codex",
1784 providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
1785 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
1786 runtimeMode: "approval-required",1779 runtimeMode: "approval-required",
1787 activeTurnId: turnId,1780 activeTurnId: turnId,
1788 lastError: null,1781 lastError: null,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts-2
...@@ -665,8 +665,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -665,8 +665,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
665 threadId: event.payload.threadId,665 threadId: event.payload.threadId,
666 status: event.payload.session.status,666 status: event.payload.session.status,
667 providerName: event.payload.session.providerName,667 providerName: event.payload.session.providerName,
668 providerSessionId: event.payload.session.providerSessionId,
669 providerThreadId: event.payload.session.providerThreadId,
670 runtimeMode: event.payload.session.runtimeMode,668 runtimeMode: event.payload.session.runtimeMode,
671 activeTurnId: event.payload.session.activeTurnId,669 activeTurnId: event.payload.session.activeTurnId,
672 lastError: event.payload.session.lastError,670 lastError: event.payload.session.lastError,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts-7
...@@ -3,8 +3,6 @@ import {...@@ -3,8 +3,6 @@ import {
3 EventId,3 EventId,
4 MessageId,4 MessageId,
5 ProjectId,5 ProjectId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ThreadId,6 ThreadId,
9 TurnId,7 TurnId,
10} from "@t3tools/contracts";8} from "@t3tools/contracts";
...@@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);...@@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
22const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);20const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
23const asEventId = (value: string): EventId => EventId.makeUnsafe(value);21const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
24const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);22const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
25const asProviderSessionId = (value: string): ProviderSessionId =>
26 ProviderSessionId.makeUnsafe(value);
27const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
2823
29const projectionSnapshotLayer = it.layer(24const projectionSnapshotLayer = it.layer(
30 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),25 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
...@@ -290,8 +285,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -290,8 +285,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
290 threadId: ThreadId.makeUnsafe("thread-1"),285 threadId: ThreadId.makeUnsafe("thread-1"),
291 status: "running",286 status: "running",
292 providerName: "codex",287 providerName: "codex",
293 providerSessionId: asProviderSessionId("provider-session-1"),
294 providerThreadId: asProviderThreadId("provider-thread-1"),
295 runtimeMode: "approval-required",288 runtimeMode: "approval-required",
296 activeTurnId: asTurnId("turn-1"),289 activeTurnId: asTurnId("turn-1"),
297 lastError: null,290 lastError: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts-2
...@@ -461,8 +461,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -461,8 +461,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
461 threadId: row.threadId,461 threadId: row.threadId,
462 status: row.status,462 status: row.status,
463 providerName: row.providerName,463 providerName: row.providerName,
464 providerSessionId: row.providerSessionId,
465 providerThreadId: row.providerThreadId,
466 runtimeMode: row.runtimeMode,464 runtimeMode: row.runtimeMode,
467 activeTurnId: row.activeTurnId,465 activeTurnId: row.activeTurnId,
468 lastError: row.lastError,466 lastError: row.lastError,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+31-39
...@@ -9,9 +9,6 @@ import {...@@ -9,9 +9,6 @@ import {
9 EventId,9 EventId,
10 MessageId,10 MessageId,
11 ProjectId,11 ProjectId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,12 ThreadId,
16 TurnId,13 TurnId,
17} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -38,8 +35,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";...@@ -38,8 +35,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
38import * as NodeServices from "@effect/platform-node/NodeServices";35import * as NodeServices from "@effect/platform-node/NodeServices";
3936
40const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);37const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
41const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
42const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
43const asApprovalRequestId = (value: string): ApprovalRequestId =>38const asApprovalRequestId = (value: string): ApprovalRequestId =>
44 ApprovalRequestId.makeUnsafe(value);39 ApprovalRequestId.makeUnsafe(value);
45const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);40const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
...@@ -110,8 +105,14 @@ describe("ProviderCommandReactor", () => {...@@ -110,8 +105,14 @@ describe("ProviderCommandReactor", () => {
110 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"105 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"
111 ? input.model106 ? input.model
112 : undefined;107 : undefined;
108 const threadId =
109 typeof input === "object" &&
110 input !== null &&
111 "threadId" in input &&
112 typeof input.threadId === "string"
113 ? ThreadId.makeUnsafe(input.threadId)
114 : ThreadId.makeUnsafe(`thread-${sessionIndex}`);
113 const session: ProviderSession = {115 const session: ProviderSession = {
114 sessionId: asSessionId(`sess-${sessionIndex}`),
115 provider,116 provider,
116 status: "ready" as const,117 status: "ready" as const,
117 runtimeMode:118 runtimeMode:
...@@ -122,7 +123,7 @@ describe("ProviderCommandReactor", () => {...@@ -122,7 +123,7 @@ describe("ProviderCommandReactor", () => {
122 ? input.runtimeMode123 ? input.runtimeMode
123 : "full-access",124 : "full-access",
124 ...(model !== undefined ? { model } : {}),125 ...(model !== undefined ? { model } : {}),
125 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),126 threadId,
126 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },127 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
127 createdAt: now,128 createdAt: now,
128 updatedAt: now,129 updatedAt: now,
...@@ -132,22 +133,22 @@ describe("ProviderCommandReactor", () => {...@@ -132,22 +133,22 @@ describe("ProviderCommandReactor", () => {
132 });133 });
133 const sendTurn = vi.fn((_: unknown) =>134 const sendTurn = vi.fn((_: unknown) =>
134 Effect.succeed({135 Effect.succeed({
135 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),136 threadId: ThreadId.makeUnsafe("thread-1"),
136 turnId: asProviderTurnId("provider-turn-1"),137 turnId: asTurnId("turn-1"),
137 }),138 }),
138 );139 );
139 const interruptTurn = vi.fn((_: unknown) => Effect.void);140 const interruptTurn = vi.fn((_: unknown) => Effect.void);
140 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);141 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
141 const stopSession = vi.fn((input: unknown) =>142 const stopSession = vi.fn((input: unknown) =>
142 Effect.sync(() => {143 Effect.sync(() => {
143 const sessionId =144 const threadId =
144 typeof input === "object" && input !== null && "sessionId" in input145 typeof input === "object" && input !== null && "threadId" in input
145 ? (input as { sessionId?: ProviderSessionId }).sessionId146 ? (input as { threadId?: ThreadId }).threadId
146 : undefined;147 : undefined;
147 if (!sessionId) {148 if (!threadId) {
148 return;149 return;
149 }150 }
150 const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId);151 const index = runtimeSessions.findIndex((session) => session.threadId === threadId);
151 if (index >= 0) {152 if (index >= 0) {
152 runtimeSessions.splice(index, 1);153 runtimeSessions.splice(index, 1);
153 }154 }
...@@ -284,7 +285,7 @@ describe("ProviderCommandReactor", () => {...@@ -284,7 +285,7 @@ describe("ProviderCommandReactor", () => {
284285
285 const readModel = await Effect.runPromise(harness.engine.getReadModel());286 const readModel = await Effect.runPromise(harness.engine.getReadModel());
286 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));287 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
287 expect(thread?.session?.providerSessionId).toBe("sess-1");288 expect(thread?.session?.threadId).toBe("thread-1");
288 expect(thread?.session?.runtimeMode).toBe("approval-required");289 expect(thread?.session?.runtimeMode).toBe("approval-required");
289 });290 });
290291
...@@ -321,7 +322,7 @@ describe("ProviderCommandReactor", () => {...@@ -321,7 +322,7 @@ describe("ProviderCommandReactor", () => {
321 const readModel = await Effect.runPromise(harness.engine.getReadModel());322 const readModel = await Effect.runPromise(harness.engine.getReadModel());
322 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));323 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
323 expect(thread?.session?.providerName).toBe("claudeCode");324 expect(thread?.session?.providerName).toBe("claudeCode");
324 expect(thread?.session?.providerSessionId).toBe("sess-1");325 expect(thread?.session?.threadId).toBe("thread-1");
325 });326 });
326327
327 it("starts first turn with cursor provider when provider is specified", async () => {328 it("starts first turn with cursor provider when provider is specified", async () => {
...@@ -357,7 +358,7 @@ describe("ProviderCommandReactor", () => {...@@ -357,7 +358,7 @@ describe("ProviderCommandReactor", () => {
357 const readModel = await Effect.runPromise(harness.engine.getReadModel());358 const readModel = await Effect.runPromise(harness.engine.getReadModel());
358 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));359 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
359 expect(thread?.session?.providerName).toBe("cursor");360 expect(thread?.session?.providerName).toBe("cursor");
360 expect(thread?.session?.providerSessionId).toBe("sess-1");361 expect(thread?.session?.threadId).toBe("thread-1");
361 });362 });
362363
363 it("reuses the same provider session when runtime mode is unchanged", async () => {364 it("reuses the same provider session when runtime mode is unchanged", async () => {
...@@ -500,7 +501,7 @@ describe("ProviderCommandReactor", () => {...@@ -500,7 +501,7 @@ describe("ProviderCommandReactor", () => {
500 expect(harness.stopSession.mock.calls.length).toBe(0);501 expect(harness.stopSession.mock.calls.length).toBe(0);
501 expect(harness.startSession.mock.calls.length).toBe(1);502 expect(harness.startSession.mock.calls.length).toBe(1);
502 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({503 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
503 sessionId: asSessionId("sess-1"),504 threadId: ThreadId.makeUnsafe("thread-1"),
504 model: "gpt-5.3-codex",505 model: "gpt-5.3-codex",
505 });506 });
506 });507 });
...@@ -554,8 +555,6 @@ describe("ProviderCommandReactor", () => {...@@ -554,8 +555,6 @@ describe("ProviderCommandReactor", () => {
554 return thread?.runtimeMode === "approval-required";555 return thread?.runtimeMode === "approval-required";
555 });556 });
556 await waitFor(() => harness.startSession.mock.calls.length === 2);557 await waitFor(() => harness.startSession.mock.calls.length === 2);
557 await waitFor(() => harness.stopSession.mock.calls.length === 1);
558
559 await Effect.runPromise(558 await Effect.runPromise(
560 harness.engine.dispatch({559 harness.engine.dispatch({
561 type: "thread.turn.start",560 type: "thread.turn.start",
...@@ -574,18 +573,19 @@ describe("ProviderCommandReactor", () => {...@@ -574,18 +573,19 @@ describe("ProviderCommandReactor", () => {
574573
575 await waitFor(() => harness.sendTurn.mock.calls.length === 2);574 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
576575
577 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });576 expect(harness.stopSession.mock.calls.length).toBe(0);
578 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({577 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
578 threadId: ThreadId.makeUnsafe("thread-1"),
579 resumeCursor: { opaque: "cursor-1" },579 resumeCursor: { opaque: "cursor-1" },
580 runtimeMode: "approval-required",580 runtimeMode: "approval-required",
581 });581 });
582 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({582 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
583 sessionId: asSessionId("sess-2"),583 threadId: ThreadId.makeUnsafe("thread-1"),
584 });584 });
585585
586 const readModel = await Effect.runPromise(harness.engine.getReadModel());586 const readModel = await Effect.runPromise(harness.engine.getReadModel());
587 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));587 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
588 expect(thread?.session?.providerSessionId).toBe("sess-2");588 expect(thread?.session?.threadId).toBe("thread-1");
589 expect(thread?.session?.runtimeMode).toBe("approval-required");589 expect(thread?.session?.runtimeMode).toBe("approval-required");
590 });590 });
591591
...@@ -629,12 +629,12 @@ describe("ProviderCommandReactor", () => {...@@ -629,12 +629,12 @@ describe("ProviderCommandReactor", () => {
629 }),629 }),
630 );630 );
631631
632 await waitFor(() => harness.stopSession.mock.calls.length === 1);
633 await waitFor(() => harness.startSession.mock.calls.length === 2);632 await waitFor(() => harness.startSession.mock.calls.length === 2);
634 await waitFor(() => harness.sendTurn.mock.calls.length === 2);633 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
635634
636 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });635 expect(harness.stopSession.mock.calls.length).toBe(0);
637 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({636 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
637 threadId: ThreadId.makeUnsafe("thread-1"),
638 provider: "claudeCode",638 provider: "claudeCode",
639 runtimeMode: "approval-required",639 runtimeMode: "approval-required",
640 });640 });
...@@ -642,7 +642,7 @@ describe("ProviderCommandReactor", () => {...@@ -642,7 +642,7 @@ describe("ProviderCommandReactor", () => {
642642
643 const readModel = await Effect.runPromise(harness.engine.getReadModel());643 const readModel = await Effect.runPromise(harness.engine.getReadModel());
644 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));644 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
645 expect(thread?.session?.providerSessionId).toBe("sess-2");645 expect(thread?.session?.threadId).toBe("thread-1");
646 expect(thread?.session?.providerName).toBe("claudeCode");646 expect(thread?.session?.providerName).toBe("claudeCode");
647 expect(thread?.session?.runtimeMode).toBe("approval-required");647 expect(thread?.session?.runtimeMode).toBe("approval-required");
648 });648 });
...@@ -707,7 +707,7 @@ describe("ProviderCommandReactor", () => {...@@ -707,7 +707,7 @@ describe("ProviderCommandReactor", () => {
707707
708 const readModel = await Effect.runPromise(harness.engine.getReadModel());708 const readModel = await Effect.runPromise(harness.engine.getReadModel());
709 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));709 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
710 expect(thread?.session?.providerSessionId).toBe("sess-1");710 expect(thread?.session?.threadId).toBe("thread-1");
711 expect(thread?.session?.runtimeMode).toBe("full-access");711 expect(thread?.session?.runtimeMode).toBe("full-access");
712 });712 });
713713
...@@ -724,8 +724,6 @@ describe("ProviderCommandReactor", () => {...@@ -724,8 +724,6 @@ describe("ProviderCommandReactor", () => {
724 threadId: ThreadId.makeUnsafe("thread-1"),724 threadId: ThreadId.makeUnsafe("thread-1"),
725 status: "running",725 status: "running",
726 providerName: "codex",726 providerName: "codex",
727 providerSessionId: asSessionId("sess-1"),
728 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
729 runtimeMode: "approval-required",727 runtimeMode: "approval-required",
730 activeTurnId: asTurnId("turn-1"),728 activeTurnId: asTurnId("turn-1"),
731 lastError: null,729 lastError: null,
...@@ -747,7 +745,8 @@ describe("ProviderCommandReactor", () => {...@@ -747,7 +745,8 @@ describe("ProviderCommandReactor", () => {
747745
748 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);746 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);
749 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({747 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
750 sessionId: "sess-1",748 threadId: "thread-1",
749 turnId: "turn-1",
751 });750 });
752 });751 });
753752
...@@ -764,8 +763,6 @@ describe("ProviderCommandReactor", () => {...@@ -764,8 +763,6 @@ describe("ProviderCommandReactor", () => {
764 threadId: ThreadId.makeUnsafe("thread-1"),763 threadId: ThreadId.makeUnsafe("thread-1"),
765 status: "running",764 status: "running",
766 providerName: "codex",765 providerName: "codex",
767 providerSessionId: asSessionId("sess-1"),
768 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
769 runtimeMode: "approval-required",766 runtimeMode: "approval-required",
770 activeTurnId: null,767 activeTurnId: null,
771 lastError: null,768 lastError: null,
...@@ -788,7 +785,7 @@ describe("ProviderCommandReactor", () => {...@@ -788,7 +785,7 @@ describe("ProviderCommandReactor", () => {
788785
789 await waitFor(() => harness.respondToRequest.mock.calls.length === 1);786 await waitFor(() => harness.respondToRequest.mock.calls.length === 1);
790 expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({787 expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({
791 sessionId: "sess-1",788 threadId: "thread-1",
792 requestId: "approval-request-1",789 requestId: "approval-request-1",
793 decision: "accept",790 decision: "accept",
794 });791 });
...@@ -816,8 +813,6 @@ describe("ProviderCommandReactor", () => {...@@ -816,8 +813,6 @@ describe("ProviderCommandReactor", () => {
816 threadId: ThreadId.makeUnsafe("thread-1"),813 threadId: ThreadId.makeUnsafe("thread-1"),
817 status: "running",814 status: "running",
818 providerName: "cursor",815 providerName: "cursor",
819 providerSessionId: asSessionId("sess-1"),
820 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
821 runtimeMode: "approval-required",816 runtimeMode: "approval-required",
822 activeTurnId: null,817 activeTurnId: null,
823 lastError: null,818 lastError: null,
...@@ -901,8 +896,6 @@ describe("ProviderCommandReactor", () => {...@@ -901,8 +896,6 @@ describe("ProviderCommandReactor", () => {
901 threadId: ThreadId.makeUnsafe("thread-1"),896 threadId: ThreadId.makeUnsafe("thread-1"),
902 status: "ready",897 status: "ready",
903 providerName: "codex",898 providerName: "codex",
904 providerSessionId: asSessionId("sess-1"),
905 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
906 runtimeMode: "approval-required",899 runtimeMode: "approval-required",
907 activeTurnId: null,900 activeTurnId: null,
908 lastError: null,901 lastError: null,
...@@ -926,8 +919,7 @@ describe("ProviderCommandReactor", () => {...@@ -926,8 +919,7 @@ describe("ProviderCommandReactor", () => {
926 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));919 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
927 expect(thread?.session).not.toBeNull();920 expect(thread?.session).not.toBeNull();
928 expect(thread?.session?.status).toBe("stopped");921 expect(thread?.session?.status).toBe("stopped");
929 expect(thread?.session?.providerSessionId).toBeNull();922 expect(thread?.session?.threadId).toBe("thread-1");
930 expect(thread?.session?.providerThreadId).toBeNull();
931 expect(thread?.session?.activeTurnId).toBeNull();923 expect(thread?.session?.activeTurnId).toBeNull();
932 });924 });
933});925});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+26-45
...@@ -4,9 +4,8 @@ import {...@@ -4,9 +4,8 @@ import {
4 EventId,4 EventId,
5 type OrchestrationEvent,5 type OrchestrationEvent,
6 type ProviderKind,6 type ProviderKind,
7 type ProviderSessionId,
8 type OrchestrationSession,7 type OrchestrationSession,
9 type ThreadId,8 ThreadId,
10 type ProviderSession,9 type ProviderSession,
11 type RuntimeMode,10 type RuntimeMode,
12 type TurnId,11 type TurnId,
...@@ -220,9 +219,9 @@ const make = Effect.gen(function* () {...@@ -220,9 +219,9 @@ const make = Effect.gen(function* () {
220 projects: readModel.projects,219 projects: readModel.projects,
221 });220 });
222221
223 const resolveActiveSession = (sessionId: ProviderSessionId) =>222 const resolveActiveSession = (threadId: ThreadId) =>
224 providerService.listSessions().pipe(223 providerService.listSessions().pipe(
225 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),224 Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)),
226 );225 );
227226
228 const startProviderSession = (input?: {227 const startProviderSession = (input?: {
...@@ -230,6 +229,7 @@ const make = Effect.gen(function* () {...@@ -230,6 +229,7 @@ const make = Effect.gen(function* () {
230 readonly provider?: ProviderKind;229 readonly provider?: ProviderKind;
231 }) =>230 }) =>
232 providerService.startSession(threadId, {231 providerService.startSession(threadId, {
232 threadId,
233 ...(input?.provider ?? preferredProvider233 ...(input?.provider ?? preferredProvider
234 ? { provider: input?.provider ?? preferredProvider }234 ? { provider: input?.provider ?? preferredProvider }
235 : {}),235 : {}),
...@@ -246,8 +246,6 @@ const make = Effect.gen(function* () {...@@ -246,8 +246,6 @@ const make = Effect.gen(function* () {
246 threadId,246 threadId,
247 status: mapProviderSessionStatusToOrchestrationStatus(session.status),247 status: mapProviderSessionStatusToOrchestrationStatus(session.status),
248 providerName: session.provider,248 providerName: session.provider,
249 providerSessionId: session.sessionId,
250 providerThreadId: session.threadId ?? null,
251 runtimeMode: desiredRuntimeMode,249 runtimeMode: desiredRuntimeMode,
252 // Provider turn ids are not orchestration turn ids.250 // Provider turn ids are not orchestration turn ids.
253 activeTurnId: null,251 activeTurnId: null,
...@@ -257,11 +255,12 @@ const make = Effect.gen(function* () {...@@ -257,11 +255,12 @@ const make = Effect.gen(function* () {
257 createdAt,255 createdAt,
258 });256 });
259257
260 const existingSessionId = thread.session?.providerSessionId;258 const existingSessionThreadId =
261 if (existingSessionId) {259 thread.session && thread.session.status !== "stopped" ? thread.id : null;
260 if (existingSessionThreadId) {
262 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;261 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
263 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;262 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
264 const activeSession = yield* resolveActiveSession(existingSessionId);263 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
265 const sessionModelSwitch =264 const sessionModelSwitch =
266 currentProvider === undefined265 currentProvider === undefined
267 ? "in-session"266 ? "in-session"
...@@ -271,12 +270,8 @@ const make = Effect.gen(function* () {...@@ -271,12 +270,8 @@ const make = Effect.gen(function* () {
271 const shouldRestartForModelChange =270 const shouldRestartForModelChange =
272 modelChanged && sessionModelSwitch === "restart-session";271 modelChanged && sessionModelSwitch === "restart-session";
273272
274 if (273 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {
275 !runtimeModeChanged &&274 return existingSessionThreadId;
276 !providerChanged &&
277 !shouldRestartForModelChange
278 ) {
279 return existingSessionId;
280 }275 }
281276
282 const resumeCursor =277 const resumeCursor =
...@@ -285,7 +280,7 @@ const make = Effect.gen(function* () {...@@ -285,7 +280,7 @@ const make = Effect.gen(function* () {
285 : (activeSession?.resumeCursor ?? undefined);280 : (activeSession?.resumeCursor ?? undefined);
286 yield* Effect.logInfo("provider command reactor restarting provider session", {281 yield* Effect.logInfo("provider command reactor restarting provider session", {
287 threadId,282 threadId,
288 existingSessionId,283 existingSessionThreadId,
289 currentProvider,284 currentProvider,
290 desiredProvider: options?.provider ?? currentProvider,285 desiredProvider: options?.provider ?? currentProvider,
291 currentRuntimeMode: thread.session?.runtimeMode,286 currentRuntimeMode: thread.session?.runtimeMode,
...@@ -302,30 +297,20 @@ const make = Effect.gen(function* () {...@@ -302,30 +297,20 @@ const make = Effect.gen(function* () {
302 });297 });
303 yield* Effect.logInfo("provider command reactor restarted provider session", {298 yield* Effect.logInfo("provider command reactor restarted provider session", {
304 threadId,299 threadId,
305 previousSessionId: existingSessionId,300 previousSessionId: existingSessionThreadId,
306 restartedSessionId: restartedSession.sessionId,301 restartedSessionThreadId: restartedSession.threadId,
307 provider: restartedSession.provider,302 provider: restartedSession.provider,
308 providerThreadId: restartedSession.threadId,
309 runtimeMode: restartedSession.runtimeMode,303 runtimeMode: restartedSession.runtimeMode,
310 });304 });
311 yield* bindSessionToThread(restartedSession);305 yield* bindSessionToThread(restartedSession);
312 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(306 return restartedSession.threadId;
313 Effect.catchCause((cause) =>
314 Effect.logWarning("provider command reactor failed to stop superseded provider session", {
315 threadId,
316 sessionId: existingSessionId,
317 cause: Cause.pretty(cause),
318 }),
319 ),
320 );
321 return restartedSession.sessionId;
322 }307 }
323308
324 const startedSession = yield* startProviderSession(309 const startedSession = yield* startProviderSession(
325 options?.provider !== undefined ? { provider: options.provider } : undefined,310 options?.provider !== undefined ? { provider: options.provider } : undefined,
326 );311 );
327 yield* bindSessionToThread(startedSession);312 yield* bindSessionToThread(startedSession);
328 return startedSession.sessionId;313 return startedSession.threadId;
329 });314 });
330315
331 const sendTurnForThread = Effect.fnUntraced(function* (input: {316 const sendTurnForThread = Effect.fnUntraced(function* (input: {
...@@ -341,14 +326,14 @@ const make = Effect.gen(function* () {...@@ -341,14 +326,14 @@ const make = Effect.gen(function* () {
341 if (!thread) {326 if (!thread) {
342 return;327 return;
343 }328 }
344 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {329 yield* ensureSessionForThread(input.threadId, input.createdAt, {
345 ...(input.provider !== undefined ? { provider: input.provider } : {}),330 ...(input.provider !== undefined ? { provider: input.provider } : {}),
346 ...(input.model !== undefined ? { model: input.model } : {}),331 ...(input.model !== undefined ? { model: input.model } : {}),
347 });332 });
348 const normalizedInput = toNonEmptyProviderInput(input.messageText);333 const normalizedInput = toNonEmptyProviderInput(input.messageText);
349 const normalizedAttachments = input.attachments ?? [];334 const normalizedAttachments = input.attachments ?? [];
350 const activeSession = yield* providerService.listSessions().pipe(335 const activeSession = yield* providerService.listSessions().pipe(
351 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),336 Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),
352 );337 );
353 const sessionModelSwitch =338 const sessionModelSwitch =
354 activeSession === undefined339 activeSession === undefined
...@@ -358,7 +343,7 @@ const make = Effect.gen(function* () {...@@ -358,7 +343,7 @@ const make = Effect.gen(function* () {
358 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;343 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
359344
360 yield* providerService.sendTurn({345 yield* providerService.sendTurn({
361 sessionId,346 threadId: input.threadId,
362 ...(normalizedInput ? { input: normalizedInput } : {}),347 ...(normalizedInput ? { input: normalizedInput } : {}),
363 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),348 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
364 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),349 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
...@@ -487,8 +472,8 @@ const make = Effect.gen(function* () {...@@ -487,8 +472,8 @@ const make = Effect.gen(function* () {
487 if (!thread) {472 if (!thread) {
488 return;473 return;
489 }474 }
490 const sessionId = thread.session?.providerSessionId;475 const hasSession = thread.session && thread.session.status !== "stopped";
491 if (!sessionId) {476 if (!hasSession) {
492 return yield* appendProviderFailureActivity({477 return yield* appendProviderFailureActivity({
493 threadId: event.payload.threadId,478 threadId: event.payload.threadId,
494 kind: "provider.turn.interrupt.failed",479 kind: "provider.turn.interrupt.failed",
...@@ -500,7 +485,7 @@ const make = Effect.gen(function* () {...@@ -500,7 +485,7 @@ const make = Effect.gen(function* () {
500 }485 }
501486
502 // Orchestration turn ids are not provider turn ids, so interrupt by session.487 // Orchestration turn ids are not provider turn ids, so interrupt by session.
503 yield* providerService.interruptTurn({ sessionId });488 yield* providerService.interruptTurn({ threadId: event.payload.threadId });
504 });489 });
505490
506 const processApprovalResponseRequested = Effect.fnUntraced(function* (491 const processApprovalResponseRequested = Effect.fnUntraced(function* (
...@@ -510,8 +495,8 @@ const make = Effect.gen(function* () {...@@ -510,8 +495,8 @@ const make = Effect.gen(function* () {
510 if (!thread) {495 if (!thread) {
511 return;496 return;
512 }497 }
513 const sessionId = thread.session?.providerSessionId;498 const hasSession = thread.session && thread.session.status !== "stopped";
514 if (!sessionId) {499 if (!hasSession) {
515 return yield* appendProviderFailureActivity({500 return yield* appendProviderFailureActivity({
516 threadId: event.payload.threadId,501 threadId: event.payload.threadId,
517 kind: "provider.approval.respond.failed",502 kind: "provider.approval.respond.failed",
...@@ -525,7 +510,7 @@ const make = Effect.gen(function* () {...@@ -525,7 +510,7 @@ const make = Effect.gen(function* () {
525510
526 yield* providerService511 yield* providerService
527 .respondToRequest({512 .respondToRequest({
528 sessionId,513 threadId: event.payload.threadId,
529 requestId: event.payload.requestId,514 requestId: event.payload.requestId,
530 decision: event.payload.decision,515 decision: event.payload.decision,
531 })516 })
...@@ -559,10 +544,8 @@ const make = Effect.gen(function* () {...@@ -559,10 +544,8 @@ const make = Effect.gen(function* () {
559 }544 }
560545
561 const now = event.payload.createdAt;546 const now = event.payload.createdAt;
562 const sessionId = thread.session?.providerSessionId;547 if (thread.session && thread.session.status !== "stopped") {
563548 yield* providerService.stopSession({ threadId: thread.id });
564 if (sessionId) {
565 yield* providerService.stopSession({ sessionId });
566 }549 }
567550
568 yield* setThreadSession({551 yield* setThreadSession({
...@@ -571,8 +554,6 @@ const make = Effect.gen(function* () {...@@ -571,8 +554,6 @@ const make = Effect.gen(function* () {
571 threadId: thread.id,554 threadId: thread.id,
572 status: "stopped",555 status: "stopped",
573 providerName: thread.session?.providerName ?? null,556 providerName: thread.session?.providerName ?? null,
574 providerSessionId: null,
575 providerThreadId: null,
576 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,557 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
577 activeTurnId: null,558 activeTurnId: null,
578 lastError: thread.session?.lastError ?? null,559 lastError: thread.session?.lastError ?? null,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+89-113
...@@ -6,10 +6,8 @@ import {...@@ -6,10 +6,8 @@ import {
6 MessageId,6 MessageId,
7 ProjectId,7 ProjectId,
8 ProviderItemId,8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ThreadId,9 ThreadId,
10 TurnId,
13} from "@t3tools/contracts";11} from "@t3tools/contracts";
14import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";12import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
15import { afterEach, describe, expect, it } from "vitest";13import { afterEach, describe, expect, it } from "vitest";
...@@ -33,20 +31,18 @@ import { ServerConfig } from "../../config.ts";...@@ -33,20 +31,18 @@ import { ServerConfig } from "../../config.ts";
33import * as NodeServices from "@effect/platform-node/NodeServices";31import * as NodeServices from "@effect/platform-node/NodeServices";
3432
35const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);33const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
36const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
37const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
38const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
39const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);34const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
40const asEventId = (value: string): EventId => EventId.makeUnsafe(value);35const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);36const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
37const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
38const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4239
43type LegacyProviderRuntimeEvent = {40type LegacyProviderRuntimeEvent = {
44 readonly type: string;41 readonly type: string;
45 readonly eventId: EventId;42 readonly eventId: EventId;
46 readonly provider: "codex" | "claudeCode" | "cursor";43 readonly provider: "codex" | "claudeCode" | "cursor";
47 readonly sessionId: string;
48 readonly createdAt: string;44 readonly createdAt: string;
49 readonly threadId?: string | undefined;45 readonly threadId: ThreadId;
50 readonly turnId?: string | undefined;46 readonly turnId?: string | undefined;
51 readonly itemId?: string | undefined;47 readonly itemId?: string | undefined;
52 readonly requestId?: string | undefined;48 readonly requestId?: string | undefined;
...@@ -182,8 +178,6 @@ describe("ProviderRuntimeIngestion", () => {...@@ -182,8 +178,6 @@ describe("ProviderRuntimeIngestion", () => {
182 threadId: ThreadId.makeUnsafe("thread-1"),178 threadId: ThreadId.makeUnsafe("thread-1"),
183 status: "ready",179 status: "ready",
184 providerName: "codex",180 providerName: "codex",
185 providerSessionId: asSessionId("sess-1"),
186 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
187 runtimeMode: "approval-required",181 runtimeMode: "approval-required",
188 activeTurnId: null,182 activeTurnId: null,
189 updatedAt: createdAt,183 updatedAt: createdAt,
...@@ -207,9 +201,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -207,9 +201,9 @@ describe("ProviderRuntimeIngestion", () => {
207 type: "turn.started",201 type: "turn.started",
208 eventId: asEventId("evt-turn-started"),202 eventId: asEventId("evt-turn-started"),
209 provider: "codex",203 provider: "codex",
210 sessionId: asSessionId("sess-1"),204 threadId: asThreadId("thread-1"),
211 createdAt: now,205 createdAt: now,
212 turnId: asProviderTurnId("turn-1"),206 turnId: asTurnId("turn-1"),
213 });207 });
214208
215 await waitForThread(209 await waitForThread(
...@@ -221,9 +215,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -221,9 +215,9 @@ describe("ProviderRuntimeIngestion", () => {
221 type: "turn.completed",215 type: "turn.completed",
222 eventId: asEventId("evt-turn-completed"),216 eventId: asEventId("evt-turn-completed"),
223 provider: "codex",217 provider: "codex",
224 sessionId: asSessionId("sess-1"),218 threadId: asThreadId("thread-1"),
225 createdAt: new Date().toISOString(),219 createdAt: new Date().toISOString(),
226 turnId: asProviderTurnId("turn-1"),220 turnId: asTurnId("turn-1"),
227 payload: {221 payload: {
228 state: "failed",222 state: "failed",
229 errorMessage: "turn failed",223 errorMessage: "turn failed",
...@@ -249,9 +243,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -249,9 +243,8 @@ describe("ProviderRuntimeIngestion", () => {
249 type: "session.state.changed",243 type: "session.state.changed",
250 eventId: asEventId("evt-session-state-waiting"),244 eventId: asEventId("evt-session-state-waiting"),
251 provider: "codex",245 provider: "codex",
252 sessionId: asSessionId("sess-1"),246 threadId: asThreadId("thread-1"),
253 createdAt: waitingAt,247 createdAt: waitingAt,
254 threadId: asProviderThreadId("provider-thread-1"),
255 payload: {248 payload: {
256 state: "waiting",249 state: "waiting",
257 reason: "awaiting approval",250 reason: "awaiting approval",
...@@ -269,9 +262,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -269,9 +262,8 @@ describe("ProviderRuntimeIngestion", () => {
269 type: "session.state.changed",262 type: "session.state.changed",
270 eventId: asEventId("evt-session-state-error"),263 eventId: asEventId("evt-session-state-error"),
271 provider: "codex",264 provider: "codex",
272 sessionId: asSessionId("sess-1"),265 threadId: asThreadId("thread-1"),
273 createdAt: new Date().toISOString(),266 createdAt: new Date().toISOString(),
274 threadId: asProviderThreadId("provider-thread-1"),
275 payload: {267 payload: {
276 state: "error",268 state: "error",
277 reason: "provider crashed",269 reason: "provider crashed",
...@@ -292,9 +284,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -292,9 +284,8 @@ describe("ProviderRuntimeIngestion", () => {
292 type: "session.state.changed",284 type: "session.state.changed",
293 eventId: asEventId("evt-session-state-stopped"),285 eventId: asEventId("evt-session-state-stopped"),
294 provider: "codex",286 provider: "codex",
295 sessionId: asSessionId("sess-1"),287 threadId: asThreadId("thread-1"),
296 createdAt: new Date().toISOString(),288 createdAt: new Date().toISOString(),
297 threadId: asProviderThreadId("provider-thread-1"),
298 payload: {289 payload: {
299 state: "stopped",290 state: "stopped",
300 },291 },
...@@ -314,9 +305,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -314,9 +305,8 @@ describe("ProviderRuntimeIngestion", () => {
314 type: "session.state.changed",305 type: "session.state.changed",
315 eventId: asEventId("evt-session-state-ready"),306 eventId: asEventId("evt-session-state-ready"),
316 provider: "codex",307 provider: "codex",
317 sessionId: asSessionId("sess-1"),308 threadId: asThreadId("thread-1"),
318 createdAt: new Date().toISOString(),309 createdAt: new Date().toISOString(),
319 threadId: asProviderThreadId("provider-thread-1"),
320 payload: {310 payload: {
321 state: "ready",311 state: "ready",
322 },312 },
...@@ -341,10 +331,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -341,10 +331,9 @@ describe("ProviderRuntimeIngestion", () => {
341 type: "turn.started",331 type: "turn.started",
342 eventId: asEventId("evt-turn-started-midturn-lifecycle"),332 eventId: asEventId("evt-turn-started-midturn-lifecycle"),
343 provider: "codex",333 provider: "codex",
344 sessionId: asSessionId("sess-1"),
345 createdAt: now,334 createdAt: now,
346 threadId: asProviderThreadId("provider-thread-1"),335 threadId: asThreadId("thread-1"),
347 turnId: asProviderTurnId("turn-midturn-lifecycle"),336 turnId: asTurnId("turn-midturn-lifecycle"),
348 });337 });
349338
350 await waitForThread(339 await waitForThread(
...@@ -358,17 +347,15 @@ describe("ProviderRuntimeIngestion", () => {...@@ -358,17 +347,15 @@ describe("ProviderRuntimeIngestion", () => {
358 type: "thread.started",347 type: "thread.started",
359 eventId: asEventId("evt-thread-started-midturn-lifecycle"),348 eventId: asEventId("evt-thread-started-midturn-lifecycle"),
360 provider: "codex",349 provider: "codex",
361 sessionId: asSessionId("sess-1"),
362 createdAt: new Date().toISOString(),350 createdAt: new Date().toISOString(),
363 threadId: asProviderThreadId("provider-thread-1"),351 threadId: asThreadId("thread-1"),
364 });352 });
365 harness.emit({353 harness.emit({
366 type: "session.started",354 type: "session.started",
367 eventId: asEventId("evt-session-started-midturn-lifecycle"),355 eventId: asEventId("evt-session-started-midturn-lifecycle"),
368 provider: "codex",356 provider: "codex",
369 sessionId: asSessionId("sess-1"),
370 createdAt: new Date().toISOString(),357 createdAt: new Date().toISOString(),
371 threadId: asProviderThreadId("provider-thread-1"),358 threadId: asThreadId("thread-1"),
372 });359 });
373360
374 await Effect.runPromise(Effect.sleep("40 millis"));361 await Effect.runPromise(Effect.sleep("40 millis"));
...@@ -381,10 +368,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -381,10 +368,9 @@ describe("ProviderRuntimeIngestion", () => {
381 type: "turn.completed",368 type: "turn.completed",
382 eventId: asEventId("evt-turn-completed-midturn-lifecycle"),369 eventId: asEventId("evt-turn-completed-midturn-lifecycle"),
383 provider: "codex",370 provider: "codex",
384 sessionId: asSessionId("sess-1"),
385 createdAt: new Date().toISOString(),371 createdAt: new Date().toISOString(),
386 threadId: asProviderThreadId("provider-thread-1"),372 threadId: asThreadId("thread-1"),
387 turnId: asProviderTurnId("turn-midturn-lifecycle"),373 turnId: asTurnId("turn-midturn-lifecycle"),
388 status: "completed",374 status: "completed",
389 });375 });
390376
...@@ -402,10 +388,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -402,10 +388,9 @@ describe("ProviderRuntimeIngestion", () => {
402 type: "turn.started",388 type: "turn.started",
403 eventId: asEventId("evt-turn-started-primary"),389 eventId: asEventId("evt-turn-started-primary"),
404 provider: "codex",390 provider: "codex",
405 sessionId: asSessionId("sess-1"),
406 createdAt: now,391 createdAt: now,
407 threadId: asProviderThreadId("provider-thread-1"),392 threadId: asThreadId("thread-1"),
408 turnId: asProviderTurnId("turn-primary"),393 turnId: asTurnId("turn-primary"),
409 });394 });
410395
411 await waitForThread(396 await waitForThread(
...@@ -418,10 +403,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -418,10 +403,9 @@ describe("ProviderRuntimeIngestion", () => {
418 type: "turn.completed",403 type: "turn.completed",
419 eventId: asEventId("evt-turn-completed-aux"),404 eventId: asEventId("evt-turn-completed-aux"),
420 provider: "codex",405 provider: "codex",
421 sessionId: asSessionId("sess-1"),
422 createdAt: new Date().toISOString(),406 createdAt: new Date().toISOString(),
423 threadId: asProviderThreadId("provider-thread-aux"),407 threadId: asThreadId("thread-1"),
424 turnId: asProviderTurnId("turn-aux"),408 turnId: asTurnId("turn-aux"),
425 status: "completed",409 status: "completed",
426 });410 });
427411
...@@ -437,10 +421,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -437,10 +421,9 @@ describe("ProviderRuntimeIngestion", () => {
437 type: "turn.completed",421 type: "turn.completed",
438 eventId: asEventId("evt-turn-completed-primary"),422 eventId: asEventId("evt-turn-completed-primary"),
439 provider: "codex",423 provider: "codex",
440 sessionId: asSessionId("sess-1"),
441 createdAt: new Date().toISOString(),424 createdAt: new Date().toISOString(),
442 threadId: asProviderThreadId("provider-thread-1"),425 threadId: asThreadId("thread-1"),
443 turnId: asProviderTurnId("turn-primary"),426 turnId: asTurnId("turn-primary"),
444 status: "completed",427 status: "completed",
445 });428 });
446429
...@@ -463,8 +446,6 @@ describe("ProviderRuntimeIngestion", () => {...@@ -463,8 +446,6 @@ describe("ProviderRuntimeIngestion", () => {
463 threadId: ThreadId.makeUnsafe("thread-1"),446 threadId: ThreadId.makeUnsafe("thread-1"),
464 status: "ready",447 status: "ready",
465 providerName: "claudeCode",448 providerName: "claudeCode",
466 providerSessionId: asSessionId("sess-1"),
467 providerThreadId: asProviderThreadId("claude-thread-placeholder"),
468 runtimeMode: "approval-required",449 runtimeMode: "approval-required",
469 activeTurnId: null,450 activeTurnId: null,
470 updatedAt: seededAt,451 updatedAt: seededAt,
...@@ -478,10 +459,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -478,10 +459,9 @@ describe("ProviderRuntimeIngestion", () => {
478 type: "turn.started",459 type: "turn.started",
479 eventId: asEventId("evt-turn-started-claude-placeholder"),460 eventId: asEventId("evt-turn-started-claude-placeholder"),
480 provider: "claudeCode",461 provider: "claudeCode",
481 sessionId: asSessionId("sess-1"),
482 createdAt: new Date().toISOString(),462 createdAt: new Date().toISOString(),
483 threadId: asProviderThreadId("provider-thread-real"),463 threadId: asThreadId("thread-1"),
484 turnId: asProviderTurnId("turn-claude-placeholder"),464 turnId: asTurnId("turn-claude-placeholder"),
485 });465 });
486466
487 await waitForThread(467 await waitForThread(
...@@ -495,10 +475,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -495,10 +475,9 @@ describe("ProviderRuntimeIngestion", () => {
495 type: "turn.completed",475 type: "turn.completed",
496 eventId: asEventId("evt-turn-completed-claude-placeholder"),476 eventId: asEventId("evt-turn-completed-claude-placeholder"),
497 provider: "claudeCode",477 provider: "claudeCode",
498 sessionId: asSessionId("sess-1"),
499 createdAt: new Date().toISOString(),478 createdAt: new Date().toISOString(),
500 threadId: asProviderThreadId("provider-thread-real"),479 threadId: asThreadId("thread-1"),
501 turnId: asProviderTurnId("turn-claude-placeholder"),480 turnId: asTurnId("turn-claude-placeholder"),
502 status: "completed",481 status: "completed",
503 });482 });
504483
...@@ -516,9 +495,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -516,9 +495,9 @@ describe("ProviderRuntimeIngestion", () => {
516 type: "turn.started",495 type: "turn.started",
517 eventId: asEventId("evt-turn-started-guarded"),496 eventId: asEventId("evt-turn-started-guarded"),
518 provider: "codex",497 provider: "codex",
519 sessionId: asSessionId("sess-1"),
520 createdAt: now,498 createdAt: now,
521 turnId: asProviderTurnId("turn-guarded-main"),499 threadId: asThreadId("thread-1"),
500 turnId: asTurnId("turn-guarded-main"),
522 });501 });
523502
524 await waitForThread(503 await waitForThread(
...@@ -532,9 +511,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -532,9 +511,9 @@ describe("ProviderRuntimeIngestion", () => {
532 type: "turn.completed",511 type: "turn.completed",
533 eventId: asEventId("evt-turn-completed-guarded-other"),512 eventId: asEventId("evt-turn-completed-guarded-other"),
534 provider: "codex",513 provider: "codex",
535 sessionId: asSessionId("sess-1"),
536 createdAt: new Date().toISOString(),514 createdAt: new Date().toISOString(),
537 turnId: asProviderTurnId("turn-guarded-other"),515 threadId: asThreadId("thread-1"),
516 turnId: asTurnId("turn-guarded-other"),
538 status: "completed",517 status: "completed",
539 });518 });
540519
...@@ -550,9 +529,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -550,9 +529,9 @@ describe("ProviderRuntimeIngestion", () => {
550 type: "turn.completed",529 type: "turn.completed",
551 eventId: asEventId("evt-turn-completed-guarded-main"),530 eventId: asEventId("evt-turn-completed-guarded-main"),
552 provider: "codex",531 provider: "codex",
553 sessionId: asSessionId("sess-1"),
554 createdAt: new Date().toISOString(),532 createdAt: new Date().toISOString(),
555 turnId: asProviderTurnId("turn-guarded-main"),533 threadId: asThreadId("thread-1"),
534 turnId: asTurnId("turn-guarded-main"),
556 status: "completed",535 status: "completed",
557 });536 });
558537
...@@ -570,9 +549,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -570,9 +549,9 @@ describe("ProviderRuntimeIngestion", () => {
570 type: "content.delta",549 type: "content.delta",
571 eventId: asEventId("evt-message-delta-1"),550 eventId: asEventId("evt-message-delta-1"),
572 provider: "codex",551 provider: "codex",
573 sessionId: asSessionId("sess-1"),
574 createdAt: now,552 createdAt: now,
575 turnId: asProviderTurnId("turn-2"),553 threadId: asThreadId("thread-1"),
554 turnId: asTurnId("turn-2"),
576 itemId: asItemId("item-1"),555 itemId: asItemId("item-1"),
577 payload: {556 payload: {
578 streamKind: "assistant_text",557 streamKind: "assistant_text",
...@@ -583,9 +562,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -583,9 +562,9 @@ describe("ProviderRuntimeIngestion", () => {
583 type: "content.delta",562 type: "content.delta",
584 eventId: asEventId("evt-message-delta-2"),563 eventId: asEventId("evt-message-delta-2"),
585 provider: "codex",564 provider: "codex",
586 sessionId: asSessionId("sess-1"),
587 createdAt: now,565 createdAt: now,
588 turnId: asProviderTurnId("turn-2"),566 threadId: asThreadId("thread-1"),
567 turnId: asTurnId("turn-2"),
589 itemId: asItemId("item-1"),568 itemId: asItemId("item-1"),
590 payload: {569 payload: {
591 streamKind: "assistant_text",570 streamKind: "assistant_text",
...@@ -596,9 +575,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -596,9 +575,9 @@ describe("ProviderRuntimeIngestion", () => {
596 type: "item.completed",575 type: "item.completed",
597 eventId: asEventId("evt-message-completed"),576 eventId: asEventId("evt-message-completed"),
598 provider: "codex",577 provider: "codex",
599 sessionId: asSessionId("sess-1"),
600 createdAt: now,578 createdAt: now,
601 turnId: asProviderTurnId("turn-2"),579 threadId: asThreadId("thread-1"),
580 turnId: asTurnId("turn-2"),
602 itemId: asItemId("item-1"),581 itemId: asItemId("item-1"),
603 payload: {582 payload: {
604 itemType: "assistant_message",583 itemType: "assistant_message",
...@@ -627,9 +606,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -627,9 +606,9 @@ describe("ProviderRuntimeIngestion", () => {
627 type: "item.completed",606 type: "item.completed",
628 eventId: asEventId("evt-assistant-item-completed-no-delta"),607 eventId: asEventId("evt-assistant-item-completed-no-delta"),
629 provider: "codex",608 provider: "codex",
630 sessionId: asSessionId("sess-1"),
631 createdAt: now,609 createdAt: now,
632 turnId: asProviderTurnId("turn-no-delta"),610 threadId: asThreadId("thread-1"),
611 turnId: asTurnId("turn-no-delta"),
633 itemId: asItemId("item-no-delta"),612 itemId: asItemId("item-no-delta"),
634 payload: {613 payload: {
635 itemType: "assistant_message",614 itemType: "assistant_message",
...@@ -659,9 +638,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -659,9 +638,9 @@ describe("ProviderRuntimeIngestion", () => {
659 type: "turn.started",638 type: "turn.started",
660 eventId: asEventId("evt-turn-started-buffered"),639 eventId: asEventId("evt-turn-started-buffered"),
661 provider: "codex",640 provider: "codex",
662 sessionId: asSessionId("sess-1"),
663 createdAt: now,641 createdAt: now,
664 turnId: asProviderTurnId("turn-buffered"),642 threadId: asThreadId("thread-1"),
643 turnId: asTurnId("turn-buffered"),
665 });644 });
666 await waitForThread(645 await waitForThread(
667 harness.engine,646 harness.engine,
...@@ -673,9 +652,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -673,9 +652,9 @@ describe("ProviderRuntimeIngestion", () => {
673 type: "content.delta",652 type: "content.delta",
674 eventId: asEventId("evt-message-delta-buffered"),653 eventId: asEventId("evt-message-delta-buffered"),
675 provider: "codex",654 provider: "codex",
676 sessionId: asSessionId("sess-1"),
677 createdAt: now,655 createdAt: now,
678 turnId: asProviderTurnId("turn-buffered"),656 threadId: asThreadId("thread-1"),
657 turnId: asTurnId("turn-buffered"),
679 itemId: asItemId("item-buffered"),658 itemId: asItemId("item-buffered"),
680 payload: {659 payload: {
681 streamKind: "assistant_text",660 streamKind: "assistant_text",
...@@ -698,9 +677,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -698,9 +677,9 @@ describe("ProviderRuntimeIngestion", () => {
698 type: "item.completed",677 type: "item.completed",
699 eventId: asEventId("evt-message-completed-buffered"),678 eventId: asEventId("evt-message-completed-buffered"),
700 provider: "codex",679 provider: "codex",
701 sessionId: asSessionId("sess-1"),
702 createdAt: now,680 createdAt: now,
703 turnId: asProviderTurnId("turn-buffered"),681 threadId: asThreadId("thread-1"),
682 turnId: asTurnId("turn-buffered"),
704 itemId: asItemId("item-buffered"),683 itemId: asItemId("item-buffered"),
705 payload: {684 payload: {
706 itemType: "assistant_message",685 itemType: "assistant_message",
...@@ -747,9 +726,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -747,9 +726,9 @@ describe("ProviderRuntimeIngestion", () => {
747 type: "turn.started",726 type: "turn.started",
748 eventId: asEventId("evt-turn-started-streaming-mode"),727 eventId: asEventId("evt-turn-started-streaming-mode"),
749 provider: "codex",728 provider: "codex",
750 sessionId: asSessionId("sess-1"),
751 createdAt: now,729 createdAt: now,
752 turnId: asProviderTurnId("turn-streaming-mode"),730 threadId: asThreadId("thread-1"),
731 turnId: asTurnId("turn-streaming-mode"),
753 });732 });
754 await waitForThread(733 await waitForThread(
755 harness.engine,734 harness.engine,
...@@ -762,9 +741,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -762,9 +741,9 @@ describe("ProviderRuntimeIngestion", () => {
762 type: "content.delta",741 type: "content.delta",
763 eventId: asEventId("evt-message-delta-streaming-mode"),742 eventId: asEventId("evt-message-delta-streaming-mode"),
764 provider: "codex",743 provider: "codex",
765 sessionId: asSessionId("sess-1"),
766 createdAt: now,744 createdAt: now,
767 turnId: asProviderTurnId("turn-streaming-mode"),745 threadId: asThreadId("thread-1"),
746 turnId: asTurnId("turn-streaming-mode"),
768 itemId: asItemId("item-streaming-mode"),747 itemId: asItemId("item-streaming-mode"),
769 payload: {748 payload: {
770 streamKind: "assistant_text",749 streamKind: "assistant_text",
...@@ -789,9 +768,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -789,9 +768,9 @@ describe("ProviderRuntimeIngestion", () => {
789 type: "item.completed",768 type: "item.completed",
790 eventId: asEventId("evt-message-completed-streaming-mode"),769 eventId: asEventId("evt-message-completed-streaming-mode"),
791 provider: "codex",770 provider: "codex",
792 sessionId: asSessionId("sess-1"),
793 createdAt: now,771 createdAt: now,
794 turnId: asProviderTurnId("turn-streaming-mode"),772 threadId: asThreadId("thread-1"),
773 turnId: asTurnId("turn-streaming-mode"),
795 itemId: asItemId("item-streaming-mode"),774 itemId: asItemId("item-streaming-mode"),
796 payload: {775 payload: {
797 itemType: "assistant_message",776 itemType: "assistant_message",
...@@ -821,9 +800,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -821,9 +800,9 @@ describe("ProviderRuntimeIngestion", () => {
821 type: "turn.started",800 type: "turn.started",
822 eventId: asEventId("evt-turn-started-buffer-spill"),801 eventId: asEventId("evt-turn-started-buffer-spill"),
823 provider: "codex",802 provider: "codex",
824 sessionId: asSessionId("sess-1"),
825 createdAt: now,803 createdAt: now,
826 turnId: asProviderTurnId("turn-buffer-spill"),804 threadId: asThreadId("thread-1"),
805 turnId: asTurnId("turn-buffer-spill"),
827 });806 });
828 await waitForThread(807 await waitForThread(
829 harness.engine,808 harness.engine,
...@@ -836,9 +815,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -836,9 +815,9 @@ describe("ProviderRuntimeIngestion", () => {
836 type: "content.delta",815 type: "content.delta",
837 eventId: asEventId("evt-message-delta-buffer-spill"),816 eventId: asEventId("evt-message-delta-buffer-spill"),
838 provider: "codex",817 provider: "codex",
839 sessionId: asSessionId("sess-1"),
840 createdAt: now,818 createdAt: now,
841 turnId: asProviderTurnId("turn-buffer-spill"),819 threadId: asThreadId("thread-1"),
820 turnId: asTurnId("turn-buffer-spill"),
842 itemId: asItemId("item-buffer-spill"),821 itemId: asItemId("item-buffer-spill"),
843 payload: {822 payload: {
844 streamKind: "assistant_text",823 streamKind: "assistant_text",
...@@ -849,9 +828,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -849,9 +828,9 @@ describe("ProviderRuntimeIngestion", () => {
849 type: "item.completed",828 type: "item.completed",
850 eventId: asEventId("evt-message-completed-buffer-spill"),829 eventId: asEventId("evt-message-completed-buffer-spill"),
851 provider: "codex",830 provider: "codex",
852 sessionId: asSessionId("sess-1"),
853 createdAt: now,831 createdAt: now,
854 turnId: asProviderTurnId("turn-buffer-spill"),832 threadId: asThreadId("thread-1"),
833 turnId: asTurnId("turn-buffer-spill"),
855 itemId: asItemId("item-buffer-spill"),834 itemId: asItemId("item-buffer-spill"),
856 payload: {835 payload: {
857 itemType: "assistant_message",836 itemType: "assistant_message",
...@@ -881,9 +860,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -881,9 +860,9 @@ describe("ProviderRuntimeIngestion", () => {
881 type: "turn.started",860 type: "turn.started",
882 eventId: asEventId("evt-turn-started-for-complete-dedup"),861 eventId: asEventId("evt-turn-started-for-complete-dedup"),
883 provider: "codex",862 provider: "codex",
884 sessionId: asSessionId("sess-1"),
885 createdAt: now,863 createdAt: now,
886 turnId: asProviderTurnId("turn-complete-dedup"),864 threadId: asThreadId("thread-1"),
865 turnId: asTurnId("turn-complete-dedup"),
887 });866 });
888867
889 await waitForThread(868 await waitForThread(
...@@ -897,9 +876,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -897,9 +876,9 @@ describe("ProviderRuntimeIngestion", () => {
897 type: "content.delta",876 type: "content.delta",
898 eventId: asEventId("evt-message-delta-for-complete-dedup"),877 eventId: asEventId("evt-message-delta-for-complete-dedup"),
899 provider: "codex",878 provider: "codex",
900 sessionId: asSessionId("sess-1"),
901 createdAt: now,879 createdAt: now,
902 turnId: asProviderTurnId("turn-complete-dedup"),880 threadId: asThreadId("thread-1"),
881 turnId: asTurnId("turn-complete-dedup"),
903 itemId: asItemId("item-complete-dedup"),882 itemId: asItemId("item-complete-dedup"),
904 payload: {883 payload: {
905 streamKind: "assistant_text",884 streamKind: "assistant_text",
...@@ -910,9 +889,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -910,9 +889,9 @@ describe("ProviderRuntimeIngestion", () => {
910 type: "item.completed",889 type: "item.completed",
911 eventId: asEventId("evt-message-completed-for-complete-dedup"),890 eventId: asEventId("evt-message-completed-for-complete-dedup"),
912 provider: "codex",891 provider: "codex",
913 sessionId: asSessionId("sess-1"),
914 createdAt: now,892 createdAt: now,
915 turnId: asProviderTurnId("turn-complete-dedup"),893 threadId: asThreadId("thread-1"),
894 turnId: asTurnId("turn-complete-dedup"),
916 itemId: asItemId("item-complete-dedup"),895 itemId: asItemId("item-complete-dedup"),
917 payload: {896 payload: {
918 itemType: "assistant_message",897 itemType: "assistant_message",
...@@ -923,9 +902,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -923,9 +902,9 @@ describe("ProviderRuntimeIngestion", () => {
923 type: "turn.completed",902 type: "turn.completed",
924 eventId: asEventId("evt-turn-completed-for-complete-dedup"),903 eventId: asEventId("evt-turn-completed-for-complete-dedup"),
925 provider: "codex",904 provider: "codex",
926 sessionId: asSessionId("sess-1"),
927 createdAt: now,905 createdAt: now,
928 turnId: asProviderTurnId("turn-complete-dedup"),906 threadId: asThreadId("thread-1"),
907 turnId: asTurnId("turn-complete-dedup"),
929 payload: {908 payload: {
930 state: "completed",909 state: "completed",
931 },910 },
...@@ -967,8 +946,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -967,8 +946,8 @@ describe("ProviderRuntimeIngestion", () => {
967 type: "request.opened",946 type: "request.opened",
968 eventId: asEventId("evt-request-opened"),947 eventId: asEventId("evt-request-opened"),
969 provider: "codex",948 provider: "codex",
970 sessionId: asSessionId("sess-1"),
971 createdAt: now,949 createdAt: now,
950 threadId: asThreadId("thread-1"),
972 requestId: ApprovalRequestId.makeUnsafe("req-open"),951 requestId: ApprovalRequestId.makeUnsafe("req-open"),
973 payload: {952 payload: {
974 requestType: "command_execution_approval",953 requestType: "command_execution_approval",
...@@ -980,8 +959,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -980,8 +959,8 @@ describe("ProviderRuntimeIngestion", () => {
980 type: "request.resolved",959 type: "request.resolved",
981 eventId: asEventId("evt-request-resolved"),960 eventId: asEventId("evt-request-resolved"),
982 provider: "codex",961 provider: "codex",
983 sessionId: asSessionId("sess-1"),
984 createdAt: now,962 createdAt: now,
963 threadId: asThreadId("thread-1"),
985 requestId: ApprovalRequestId.makeUnsafe("req-open"),964 requestId: ApprovalRequestId.makeUnsafe("req-open"),
986 payload: {965 payload: {
987 requestType: "command_execution_approval",966 requestType: "command_execution_approval",
...@@ -1033,9 +1012,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1033,9 +1012,9 @@ describe("ProviderRuntimeIngestion", () => {
1033 type: "runtime.error",1012 type: "runtime.error",
1034 eventId: asEventId("evt-runtime-error"),1013 eventId: asEventId("evt-runtime-error"),
1035 provider: "codex",1014 provider: "codex",
1036 sessionId: asSessionId("sess-1"),
1037 createdAt: now,1015 createdAt: now,
1038 turnId: asProviderTurnId("turn-3"),1016 threadId: asThreadId("thread-1"),
1017 turnId: asTurnId("turn-3"),
1039 payload: {1018 payload: {
1040 message: "runtime exploded",1019 message: "runtime exploded",
1041 },1020 },
...@@ -1060,26 +1039,24 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1060,26 +1039,24 @@ describe("ProviderRuntimeIngestion", () => {
1060 type: "session.started",1039 type: "session.started",
1061 eventId: asEventId("evt-session-started"),1040 eventId: asEventId("evt-session-started"),
1062 provider: "codex",1041 provider: "codex",
1063 sessionId: asSessionId("sess-1"),
1064 createdAt: now,1042 createdAt: now,
1065 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),1043 threadId: asThreadId("thread-1"),
1066 message: "session started",1044 message: "session started",
1067 });1045 });
1068 harness.emit({1046 harness.emit({
1069 type: "thread.started",1047 type: "thread.started",
1070 eventId: asEventId("evt-thread-started"),1048 eventId: asEventId("evt-thread-started"),
1071 provider: "codex",1049 provider: "codex",
1072 sessionId: asSessionId("sess-1"),
1073 createdAt: now,1050 createdAt: now,
1074 threadId: ProviderThreadId.makeUnsafe("provider-thread-2"),1051 threadId: asThreadId("thread-1"),
1075 });1052 });
1076 harness.emit({1053 harness.emit({
1077 type: "item.started",1054 type: "item.started",
1078 eventId: asEventId("evt-tool-started"),1055 eventId: asEventId("evt-tool-started"),
1079 provider: "codex",1056 provider: "codex",
1080 sessionId: asSessionId("sess-1"),
1081 createdAt: now,1057 createdAt: now,
1082 turnId: asProviderTurnId("turn-9"),1058 threadId: asThreadId("thread-1"),
1059 turnId: asTurnId("turn-9"),
1083 payload: {1060 payload: {
1084 itemType: "command_execution",1061 itemType: "command_execution",
1085 status: "in_progress",1062 status: "in_progress",
...@@ -1114,9 +1091,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1114,9 +1091,8 @@ describe("ProviderRuntimeIngestion", () => {
1114 type: "thread.metadata.updated",1091 type: "thread.metadata.updated",
1115 eventId: asEventId("evt-thread-metadata-updated"),1092 eventId: asEventId("evt-thread-metadata-updated"),
1116 provider: "codex",1093 provider: "codex",
1117 sessionId: asSessionId("sess-1"),
1118 createdAt: now,1094 createdAt: now,
1119 threadId: asProviderThreadId("provider-thread-1"),1095 threadId: asThreadId("thread-1"),
1120 payload: {1096 payload: {
1121 name: "Renamed by provider",1097 name: "Renamed by provider",
1122 metadata: { source: "provider" },1098 metadata: { source: "provider" },
...@@ -1127,9 +1103,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1127,9 +1103,9 @@ describe("ProviderRuntimeIngestion", () => {
1127 type: "turn.plan.updated",1103 type: "turn.plan.updated",
1128 eventId: asEventId("evt-turn-plan-updated"),1104 eventId: asEventId("evt-turn-plan-updated"),
1129 provider: "codex",1105 provider: "codex",
1130 sessionId: asSessionId("sess-1"),
1131 createdAt: now,1106 createdAt: now,
1132 turnId: asProviderTurnId("turn-p1"),1107 threadId: asThreadId("thread-1"),
1108 turnId: asTurnId("turn-p1"),
1133 payload: {1109 payload: {
1134 explanation: "Working through the plan",1110 explanation: "Working through the plan",
1135 plan: [1111 plan: [
...@@ -1143,9 +1119,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1143,9 +1119,9 @@ describe("ProviderRuntimeIngestion", () => {
1143 type: "item.updated",1119 type: "item.updated",
1144 eventId: asEventId("evt-item-updated"),1120 eventId: asEventId("evt-item-updated"),
1145 provider: "codex",1121 provider: "codex",
1146 sessionId: asSessionId("sess-1"),
1147 createdAt: now,1122 createdAt: now,
1148 turnId: asProviderTurnId("turn-p1"),1123 threadId: asThreadId("thread-1"),
1124 turnId: asTurnId("turn-p1"),
1149 itemId: asItemId("item-p1-tool"),1125 itemId: asItemId("item-p1-tool"),
1150 payload: {1126 payload: {
1151 itemType: "command_execution",1127 itemType: "command_execution",
...@@ -1160,9 +1136,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1160,9 +1136,9 @@ describe("ProviderRuntimeIngestion", () => {
1160 type: "runtime.warning",1136 type: "runtime.warning",
1161 eventId: asEventId("evt-runtime-warning"),1137 eventId: asEventId("evt-runtime-warning"),
1162 provider: "codex",1138 provider: "codex",
1163 sessionId: asSessionId("sess-1"),
1164 createdAt: now,1139 createdAt: now,
1165 turnId: asProviderTurnId("turn-p1"),1140 threadId: asThreadId("thread-1"),
1141 turnId: asTurnId("turn-p1"),
1166 payload: {1142 payload: {
1167 message: "Provider got slow",1143 message: "Provider got slow",
1168 detail: { latencyMs: 1500 },1144 detail: { latencyMs: 1500 },
...@@ -1173,9 +1149,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1173,9 +1149,9 @@ describe("ProviderRuntimeIngestion", () => {
1173 type: "turn.diff.updated",1149 type: "turn.diff.updated",
1174 eventId: asEventId("evt-turn-diff-updated"),1150 eventId: asEventId("evt-turn-diff-updated"),
1175 provider: "codex",1151 provider: "codex",
1176 sessionId: asSessionId("sess-1"),
1177 createdAt: now,1152 createdAt: now,
1178 turnId: asProviderTurnId("turn-p1"),1153 threadId: asThreadId("thread-1"),
1154 turnId: asTurnId("turn-p1"),
1179 itemId: asItemId("item-p1-assistant"),1155 itemId: asItemId("item-p1-assistant"),
1180 payload: {1156 payload: {
1181 unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n",1157 unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n",
...@@ -1249,9 +1225,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1249,9 +1225,9 @@ describe("ProviderRuntimeIngestion", () => {
1249 type: "content.delta",1225 type: "content.delta",
1250 eventId: asEventId("evt-invalid-delta"),1226 eventId: asEventId("evt-invalid-delta"),
1251 provider: "codex",1227 provider: "codex",
1252 sessionId: asSessionId("sess-1"),
1253 createdAt: now,1228 createdAt: now,
1254 turnId: asProviderTurnId("turn-invalid"),1229 threadId: asThreadId("thread-1"),
1230 turnId: asTurnId("turn-invalid"),
1255 itemId: asItemId("item-invalid"),1231 itemId: asItemId("item-invalid"),
1256 payload: {1232 payload: {
1257 streamKind: "assistant_text",1233 streamKind: "assistant_text",
...@@ -1263,9 +1239,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1263,9 +1239,9 @@ describe("ProviderRuntimeIngestion", () => {
1263 type: "runtime.error",1239 type: "runtime.error",
1264 eventId: asEventId("evt-runtime-error-after-failure"),1240 eventId: asEventId("evt-runtime-error-after-failure"),
1265 provider: "codex",1241 provider: "codex",
1266 sessionId: asSessionId("sess-1"),
1267 createdAt: new Date().toISOString(),1242 createdAt: new Date().toISOString(),
1268 turnId: asProviderTurnId("turn-after-failure"),1243 threadId: asThreadId("thread-1"),
1244 turnId: asTurnId("turn-after-failure"),
1269 payload: {1245 payload: {
1270 message: "runtime still processed",1246 message: "runtime still processed",
1271 },1247 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+33-95
...@@ -5,13 +5,10 @@ import {...@@ -5,13 +5,10 @@ import {
5 MessageId,5 MessageId,
6 type OrchestrationEvent,6 type OrchestrationEvent,
7 CheckpointRef,7 CheckpointRef,
8 ProviderThreadId,8 ThreadId,
9 type ThreadId,
10 TurnId,9 TurnId,
11 type OrchestrationThreadActivity,10 type OrchestrationThreadActivity,
12 type ProviderRuntimeEvent,11 type ProviderRuntimeEvent,
13 ProviderSessionId,
14 RuntimeSessionId,
15} from "@t3tools/contracts";12} from "@t3tools/contracts";
16import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";13import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";
1714
...@@ -22,7 +19,7 @@ import {...@@ -22,7 +19,7 @@ import {
22 type ProviderRuntimeIngestionShape,19 type ProviderRuntimeIngestionShape,
23} from "../Services/ProviderRuntimeIngestion.ts";20} from "../Services/ProviderRuntimeIngestion.ts";
2421
25const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;22const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
26const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>23const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
27 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);24 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
2825
...@@ -53,14 +50,6 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined {...@@ -53,14 +50,6 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined {
53 return value === undefined ? undefined : TurnId.makeUnsafe(String(value));50 return value === undefined ? undefined : TurnId.makeUnsafe(String(value));
54}51}
5552
56function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
57 return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value));
58}
59
60function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId {
61 return ProviderSessionId.makeUnsafe(String(value));
62}
63
64function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined {53function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined {
65 return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value);54 return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value);
66}55}
...@@ -72,13 +61,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine...@@ -72,13 +61,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine
72 return left === right;61 return left === right;
73}62}
7463
75function isSyntheticClaudeThreadId(
76 provider: ProviderRuntimeEvent["provider"],
77 threadId: ProviderThreadId | null,
78): boolean {
79 return provider === "claudeCode" && threadId !== null && threadId.startsWith("claude-thread-");
80}
81
82function truncateDetail(value: string, limit = 180): string {64function truncateDetail(value: string, limit = 180): string {
83 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;65 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
84}66}
...@@ -148,11 +130,13 @@ function orchestrationSessionStatusFromRuntimeState(...@@ -148,11 +130,13 @@ function orchestrationSessionStatusFromRuntimeState(
148130
149function requestKindFromCanonicalRequestType(131function requestKindFromCanonicalRequestType(
150 requestType: string | undefined,132 requestType: string | undefined,
151): "command" | "file-change" | undefined {133): "command" | "file-read" | "file-change" | undefined {
152 switch (requestType) {134 switch (requestType) {
153 case "command_execution_approval":135 case "command_execution_approval":
154 case "exec_command_approval":136 case "exec_command_approval":
155 return "command";137 return "command";
138 case "file_read_approval":
139 return "file-read";
156 case "file_change_approval":140 case "file_change_approval":
157 case "apply_patch_approval":141 case "apply_patch_approval":
158 return "file-change";142 return "file-change";
...@@ -194,6 +178,8 @@ function runtimeEventToActivities(...@@ -194,6 +178,8 @@ function runtimeEventToActivities(
194 summary:178 summary:
195 requestKind === "command"179 requestKind === "command"
196 ? "Command approval requested"180 ? "Command approval requested"
181 : requestKind === "file-read"
182 ? "File-read approval requested"
197 : requestKind === "file-change"183 : requestKind === "file-change"
198 ? "File-change approval requested"184 ? "File-change approval requested"
199 : "Approval requested",185 : "Approval requested",
...@@ -380,15 +366,15 @@ const make = Effect.gen(function* () {...@@ -380,15 +366,15 @@ const make = Effect.gen(function* () {
380 });366 });
381367
382 const rememberAssistantMessageId = (368 const rememberAssistantMessageId = (
383 sessionId: ProviderSessionId,369 threadId: ThreadId,
384 turnId: TurnId,370 turnId: TurnId,
385 messageId: MessageId,371 messageId: MessageId,
386 ) =>372 ) =>
387 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(373 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
388 Effect.flatMap((existingIds) =>374 Effect.flatMap((existingIds) =>
389 Cache.set(375 Cache.set(
390 turnMessageIdsByTurnKey,376 turnMessageIdsByTurnKey,
391 providerTurnKey(sessionId, turnId),377 providerTurnKey(threadId, turnId),
392 Option.match(existingIds, {378 Option.match(existingIds, {
393 onNone: () => new Set([messageId]),379 onNone: () => new Set([messageId]),
394 onSome: (ids) => {380 onSome: (ids) => {
...@@ -402,11 +388,11 @@ const make = Effect.gen(function* () {...@@ -402,11 +388,11 @@ const make = Effect.gen(function* () {
402 );388 );
403389
404 const forgetAssistantMessageId = (390 const forgetAssistantMessageId = (
405 sessionId: ProviderSessionId,391 threadId: ThreadId,
406 turnId: TurnId,392 turnId: TurnId,
407 messageId: MessageId,393 messageId: MessageId,
408 ) =>394 ) =>
409 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(395 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
410 Effect.flatMap((existingIds) =>396 Effect.flatMap((existingIds) =>
411 Option.match(existingIds, {397 Option.match(existingIds, {
412 onNone: () => Effect.void,398 onNone: () => Effect.void,
...@@ -414,23 +400,23 @@ const make = Effect.gen(function* () {...@@ -414,23 +400,23 @@ const make = Effect.gen(function* () {
414 const nextIds = new Set(ids);400 const nextIds = new Set(ids);
415 nextIds.delete(messageId);401 nextIds.delete(messageId);
416 if (nextIds.size === 0) {402 if (nextIds.size === 0) {
417 return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId));403 return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
418 }404 }
419 return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds);405 return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds);
420 },406 },
421 }),407 }),
422 ),408 ),
423 );409 );
424410
425 const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) =>411 const getAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
426 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(412 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
427 Effect.map((existingIds) =>413 Effect.map((existingIds) =>
428 Option.getOrElse(existingIds, (): Set<MessageId> => new Set<MessageId>()),414 Option.getOrElse(existingIds, (): Set<MessageId> => new Set<MessageId>()),
429 ),415 ),
430 );416 );
431417
432 const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) =>418 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
433 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId));419 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
434420
435 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>421 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
436 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(422 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
...@@ -505,9 +491,9 @@ const make = Effect.gen(function* () {...@@ -505,9 +491,9 @@ const make = Effect.gen(function* () {
505 yield* clearAssistantMessageState(input.messageId);491 yield* clearAssistantMessageState(input.messageId);
506 });492 });
507493
508 const clearTurnStateForSession = (sessionId: ProviderSessionId) =>494 const clearTurnStateForSession = (threadId: ThreadId) =>
509 Effect.gen(function* () {495 Effect.gen(function* () {
510 const prefix = `${sessionId}:`;496 const prefix = `${threadId}:`;
511 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));497 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
512 yield* Effect.forEach(498 yield* Effect.forEach(
513 turnKeys,499 turnKeys,
...@@ -532,29 +518,14 @@ const make = Effect.gen(function* () {...@@ -532,29 +518,14 @@ const make = Effect.gen(function* () {
532518
533 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>519 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
534 Effect.gen(function* () {520 Effect.gen(function* () {
535 const providerSessionId = toProviderSessionId(event.sessionId);
536 const readModel = yield* orchestrationEngine.getReadModel();521 const readModel = yield* orchestrationEngine.getReadModel();
537 const thread = readModel.threads.find(522 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
538 (entry) => entry.session?.providerSessionId === providerSessionId,
539 );
540 if (!thread) return;523 if (!thread) return;
541524
542 const now = event.createdAt;525 const now = event.createdAt;
543 const sessionProviderThreadId = thread.session?.providerThreadId ?? null;
544 const scopedSessionProviderThreadId = isSyntheticClaudeThreadId(
545 event.provider,
546 sessionProviderThreadId,
547 )
548 ? null
549 : sessionProviderThreadId;
550 const eventProviderThreadId = toProviderThreadId(event.threadId);
551 const eventTurnId = toTurnId(event.turnId);526 const eventTurnId = toTurnId(event.turnId);
552 const activeTurnId = thread.session?.activeTurnId ?? null;527 const activeTurnId = thread.session?.activeTurnId ?? null;
553528
554 const matchesThreadScope =
555 eventProviderThreadId === null ||
556 scopedSessionProviderThreadId === null ||
557 sameId(eventProviderThreadId, scopedSessionProviderThreadId);
558 const conflictsWithActiveTurn =529 const conflictsWithActiveTurn =
559 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);530 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
560 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;531 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
...@@ -568,27 +539,10 @@ const make = Effect.gen(function* () {...@@ -568,27 +539,10 @@ const make = Effect.gen(function* () {
568 return true;539 return true;
569 case "session.started":540 case "session.started":
570 case "thread.started":541 case "thread.started":
571 if (!matchesThreadScope) {
572 return false;
573 }
574 // Never let auxiliary/provider-side spawned threads replace the primary thread binding.
575 if (
576 eventProviderThreadId !== null &&
577 scopedSessionProviderThreadId !== null &&
578 !sameId(eventProviderThreadId, scopedSessionProviderThreadId)
579 ) {
580 return false;
581 }
582 return true;542 return true;
583 case "turn.started":543 case "turn.started":
584 if (!matchesThreadScope) {
585 return false;
586 }
587 return !conflictsWithActiveTurn;544 return !conflictsWithActiveTurn;
588 case "turn.completed":545 case "turn.completed":
589 if (!matchesThreadScope) {
590 return false;
591 }
592 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {546 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
593 return false;547 return false;
594 }548 }
...@@ -617,12 +571,6 @@ const make = Effect.gen(function* () {...@@ -617,12 +571,6 @@ const make = Effect.gen(function* () {
617 : event.type === "turn.completed" || event.type === "session.exited"571 : event.type === "turn.completed" || event.type === "session.exited"
618 ? null572 ? null
619 : activeTurnId;573 : activeTurnId;
620 const providerThreadIdFromEvent =
621 event.type === "thread.started"
622 ? toProviderThreadId(event.threadId)
623 : toProviderThreadId(event.threadId);
624 const providerThreadId =
625 providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null;
626 const status = (() => {574 const status = (() => {
627 switch (event.type) {575 switch (event.type) {
628 case "session.state.changed":576 case "session.state.changed":
...@@ -658,8 +606,6 @@ const make = Effect.gen(function* () {...@@ -658,8 +606,6 @@ const make = Effect.gen(function* () {
658 threadId: thread.id,606 threadId: thread.id,
659 status,607 status,
660 providerName: event.provider,608 providerName: event.provider,
661 providerSessionId,
662 providerThreadId,
663 runtimeMode: thread.session?.runtimeMode ?? "full-access",609 runtimeMode: thread.session?.runtimeMode ?? "full-access",
664 activeTurnId: nextActiveTurnId,610 activeTurnId: nextActiveTurnId,
665 lastError,611 lastError,
...@@ -677,11 +623,11 @@ const make = Effect.gen(function* () {...@@ -677,11 +623,11 @@ const make = Effect.gen(function* () {
677623
678 if (assistantDelta && assistantDelta.length > 0) {624 if (assistantDelta && assistantDelta.length > 0) {
679 const assistantMessageId = MessageId.makeUnsafe(625 const assistantMessageId = MessageId.makeUnsafe(
680 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,626 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
681 );627 );
682 const turnId = toTurnId(event.turnId);628 const turnId = toTurnId(event.turnId);
683 if (turnId) {629 if (turnId) {
684 yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId);630 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
685 }631 }
686632
687 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);633 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
...@@ -714,7 +660,7 @@ const make = Effect.gen(function* () {...@@ -714,7 +660,7 @@ const make = Effect.gen(function* () {
714 const assistantCompletion =660 const assistantCompletion =
715 event.type === "item.completed" && event.payload.itemType === "assistant_message"661 event.type === "item.completed" && event.payload.itemType === "assistant_message"
716 ? {662 ? {
717 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`),663 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),
718 fallbackText: event.payload.detail,664 fallbackText: event.payload.detail,
719 }665 }
720 : undefined;666 : undefined;
...@@ -723,7 +669,7 @@ const make = Effect.gen(function* () {...@@ -723,7 +669,7 @@ const make = Effect.gen(function* () {
723 const assistantMessageId = assistantCompletion.messageId;669 const assistantMessageId = assistantCompletion.messageId;
724 const turnId = toTurnId(event.turnId);670 const turnId = toTurnId(event.turnId);
725 if (turnId) {671 if (turnId) {
726 yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId);672 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
727 }673 }
728674
729 yield* finalizeAssistantMessage({675 yield* finalizeAssistantMessage({
...@@ -740,14 +686,14 @@ const make = Effect.gen(function* () {...@@ -740,14 +686,14 @@ const make = Effect.gen(function* () {
740 });686 });
741687
742 if (turnId) {688 if (turnId) {
743 yield* forgetAssistantMessageId(providerSessionId, turnId, assistantMessageId);689 yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);
744 }690 }
745 }691 }
746692
747 if (event.type === "turn.completed") {693 if (event.type === "turn.completed") {
748 const turnId = toTurnId(event.turnId);694 const turnId = toTurnId(event.turnId);
749 if (turnId) {695 if (turnId) {
750 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(providerSessionId, turnId);696 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
751 yield* Effect.forEach(697 yield* Effect.forEach(
752 assistantMessageIds,698 assistantMessageIds,
753 (assistantMessageId) =>699 (assistantMessageId) =>
...@@ -762,12 +708,12 @@ const make = Effect.gen(function* () {...@@ -762,12 +708,12 @@ const make = Effect.gen(function* () {
762 }),708 }),
763 { concurrency: 1 },709 { concurrency: 1 },
764 ).pipe(Effect.asVoid);710 ).pipe(Effect.asVoid);
765 yield* clearAssistantMessageIdsForTurn(providerSessionId, turnId);711 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
766 }712 }
767 }713 }
768714
769 if (event.type === "session.exited") {715 if (event.type === "session.exited") {
770 yield* clearTurnStateForSession(providerSessionId);716 yield* clearTurnStateForSession(thread.id);
771 }717 }
772718
773 if (event.type === "runtime.error") {719 if (event.type === "runtime.error") {
...@@ -775,15 +721,9 @@ const make = Effect.gen(function* () {...@@ -775,15 +721,9 @@ const make = Effect.gen(function* () {
775721
776 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD722 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
777 ? true723 ? true
778 : matchesThreadScope &&724 : activeTurnId === null ||
779 (activeTurnId === null ||725 eventTurnId === undefined ||
780 eventTurnId === undefined ||726 sameId(activeTurnId, eventTurnId);
781 sameId(activeTurnId, eventTurnId));
782
783 const providerThreadId =
784 event.threadId !== undefined
785 ? ProviderThreadId.makeUnsafe(event.threadId)
786 : (thread.session?.providerThreadId ?? null);
787727
788 if (shouldApplyRuntimeError) {728 if (shouldApplyRuntimeError) {
789 yield* orchestrationEngine.dispatch({729 yield* orchestrationEngine.dispatch({
...@@ -794,8 +734,6 @@ const make = Effect.gen(function* () {...@@ -794,8 +734,6 @@ const make = Effect.gen(function* () {
794 threadId: thread.id,734 threadId: thread.id,
795 status: "error",735 status: "error",
796 providerName: event.provider,736 providerName: event.provider,
797 providerSessionId,
798 providerThreadId,
799 runtimeMode: thread.session?.runtimeMode ?? "full-access",737 runtimeMode: thread.session?.runtimeMode ?? "full-access",
800 activeTurnId: eventTurnId ?? null,738 activeTurnId: eventTurnId ?? null,
801 lastError: runtimeErrorMessage,739 lastError: runtimeErrorMessage,
...@@ -819,7 +757,7 @@ const make = Effect.gen(function* () {...@@ -819,7 +757,7 @@ const make = Effect.gen(function* () {
819 const turnId = toTurnId(event.turnId);757 const turnId = toTurnId(event.turnId);
820 if (turnId) {758 if (turnId) {
821 const assistantMessageId = MessageId.makeUnsafe(759 const assistantMessageId = MessageId.makeUnsafe(
822 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,760 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
823 );761 );
824 yield* orchestrationEngine.dispatch({762 yield* orchestrationEngine.dispatch({
825 type: "thread.turn.diff.complete",763 type: "thread.turn.diff.complete",
apps/server/src/orchestration/decider.ts+1-8
...@@ -391,14 +391,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -391,14 +391,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
391 aggregateId: command.threadId,391 aggregateId: command.threadId,
392 occurredAt: command.createdAt,392 occurredAt: command.createdAt,
393 commandId: command.commandId,393 commandId: command.commandId,
394 metadata: {394 metadata: {},
395 ...(command.session.providerSessionId !== null
396 ? { providerSessionId: command.session.providerSessionId }
397 : {}),
398 ...(command.session.providerThreadId !== null
399 ? { providerThreadId: command.session.providerThreadId }
400 : {}),
401 },
402 }),395 }),
403 type: "thread.session-set",396 type: "thread.session-set",
404 payload: {397 payload: {
apps/server/src/persistence/Layers/OrchestrationEventStore.ts-2
...@@ -74,8 +74,6 @@ function inferActorKind(...@@ -74,8 +74,6 @@ function inferActorKind(
74 return "server";74 return "server";
75 }75 }
76 if (76 if (
77 event.metadata.providerSessionId !== undefined ||
78 event.metadata.providerThreadId !== undefined ||
79 event.metadata.providerTurnId !== undefined ||77 event.metadata.providerTurnId !== undefined ||
80 event.metadata.providerItemId !== undefined ||78 event.metadata.providerItemId !== undefined ||
81 event.metadata.adapterKey !== undefined79 event.metadata.adapterKey !== undefined
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts-8
...@@ -23,8 +23,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {...@@ -23,8 +23,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
23 thread_id,23 thread_id,
24 status,24 status,
25 provider_name,25 provider_name,
26 provider_session_id,
27 provider_thread_id,
28 runtime_mode,26 runtime_mode,
29 active_turn_id,27 active_turn_id,
30 last_error,28 last_error,
...@@ -34,8 +32,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {...@@ -34,8 +32,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
34 ${row.threadId},32 ${row.threadId},
35 ${row.status},33 ${row.status},
36 ${row.providerName},34 ${row.providerName},
37 ${row.providerSessionId},
38 ${row.providerThreadId},
39 ${row.runtimeMode},35 ${row.runtimeMode},
40 ${row.activeTurnId},36 ${row.activeTurnId},
41 ${row.lastError},37 ${row.lastError},
...@@ -45,8 +41,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {...@@ -45,8 +41,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
45 DO UPDATE SET41 DO UPDATE SET
46 status = excluded.status,42 status = excluded.status,
47 provider_name = excluded.provider_name,43 provider_name = excluded.provider_name,
48 provider_session_id = excluded.provider_session_id,
49 provider_thread_id = excluded.provider_thread_id,
50 runtime_mode = excluded.runtime_mode,44 runtime_mode = excluded.runtime_mode,
51 active_turn_id = excluded.active_turn_id,45 active_turn_id = excluded.active_turn_id,
52 last_error = excluded.last_error,46 last_error = excluded.last_error,
...@@ -63,8 +57,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {...@@ -63,8 +57,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
63 thread_id AS "threadId",57 thread_id AS "threadId",
64 status,58 status,
65 provider_name AS "providerName",59 provider_name AS "providerName",
66 provider_session_id AS "providerSessionId",
67 provider_thread_id AS "providerThreadId",
68 runtime_mode AS "runtimeMode",60 runtime_mode AS "runtimeMode",
69 active_turn_id AS "activeTurnId",61 active_turn_id AS "activeTurnId",
70 last_error AS "lastError",62 last_error AS "lastError",
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+20-30
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ProviderSessionId } from "@t3tools/contracts";1import { ThreadId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";4import { Effect, Layer, Option, Schema, Struct } from "effect";
...@@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(...@@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(
24const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);24const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);
2525
26const GetRuntimeRequestSchema = Schema.Struct({26const GetRuntimeRequestSchema = Schema.Struct({
27 providerSessionId: ProviderSessionId,27 threadId: ThreadId,
28});28});
2929
30const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;30const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
...@@ -44,11 +44,9 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -44,11 +44,9 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
44 execute: (runtime) =>44 execute: (runtime) =>
45 sql`45 sql`
46 INSERT INTO provider_session_runtime (46 INSERT INTO provider_session_runtime (
47 provider_session_id,
48 thread_id,47 thread_id,
49 provider_name,48 provider_name,
50 adapter_key,49 adapter_key,
51 provider_thread_id,
52 runtime_mode,50 runtime_mode,
53 status,51 status,
54 last_seen_at,52 last_seen_at,
...@@ -56,23 +54,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -56,23 +54,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
56 runtime_payload_json54 runtime_payload_json
57 )55 )
58 VALUES (56 VALUES (
59 ${runtime.providerSessionId},
60 ${runtime.threadId},57 ${runtime.threadId},
61 ${runtime.providerName},58 ${runtime.providerName},
62 ${runtime.adapterKey},59 ${runtime.adapterKey},
63 ${runtime.providerThreadId},
64 ${runtime.runtimeMode},60 ${runtime.runtimeMode},
65 ${runtime.status},61 ${runtime.status},
66 ${runtime.lastSeenAt},62 ${runtime.lastSeenAt},
67 ${runtime.resumeCursor},63 ${runtime.resumeCursor},
68 ${runtime.runtimePayload}64 ${runtime.runtimePayload}
69 )65 )
70 ON CONFLICT (provider_session_id)66 ON CONFLICT (thread_id)
71 DO UPDATE SET67 DO UPDATE SET
72 thread_id = excluded.thread_id,
73 provider_name = excluded.provider_name,68 provider_name = excluded.provider_name,
74 adapter_key = excluded.adapter_key,69 adapter_key = excluded.adapter_key,
75 provider_thread_id = excluded.provider_thread_id,
76 runtime_mode = excluded.runtime_mode,70 runtime_mode = excluded.runtime_mode,
77 status = excluded.status,71 status = excluded.status,
78 last_seen_at = excluded.last_seen_at,72 last_seen_at = excluded.last_seen_at,
...@@ -81,24 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -81,24 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
81 `,75 `,
82 });76 });
8377
84 const getRuntimeRowBySessionId = SqlSchema.findOneOption({78 const getRuntimeRowByThreadId = SqlSchema.findOneOption({
85 Request: GetRuntimeRequestSchema,79 Request: GetRuntimeRequestSchema,
86 Result: ProviderSessionRuntimeDbRowSchema,80 Result: ProviderSessionRuntimeDbRowSchema,
87 execute: ({ providerSessionId }) =>81 execute: ({ threadId }) =>
88 sql`82 sql`
89 SELECT83 SELECT
90 provider_session_id AS "providerSessionId",
91 thread_id AS "threadId",84 thread_id AS "threadId",
92 provider_name AS "providerName",85 provider_name AS "providerName",
93 adapter_key AS "adapterKey",86 adapter_key AS "adapterKey",
94 provider_thread_id AS "providerThreadId",
95 runtime_mode AS "runtimeMode",87 runtime_mode AS "runtimeMode",
96 status,88 status,
97 last_seen_at AS "lastSeenAt",89 last_seen_at AS "lastSeenAt",
98 resume_cursor_json AS "resumeCursor",90 resume_cursor_json AS "resumeCursor",
99 runtime_payload_json AS "runtimePayload"91 runtime_payload_json AS "runtimePayload"
100 FROM provider_session_runtime92 FROM provider_session_runtime
101 WHERE provider_session_id = ${providerSessionId}93 WHERE thread_id = ${threadId}
102 `,94 `,
103 });95 });
10496
...@@ -108,27 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -108,27 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
108 execute: () =>100 execute: () =>
109 sql`101 sql`
110 SELECT102 SELECT
111 provider_session_id AS "providerSessionId",
112 thread_id AS "threadId",103 thread_id AS "threadId",
113 provider_name AS "providerName",104 provider_name AS "providerName",
114 adapter_key AS "adapterKey",105 adapter_key AS "adapterKey",
115 provider_thread_id AS "providerThreadId",
116 runtime_mode AS "runtimeMode",106 runtime_mode AS "runtimeMode",
117 status,107 status,
118 last_seen_at AS "lastSeenAt",108 last_seen_at AS "lastSeenAt",
119 resume_cursor_json AS "resumeCursor",109 resume_cursor_json AS "resumeCursor",
120 runtime_payload_json AS "runtimePayload"110 runtime_payload_json AS "runtimePayload"
121 FROM provider_session_runtime111 FROM provider_session_runtime
122 ORDER BY last_seen_at ASC, provider_session_id ASC112 ORDER BY last_seen_at ASC, thread_id ASC
123 `,113 `,
124 });114 });
125115
126 const deleteRuntimeBySessionId = SqlSchema.void({116 const deleteRuntimeByThreadId = SqlSchema.void({
127 Request: DeleteRuntimeRequestSchema,117 Request: DeleteRuntimeRequestSchema,
128 execute: ({ providerSessionId }) =>118 execute: ({ threadId }) =>
129 sql`119 sql`
130 DELETE FROM provider_session_runtime120 DELETE FROM provider_session_runtime
131 WHERE provider_session_id = ${providerSessionId}121 WHERE thread_id = ${threadId}
132 `,122 `,
133 });123 });
134124
...@@ -142,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -142,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
142 ),132 ),
143 );133 );
144134
145 const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) =>135 const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) =>
146 getRuntimeRowBySessionId(input).pipe(136 getRuntimeRowByThreadId(input).pipe(
147 Effect.mapError(137 Effect.mapError(
148 toPersistenceSqlOrDecodeError(138 toPersistenceSqlOrDecodeError(
149 "ProviderSessionRuntimeRepository.getBySessionId:query",139 "ProviderSessionRuntimeRepository.getByThreadId:query",
150 "ProviderSessionRuntimeRepository.getBySessionId:decodeRow",140 "ProviderSessionRuntimeRepository.getByThreadId:decodeRow",
151 ),141 ),
152 ),142 ),
153 Effect.flatMap((runtimeRowOption) =>143 Effect.flatMap((runtimeRowOption) =>
...@@ -157,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -157,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
157 decodeRuntime(row).pipe(147 decodeRuntime(row).pipe(
158 Effect.mapError(148 Effect.mapError(
159 toPersistenceDecodeError(149 toPersistenceDecodeError(
160 "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime",150 "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime",
161 ),151 ),
162 ),152 ),
163 Effect.map((runtime) => Option.some(runtime)),153 Effect.map((runtime) => Option.some(runtime)),
...@@ -188,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -188,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
188 ),178 ),
189 );179 );
190180
191 const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) =>181 const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) =>
192 deleteRuntimeBySessionId(input).pipe(182 deleteRuntimeByThreadId(input).pipe(
193 Effect.mapError(183 Effect.mapError(
194 toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"),184 toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"),
195 ),185 ),
196 );186 );
197187
198 return {188 return {
199 upsert,189 upsert,
200 getBySessionId,190 getByThreadId,
201 list,191 list,
202 deleteBySessionId,192 deleteByThreadId,
203 } satisfies ProviderSessionRuntimeRepositoryShape;193 } satisfies ProviderSessionRuntimeRepositoryShape;
204});194});
205195
apps/server/src/persistence/Layers/ProviderSessions.ts deleted-225
...@@ -1,225 +0,0 @@
1import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema } from "effect";
5
6import {
7 ProviderSessionRepositoryPersistenceError,
8 ProviderSessionRepositoryValidationError,
9} from "../Errors.ts";
10import {
11 ProviderSessionRepository,
12 type ProviderSessionEntry,
13 type ProviderSessionRepositoryShape,
14} from "../Services/ProviderSessions.ts";
15
16const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]);
17
18const ProviderSessionRowSchema = Schema.Struct({
19 sessionId: ProviderSessionId,
20 provider: ProviderKind,
21 threadId: Schema.NullOr(ThreadId),
22 createdAt: Schema.String,
23 updatedAt: Schema.String,
24});
25
26const SessionIdRequestSchema = Schema.Struct({
27 sessionId: ProviderSessionId,
28});
29
30const UpsertSessionRequestSchema = Schema.Struct({
31 sessionId: ProviderSessionId,
32 provider: ProviderKind,
33 threadId: Schema.NullOr(ThreadId),
34});
35
36function errorMessage(cause: unknown, fallback: string): string {
37 if (cause instanceof Error && cause.message.length > 0) {
38 return cause.message;
39 }
40 return fallback;
41}
42
43function toValidationError(
44 operation: string,
45 cause: unknown,
46): ProviderSessionRepositoryValidationError {
47 return new ProviderSessionRepositoryValidationError({
48 operation,
49 issue: errorMessage(cause, "Invalid provider session repository input."),
50 cause,
51 });
52}
53
54function decodeInput<S extends Schema.Top>(schema: S, input: unknown, operation: string) {
55 return Schema.decodeUnknownEffect(schema)(input).pipe(
56 Effect.mapError((cause) => toValidationError(operation, cause)),
57 );
58}
59
60function toPersistenceError(
61 operation: string,
62 cause: unknown,
63): ProviderSessionRepositoryPersistenceError {
64 return new ProviderSessionRepositoryPersistenceError({
65 operation,
66 detail: `Failed to execute ${operation}.`,
67 cause,
68 });
69}
70
71function toEntry(row: Schema.Schema.Type<typeof ProviderSessionRowSchema>): ProviderSessionEntry {
72 return {
73 sessionId: row.sessionId,
74 provider: row.provider,
75 ...(row.threadId !== null ? { threadId: row.threadId } : {}),
76 createdAt: row.createdAt,
77 updatedAt: row.updatedAt,
78 };
79}
80
81const makeProviderSessionRepository = Effect.gen(function* () {
82 const sql = yield* SqlClient.SqlClient;
83
84 const upsertSessionRow = SqlSchema.findOne({
85 Request: UpsertSessionRequestSchema,
86 Result: ProviderSessionRowSchema,
87 execute: (request) =>
88 sql`
89 INSERT INTO provider_sessions (
90 session_id,
91 provider,
92 thread_id,
93 created_at,
94 updated_at
95 )
96 VALUES (
97 ${request.sessionId},
98 ${request.provider},
99 ${request.threadId},
100 CURRENT_TIMESTAMP,
101 CURRENT_TIMESTAMP
102 )
103 ON CONFLICT (session_id)
104 DO UPDATE SET
105 provider = excluded.provider,
106 thread_id = excluded.thread_id,
107 updated_at = CURRENT_TIMESTAMP
108 RETURNING
109 session_id AS "sessionId",
110 provider,
111 thread_id AS "threadId",
112 created_at AS "createdAt",
113 updated_at AS "updatedAt"
114 `,
115 });
116
117 const findSessionRow = SqlSchema.findOneOption({
118 Request: SessionIdRequestSchema,
119 Result: ProviderSessionRowSchema,
120 execute: ({ sessionId }) =>
121 sql`
122 SELECT
123 session_id AS "sessionId",
124 provider,
125 thread_id AS "threadId",
126 created_at AS "createdAt",
127 updated_at AS "updatedAt"
128 FROM provider_sessions
129 WHERE session_id = ${sessionId}
130 `,
131 });
132
133 const listSessionRows = SqlSchema.findAll({
134 Request: Schema.Void,
135 Result: ProviderSessionRowSchema,
136 execute: () =>
137 sql`
138 SELECT
139 session_id AS "sessionId",
140 provider,
141 thread_id AS "threadId",
142 created_at AS "createdAt",
143 updated_at AS "updatedAt"
144 FROM provider_sessions
145 ORDER BY created_at ASC, session_id ASC
146 `,
147 });
148
149 const deleteSessionRow = SqlSchema.void({
150 Request: SessionIdRequestSchema,
151 execute: ({ sessionId }) => sql`DELETE FROM provider_sessions WHERE session_id = ${sessionId}`,
152 });
153
154 const upsertSession: ProviderSessionRepositoryShape["upsertSession"] = (input) =>
155 Effect.gen(function* () {
156 const parsed = yield* decodeInput(
157 UpsertSessionRequestSchema,
158 {
159 sessionId: input.sessionId,
160 provider: input.provider,
161 threadId: input.threadId ?? null,
162 },
163 "ProviderSessionRepository.upsertSession",
164 );
165
166 yield* upsertSessionRow(parsed).pipe(
167 Effect.mapError((cause) =>
168 toPersistenceError("ProviderSessionRepository.upsertSession:query", cause),
169 ),
170 Effect.asVoid,
171 );
172 });
173
174 const getSession: ProviderSessionRepositoryShape["getSession"] = (input) =>
175 Effect.gen(function* () {
176 const parsed = yield* decodeInput(
177 SessionIdRequestSchema,
178 { sessionId: input.sessionId },
179 "ProviderSessionRepository.getSession",
180 );
181
182 const row = yield* findSessionRow(parsed).pipe(
183 Effect.mapError((cause) =>
184 toPersistenceError("ProviderSessionRepository.getSession:query", cause),
185 ),
186 );
187
188 return Option.map(row, toEntry);
189 });
190
191 const listSessions: ProviderSessionRepositoryShape["listSessions"] = () =>
192 listSessionRows(undefined).pipe(
193 Effect.mapError((cause) =>
194 toPersistenceError("ProviderSessionRepository.listSessions:query", cause),
195 ),
196 Effect.map((rows) => rows.map(toEntry)),
197 );
198
199 const deleteSession: ProviderSessionRepositoryShape["deleteSession"] = (input) =>
200 Effect.gen(function* () {
201 const parsed = yield* decodeInput(
202 SessionIdRequestSchema,
203 { sessionId: input.sessionId },
204 "ProviderSessionRepository.deleteSession",
205 );
206
207 yield* deleteSessionRow(parsed).pipe(
208 Effect.mapError((cause) =>
209 toPersistenceError("ProviderSessionRepository.deleteSession:query", cause),
210 ),
211 );
212 });
213
214 return {
215 upsertSession,
216 getSession,
217 listSessions,
218 deleteSession,
219 } satisfies ProviderSessionRepositoryShape;
220});
221
222export const ProviderSessionRepositoryLive = Layer.effect(
223 ProviderSessionRepository,
224 makeProviderSessionRepository,
225);
apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts+2-8
...@@ -6,11 +6,10 @@ export default Effect.gen(function* () {...@@ -6,11 +6,10 @@ export default Effect.gen(function* () {
66
7 yield* sql`7 yield* sql`
8 CREATE TABLE IF NOT EXISTS provider_session_runtime (8 CREATE TABLE IF NOT EXISTS provider_session_runtime (
9 provider_session_id TEXT PRIMARY KEY,9 thread_id TEXT PRIMARY KEY,
10 thread_id TEXT NOT NULL,
11 provider_name TEXT NOT NULL,10 provider_name TEXT NOT NULL,
12 adapter_key TEXT NOT NULL,11 adapter_key TEXT NOT NULL,
13 provider_thread_id TEXT,12 runtime_mode TEXT NOT NULL DEFAULT 'full-access',
14 status TEXT NOT NULL,13 status TEXT NOT NULL,
15 last_seen_at TEXT NOT NULL,14 last_seen_at TEXT NOT NULL,
16 resume_cursor_json TEXT,15 resume_cursor_json TEXT,
...@@ -18,11 +17,6 @@ export default Effect.gen(function* () {...@@ -18,11 +17,6 @@ export default Effect.gen(function* () {
18 )17 )
19 `;18 `;
2019
21 yield* sql`
22 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread
23 ON provider_session_runtime(thread_id)
24 `;
25
26 yield* sql`20 yield* sql`
27 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status21 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status
28 ON provider_session_runtime(status)22 ON provider_session_runtime(status)
apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts+1-12
...@@ -2,16 +2,5 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -2,16 +2,5 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
33
4export default Effect.gen(function* () {4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;5 yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE provider_session_runtime
9 ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access'
10 `;
11
12 yield* sql`
13 UPDATE provider_session_runtime
14 SET runtime_mode = 'full-access'
15 WHERE runtime_mode IS NULL
16 `;
17});6});
apps/server/src/persistence/Services/ProjectionThreadSessions.ts-4
...@@ -10,8 +10,6 @@ import {...@@ -10,8 +10,6 @@ import {
10 RuntimeMode,10 RuntimeMode,
11 IsoDateTime,11 IsoDateTime,
12 OrchestrationSessionStatus,12 OrchestrationSessionStatus,
13 ProviderSessionId,
14 ProviderThreadId,
15 ThreadId,13 ThreadId,
16 TurnId,14 TurnId,
17} from "@t3tools/contracts";15} from "@t3tools/contracts";
...@@ -24,8 +22,6 @@ export const ProjectionThreadSession = Schema.Struct({...@@ -24,8 +22,6 @@ export const ProjectionThreadSession = Schema.Struct({
24 threadId: ThreadId,22 threadId: ThreadId,
25 status: OrchestrationSessionStatus,23 status: OrchestrationSessionStatus,
26 providerName: Schema.NullOr(Schema.String),24 providerName: Schema.NullOr(Schema.String),
27 providerSessionId: Schema.NullOr(ProviderSessionId),
28 providerThreadId: Schema.NullOr(ProviderThreadId),
29 runtimeMode: RuntimeMode,25 runtimeMode: RuntimeMode,
30 activeTurnId: Schema.NullOr(TurnId),26 activeTurnId: Schema.NullOr(TurnId),
31 lastError: Schema.NullOr(Schema.String),27 lastError: Schema.NullOr(Schema.String),
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+7-15
...@@ -7,9 +7,7 @@...@@ -7,9 +7,7 @@
7 */7 */
8import {8import {
9 IsoDateTime,9 IsoDateTime,
10 ProviderSessionId,
11 ProviderSessionRuntimeStatus,10 ProviderSessionRuntimeStatus,
12 ProviderThreadId,
13 RuntimeMode,11 RuntimeMode,
14 ThreadId,12 ThreadId,
15} from "@t3tools/contracts";13} from "@t3tools/contracts";
...@@ -19,11 +17,9 @@ import type { Effect } from "effect";...@@ -19,11 +17,9 @@ import type { Effect } from "effect";
19import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";17import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";
2018
21export const ProviderSessionRuntime = Schema.Struct({19export const ProviderSessionRuntime = Schema.Struct({
22 providerSessionId: ProviderSessionId,
23 threadId: ThreadId,20 threadId: ThreadId,
24 providerName: Schema.String,21 providerName: Schema.String,
25 adapterKey: Schema.String,22 adapterKey: Schema.String,
26 providerThreadId: Schema.NullOr(ProviderThreadId),
27 runtimeMode: RuntimeMode,23 runtimeMode: RuntimeMode,
28 status: ProviderSessionRuntimeStatus,24 status: ProviderSessionRuntimeStatus,
29 lastSeenAt: IsoDateTime,25 lastSeenAt: IsoDateTime,
...@@ -32,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({...@@ -32,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({
32});28});
33export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type;29export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type;
3430
35export const GetProviderSessionRuntimeInput = Schema.Struct({31export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
36 providerSessionId: ProviderSessionId,
37});
38export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type;32export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type;
3933
40export const DeleteProviderSessionRuntimeInput = Schema.Struct({34export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
41 providerSessionId: ProviderSessionId,
42});
43export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;35export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
4436
45/**37/**
...@@ -49,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape {...@@ -49,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape {
49 /**41 /**
50 * Insert or replace a provider runtime row.42 * Insert or replace a provider runtime row.
51 *43 *
52 * Upserts by `providerSessionId`, including JSON payload/cursor fields.44 * Upserts by canonical `threadId`, including JSON payload/cursor fields.
53 */45 */
54 readonly upsert: (46 readonly upsert: (
55 runtime: ProviderSessionRuntime,47 runtime: ProviderSessionRuntime,
56 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;48 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
5749
58 /**50 /**
59 * Read provider runtime state by provider session id.51 * Read provider runtime state by canonical thread id.
60 */52 */
61 readonly getBySessionId: (53 readonly getByThreadId: (
62 input: GetProviderSessionRuntimeInput,54 input: GetProviderSessionRuntimeInput,
63 ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>;55 ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>;
6456
...@@ -73,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape {...@@ -73,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape {
73 >;65 >;
7466
75 /**67 /**
76 * Delete provider runtime state by provider session id.68 * Delete provider runtime state by canonical thread id.
77 */69 */
78 readonly deleteBySessionId: (70 readonly deleteByThreadId: (
79 input: DeleteProviderSessionRuntimeInput,71 input: DeleteProviderSessionRuntimeInput,
80 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;72 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
81}73}
apps/server/src/persistence/Services/ProviderSessions.ts deleted-81
...@@ -1,81 +0,0 @@
1/**
2 * ProviderSessionRepository - Repository interface for provider session lookup.
3 *
4 * Owns persistence operations that map internal sessions to provider kinds and
5 * optional thread ownership.
6 *
7 * @module ProviderSessionRepository
8 */
9import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts";
10import { Option, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProviderSessionRepositoryError } from "../Errors.ts";
14
15export interface ProviderSessionEntry {
16 readonly sessionId: ProviderSessionId;
17 readonly provider: ProviderKind;
18 readonly threadId?: ThreadId;
19 readonly createdAt: string;
20 readonly updatedAt: string;
21}
22
23export interface UpsertProviderSessionInput {
24 readonly sessionId: ProviderSessionId;
25 readonly provider: ProviderKind;
26 readonly threadId?: ThreadId;
27}
28
29export interface DeleteProviderSessionInput {
30 readonly sessionId: ProviderSessionId;
31}
32
33export interface GetProviderSessionInput {
34 readonly sessionId: ProviderSessionId;
35}
36
37/**
38 * ProviderSessionRepositoryShape - Service API for provider-session records.
39 */
40export interface ProviderSessionRepositoryShape {
41 /**
42 * Insert or replace a provider-session row.
43 *
44 * Upserts by `sessionId`.
45 */
46 readonly upsertSession: (
47 input: UpsertProviderSessionInput,
48 ) => Effect.Effect<void, ProviderSessionRepositoryError>;
49
50 /**
51 * Read a provider-session row by session id.
52 */
53 readonly getSession: (
54 input: GetProviderSessionInput,
55 ) => Effect.Effect<Option.Option<ProviderSessionEntry>, ProviderSessionRepositoryError>;
56
57 /**
58 * List all provider-session rows.
59 *
60 * Returned in deterministic creation order.
61 */
62 readonly listSessions: () => Effect.Effect<
63 ReadonlyArray<ProviderSessionEntry>,
64 ProviderSessionRepositoryError
65 >;
66
67 /**
68 * Delete a provider-session row by session id.
69 */
70 readonly deleteSession: (
71 input: DeleteProviderSessionInput,
72 ) => Effect.Effect<void, ProviderSessionRepositoryError>;
73}
74
75/**
76 * ProviderSessionRepository - Service tag for provider-session persistence.
77 */
78export class ProviderSessionRepository extends ServiceMap.Service<
79 ProviderSessionRepository,
80 ProviderSessionRepositoryShape
81>()("t3/persistence/Services/ProviderSessions/ProviderSessionRepository") {}
apps/server/src/provider/Errors.ts+8-8
...@@ -26,12 +26,12 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass...@@ -26,12 +26,12 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass
26 "ProviderAdapterSessionNotFoundError",26 "ProviderAdapterSessionNotFoundError",
27 {27 {
28 provider: Schema.String,28 provider: Schema.String,
29 sessionId: Schema.String,29 threadId: Schema.String,
30 cause: Schema.optional(Schema.Defect),30 cause: Schema.optional(Schema.Defect),
31 },31 },
32) {32) {
33 override get message(): string {33 override get message(): string {
34 return `Unknown ${this.provider} adapter session: ${this.sessionId}`;34 return `Unknown ${this.provider} adapter thread: ${this.threadId}`;
35 }35 }
36}36}
3737
...@@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P...@@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P
42 "ProviderAdapterSessionClosedError",42 "ProviderAdapterSessionClosedError",
43 {43 {
44 provider: Schema.String,44 provider: Schema.String,
45 sessionId: Schema.String,45 threadId: Schema.String,
46 cause: Schema.optional(Schema.Defect),46 cause: Schema.optional(Schema.Defect),
47 },47 },
48) {48) {
49 override get message(): string {49 override get message(): string {
50 return `${this.provider} adapter session is closed: ${this.sessionId}`;50 return `${this.provider} adapter thread is closed: ${this.threadId}`;
51 }51 }
52}52}
5353
...@@ -75,13 +75,13 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide...@@ -75,13 +75,13 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide
75 "ProviderAdapterProcessError",75 "ProviderAdapterProcessError",
76 {76 {
77 provider: Schema.String,77 provider: Schema.String,
78 sessionId: Schema.String,78 threadId: Schema.String,
79 detail: Schema.String,79 detail: Schema.String,
80 cause: Schema.optional(Schema.Defect),80 cause: Schema.optional(Schema.Defect),
81 },81 },
82) {82) {
83 override get message(): string {83 override get message(): string {
84 return `Provider adapter process error (${this.provider}) for session ${this.sessionId}: ${this.detail}`;84 return `Provider adapter process error (${this.provider}) for thread ${this.threadId}: ${this.detail}`;
85 }85 }
86}86}
8787
...@@ -122,12 +122,12 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn...@@ -122,12 +122,12 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn
122export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<ProviderSessionNotFoundError>()(122export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<ProviderSessionNotFoundError>()(
123 "ProviderSessionNotFoundError",123 "ProviderSessionNotFoundError",
124 {124 {
125 sessionId: Schema.String,125 threadId: Schema.String,
126 cause: Schema.optional(Schema.Defect),126 cause: Schema.optional(Schema.Defect),
127 },127 },
128) {128) {
129 override get message(): string {129 override get message(): string {
130 return `Unknown provider session: ${this.sessionId}`;130 return `Unknown provider thread: ${this.threadId}`;
131 }131 }
132}132}
133133
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+34-18
...@@ -5,7 +5,7 @@ import type {...@@ -5,7 +5,7 @@ import type {
5 SDKMessage,5 SDKMessage,
6 SDKUserMessage,6 SDKUserMessage,
7} from "@anthropic-ai/claude-agent-sdk";7} from "@anthropic-ai/claude-agent-sdk";
8import { ApprovalRequestId, type ProviderTurnId } from "@t3tools/contracts";8import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
9import { assert, describe, it } from "@effect/vitest";9import { assert, describe, it } from "@effect/vitest";
10import { Effect, Fiber, Random, Stream } from "effect";10import { Effect, Fiber, Random, Stream } from "effect";
1111
...@@ -161,13 +161,17 @@ function makeDeterministicRandomService(seed = 0x1234_5678): {...@@ -161,13 +161,17 @@ function makeDeterministicRandomService(seed = 0x1234_5678): {
161 };161 };
162}162}
163163
164
165const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
166const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
167
164describe("ClaudeCodeAdapterLive", () => {168describe("ClaudeCodeAdapterLive", () => {
165 it.effect("returns validation error for non-claudeCode provider on startSession", () => {169 it.effect("returns validation error for non-claudeCode provider on startSession", () => {
166 const harness = makeHarness();170 const harness = makeHarness();
167 return Effect.gen(function* () {171 return Effect.gen(function* () {
168 const adapter = yield* ClaudeCodeAdapter;172 const adapter = yield* ClaudeCodeAdapter;
169 const result = yield* adapter173 const result = yield* adapter
170 .startSession({ provider: "codex", runtimeMode: "full-access" })174 .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" })
171 .pipe(Effect.result);175 .pipe(Effect.result);
172176
173 assert.equal(result._tag, "Failure");177 assert.equal(result._tag, "Failure");
...@@ -193,6 +197,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -193,6 +197,7 @@ describe("ClaudeCodeAdapterLive", () => {
193 return Effect.gen(function* () {197 return Effect.gen(function* () {
194 const adapter = yield* ClaudeCodeAdapter;198 const adapter = yield* ClaudeCodeAdapter;
195 yield* adapter.startSession({199 yield* adapter.startSession({
200 threadId: THREAD_ID,
196 provider: "claudeCode",201 provider: "claudeCode",
197 runtimeMode: "full-access",202 runtimeMode: "full-access",
198 });203 });
...@@ -211,6 +216,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -211,6 +216,7 @@ describe("ClaudeCodeAdapterLive", () => {
211 return Effect.gen(function* () {216 return Effect.gen(function* () {
212 const adapter = yield* ClaudeCodeAdapter;217 const adapter = yield* ClaudeCodeAdapter;
213 yield* adapter.startSession({218 yield* adapter.startSession({
219 threadId: THREAD_ID,
214 provider: "claudeCode",220 provider: "claudeCode",
215 runtimeMode: "full-access",221 runtimeMode: "full-access",
216 providerOptions: {222 providerOptions: {
...@@ -240,13 +246,14 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -240,13 +246,14 @@ describe("ClaudeCodeAdapterLive", () => {
240 );246 );
241247
242 const session = yield* adapter.startSession({248 const session = yield* adapter.startSession({
249 threadId: THREAD_ID,
243 provider: "claudeCode",250 provider: "claudeCode",
244 model: "claude-sonnet-4-5",251 model: "claude-sonnet-4-5",
245 runtimeMode: "full-access",252 runtimeMode: "full-access",
246 });253 });
247254
248 const turn = yield* adapter.sendTurn({255 const turn = yield* adapter.sendTurn({
249 sessionId: session.sessionId,256 threadId: session.threadId,
250 input: "hello",257 input: "hello",
251 attachments: [],258 attachments: [],
252 });259 });
...@@ -376,12 +383,13 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -376,12 +383,13 @@ describe("ClaudeCodeAdapterLive", () => {
376 );383 );
377384
378 const session = yield* adapter.startSession({385 const session = yield* adapter.startSession({
386 threadId: THREAD_ID,
379 provider: "claudeCode",387 provider: "claudeCode",
380 runtimeMode: "full-access",388 runtimeMode: "full-access",
381 });389 });
382390
383 const turn = yield* adapter.sendTurn({391 const turn = yield* adapter.sendTurn({
384 sessionId: session.sessionId,392 threadId: session.threadId,
385 input: "hello",393 input: "hello",
386 attachments: [],394 attachments: [],
387 });395 });
...@@ -464,12 +472,13 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -464,12 +472,13 @@ describe("ClaudeCodeAdapterLive", () => {
464 );472 );
465473
466 const session = yield* adapter.startSession({474 const session = yield* adapter.startSession({
475 threadId: THREAD_ID,
467 provider: "claudeCode",476 provider: "claudeCode",
468 runtimeMode: "full-access",477 runtimeMode: "full-access",
469 });478 });
470479
471 const turn = yield* adapter.sendTurn({480 const turn = yield* adapter.sendTurn({
472 sessionId: session.sessionId,481 threadId: session.threadId,
473 input: "hello",482 input: "hello",
474 attachments: [],483 attachments: [],
475 });484 });
...@@ -533,13 +542,14 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -533,13 +542,14 @@ describe("ClaudeCodeAdapterLive", () => {
533 );542 );
534543
535 const session = yield* adapter.startSession({544 const session = yield* adapter.startSession({
545 threadId: THREAD_ID,
536 provider: "claudeCode",546 provider: "claudeCode",
537 runtimeMode: "full-access",547 runtimeMode: "full-access",
538 });548 });
539 assert.equal(session.threadId, undefined);549 assert.equal(session.threadId, undefined);
540550
541 const turn = yield* adapter.sendTurn({551 const turn = yield* adapter.sendTurn({
542 sessionId: session.sessionId,552 threadId: session.threadId,
543 input: "hello",553 input: "hello",
544 attachments: [],554 attachments: [],
545 });555 });
...@@ -602,6 +612,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -602,6 +612,7 @@ describe("ClaudeCodeAdapterLive", () => {
602 const adapter = yield* ClaudeCodeAdapter;612 const adapter = yield* ClaudeCodeAdapter;
603613
604 const session = yield* adapter.startSession({614 const session = yield* adapter.startSession({
615 threadId: THREAD_ID,
605 provider: "claudeCode",616 provider: "claudeCode",
606 runtimeMode: "approval-required",617 runtimeMode: "approval-required",
607 });618 });
...@@ -647,7 +658,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -647,7 +658,7 @@ describe("ClaudeCodeAdapterLive", () => {
647 }658 }
648659
649 yield* adapter.respondToRequest(660 yield* adapter.respondToRequest(
650 session.sessionId,661 session.threadId,
651 ApprovalRequestId.makeUnsafe(runtimeRequestId),662 ApprovalRequestId.makeUnsafe(runtimeRequestId),
652 "accept",663 "accept",
653 );664 );
...@@ -678,6 +689,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -678,6 +689,7 @@ describe("ClaudeCodeAdapterLive", () => {
678 const adapter = yield* ClaudeCodeAdapter;689 const adapter = yield* ClaudeCodeAdapter;
679690
680 const session = yield* adapter.startSession({691 const session = yield* adapter.startSession({
692 threadId: RESUME_THREAD_ID,
681 provider: "claudeCode",693 provider: "claudeCode",
682 resumeCursor: {694 resumeCursor: {
683 threadId: "resume-thread-1",695 threadId: "resume-thread-1",
...@@ -711,6 +723,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -711,6 +723,7 @@ describe("ClaudeCodeAdapterLive", () => {
711 const adapter = yield* ClaudeCodeAdapter;723 const adapter = yield* ClaudeCodeAdapter;
712724
713 const session = yield* adapter.startSession({725 const session = yield* adapter.startSession({
726 threadId: THREAD_ID,
714 provider: "claudeCode",727 provider: "claudeCode",
715 runtimeMode: "full-access",728 runtimeMode: "full-access",
716 });729 });
...@@ -734,12 +747,13 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -734,12 +747,13 @@ describe("ClaudeCodeAdapterLive", () => {
734 const adapter = yield* ClaudeCodeAdapter;747 const adapter = yield* ClaudeCodeAdapter;
735748
736 const session = yield* adapter.startSession({749 const session = yield* adapter.startSession({
750 threadId: THREAD_ID,
737 provider: "claudeCode",751 provider: "claudeCode",
738 runtimeMode: "full-access",752 runtimeMode: "full-access",
739 });753 });
740754
741 const firstTurn = yield* adapter.sendTurn({755 const firstTurn = yield* adapter.sendTurn({
742 sessionId: session.sessionId,756 threadId: session.threadId,
743 input: "first",757 input: "first",
744 attachments: [],758 attachments: [],
745 });759 });
...@@ -765,7 +779,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -765,7 +779,7 @@ describe("ClaudeCodeAdapterLive", () => {
765 }779 }
766780
767 const secondTurn = yield* adapter.sendTurn({781 const secondTurn = yield* adapter.sendTurn({
768 sessionId: session.sessionId,782 threadId: session.threadId,
769 input: "second",783 input: "second",
770 attachments: [],784 attachments: [],
771 });785 });
...@@ -790,16 +804,16 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -790,16 +804,16 @@ describe("ClaudeCodeAdapterLive", () => {
790 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));804 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
791 }805 }
792806
793 const threadBeforeRollback = yield* adapter.readThread(session.sessionId);807 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
794 assert.equal(threadBeforeRollback.turns.length, 2);808 assert.equal(threadBeforeRollback.turns.length, 2);
795809
796 const rolledBack = yield* adapter.rollbackThread(session.sessionId, 1);810 const rolledBack = yield* adapter.rollbackThread(session.threadId, 1);
797 assert.equal(rolledBack.turns.length, 1);811 assert.equal(rolledBack.turns.length, 1);
798 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId as ProviderTurnId);812 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId);
799813
800 const threadAfterRollback = yield* adapter.readThread(session.sessionId);814 const threadAfterRollback = yield* adapter.readThread(session.threadId);
801 assert.equal(threadAfterRollback.turns.length, 1);815 assert.equal(threadAfterRollback.turns.length, 1);
802 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId as ProviderTurnId);816 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId);
803 }).pipe(817 }).pipe(
804 Effect.provideService(Random.Random, makeDeterministicRandomService()),818 Effect.provideService(Random.Random, makeDeterministicRandomService()),
805 Effect.provide(harness.layer),819 Effect.provide(harness.layer),
...@@ -812,11 +826,12 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -812,11 +826,12 @@ describe("ClaudeCodeAdapterLive", () => {
812 const adapter = yield* ClaudeCodeAdapter;826 const adapter = yield* ClaudeCodeAdapter;
813827
814 const session = yield* adapter.startSession({828 const session = yield* adapter.startSession({
829 threadId: THREAD_ID,
815 provider: "claudeCode",830 provider: "claudeCode",
816 runtimeMode: "full-access",831 runtimeMode: "full-access",
817 });832 });
818 yield* adapter.sendTurn({833 yield* adapter.sendTurn({
819 sessionId: session.sessionId,834 threadId: session.threadId,
820 input: "hello",835 input: "hello",
821 model: "claude-opus-4-6",836 model: "claude-opus-4-6",
822 attachments: [],837 attachments: [],
...@@ -834,7 +849,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -834,7 +849,7 @@ describe("ClaudeCodeAdapterLive", () => {
834 event?: {849 event?: {
835 provider?: string;850 provider?: string;
836 method?: string;851 method?: string;
837 sessionId?: string;852 threadId?: string;
838 turnId?: string;853 turnId?: string;
839 };854 };
840 }> = [];855 }> = [];
...@@ -852,11 +867,12 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -852,11 +867,12 @@ describe("ClaudeCodeAdapterLive", () => {
852 const adapter = yield* ClaudeCodeAdapter;867 const adapter = yield* ClaudeCodeAdapter;
853868
854 const session = yield* adapter.startSession({869 const session = yield* adapter.startSession({
870 threadId: THREAD_ID,
855 provider: "claudeCode",871 provider: "claudeCode",
856 runtimeMode: "full-access",872 runtimeMode: "full-access",
857 });873 });
858 const turn = yield* adapter.sendTurn({874 const turn = yield* adapter.sendTurn({
859 sessionId: session.sessionId,875 threadId: session.threadId,
860 input: "hello",876 input: "hello",
861 attachments: [],877 attachments: [],
862 });878 });
...@@ -895,7 +911,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -895,7 +911,7 @@ describe("ClaudeCodeAdapterLive", () => {
895911
896 assert.equal(nativeEvents.length > 0, true);912 assert.equal(nativeEvents.length > 0, true);
897 assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true);913 assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true);
898 assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true);914 assert.equal(nativeEvents.some((record) => String(record.event?.threadId) === String(session.threadId)), true);
899 assert.equal(915 assert.equal(
900 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),916 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),
901 true,917 true,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+135-202
...@@ -27,13 +27,9 @@ import {...@@ -27,13 +27,9 @@ import {
27 type ProviderRuntimeEvent,27 type ProviderRuntimeEvent,
28 type ProviderRuntimeTurnStatus,28 type ProviderRuntimeTurnStatus,
29 type ProviderSendTurnInput,29 type ProviderSendTurnInput,
30 ProviderSessionId,
31 type ProviderSession,30 type ProviderSession,
32 ProviderThreadId,
33 ProviderTurnId,
34 RuntimeItemId,31 RuntimeItemId,
35 RuntimeRequestId,32 RuntimeRequestId,
36 RuntimeSessionId,
37 RuntimeTaskId,33 RuntimeTaskId,
38 ThreadId,34 ThreadId,
39 TurnId,35 TurnId,
...@@ -63,14 +59,14 @@ type PromptQueueItem =...@@ -63,14 +59,14 @@ type PromptQueueItem =
63 };59 };
6460
65interface ClaudeResumeState {61interface ClaudeResumeState {
66 readonly threadId?: ProviderThreadId;62 readonly threadId?: ThreadId;
67 readonly resume?: string;63 readonly resume?: string;
68 readonly resumeSessionAt?: string;64 readonly resumeSessionAt?: string;
69 readonly turnCount?: number;65 readonly turnCount?: number;
70}66}
7167
72interface ClaudeTurnState {68interface ClaudeTurnState {
73 readonly turnId: ProviderTurnId;69 readonly turnId: TurnId;
74 readonly assistantItemId: string;70 readonly assistantItemId: string;
75 readonly startedAt: string;71 readonly startedAt: string;
76 readonly items: Array<unknown>;72 readonly items: Array<unknown>;
...@@ -102,13 +98,13 @@ interface ClaudeSessionContext {...@@ -102,13 +98,13 @@ interface ClaudeSessionContext {
102 resumeSessionId: string | undefined;98 resumeSessionId: string | undefined;
103 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;99 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
104 readonly turns: Array<{100 readonly turns: Array<{
105 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;101 id: TurnId;
106 items: Array<unknown>;102 items: Array<unknown>;
107 }>;103 }>;
108 readonly inFlightTools: Map<number, ToolInFlight>;104 readonly inFlightTools: Map<number, ToolInFlight>;
109 turnState: ClaudeTurnState | undefined;105 turnState: ClaudeTurnState | undefined;
110 lastAssistantUuid: string | undefined;106 lastAssistantUuid: string | undefined;
111 lastThreadStartedId: ProviderThreadId | undefined;107 lastThreadStartedId: string | undefined;
112 stopped: boolean;108 stopped: boolean;
113}109}
114110
...@@ -144,22 +140,14 @@ function toMessage(cause: unknown, fallback: string): string {...@@ -144,22 +140,14 @@ function toMessage(cause: unknown, fallback: string): string {
144 return fallback;140 return fallback;
145}141}
146142
147function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId {
148 return RuntimeSessionId.makeUnsafe(value);
149}
150
151function asRuntimeThreadId(value: ProviderThreadId): ThreadId {
152 return ThreadId.makeUnsafe(value);
153}
154
155function asRuntimeTurnId(value: ProviderTurnId): TurnId {
156 return TurnId.makeUnsafe(value);
157}
158
159function asRuntimeItemId(value: string): RuntimeItemId {143function asRuntimeItemId(value: string): RuntimeItemId {
160 return RuntimeItemId.makeUnsafe(value);144 return RuntimeItemId.makeUnsafe(value);
161}145}
162146
147function asCanonicalTurnId(value: TurnId): TurnId {
148 return value;
149}
150
163function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {151function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
164 return RuntimeRequestId.makeUnsafe(value);152 return RuntimeRequestId.makeUnsafe(value);
165}153}
...@@ -192,7 +180,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -192,7 +180,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
192 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;180 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
193 const threadId =181 const threadId =
194 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)182 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
195 ? threadIdCandidate183 ? ThreadId.makeUnsafe(threadIdCandidate)
196 : undefined;184 : undefined;
197 const resumeCandidate =185 const resumeCandidate =
198 typeof cursor.resume === "string"186 typeof cursor.resume === "string"
...@@ -206,7 +194,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -206,7 +194,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
206 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;194 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
207195
208 return {196 return {
209 ...(threadId ? { threadId: ProviderThreadId.makeUnsafe(threadId) } : {}),197 ...(threadId ? { threadId } : {}),
210 ...(resume ? { resume } : {}),198 ...(resume ? { resume } : {}),
211 ...(resumeSessionAt ? { resumeSessionAt } : {}),199 ...(resumeSessionAt ? { resumeSessionAt } : {}),
212 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0200 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
...@@ -243,6 +231,10 @@ function classifyToolItemType(toolName: string): CanonicalItemType {...@@ -243,6 +231,10 @@ function classifyToolItemType(toolName: string): CanonicalItemType {
243}231}
244232
245function classifyRequestType(toolName: string): CanonicalRequestType {233function classifyRequestType(toolName: string): CanonicalRequestType {
234 const normalized = toolName.toLowerCase();
235 if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) {
236 return "file_read_approval";
237 }
246 return classifyToolItemType(toolName) === "command_execution"238 return classifyToolItemType(toolName) === "command_execution"
247 ? "command_execution_approval"239 ? "command_execution_approval"
248 : "file_change_approval";240 : "file_change_approval";
...@@ -324,6 +316,12 @@ function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoni...@@ -324,6 +316,12 @@ function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoni
324 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";316 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";
325}317}
326318
319function providerThreadRef(
320 context: ClaudeSessionContext,
321): { readonly providerThreadId: string } | {} {
322 return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {};
323}
324
327function extractAssistantText(message: SDKMessage): string {325function extractAssistantText(message: SDKMessage): string {
328 if (message.type !== "assistant") {326 if (message.type !== "assistant") {
329 return "";327 return "";
...@@ -353,21 +351,21 @@ function extractAssistantText(message: SDKMessage): string {...@@ -353,21 +351,21 @@ function extractAssistantText(message: SDKMessage): string {
353}351}
354352
355function toSessionError(353function toSessionError(
356 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,354 threadId: ThreadId,
357 cause: unknown,355 cause: unknown,
358): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {356): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
359 const normalized = toMessage(cause, "").toLowerCase();357 const normalized = toMessage(cause, "").toLowerCase();
360 if (normalized.includes("unknown session") || normalized.includes("not found")) {358 if (normalized.includes("unknown session") || normalized.includes("not found")) {
361 return new ProviderAdapterSessionNotFoundError({359 return new ProviderAdapterSessionNotFoundError({
362 provider: PROVIDER,360 provider: PROVIDER,
363 sessionId,361 threadId,
364 cause,362 cause,
365 });363 });
366 }364 }
367 if (normalized.includes("closed")) {365 if (normalized.includes("closed")) {
368 return new ProviderAdapterSessionClosedError({366 return new ProviderAdapterSessionClosedError({
369 provider: PROVIDER,367 provider: PROVIDER,
370 sessionId,368 threadId,
371 cause,369 cause,
372 });370 });
373 }371 }
...@@ -375,11 +373,11 @@ function toSessionError(...@@ -375,11 +373,11 @@ function toSessionError(
375}373}
376374
377function toRequestError(375function toRequestError(
378 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,376 threadId: ThreadId,
379 method: string,377 method: string,
380 cause: unknown,378 cause: unknown,
381): ProviderAdapterError {379): ProviderAdapterError {
382 const sessionError = toSessionError(sessionId, cause);380 const sessionError = toSessionError(threadId, cause);
383 if (sessionError) {381 if (sessionError) {
384 return sessionError;382 return sessionError;
385 }383 }
...@@ -469,7 +467,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -469,7 +467,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
469 readonly options: ClaudeQueryOptions;467 readonly options: ClaudeQueryOptions;
470 }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);468 }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);
471469
472 const sessions = new Map<ProviderSessionId, ClaudeSessionContext>();470 const sessions = new Map<ThreadId, ClaudeSessionContext>();
473 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();471 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
474472
475 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);473 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
...@@ -502,11 +500,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -502,11 +500,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
502 : crypto.randomUUID(),500 : crypto.randomUUID(),
503 kind: "notification",501 kind: "notification",
504 provider: PROVIDER,502 provider: PROVIDER,
505 sessionId: asRuntimeSessionId(context.session.sessionId),
506 createdAt: observedAt,503 createdAt: observedAt,
507 method: sdkNativeMethod(message),504 method: sdkNativeMethod(message),
508 ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}),505 ...(typeof message.session_id === "string"
509 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),506 ? { providerThreadId: message.session_id }
507 : {}),
508 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
510 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),509 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),
511 payload: message,510 payload: message,
512 },511 },
...@@ -518,9 +517,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -518,9 +517,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
518 const snapshotThread = (517 const snapshotThread = (
519 context: ClaudeSessionContext,518 context: ClaudeSessionContext,
520 ): Effect.Effect<{519 ): Effect.Effect<{
521 threadId: ReturnType<typeof ProviderThreadId.makeUnsafe>;520 threadId: ThreadId;
522 turns: ReadonlyArray<{521 turns: ReadonlyArray<{
523 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;522 id: TurnId;
524 items: ReadonlyArray<unknown>;523 items: ReadonlyArray<unknown>;
525 }>;524 }>;
526 }, ProviderAdapterValidationError> =>525 }, ProviderAdapterValidationError> =>
...@@ -569,19 +568,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -569,19 +568,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
569 if (typeof message.session_id !== "string" || message.session_id.length === 0) {568 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
570 return;569 return;
571 }570 }
572 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);571 const nextThreadId = message.session_id;
573 context.resumeSessionId = message.session_id;572 context.resumeSessionId = message.session_id;
574 const changed = context.session.threadId !== nextThreadId;573 yield* updateResumeCursor(context);
575
576 if (changed) {
577 const updatedAt = yield* nowIso;
578 context.session = {
579 ...context.session,
580 threadId: nextThreadId,
581 updatedAt,
582 };
583 yield* updateResumeCursor(context);
584 }
585574
586 if (context.lastThreadStartedId !== nextThreadId) {575 if (context.lastThreadStartedId !== nextThreadId) {
587 context.lastThreadStartedId = nextThreadId;576 context.lastThreadStartedId = nextThreadId;
...@@ -590,16 +579,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -590,16 +579,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
590 type: "thread.started",579 type: "thread.started",
591 eventId: stamp.eventId,580 eventId: stamp.eventId,
592 provider: PROVIDER,581 provider: PROVIDER,
593 sessionId: asRuntimeSessionId(context.session.sessionId),
594 createdAt: stamp.createdAt,582 createdAt: stamp.createdAt,
595 threadId: asRuntimeThreadId(nextThreadId),583 threadId: context.session.threadId,
596 payload: {584 payload: {
597 providerThreadId: nextThreadId,585 providerThreadId: nextThreadId,
598 },586 },
599 providerRefs: {587 providerRefs: {},
600 providerSessionId: context.session.sessionId,
601 providerThreadId: nextThreadId,
602 },
603 raw: {588 raw: {
604 source: "claude.sdk.message",589 source: "claude.sdk.message",
605 method: "claude/thread/started",590 method: "claude/thread/started",
...@@ -626,19 +611,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -626,19 +611,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
626 type: "runtime.error",611 type: "runtime.error",
627 eventId: stamp.eventId,612 eventId: stamp.eventId,
628 provider: PROVIDER,613 provider: PROVIDER,
629 sessionId: asRuntimeSessionId(context.session.sessionId),
630 createdAt: stamp.createdAt,614 createdAt: stamp.createdAt,
631 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),615 threadId: context.session.threadId,
632 ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}),616 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
633 payload: {617 payload: {
634 message,618 message,
635 class: "provider_error",619 class: "provider_error",
636 ...(cause !== undefined ? { detail: cause } : {}),620 ...(cause !== undefined ? { detail: cause } : {}),
637 },621 },
638 providerRefs: {622 providerRefs: {
639 providerSessionId: context.session.sessionId,623 ...providerThreadRef(context),
640 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),624 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
641 ...(turnState ? { providerTurnId: turnState.turnId } : {}),
642 },625 },
643 });626 });
644 });627 });
...@@ -655,18 +638,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -655,18 +638,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
655 type: "runtime.warning",638 type: "runtime.warning",
656 eventId: stamp.eventId,639 eventId: stamp.eventId,
657 provider: PROVIDER,640 provider: PROVIDER,
658 sessionId: asRuntimeSessionId(context.session.sessionId),
659 createdAt: stamp.createdAt,641 createdAt: stamp.createdAt,
660 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),642 threadId: context.session.threadId,
661 ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}),643 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
662 payload: {644 payload: {
663 message,645 message,
664 ...(detail !== undefined ? { detail } : {}),646 ...(detail !== undefined ? { detail } : {}),
665 },647 },
666 providerRefs: {648 providerRefs: {
667 providerSessionId: context.session.sessionId,649 ...providerThreadRef(context),
668 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),650 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
669 ...(turnState ? { providerTurnId: turnState.turnId } : {}),
670 },651 },
671 });652 });
672 });653 });
...@@ -685,9 +666,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -685,9 +666,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
685 type: "turn.completed",666 type: "turn.completed",
686 eventId: stamp.eventId,667 eventId: stamp.eventId,
687 provider: PROVIDER,668 provider: PROVIDER,
688 sessionId: asRuntimeSessionId(context.session.sessionId),
689 createdAt: stamp.createdAt,669 createdAt: stamp.createdAt,
690 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),670 threadId: context.session.threadId,
691 payload: {671 payload: {
692 state: status,672 state: status,
693 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),673 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
...@@ -698,10 +678,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -698,10 +678,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
698 : {}),678 : {}),
699 ...(errorMessage ? { errorMessage } : {}),679 ...(errorMessage ? { errorMessage } : {}),
700 },680 },
701 providerRefs: {681 providerRefs: {},
702 providerSessionId: context.session.sessionId,
703 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
704 },
705 });682 });
706 return;683 return;
707 }684 }
...@@ -713,19 +690,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -713,19 +690,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
713 type: "content.delta",690 type: "content.delta",
714 eventId: deltaStamp.eventId,691 eventId: deltaStamp.eventId,
715 provider: PROVIDER,692 provider: PROVIDER,
716 sessionId: asRuntimeSessionId(context.session.sessionId),
717 createdAt: deltaStamp.createdAt,693 createdAt: deltaStamp.createdAt,
718 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),694 threadId: context.session.threadId,
719 turnId: asRuntimeTurnId(turnState.turnId),695 turnId: turnState.turnId,
720 itemId: asRuntimeItemId(turnState.assistantItemId),696 itemId: asRuntimeItemId(turnState.assistantItemId),
721 payload: {697 payload: {
722 streamKind: "assistant_text",698 streamKind: "assistant_text",
723 delta: turnState.fallbackAssistantText,699 delta: turnState.fallbackAssistantText,
724 },700 },
725 providerRefs: {701 providerRefs: {
726 providerSessionId: context.session.sessionId,702 ...providerThreadRef(context),
727 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),703 providerTurnId: String(turnState.turnId),
728 providerTurnId: turnState.turnId,
729 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),704 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
730 },705 },
731 });706 });
...@@ -736,19 +711,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -736,19 +711,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
736 type: "item.completed",711 type: "item.completed",
737 eventId: stamp.eventId,712 eventId: stamp.eventId,
738 provider: PROVIDER,713 provider: PROVIDER,
739 sessionId: asRuntimeSessionId(context.session.sessionId),
740 createdAt: stamp.createdAt,714 createdAt: stamp.createdAt,
741 itemId: asRuntimeItemId(turnState.assistantItemId),715 itemId: asRuntimeItemId(turnState.assistantItemId),
742 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),716 threadId: context.session.threadId,
743 turnId: asRuntimeTurnId(turnState.turnId),717 turnId: turnState.turnId,
744 payload: {718 payload: {
745 itemType: "assistant_message",719 itemType: "assistant_message",
746 status: "completed",720 status: "completed",
747 title: "Assistant message",721 title: "Assistant message",
748 },722 },
749 providerRefs: {723 providerRefs: {
750 providerSessionId: context.session.sessionId,724 ...providerThreadRef(context),
751 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
752 providerTurnId: turnState.turnId,725 providerTurnId: turnState.turnId,
753 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),726 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
754 },727 },
...@@ -765,10 +738,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -765,10 +738,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
765 type: "turn.completed",738 type: "turn.completed",
766 eventId: stamp.eventId,739 eventId: stamp.eventId,
767 provider: PROVIDER,740 provider: PROVIDER,
768 sessionId: asRuntimeSessionId(context.session.sessionId),
769 createdAt: stamp.createdAt,741 createdAt: stamp.createdAt,
770 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),742 threadId: context.session.threadId,
771 turnId: asRuntimeTurnId(turnState.turnId),743 turnId: turnState.turnId,
772 payload: {744 payload: {
773 state: status,745 state: status,
774 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),746 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
...@@ -780,8 +752,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -780,8 +752,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
780 ...(errorMessage ? { errorMessage } : {}),752 ...(errorMessage ? { errorMessage } : {}),
781 },753 },
782 providerRefs: {754 providerRefs: {
783 providerSessionId: context.session.sessionId,755 ...providerThreadRef(context),
784 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
785 providerTurnId: turnState.turnId,756 providerTurnId: turnState.turnId,
786 },757 },
787 });758 });
...@@ -826,18 +797,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -826,18 +797,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
826 type: "content.delta",797 type: "content.delta",
827 eventId: stamp.eventId,798 eventId: stamp.eventId,
828 provider: PROVIDER,799 provider: PROVIDER,
829 sessionId: asRuntimeSessionId(context.session.sessionId),
830 createdAt: stamp.createdAt,800 createdAt: stamp.createdAt,
831 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),801 threadId: context.session.threadId,
832 turnId: asRuntimeTurnId(context.turnState.turnId),802 turnId: context.turnState.turnId,
833 itemId: asRuntimeItemId(context.turnState.assistantItemId),803 itemId: asRuntimeItemId(context.turnState.assistantItemId),
834 payload: {804 payload: {
835 streamKind: streamKindFromDeltaType(event.delta.type),805 streamKind: streamKindFromDeltaType(event.delta.type),
836 delta: event.delta.text,806 delta: event.delta.text,
837 },807 },
838 providerRefs: {808 providerRefs: {
839 providerSessionId: context.session.sessionId,809 ...providerThreadRef(context),
840 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
841 providerTurnId: context.turnState.turnId,810 providerTurnId: context.turnState.turnId,
842 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),811 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
843 },812 },
...@@ -884,10 +853,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -884,10 +853,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
884 type: "item.started",853 type: "item.started",
885 eventId: stamp.eventId,854 eventId: stamp.eventId,
886 provider: PROVIDER,855 provider: PROVIDER,
887 sessionId: asRuntimeSessionId(context.session.sessionId),856 createdAt: stamp.createdAt,
888 createdAt: stamp.createdAt,857 threadId: context.session.threadId,
889 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),858 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
890 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
891 itemId: asRuntimeItemId(tool.itemId),859 itemId: asRuntimeItemId(tool.itemId),
892 payload: {860 payload: {
893 itemType: tool.itemType,861 itemType: tool.itemType,
...@@ -900,9 +868,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -900,9 +868,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
900 },868 },
901 },869 },
902 providerRefs: {870 providerRefs: {
903 providerSessionId: context.session.sessionId,871 ...providerThreadRef(context),
904 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),872 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
905 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
906 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),873 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
907 },874 },
908 raw: {875 raw: {
...@@ -927,10 +894,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -927,10 +894,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
927 type: "item.completed",894 type: "item.completed",
928 eventId: stamp.eventId,895 eventId: stamp.eventId,
929 provider: PROVIDER,896 provider: PROVIDER,
930 sessionId: asRuntimeSessionId(context.session.sessionId),897 createdAt: stamp.createdAt,
931 createdAt: stamp.createdAt,898 threadId: context.session.threadId,
932 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),899 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
933 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
934 itemId: asRuntimeItemId(tool.itemId),900 itemId: asRuntimeItemId(tool.itemId),
935 payload: {901 payload: {
936 itemType: tool.itemType,902 itemType: tool.itemType,
...@@ -939,9 +905,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -939,9 +905,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
939 ...(tool.detail ? { detail: tool.detail } : {}),905 ...(tool.detail ? { detail: tool.detail } : {}),
940 },906 },
941 providerRefs: {907 providerRefs: {
942 providerSessionId: context.session.sessionId,908 ...providerThreadRef(context),
943 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),909 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
944 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
945 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),910 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
946 },911 },
947 raw: {912 raw: {
...@@ -980,10 +945,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -980,10 +945,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
980 type: "item.updated",945 type: "item.updated",
981 eventId: stamp.eventId,946 eventId: stamp.eventId,
982 provider: PROVIDER,947 provider: PROVIDER,
983 sessionId: asRuntimeSessionId(context.session.sessionId),
984 createdAt: stamp.createdAt,948 createdAt: stamp.createdAt,
985 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),949 threadId: context.session.threadId,
986 turnId: asRuntimeTurnId(context.turnState.turnId),950 turnId: context.turnState.turnId,
987 itemId: asRuntimeItemId(context.turnState.assistantItemId),951 itemId: asRuntimeItemId(context.turnState.assistantItemId),
988 payload: {952 payload: {
989 itemType: "assistant_message",953 itemType: "assistant_message",
...@@ -992,8 +956,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -992,8 +956,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
992 data: message.message,956 data: message.message,
993 },957 },
994 providerRefs: {958 providerRefs: {
995 providerSessionId: context.session.sessionId,959 ...providerThreadRef(context),
996 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
997 providerTurnId: context.turnState.turnId,960 providerTurnId: context.turnState.turnId,
998 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),961 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
999 },962 },
...@@ -1041,13 +1004,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1041,13 +1004,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1041 const base = {1004 const base = {
1042 eventId: stamp.eventId,1005 eventId: stamp.eventId,
1043 provider: PROVIDER,1006 provider: PROVIDER,
1044 sessionId: asRuntimeSessionId(context.session.sessionId),
1045 createdAt: stamp.createdAt,1007 createdAt: stamp.createdAt,
1046 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1008 threadId: context.session.threadId,
1047 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),1009 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1048 providerRefs: {1010 providerRefs: {
1049 providerSessionId: context.session.sessionId,1011 ...providerThreadRef(context),
1050 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1051 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),1012 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1052 },1013 },
1053 raw: {1014 raw: {
...@@ -1202,13 +1163,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1202,13 +1163,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1202 const base = {1163 const base = {
1203 eventId: stamp.eventId,1164 eventId: stamp.eventId,
1204 provider: PROVIDER,1165 provider: PROVIDER,
1205 sessionId: asRuntimeSessionId(context.session.sessionId),
1206 createdAt: stamp.createdAt,1166 createdAt: stamp.createdAt,
1207 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1167 threadId: context.session.threadId,
1208 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),1168 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1209 providerRefs: {1169 providerRefs: {
1210 providerSessionId: context.session.sessionId,1170 ...providerThreadRef(context),
1211 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1212 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),1171 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1213 },1172 },
1214 raw: {1173 raw: {
...@@ -1284,6 +1243,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1284,6 +1243,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1284 case "stream_event":1243 case "stream_event":
1285 yield* handleStreamEvent(context, message);1244 yield* handleStreamEvent(context, message);
1286 return;1245 return;
1246 case "user":
1247 return;
1287 case "assistant":1248 case "assistant":
1288 yield* handleAssistantMessage(context, message);1249 yield* handleAssistantMessage(context, message);
1289 return;1250 return;
...@@ -1341,19 +1302,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1341,19 +1302,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1341 type: "request.resolved",1302 type: "request.resolved",
1342 eventId: stamp.eventId,1303 eventId: stamp.eventId,
1343 provider: PROVIDER,1304 provider: PROVIDER,
1344 sessionId: asRuntimeSessionId(context.session.sessionId),1305 createdAt: stamp.createdAt,
1345 createdAt: stamp.createdAt,1306 threadId: context.session.threadId,
1346 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1307 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1347 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1348 requestId: asRuntimeRequestId(requestId),1308 requestId: asRuntimeRequestId(requestId),
1349 payload: {1309 payload: {
1350 requestType: pending.requestType,1310 requestType: pending.requestType,
1351 decision: "cancel",1311 decision: "cancel",
1352 },1312 },
1353 providerRefs: {1313 providerRefs: {
1354 providerSessionId: context.session.sessionId,1314 ...providerThreadRef(context),
1355 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),1315 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1356 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1357 providerRequestId: requestId,1316 providerRequestId: requestId,
1358 },1317 },
1359 });1318 });
...@@ -1382,32 +1341,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1382,32 +1341,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1382 type: "session.exited",1341 type: "session.exited",
1383 eventId: stamp.eventId,1342 eventId: stamp.eventId,
1384 provider: PROVIDER,1343 provider: PROVIDER,
1385 sessionId: asRuntimeSessionId(context.session.sessionId),1344 createdAt: stamp.createdAt,
1386 createdAt: stamp.createdAt,1345 threadId: context.session.threadId,
1387 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1388 payload: {1346 payload: {
1389 reason: "Session stopped",1347 reason: "Session stopped",
1390 exitKind: "graceful",1348 exitKind: "graceful",
1391 },1349 },
1392 providerRefs: {1350 providerRefs: {},
1393 providerSessionId: context.session.sessionId,
1394 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1395 },
1396 });1351 });
1397 }1352 }
13981353
1399 sessions.delete(context.session.sessionId);1354 sessions.delete(context.session.threadId);
1400 });1355 });
14011356
1402 const requireSession = (1357 const requireSession = (
1403 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,1358 threadId: ThreadId,
1404 ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => {1359 ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => {
1405 const context = sessions.get(sessionId);1360 const context = sessions.get(threadId);
1406 if (!context) {1361 if (!context) {
1407 return Effect.fail(1362 return Effect.fail(
1408 new ProviderAdapterSessionNotFoundError({1363 new ProviderAdapterSessionNotFoundError({
1409 provider: PROVIDER,1364 provider: PROVIDER,
1410 sessionId,1365 threadId,
1411 }),1366 }),
1412 );1367 );
1413 }1368 }
...@@ -1415,7 +1370,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1415,7 +1370,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1415 return Effect.fail(1370 return Effect.fail(
1416 new ProviderAdapterSessionClosedError({1371 new ProviderAdapterSessionClosedError({
1417 provider: PROVIDER,1372 provider: PROVIDER,
1418 sessionId,1373 threadId,
1419 }),1374 }),
1420 );1375 );
1421 }1376 }
...@@ -1433,11 +1388,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1433,11 +1388,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1433 }1388 }
14341389
1435 const startedAt = yield* nowIso;1390 const startedAt = yield* nowIso;
1436 const sessionId = ProviderSessionId.makeUnsafe(
1437 `claude-session-${yield* Random.nextUUIDv4}`,
1438 );
1439 const resumeState = readClaudeResumeState(input.resumeCursor);1391 const resumeState = readClaudeResumeState(input.resumeCursor);
1440 const threadId = resumeState?.threadId;1392 const threadId = input.threadId;
14411393
1442 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();1394 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
1443 const prompt = Stream.fromQueue(promptQueue).pipe(1395 const prompt = Stream.fromQueue(promptQueue).pipe(
...@@ -1488,10 +1440,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1488,10 +1440,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1488 type: "request.opened",1440 type: "request.opened",
1489 eventId: requestedStamp.eventId,1441 eventId: requestedStamp.eventId,
1490 provider: PROVIDER,1442 provider: PROVIDER,
1491 sessionId: asRuntimeSessionId(context.session.sessionId),1443 createdAt: requestedStamp.createdAt,
1492 createdAt: requestedStamp.createdAt,1444 threadId: context.session.threadId,
1493 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1445 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1494 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1495 requestId: asRuntimeRequestId(requestId),1446 requestId: asRuntimeRequestId(requestId),
1496 payload: {1447 payload: {
1497 requestType,1448 requestType,
...@@ -1503,11 +1454,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1503,11 +1454,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1503 },1454 },
1504 },1455 },
1505 providerRefs: {1456 providerRefs: {
1506 providerSessionId: context.session.sessionId,1457 ...(context.session.threadId
1507 ...(context.session.threadId
1508 ? { providerThreadId: context.session.threadId }1458 ? { providerThreadId: context.session.threadId }
1509 : {}),1459 : {}),
1510 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),1460 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1511 providerRequestId: requestId,1461 providerRequestId: requestId,
1512 },1462 },
1513 raw: {1463 raw: {
...@@ -1542,21 +1492,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1542,21 +1492,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1542 type: "request.resolved",1492 type: "request.resolved",
1543 eventId: resolvedStamp.eventId,1493 eventId: resolvedStamp.eventId,
1544 provider: PROVIDER,1494 provider: PROVIDER,
1545 sessionId: asRuntimeSessionId(context.session.sessionId),1495 createdAt: resolvedStamp.createdAt,
1546 createdAt: resolvedStamp.createdAt,1496 threadId: context.session.threadId,
1547 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1497 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1548 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1549 requestId: asRuntimeRequestId(requestId),1498 requestId: asRuntimeRequestId(requestId),
1550 payload: {1499 payload: {
1551 requestType,1500 requestType,
1552 decision,1501 decision,
1553 },1502 },
1554 providerRefs: {1503 providerRefs: {
1555 providerSessionId: context.session.sessionId,1504 ...(context.session.threadId
1556 ...(context.session.threadId
1557 ? { providerThreadId: context.session.threadId }1505 ? { providerThreadId: context.session.threadId }
1558 : {}),1506 : {}),
1559 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),1507 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1560 providerRequestId: requestId,1508 providerRequestId: requestId,
1561 },1509 },
1562 raw: {1510 raw: {
...@@ -1623,14 +1571,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1623,14 +1571,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1623 catch: (cause) =>1571 catch: (cause) =>
1624 new ProviderAdapterProcessError({1572 new ProviderAdapterProcessError({
1625 provider: PROVIDER,1573 provider: PROVIDER,
1626 sessionId,1574 threadId,
1627 detail: toMessage(cause, "Failed to start Claude runtime session."),1575 detail: toMessage(cause, "Failed to start Claude runtime session."),
1628 cause,1576 cause,
1629 }),1577 }),
1630 });1578 });
16311579
1632 const session: ProviderSession = {1580 const session: ProviderSession = {
1633 sessionId,1581 threadId,
1634 provider: PROVIDER,1582 provider: PROVIDER,
1635 status: "ready",1583 status: "ready",
1636 runtimeMode: input.runtimeMode,1584 runtimeMode: input.runtimeMode,
...@@ -1664,21 +1612,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1664,21 +1612,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1664 stopped: false,1612 stopped: false,
1665 };1613 };
1666 yield* Ref.set(contextRef, context);1614 yield* Ref.set(contextRef, context);
1667 sessions.set(sessionId, context);1615 sessions.set(threadId, context);
16681616
1669 const sessionStartedStamp = yield* makeEventStamp();1617 const sessionStartedStamp = yield* makeEventStamp();
1670 yield* offerRuntimeEvent({1618 yield* offerRuntimeEvent({
1671 type: "session.started",1619 type: "session.started",
1672 eventId: sessionStartedStamp.eventId,1620 eventId: sessionStartedStamp.eventId,
1673 provider: PROVIDER,1621 provider: PROVIDER,
1674 sessionId: asRuntimeSessionId(sessionId),
1675 createdAt: sessionStartedStamp.createdAt,1622 createdAt: sessionStartedStamp.createdAt,
1676 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),1623 threadId,
1677 payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {},1624 payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {},
1678 providerRefs: {1625 providerRefs: {},
1679 providerSessionId: sessionId,
1680 ...(threadId ? { providerThreadId: threadId } : {}),
1681 },
1682 });1626 });
16831627
1684 const configuredStamp = yield* makeEventStamp();1628 const configuredStamp = yield* makeEventStamp();
...@@ -1686,9 +1630,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1686,9 +1630,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1686 type: "session.configured",1630 type: "session.configured",
1687 eventId: configuredStamp.eventId,1631 eventId: configuredStamp.eventId,
1688 provider: PROVIDER,1632 provider: PROVIDER,
1689 sessionId: asRuntimeSessionId(sessionId),
1690 createdAt: configuredStamp.createdAt,1633 createdAt: configuredStamp.createdAt,
1691 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),1634 threadId,
1692 payload: {1635 payload: {
1693 config: {1636 config: {
1694 ...(input.model ? { model: input.model } : {}),1637 ...(input.model ? { model: input.model } : {}),
...@@ -1699,10 +1642,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1699,10 +1642,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1699 : {}),1642 : {}),
1700 },1643 },
1701 },1644 },
1702 providerRefs: {1645 providerRefs: {},
1703 providerSessionId: sessionId,
1704 ...(threadId ? { providerThreadId: threadId } : {}),
1705 },
1706 });1646 });
17071647
1708 const readyStamp = yield* makeEventStamp();1648 const readyStamp = yield* makeEventStamp();
...@@ -1710,16 +1650,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1710,16 +1650,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1710 type: "session.state.changed",1650 type: "session.state.changed",
1711 eventId: readyStamp.eventId,1651 eventId: readyStamp.eventId,
1712 provider: PROVIDER,1652 provider: PROVIDER,
1713 sessionId: asRuntimeSessionId(sessionId),
1714 createdAt: readyStamp.createdAt,1653 createdAt: readyStamp.createdAt,
1715 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),1654 threadId,
1716 payload: {1655 payload: {
1717 state: "ready",1656 state: "ready",
1718 },1657 },
1719 providerRefs: {1658 providerRefs: {},
1720 providerSessionId: sessionId,
1721 ...(threadId ? { providerThreadId: threadId } : {}),
1722 },
1723 });1659 });
17241660
1725 Effect.runFork(runSdkStream(context));1661 Effect.runFork(runSdkStream(context));
...@@ -1731,24 +1667,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1731,24 +1667,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17311667
1732 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>1668 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
1733 Effect.gen(function* () {1669 Effect.gen(function* () {
1734 const context = yield* requireSession(input.sessionId);1670 const context = yield* requireSession(input.threadId);
17351671
1736 if (context.turnState) {1672 if (context.turnState) {
1737 return yield* new ProviderAdapterValidationError({1673 return yield* new ProviderAdapterValidationError({
1738 provider: PROVIDER,1674 provider: PROVIDER,
1739 operation: "sendTurn",1675 operation: "sendTurn",
1740 issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`,1676 issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`,
1741 });1677 });
1742 }1678 }
17431679
1744 if (input.model) {1680 if (input.model) {
1745 yield* Effect.tryPromise({1681 yield* Effect.tryPromise({
1746 try: () => context.query.setModel(input.model),1682 try: () => context.query.setModel(input.model),
1747 catch: (cause) => toRequestError(input.sessionId, "turn/setModel", cause),1683 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
1748 });1684 });
1749 }1685 }
17501686
1751 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);1687 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
1752 const turnState: ClaudeTurnState = {1688 const turnState: ClaudeTurnState = {
1753 turnId,1689 turnId,
1754 assistantItemId: yield* Random.nextUUIDv4,1690 assistantItemId: yield* Random.nextUUIDv4,
...@@ -1773,18 +1709,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1773,18 +1709,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1773 type: "turn.started",1709 type: "turn.started",
1774 eventId: turnStartedStamp.eventId,1710 eventId: turnStartedStamp.eventId,
1775 provider: PROVIDER,1711 provider: PROVIDER,
1776 sessionId: asRuntimeSessionId(context.session.sessionId),
1777 createdAt: turnStartedStamp.createdAt,1712 createdAt: turnStartedStamp.createdAt,
1778 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1713 threadId: context.session.threadId,
1779 turnId: asRuntimeTurnId(turnId),1714 turnId,
1780 payload: {1715 payload: {
1781 ...(input.model ? { model: input.model } : {}),1716 ...(input.model ? { model: input.model } : {}),
1782 ...(input.effort ? { effort: input.effort } : {}),1717 ...(input.effort ? { effort: input.effort } : {}),
1783 },1718 },
1784 providerRefs: {1719 providerRefs: {
1785 providerSessionId: context.session.sessionId,1720 providerTurnId: String(turnId),
1786 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1787 providerTurnId: turnId,
1788 },1721 },
1789 });1722 });
17901723
...@@ -1793,10 +1726,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1793,10 +1726,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1793 yield* Queue.offer(context.promptQueue, {1726 yield* Queue.offer(context.promptQueue, {
1794 type: "message",1727 type: "message",
1795 message,1728 message,
1796 }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)));1729 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
17971730
1798 return {1731 return {
1799 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),1732 threadId: context.session.threadId,
1800 turnId,1733 turnId,
1801 ...(context.session.resumeCursor !== undefined1734 ...(context.session.resumeCursor !== undefined
1802 ? { resumeCursor: context.session.resumeCursor }1735 ? { resumeCursor: context.session.resumeCursor }
...@@ -1804,24 +1737,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1804,24 +1737,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1804 };1737 };
1805 });1738 });
18061739
1807 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, _turnId) =>1740 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) =>
1808 Effect.gen(function* () {1741 Effect.gen(function* () {
1809 const context = yield* requireSession(sessionId);1742 const context = yield* requireSession(threadId);
1810 yield* Effect.tryPromise({1743 yield* Effect.tryPromise({
1811 try: () => context.query.interrupt(),1744 try: () => context.query.interrupt(),
1812 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),1745 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
1813 });1746 });
1814 });1747 });
18151748
1816 const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) =>1749 const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) =>
1817 Effect.gen(function* () {1750 Effect.gen(function* () {
1818 const context = yield* requireSession(sessionId);1751 const context = yield* requireSession(threadId);
1819 return yield* snapshotThread(context);1752 return yield* snapshotThread(context);
1820 });1753 });
18211754
1822 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) =>1755 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) =>
1823 Effect.gen(function* () {1756 Effect.gen(function* () {
1824 const context = yield* requireSession(sessionId);1757 const context = yield* requireSession(threadId);
1825 const nextLength = Math.max(0, context.turns.length - numTurns);1758 const nextLength = Math.max(0, context.turns.length - numTurns);
1826 context.turns.splice(nextLength);1759 context.turns.splice(nextLength);
1827 yield* updateResumeCursor(context);1760 yield* updateResumeCursor(context);
...@@ -1829,12 +1762,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1829,12 +1762,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1829 });1762 });
18301763
1831 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (1764 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
1832 sessionId,1765 threadId,
1833 requestId,1766 requestId,
1834 decision,1767 decision,
1835 ) =>1768 ) =>
1836 Effect.gen(function* () {1769 Effect.gen(function* () {
1837 const context = yield* requireSession(sessionId);1770 const context = yield* requireSession(threadId);
1838 const pending = context.pendingApprovals.get(requestId);1771 const pending = context.pendingApprovals.get(requestId);
1839 if (!pending) {1772 if (!pending) {
1840 return yield* new ProviderAdapterRequestError({1773 return yield* new ProviderAdapterRequestError({
...@@ -1848,9 +1781,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1848,9 +1781,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1848 yield* Deferred.succeed(pending.decision, decision);1781 yield* Deferred.succeed(pending.decision, decision);
1849 });1782 });
18501783
1851 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) =>1784 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
1852 Effect.gen(function* () {1785 Effect.gen(function* () {
1853 const context = yield* requireSession(sessionId);1786 const context = yield* requireSession(threadId);
1854 yield* stopSessionInternal(context, {1787 yield* stopSessionInternal(context, {
1855 emitExitEvent: true,1788 emitExitEvent: true,
1856 });1789 });
...@@ -1859,9 +1792,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1859,9 +1792,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1859 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>1792 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>
1860 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));1793 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
18611794
1862 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) =>1795 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) =>
1863 Effect.sync(() => {1796 Effect.sync(() => {
1864 const context = sessions.get(sessionId);1797 const context = sessions.get(threadId);
1865 return context !== undefined && !context.stopped;1798 return context !== undefined && !context.stopped;
1866 });1799 });
18671800
apps/server/src/provider/Layers/CodexAdapter.test.ts+31-34
...@@ -4,12 +4,11 @@ import {...@@ -4,12 +4,11 @@ import {
4 ProviderItemId,4 ProviderItemId,
5 type ProviderApprovalDecision,5 type ProviderApprovalDecision,
6 type ProviderEvent,6 type ProviderEvent,
7 ProviderSessionId,
8 type ProviderSession,7 type ProviderSession,
9 type ProviderSessionStartInput,8 type ProviderSessionStartInput,
10 ProviderThreadId,
11 ProviderTurnId,
12 type ProviderTurnStartResult,9 type ProviderTurnStartResult,
10 ThreadId,
11 TurnId,
13} from "@t3tools/contracts";12} from "@t3tools/contracts";
14import * as NodeServices from "@effect/platform-node/NodeServices";13import * as NodeServices from "@effect/platform-node/NodeServices";
15import { afterAll, assert, it, vi } from "@effect/vitest";14import { afterAll, assert, it, vi } from "@effect/vitest";
...@@ -27,8 +26,8 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";...@@ -27,8 +26,8 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";
27import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";26import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
28import { makeCodexAdapterLive } from "./CodexAdapter.ts";27import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2928
30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);29const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
31const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);30const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
32const asEventId = (value: string): EventId => EventId.makeUnsafe(value);31const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
33const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);32const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
3433
...@@ -37,11 +36,10 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -37,11 +36,10 @@ class FakeCodexManager extends CodexAppServerManager {
37 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {36 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
38 const now = new Date().toISOString();37 const now = new Date().toISOString();
39 return {38 return {
40 sessionId: asSessionId("sess-1"),
41 provider: "codex",39 provider: "codex",
42 status: "ready",40 status: "ready",
43 runtimeMode: input.runtimeMode,41 runtimeMode: input.runtimeMode,
44 threadId: ProviderThreadId.makeUnsafe("thread-1"),42 threadId: input.threadId,
45 cwd: input.cwd,43 cwd: input.cwd,
46 createdAt: now,44 createdAt: now,
47 updatedAt: now,45 updatedAt: now,
...@@ -51,28 +49,28 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -51,28 +49,28 @@ class FakeCodexManager extends CodexAppServerManager {
5149
52 public sendTurnImpl = vi.fn(50 public sendTurnImpl = vi.fn(
53 async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({51 async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({
54 threadId: ProviderThreadId.makeUnsafe("thread-1"),52 threadId: asThreadId("thread-1"),
55 turnId: asTurnId("turn-1"),53 turnId: asTurnId("turn-1"),
56 }),54 }),
57 );55 );
5856
59 public interruptTurnImpl = vi.fn(57 public interruptTurnImpl = vi.fn(
60 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,58 async (_threadId: ThreadId, _turnId?: TurnId): Promise<void> => undefined,
61 );59 );
6260
63 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({61 public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({
64 threadId: ProviderThreadId.makeUnsafe("thread-1"),62 threadId: asThreadId("thread-1"),
65 turns: [],63 turns: [],
66 }));64 }));
6765
68 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({66 public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({
69 threadId: ProviderThreadId.makeUnsafe("thread-1"),67 threadId: asThreadId("thread-1"),
70 turns: [],68 turns: [],
71 }));69 }));
7270
73 public respondToRequestImpl = vi.fn(71 public respondToRequestImpl = vi.fn(
74 async (72 async (
75 _sessionId: ProviderSessionId,73 _threadId: ThreadId,
76 _requestId: ApprovalRequestId,74 _requestId: ApprovalRequestId,
77 _decision: ProviderApprovalDecision,75 _decision: ProviderApprovalDecision,
78 ): Promise<void> => undefined,76 ): Promise<void> => undefined,
...@@ -88,33 +86,33 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -88,33 +86,33 @@ class FakeCodexManager extends CodexAppServerManager {
88 return this.sendTurnImpl(input);86 return this.sendTurnImpl(input);
89 }87 }
9088
91 override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {89 override interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise<void> {
92 return this.interruptTurnImpl(sessionId, turnId);90 return this.interruptTurnImpl(threadId, turnId);
93 }91 }
9492
95 override readThread(sessionId: ProviderSessionId) {93 override readThread(threadId: ThreadId) {
96 return this.readThreadImpl(sessionId);94 return this.readThreadImpl(threadId);
97 }95 }
9896
99 override rollbackThread(sessionId: ProviderSessionId, numTurns: number) {97 override rollbackThread(threadId: ThreadId, numTurns: number) {
100 return this.rollbackThreadImpl(sessionId, numTurns);98 return this.rollbackThreadImpl(threadId, numTurns);
101 }99 }
102100
103 override respondToRequest(101 override respondToRequest(
104 sessionId: ProviderSessionId,102 threadId: ThreadId,
105 requestId: ApprovalRequestId,103 requestId: ApprovalRequestId,
106 decision: ProviderApprovalDecision,104 decision: ProviderApprovalDecision,
107 ): Promise<void> {105 ): Promise<void> {
108 return this.respondToRequestImpl(sessionId, requestId, decision);106 return this.respondToRequestImpl(threadId, requestId, decision);
109 }107 }
110108
111 override stopSession(_sessionId: ProviderSessionId): void {}109 override stopSession(_threadId: ThreadId): void {}
112110
113 override listSessions(): ProviderSession[] {111 override listSessions(): ProviderSession[] {
114 return [];112 return [];
115 }113 }
116114
117 override hasSession(_sessionId: ProviderSessionId): boolean {115 override hasSession(_threadId: ThreadId): boolean {
118 return false;116 return false;
119 }117 }
120118
...@@ -128,9 +126,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory...@@ -128,9 +126,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
128 getProvider: () =>126 getProvider: () =>
129 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),127 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
130 getBinding: () => Effect.succeed(Option.none()),128 getBinding: () => Effect.succeed(Option.none()),
131 getThreadId: () => Effect.succeed(Option.none()),
132 remove: () => Effect.void,129 remove: () => Effect.void,
133 listSessionIds: () => Effect.succeed([]),130 listThreadIds: () => Effect.succeed([]),
134});131});
135132
136const validationManager = new FakeCodexManager();133const validationManager = new FakeCodexManager();
...@@ -149,6 +146,7 @@ validationLayer("CodexAdapterLive validation", (it) => {...@@ -149,6 +146,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
149 const result = yield* adapter146 const result = yield* adapter
150 .startSession({147 .startSession({
151 provider: "claudeCode",148 provider: "claudeCode",
149 threadId: asThreadId("thread-1"),
152 runtimeMode: "full-access",150 runtimeMode: "full-access",
153 })151 })
154 .pipe(Effect.result);152 .pipe(Effect.result);
...@@ -184,7 +182,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -184,7 +182,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
184 const adapter = yield* CodexAdapter;182 const adapter = yield* CodexAdapter;
185 const result = yield* adapter183 const result = yield* adapter
186 .sendTurn({184 .sendTurn({
187 sessionId: asSessionId("sess-missing"),185 threadId: asThreadId("sess-missing"),
188 input: "hello",186 input: "hello",
189 attachments: [],187 attachments: [],
190 })188 })
...@@ -200,7 +198,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -200,7 +198,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
200 return;198 return;
201 }199 }
202 assert.equal(result.failure.provider, "codex");200 assert.equal(result.failure.provider, "codex");
203 assert.equal(result.failure.sessionId, "sess-missing");201 assert.equal(result.failure.threadId, "sess-missing");
204 assert.instanceOf(result.failure.cause, Error);202 assert.instanceOf(result.failure.cause, Error);
205 }),203 }),
206 );204 );
...@@ -225,10 +223,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -225,10 +223,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
225 id: asEventId("evt-msg-complete"),223 id: asEventId("evt-msg-complete"),
226 kind: "notification",224 kind: "notification",
227 provider: "codex",225 provider: "codex",
228 sessionId: asSessionId("sess-1"),
229 createdAt: new Date().toISOString(),226 createdAt: new Date().toISOString(),
230 method: "item/completed",227 method: "item/completed",
231 threadId: ProviderThreadId.makeUnsafe("thread-1"),228 threadId: asThreadId("thread-1"),
232 turnId: asTurnId("turn-1"),229 turnId: asTurnId("turn-1"),
233 itemId: asItemId("msg_1"),230 itemId: asItemId("msg_1"),
234 payload: {231 payload: {
...@@ -265,7 +262,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -265,7 +262,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
265 id: asEventId("evt-session-closed"),262 id: asEventId("evt-session-closed"),
266 kind: "session",263 kind: "session",
267 provider: "codex",264 provider: "codex",
268 sessionId: asSessionId("sess-1"),265 threadId: asThreadId("thread-1"),
269 createdAt: new Date().toISOString(),266 createdAt: new Date().toISOString(),
270 method: "session/closed",267 method: "session/closed",
271 message: "Session stopped",268 message: "Session stopped",
...@@ -282,7 +279,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -282,7 +279,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
282 if (firstEvent.value.type !== "session.exited") {279 if (firstEvent.value.type !== "session.exited") {
283 return;280 return;
284 }281 }
285 assert.equal(firstEvent.value.sessionId, "sess-1");282 assert.equal(firstEvent.value.threadId, "thread-1");
286 assert.equal(firstEvent.value.payload.reason, "Session stopped");283 assert.equal(firstEvent.value.payload.reason, "Session stopped");
287 }),284 }),
288 );285 );
...@@ -296,7 +293,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -296,7 +293,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
296 id: asEventId("evt-request-resolved"),293 id: asEventId("evt-request-resolved"),
297 kind: "notification",294 kind: "notification",
298 provider: "codex",295 provider: "codex",
299 sessionId: asSessionId("sess-1"),296 threadId: asThreadId("thread-1"),
300 createdAt: new Date().toISOString(),297 createdAt: new Date().toISOString(),
301 method: "serverRequest/resolved",298 method: "serverRequest/resolved",
302 requestId: ApprovalRequestId.makeUnsafe("req-1"),299 requestId: ApprovalRequestId.makeUnsafe("req-1"),
...@@ -334,7 +331,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -334,7 +331,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
334 id: asEventId("evt-windows-sandbox-failed"),331 id: asEventId("evt-windows-sandbox-failed"),
335 kind: "notification",332 kind: "notification",
336 provider: "codex",333 provider: "codex",
337 sessionId: asSessionId("sess-1"),334 threadId: asThreadId("thread-1"),
338 createdAt: new Date().toISOString(),335 createdAt: new Date().toISOString(),
339 method: "windowsSandbox/setupCompleted",336 method: "windowsSandbox/setupCompleted",
340 message: "Sandbox setup failed",337 message: "Sandbox setup failed",
apps/server/src/provider/Layers/CodexAdapter.ts+150-153
...@@ -13,16 +13,11 @@ import {...@@ -13,16 +13,11 @@ import {
13 type ProviderRuntimeEvent,13 type ProviderRuntimeEvent,
14 RuntimeItemId,14 RuntimeItemId,
15 RuntimeRequestId,15 RuntimeRequestId,
16 RuntimeSessionId,
17 ProviderApprovalDecision,16 ProviderApprovalDecision,
18 ProviderItemId,17 ProviderItemId,
19 ProviderSessionId,
20 ProviderThreadId,
21 ProviderTurnId,
22 ThreadId,18 ThreadId,
23 TurnId,
24} from "@t3tools/contracts";19} from "@t3tools/contracts";
25import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect";20import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect";
2621
27import {22import {
28 ProviderAdapterProcessError,23 ProviderAdapterProcessError,
...@@ -33,7 +28,6 @@ import {...@@ -33,7 +28,6 @@ import {
33 type ProviderAdapterError,28 type ProviderAdapterError,
34} from "../Errors.ts";29} from "../Errors.ts";
35import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";30import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
36import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
37import { CodexAppServerManager } from "../../codexAppServerManager.ts";31import { CodexAppServerManager } from "../../codexAppServerManager.ts";
38import { resolveAttachmentPath } from "../../attachmentStore.ts";32import { resolveAttachmentPath } from "../../attachmentStore.ts";
39import { ServerConfig } from "../../config.ts";33import { ServerConfig } from "../../config.ts";
...@@ -43,7 +37,7 @@ const PROVIDER = "codex" as const;...@@ -43,7 +37,7 @@ const PROVIDER = "codex" as const;
4337
44export interface CodexAdapterLiveOptions {38export interface CodexAdapterLiveOptions {
45 readonly manager?: CodexAppServerManager;39 readonly manager?: CodexAppServerManager;
46 readonly makeManager?: () => CodexAppServerManager;40 readonly makeManager?: (services?: ServiceMap.ServiceMap<never>) => CodexAppServerManager;
47 readonly nativeEventLogPath?: string;41 readonly nativeEventLogPath?: string;
48 readonly nativeEventLogger?: EventNdjsonLogger;42 readonly nativeEventLogger?: EventNdjsonLogger;
49}43}
...@@ -56,33 +50,29 @@ function toMessage(cause: unknown, fallback: string): string {...@@ -56,33 +50,29 @@ function toMessage(cause: unknown, fallback: string): string {
56}50}
5751
58function toSessionError(52function toSessionError(
59 sessionId: ProviderSessionId,53 threadId: ThreadId,
60 cause: unknown,54 cause: unknown,
61): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {55): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
62 const normalized = toMessage(cause, "").toLowerCase();56 const normalized = toMessage(cause, "").toLowerCase();
63 if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) {57 if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) {
64 return new ProviderAdapterSessionNotFoundError({58 return new ProviderAdapterSessionNotFoundError({
65 provider: PROVIDER,59 provider: PROVIDER,
66 sessionId,60 threadId,
67 cause,61 cause,
68 });62 });
69 }63 }
70 if (normalized.includes("session is closed")) {64 if (normalized.includes("session is closed")) {
71 return new ProviderAdapterSessionClosedError({65 return new ProviderAdapterSessionClosedError({
72 provider: PROVIDER,66 provider: PROVIDER,
73 sessionId,67 threadId,
74 cause,68 cause,
75 });69 });
76 }70 }
77 return undefined;71 return undefined;
78}72}
7973
80function toRequestError(74function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError {
81 sessionId: ProviderSessionId,75 const sessionError = toSessionError(threadId, cause);
82 method: string,
83 cause: unknown,
84): ProviderAdapterError {
85 const sessionError = toSessionError(sessionId, cause);
86 if (sessionError) {76 if (sessionError) {
87 return sessionError;77 return sessionError;
88 }78 }
...@@ -139,7 +129,8 @@ function toCanonicalItemType(raw: unknown): CanonicalItemType {...@@ -139,7 +129,8 @@ function toCanonicalItemType(raw: unknown): CanonicalItemType {
139 if (type.includes("reasoning") || type.includes("thought")) return "reasoning";129 if (type.includes("reasoning") || type.includes("thought")) return "reasoning";
140 if (type.includes("plan") || type.includes("todo")) return "plan";130 if (type.includes("plan") || type.includes("todo")) return "plan";
141 if (type.includes("command")) return "command_execution";131 if (type.includes("command")) return "command_execution";
142 if (type.includes("file change") || type.includes("patch") || type.includes("edit")) return "file_change";132 if (type.includes("file change") || type.includes("patch") || type.includes("edit"))
133 return "file_change";
143 if (type.includes("mcp")) return "mcp_tool_call";134 if (type.includes("mcp")) return "mcp_tool_call";
144 if (type.includes("dynamic tool")) return "dynamic_tool_call";135 if (type.includes("dynamic tool")) return "dynamic_tool_call";
145 if (type.includes("collab")) return "collab_agent_tool_call";136 if (type.includes("collab")) return "collab_agent_tool_call";
...@@ -211,6 +202,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {...@@ -211,6 +202,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {
211 switch (method) {202 switch (method) {
212 case "item/commandExecution/requestApproval":203 case "item/commandExecution/requestApproval":
213 return "command_execution_approval";204 return "command_execution_approval";
205 case "item/fileRead/requestApproval":
206 return "file_read_approval";
214 case "item/fileChange/requestApproval":207 case "item/fileChange/requestApproval":
215 return "file_change_approval";208 return "file_change_approval";
216 case "applyPatchApproval":209 case "applyPatchApproval":
...@@ -232,6 +225,8 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {...@@ -232,6 +225,8 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
232 switch (kind) {225 switch (kind) {
233 case "command":226 case "command":
234 return "command_execution_approval";227 return "command_execution_approval";
228 case "file-read":
229 return "file_read_approval";
235 case "file-change":230 case "file-change":
236 return "file_change_approval";231 return "file_change_approval";
237 default:232 default:
...@@ -239,7 +234,9 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {...@@ -239,7 +234,9 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
239 }234 }
240}235}
241236
242function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | undefined): CanonicalRequestType {237function toRequestTypeFromResolvedPayload(
238 payload: Record<string, unknown> | undefined,
239): CanonicalRequestType {
243 const request = asObject(payload?.request);240 const request = asObject(payload?.request);
244 const method = asString(request?.method) ?? asString(payload?.method);241 const method = asString(request?.method) ?? asString(payload?.method);
245 if (method) {242 if (method) {
...@@ -252,13 +249,9 @@ function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | und...@@ -252,13 +249,9 @@ function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | und
252 return "unknown";249 return "unknown";
253}250}
254251
255function toThreadState(value: unknown):252function toThreadState(
256 | "active"253 value: unknown,
257 | "idle"254): "active" | "idle" | "archived" | "closed" | "compacted" | "error" {
258 | "archived"
259 | "closed"
260 | "compacted"
261 | "error" {
262 switch (value) {255 switch (value) {
263 case "idle":256 case "idle":
264 return "idle";257 return "idle";
...@@ -278,7 +271,13 @@ function toThreadState(value: unknown):...@@ -278,7 +271,13 @@ function toThreadState(value: unknown):
278271
279function contentStreamKindFromMethod(272function contentStreamKindFromMethod(
280 method: string,273 method: string,
281): "assistant_text" | "reasoning_text" | "reasoning_summary_text" | "plan_text" | "command_output" | "file_change_output" {274):
275 | "assistant_text"
276 | "reasoning_text"
277 | "reasoning_summary_text"
278 | "plan_text"
279 | "command_output"
280 | "file_change_output" {
282 switch (method) {281 switch (method) {
283 case "item/agentMessage/delta":282 case "item/agentMessage/delta":
284 return "assistant_text";283 return "assistant_text";
...@@ -297,18 +296,6 @@ function contentStreamKindFromMethod(...@@ -297,18 +296,6 @@ function contentStreamKindFromMethod(
297 }296 }
298}297}
299298
300function asRuntimeSessionId(sessionId: ProviderSessionId): RuntimeSessionId {
301 return RuntimeSessionId.makeUnsafe(sessionId);
302}
303
304function asRuntimeThreadId(threadId: ProviderThreadId): ThreadId {
305 return ThreadId.makeUnsafe(threadId);
306}
307
308function asRuntimeTurnId(turnId: ProviderTurnId): TurnId {
309 return TurnId.makeUnsafe(turnId);
310}
311
312function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {299function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {
313 return RuntimeItemId.makeUnsafe(itemId);300 return RuntimeItemId.makeUnsafe(itemId);
314}301}
...@@ -321,29 +308,28 @@ function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent[...@@ -321,29 +308,28 @@ function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent[
321 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";308 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";
322}309}
323310
324function providerRefsFromEvent(event: ProviderEvent): ProviderRuntimeEvent["providerRefs"] | undefined {311function providerRefsFromEvent(
325 const refs: Record<string, string> = {312 event: ProviderEvent,
326 providerSessionId: event.sessionId,313): ProviderRuntimeEvent["providerRefs"] | undefined {
327 };314 const refs: Record<string, string> = {};
328 if (event.threadId) refs.providerThreadId = event.threadId;
329 if (event.turnId) refs.providerTurnId = event.turnId;315 if (event.turnId) refs.providerTurnId = event.turnId;
330 if (event.itemId) refs.providerItemId = event.itemId;316 if (event.itemId) refs.providerItemId = event.itemId;
331 if (event.requestId) refs.providerRequestId = event.requestId;317 if (event.requestId) refs.providerRequestId = event.requestId;
332318
333 return Object.keys(refs).length > 0319 return Object.keys(refs).length > 0 ? (refs as ProviderRuntimeEvent["providerRefs"]) : undefined;
334 ? (refs as ProviderRuntimeEvent["providerRefs"])
335 : undefined;
336}320}
337321
338function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "type" | "payload"> {322function runtimeEventBase(
323 event: ProviderEvent,
324 canonicalThreadId: ThreadId,
325): Omit<ProviderRuntimeEvent, "type" | "payload"> {
339 const refs = providerRefsFromEvent(event);326 const refs = providerRefsFromEvent(event);
340 return {327 return {
341 eventId: event.id,328 eventId: event.id,
342 provider: event.provider,329 provider: event.provider,
343 sessionId: asRuntimeSessionId(event.sessionId),330 threadId: canonicalThreadId,
344 createdAt: event.createdAt,331 createdAt: event.createdAt,
345 ...(event.threadId ? { threadId: asRuntimeThreadId(event.threadId) } : {}),332 ...(event.turnId ? { turnId: event.turnId } : {}),
346 ...(event.turnId ? { turnId: asRuntimeTurnId(event.turnId) } : {}),
347 ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}),333 ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}),
348 ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}),334 ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}),
349 ...(refs ? { providerRefs: refs } : {}),335 ...(refs ? { providerRefs: refs } : {}),
...@@ -357,6 +343,7 @@ function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "typ...@@ -357,6 +343,7 @@ function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "typ
357343
358function mapItemLifecycle(344function mapItemLifecycle(
359 event: ProviderEvent,345 event: ProviderEvent,
346 canonicalThreadId: ThreadId,
360 lifecycle: "item.started" | "item.updated" | "item.completed",347 lifecycle: "item.started" | "item.updated" | "item.completed",
361): ProviderRuntimeEvent | undefined {348): ProviderRuntimeEvent | undefined {
362 const payload = asObject(event.payload);349 const payload = asObject(event.payload);
...@@ -380,7 +367,7 @@ function mapItemLifecycle(...@@ -380,7 +367,7 @@ function mapItemLifecycle(
380 : undefined;367 : undefined;
381368
382 return {369 return {
383 ...runtimeEventBase(event),370 ...runtimeEventBase(event, canonicalThreadId),
384 type: lifecycle,371 type: lifecycle,
385 payload: {372 payload: {
386 itemType,373 itemType,
...@@ -392,7 +379,10 @@ function mapItemLifecycle(...@@ -392,7 +379,10 @@ function mapItemLifecycle(
392 };379 };
393}380}
394381
395function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntimeEvent> {382function mapToRuntimeEvents(
383 event: ProviderEvent,
384 canonicalThreadId: ThreadId,
385): ReadonlyArray<ProviderRuntimeEvent> {
396 const payload = asObject(event.payload);386 const payload = asObject(event.payload);
397 const turn = asObject(payload?.turn);387 const turn = asObject(payload?.turn);
398388
...@@ -402,7 +392,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -402,7 +392,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
402 }392 }
403 return [393 return [
404 {394 {
405 ...runtimeEventBase(event),395 ...runtimeEventBase(event, canonicalThreadId),
406 type: "runtime.error",396 type: "runtime.error",
407 payload: {397 payload: {
408 message: event.message,398 message: event.message,
...@@ -418,7 +408,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -418,7 +408,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
418 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);408 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);
419 return [409 return [
420 {410 {
421 ...runtimeEventBase(event),411 ...runtimeEventBase(event, canonicalThreadId),
422 type: "request.opened",412 type: "request.opened",
423 payload: {413 payload: {
424 requestType: toRequestTypeFromMethod(event.method),414 requestType: toRequestTypeFromMethod(event.method),
...@@ -437,7 +427,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -437,7 +427,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
437 : toRequestTypeFromMethod(event.method);427 : toRequestTypeFromMethod(event.method);
438 return [428 return [
439 {429 {
440 ...runtimeEventBase(event),430 ...runtimeEventBase(event, canonicalThreadId),
441 type: "request.resolved",431 type: "request.resolved",
442 payload: {432 payload: {
443 requestType,433 requestType,
...@@ -451,7 +441,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -451,7 +441,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
451 if (event.method === "session/connecting") {441 if (event.method === "session/connecting") {
452 return [442 return [
453 {443 {
454 ...runtimeEventBase(event),444 ...runtimeEventBase(event, canonicalThreadId),
455 type: "session.state.changed",445 type: "session.state.changed",
456 payload: {446 payload: {
457 state: "starting",447 state: "starting",
...@@ -464,7 +454,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -464,7 +454,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
464 if (event.method === "session/ready") {454 if (event.method === "session/ready") {
465 return [455 return [
466 {456 {
467 ...runtimeEventBase(event),457 ...runtimeEventBase(event, canonicalThreadId),
468 type: "session.state.changed",458 type: "session.state.changed",
469 payload: {459 payload: {
470 state: "ready",460 state: "ready",
...@@ -477,7 +467,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -477,7 +467,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
477 if (event.method === "session/started") {467 if (event.method === "session/started") {
478 return [468 return [
479 {469 {
480 ...runtimeEventBase(event),470 ...runtimeEventBase(event, canonicalThreadId),
481 type: "session.started",471 type: "session.started",
482 payload: {472 payload: {
483 ...(event.message ? { message: event.message } : {}),473 ...(event.message ? { message: event.message } : {}),
...@@ -490,7 +480,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -490,7 +480,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
490 if (event.method === "session/exited" || event.method === "session/closed") {480 if (event.method === "session/exited" || event.method === "session/closed") {
491 return [481 return [
492 {482 {
493 ...runtimeEventBase(event),483 ...runtimeEventBase(event, canonicalThreadId),
494 type: "session.exited",484 type: "session.exited",
495 payload: {485 payload: {
496 ...(event.message ? { reason: event.message } : {}),486 ...(event.message ? { reason: event.message } : {}),
...@@ -502,15 +492,13 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -502,15 +492,13 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
502492
503 if (event.method === "thread/started") {493 if (event.method === "thread/started") {
504 const payloadThreadId = asString(asObject(payload?.thread)?.id);494 const payloadThreadId = asString(asObject(payload?.thread)?.id);
505 const providerThreadId =495 const providerThreadId = payloadThreadId ?? asString(payload?.threadId);
506 event.threadId ??
507 (payloadThreadId ? ProviderThreadId.makeUnsafe(payloadThreadId) : undefined);
508 if (!providerThreadId) {496 if (!providerThreadId) {
509 return [];497 return [];
510 }498 }
511 return [499 return [
512 {500 {
513 ...runtimeEventBase(event),501 ...runtimeEventBase(event, canonicalThreadId),
514 type: "thread.started",502 type: "thread.started",
515 payload: {503 payload: {
516 providerThreadId,504 providerThreadId,
...@@ -528,8 +516,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -528,8 +516,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
528 ) {516 ) {
529 return [517 return [
530 {518 {
531 ...runtimeEventBase(event),
532 type: "thread.state.changed",519 type: "thread.state.changed",
520 ...runtimeEventBase(event, canonicalThreadId),
533 payload: {521 payload: {
534 state:522 state:
535 event.method === "thread/archived"523 event.method === "thread/archived"
...@@ -548,8 +536,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -548,8 +536,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
548 if (event.method === "thread/name/updated") {536 if (event.method === "thread/name/updated") {
549 return [537 return [
550 {538 {
551 ...runtimeEventBase(event),
552 type: "thread.metadata.updated",539 type: "thread.metadata.updated",
540 ...runtimeEventBase(event, canonicalThreadId),
553 payload: {541 payload: {
554 ...(asString(payload?.threadName) ? { name: asString(payload?.threadName) } : {}),542 ...(asString(payload?.threadName) ? { name: asString(payload?.threadName) } : {}),
555 ...(event.payload !== undefined ? { metadata: asObject(event.payload) } : {}),543 ...(event.payload !== undefined ? { metadata: asObject(event.payload) } : {}),
...@@ -561,8 +549,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -561,8 +549,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
561 if (event.method === "thread/tokenUsage/updated") {549 if (event.method === "thread/tokenUsage/updated") {
562 return [550 return [
563 {551 {
564 ...runtimeEventBase(event),
565 type: "thread.token-usage.updated",552 type: "thread.token-usage.updated",
553 ...runtimeEventBase(event, canonicalThreadId),
566 payload: {554 payload: {
567 usage: event.payload ?? {},555 usage: event.payload ?? {},
568 },556 },
...@@ -571,16 +559,14 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -571,16 +559,14 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
571 }559 }
572560
573 if (event.method === "turn/started") {561 if (event.method === "turn/started") {
574 const payloadTurnId = asString(turn?.id);562 const turnId = event.turnId;
575 const turnId =
576 event.turnId ?? (payloadTurnId ? ProviderTurnId.makeUnsafe(payloadTurnId) : undefined);
577 if (!turnId) {563 if (!turnId) {
578 return [];564 return [];
579 }565 }
580 return [566 return [
581 {567 {
582 ...runtimeEventBase(event),568 ...runtimeEventBase(event, canonicalThreadId),
583 turnId: asRuntimeTurnId(turnId),569 turnId,
584 type: "turn.started",570 type: "turn.started",
585 payload: {571 payload: {
586 ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}),572 ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}),
...@@ -594,7 +580,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -594,7 +580,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
594 const errorMessage = asString(asObject(turn?.error)?.message);580 const errorMessage = asString(asObject(turn?.error)?.message);
595 return [581 return [
596 {582 {
597 ...runtimeEventBase(event),583 ...runtimeEventBase(event, canonicalThreadId),
598 type: "turn.completed",584 type: "turn.completed",
599 payload: {585 payload: {
600 state: toTurnStatus(turn?.status),586 state: toTurnStatus(turn?.status),
...@@ -613,7 +599,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -613,7 +599,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
613 if (event.method === "turn/aborted") {599 if (event.method === "turn/aborted") {
614 return [600 return [
615 {601 {
616 ...runtimeEventBase(event),602 ...runtimeEventBase(event, canonicalThreadId),
617 type: "turn.aborted",603 type: "turn.aborted",
618 payload: {604 payload: {
619 reason: event.message ?? "Turn aborted",605 reason: event.message ?? "Turn aborted",
...@@ -626,10 +612,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -626,10 +612,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
626 const steps = Array.isArray(payload?.plan) ? payload.plan : [];612 const steps = Array.isArray(payload?.plan) ? payload.plan : [];
627 return [613 return [
628 {614 {
629 ...runtimeEventBase(event),615 ...runtimeEventBase(event, canonicalThreadId),
630 type: "turn.plan.updated",616 type: "turn.plan.updated",
631 payload: {617 payload: {
632 ...(asString(payload?.explanation) ? { explanation: asString(payload?.explanation) } : {}),618 ...(asString(payload?.explanation)
619 ? { explanation: asString(payload?.explanation) }
620 : {}),
633 plan: steps621 plan: steps
634 .map((entry) => asObject(entry))622 .map((entry) => asObject(entry))
635 .filter((entry): entry is Record<string, unknown> => entry !== undefined)623 .filter((entry): entry is Record<string, unknown> => entry !== undefined)
...@@ -648,7 +636,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -648,7 +636,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
648 if (event.method === "turn/diff/updated") {636 if (event.method === "turn/diff/updated") {
649 return [637 return [
650 {638 {
651 ...runtimeEventBase(event),639 ...runtimeEventBase(event, canonicalThreadId),
652 type: "turn.diff.updated",640 type: "turn.diff.updated",
653 payload: {641 payload: {
654 unifiedDiff:642 unifiedDiff:
...@@ -662,12 +650,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -662,12 +650,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
662 }650 }
663651
664 if (event.method === "item/started") {652 if (event.method === "item/started") {
665 const started = mapItemLifecycle(event, "item.started");653 const started = mapItemLifecycle(event, canonicalThreadId, "item.started");
666 return started ? [started] : [];654 return started ? [started] : [];
667 }655 }
668656
669 if (event.method === "item/completed") {657 if (event.method === "item/completed") {
670 const completed = mapItemLifecycle(event, "item.completed");658 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");
671 return completed ? [completed] : [];659 return completed ? [completed] : [];
672 }660 }
673661
...@@ -675,7 +663,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -675,7 +663,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
675 event.method === "item/reasoning/summaryPartAdded" ||663 event.method === "item/reasoning/summaryPartAdded" ||
676 event.method === "item/commandExecution/terminalInteraction"664 event.method === "item/commandExecution/terminalInteraction"
677 ) {665 ) {
678 const updated = mapItemLifecycle(event, "item.updated");666 const updated = mapItemLifecycle(event, canonicalThreadId, "item.updated");
679 return updated ? [updated] : [];667 return updated ? [updated] : [];
680 }668 }
681669
...@@ -697,13 +685,17 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -697,13 +685,17 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
697 }685 }
698 return [686 return [
699 {687 {
700 ...runtimeEventBase(event),688 ...runtimeEventBase(event, canonicalThreadId),
701 type: "content.delta",689 type: "content.delta",
702 payload: {690 payload: {
703 streamKind: contentStreamKindFromMethod(event.method),691 streamKind: contentStreamKindFromMethod(event.method),
704 delta,692 delta,
705 ...(typeof payload?.contentIndex === "number" ? { contentIndex: payload.contentIndex } : {}),693 ...(typeof payload?.contentIndex === "number"
706 ...(typeof payload?.summaryIndex === "number" ? { summaryIndex: payload.summaryIndex } : {}),694 ? { contentIndex: payload.contentIndex }
695 : {}),
696 ...(typeof payload?.summaryIndex === "number"
697 ? { summaryIndex: payload.summaryIndex }
698 : {}),
707 },699 },
708 },700 },
709 ];701 ];
...@@ -712,7 +704,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -712,7 +704,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
712 if (event.method === "item/mcpToolCall/progress") {704 if (event.method === "item/mcpToolCall/progress") {
713 return [705 return [
714 {706 {
715 ...runtimeEventBase(event),707 ...runtimeEventBase(event, canonicalThreadId),
716 type: "tool.progress",708 type: "tool.progress",
717 payload: {709 payload: {
718 ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}),710 ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}),
...@@ -735,7 +727,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -735,7 +727,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
735 : "unknown";727 : "unknown";
736 return [728 return [
737 {729 {
738 ...runtimeEventBase(event),730 ...runtimeEventBase(event, canonicalThreadId),
739 type: "request.resolved",731 type: "request.resolved",
740 payload: {732 payload: {
741 requestType,733 requestType,
...@@ -748,8 +740,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -748,8 +740,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
748 if (event.method === "model/rerouted") {740 if (event.method === "model/rerouted") {
749 return [741 return [
750 {742 {
751 ...runtimeEventBase(event),
752 type: "model.rerouted",743 type: "model.rerouted",
744 ...runtimeEventBase(event, canonicalThreadId),
753 payload: {745 payload: {
754 fromModel: asString(payload?.fromModel) ?? "unknown",746 fromModel: asString(payload?.fromModel) ?? "unknown",
755 toModel: asString(payload?.toModel) ?? "unknown",747 toModel: asString(payload?.toModel) ?? "unknown",
...@@ -762,8 +754,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -762,8 +754,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
762 if (event.method === "deprecationNotice") {754 if (event.method === "deprecationNotice") {
763 return [755 return [
764 {756 {
765 ...runtimeEventBase(event),
766 type: "deprecation.notice",757 type: "deprecation.notice",
758 ...runtimeEventBase(event, canonicalThreadId),
767 payload: {759 payload: {
768 summary: asString(payload?.summary) ?? "Deprecation notice",760 summary: asString(payload?.summary) ?? "Deprecation notice",
769 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),761 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),
...@@ -775,8 +767,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -775,8 +767,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
775 if (event.method === "configWarning") {767 if (event.method === "configWarning") {
776 return [768 return [
777 {769 {
778 ...runtimeEventBase(event),
779 type: "config.warning",770 type: "config.warning",
771 ...runtimeEventBase(event, canonicalThreadId),
780 payload: {772 payload: {
781 summary: asString(payload?.summary) ?? "Configuration warning",773 summary: asString(payload?.summary) ?? "Configuration warning",
782 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),774 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),
...@@ -790,8 +782,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -790,8 +782,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
790 if (event.method === "account/updated") {782 if (event.method === "account/updated") {
791 return [783 return [
792 {784 {
793 ...runtimeEventBase(event),
794 type: "account.updated",785 type: "account.updated",
786 ...runtimeEventBase(event, canonicalThreadId),
795 payload: {787 payload: {
796 account: event.payload ?? {},788 account: event.payload ?? {},
797 },789 },
...@@ -802,8 +794,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -802,8 +794,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
802 if (event.method === "account/rateLimits/updated") {794 if (event.method === "account/rateLimits/updated") {
803 return [795 return [
804 {796 {
805 ...runtimeEventBase(event),
806 type: "account.rate-limits.updated",797 type: "account.rate-limits.updated",
798 ...runtimeEventBase(event, canonicalThreadId),
807 payload: {799 payload: {
808 rateLimits: event.payload ?? {},800 rateLimits: event.payload ?? {},
809 },801 },
...@@ -814,8 +806,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -814,8 +806,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
814 if (event.method === "mcpServer/oauthLogin/completed") {806 if (event.method === "mcpServer/oauthLogin/completed") {
815 return [807 return [
816 {808 {
817 ...runtimeEventBase(event),
818 type: "mcp.oauth.completed",809 type: "mcp.oauth.completed",
810 ...runtimeEventBase(event, canonicalThreadId),
819 payload: {811 payload: {
820 success: payload?.success === true,812 success: payload?.success === true,
821 ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}),813 ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}),
...@@ -829,8 +821,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -829,8 +821,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
829 const realtimeSessionId = asString(payload?.realtimeSessionId);821 const realtimeSessionId = asString(payload?.realtimeSessionId);
830 return [822 return [
831 {823 {
832 ...runtimeEventBase(event),
833 type: "thread.realtime.started",824 type: "thread.realtime.started",
825 ...runtimeEventBase(event, canonicalThreadId),
834 payload: {826 payload: {
835 realtimeSessionId,827 realtimeSessionId,
836 },828 },
...@@ -841,8 +833,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -841,8 +833,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
841 if (event.method === "thread/realtime/itemAdded") {833 if (event.method === "thread/realtime/itemAdded") {
842 return [834 return [
843 {835 {
844 ...runtimeEventBase(event),
845 type: "thread.realtime.item-added",836 type: "thread.realtime.item-added",
837 ...runtimeEventBase(event, canonicalThreadId),
846 payload: {838 payload: {
847 item: event.payload ?? {},839 item: event.payload ?? {},
848 },840 },
...@@ -853,8 +845,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -853,8 +845,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
853 if (event.method === "thread/realtime/outputAudio/delta") {845 if (event.method === "thread/realtime/outputAudio/delta") {
854 return [846 return [
855 {847 {
856 ...runtimeEventBase(event),
857 type: "thread.realtime.audio.delta",848 type: "thread.realtime.audio.delta",
849 ...runtimeEventBase(event, canonicalThreadId),
858 payload: {850 payload: {
859 audio: event.payload ?? {},851 audio: event.payload ?? {},
860 },852 },
...@@ -866,8 +858,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -866,8 +858,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
866 const message = asString(payload?.message) ?? event.message ?? "Realtime error";858 const message = asString(payload?.message) ?? event.message ?? "Realtime error";
867 return [859 return [
868 {860 {
869 ...runtimeEventBase(event),
870 type: "thread.realtime.error",861 type: "thread.realtime.error",
862 ...runtimeEventBase(event, canonicalThreadId),
871 payload: {863 payload: {
872 message,864 message,
873 },865 },
...@@ -878,8 +870,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -878,8 +870,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
878 if (event.method === "thread/realtime/closed") {870 if (event.method === "thread/realtime/closed") {
879 return [871 return [
880 {872 {
881 ...runtimeEventBase(event),
882 type: "thread.realtime.closed",873 type: "thread.realtime.closed",
874 ...runtimeEventBase(event, canonicalThreadId),
883 payload: {875 payload: {
884 reason: event.message,876 reason: event.message,
885 },877 },
...@@ -892,8 +884,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -892,8 +884,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
892 asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error";884 asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error";
893 return [885 return [
894 {886 {
895 ...runtimeEventBase(event),
896 type: "runtime.error",887 type: "runtime.error",
888 ...runtimeEventBase(event, canonicalThreadId),
897 payload: {889 payload: {
898 message,890 message,
899 class: "provider_error",891 class: "provider_error",
...@@ -906,8 +898,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -906,8 +898,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
906 if (event.method === "windows/worldWritableWarning") {898 if (event.method === "windows/worldWritableWarning") {
907 return [899 return [
908 {900 {
909 ...runtimeEventBase(event),
910 type: "runtime.warning",901 type: "runtime.warning",
902 ...runtimeEventBase(event, canonicalThreadId),
911 payload: {903 payload: {
912 message: event.message ?? "Windows world-writable warning",904 message: event.message ?? "Windows world-writable warning",
913 ...(event.payload !== undefined ? { detail: event.payload } : {}),905 ...(event.payload !== undefined ? { detail: event.payload } : {}),
...@@ -924,8 +916,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -924,8 +916,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
924916
925 return [917 return [
926 {918 {
927 ...runtimeEventBase(event),
928 type: "session.state.changed",919 type: "session.state.changed",
920 ...runtimeEventBase(event, canonicalThreadId),
929 payload: {921 payload: {
930 state: success === false ? "error" : "ready",922 state: success === false ? "error" : "ready",
931 reason: success === false ? failureMessage : successMessage,923 reason: success === false ? failureMessage : successMessage,
...@@ -935,8 +927,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -935,8 +927,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
935 ...(success === false927 ...(success === false
936 ? [928 ? [
937 {929 {
938 ...runtimeEventBase(event),
939 type: "runtime.warning" as const,930 type: "runtime.warning" as const,
931 ...runtimeEventBase(event, canonicalThreadId),
940 payload: {932 payload: {
941 message: failureMessage,933 message: failureMessage,
942 ...(event.payload !== undefined ? { detail: event.payload } : {}),934 ...(event.payload !== undefined ? { detail: event.payload } : {}),
...@@ -954,7 +946,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -954,7 +946,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
954 Effect.gen(function* () {946 Effect.gen(function* () {
955 const fileSystem = yield* FileSystem.FileSystem;947 const fileSystem = yield* FileSystem.FileSystem;
956 const serverConfig = yield* Effect.service(ServerConfig);948 const serverConfig = yield* Effect.service(ServerConfig);
957 const directory = yield* ProviderSessionDirectory;
958 const nativeEventLogger =949 const nativeEventLogger =
959 options?.nativeEventLogger ??950 options?.nativeEventLogger ??
960 (options?.nativeEventLogPath !== undefined951 (options?.nativeEventLogPath !== undefined
...@@ -964,14 +955,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -964,14 +955,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
964 : undefined);955 : undefined);
965956
966 const manager = yield* Effect.acquireRelease(957 const manager = yield* Effect.acquireRelease(
967 Effect.sync(() => {958 Effect.gen(function* () {
968 if (options?.manager) {959 if (options?.manager) {
969 return options.manager;960 return options.manager;
970 }961 }
971 if (options?.makeManager) {962 const services = yield* Effect.services<never>();
972 return options.makeManager();963 return options?.makeManager?.(services) ?? new CodexAppServerManager(services);
973 }
974 return new CodexAppServerManager();
975 }),964 }),
976 (manager) =>965 (manager) =>
977 Effect.sync(() => {966 Effect.sync(() => {
...@@ -999,11 +988,13 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -999,11 +988,13 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
999 catch: (cause) =>988 catch: (cause) =>
1000 new ProviderAdapterProcessError({989 new ProviderAdapterProcessError({
1001 provider: PROVIDER,990 provider: PROVIDER,
1002 sessionId: "pending",991 threadId: input.threadId,
1003 detail: toMessage(cause, "Failed to start Codex adapter session."),992 detail: toMessage(cause, "Failed to start Codex adapter session."),
1004 cause,993 cause,
1005 }),994 }),
1006 });995 }).pipe(
996 Effect.map((session) => session),
997 );
1007 };998 };
1008999
1009 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>1000 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>
...@@ -1018,7 +1009,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1018,7 +1009,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1018 });1009 });
1019 if (!attachmentPath) {1010 if (!attachmentPath) {
1020 return yield* toRequestError(1011 return yield* toRequestError(
1021 input.sessionId,1012 input.threadId,
1022 "turn/start",1013 "turn/start",
1023 new Error(`Invalid attachment id '${attachment.id}'.`),1014 new Error(`Invalid attachment id '${attachment.id}'.`),
1024 );1015 );
...@@ -1026,7 +1017,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1026,7 +1017,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1026 const bytes = yield* fileSystem1017 const bytes = yield* fileSystem
1027 .readFile(attachmentPath)1018 .readFile(attachmentPath)
1028 .pipe(1019 .pipe(
1029 Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)),1020 Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)),
1030 );1021 );
1031 return {1022 return {
1032 type: "image" as const,1023 type: "image" as const,
...@@ -1039,7 +1030,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1039,7 +1030,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1039 return yield* Effect.tryPromise({1030 return yield* Effect.tryPromise({
1040 try: () => {1031 try: () => {
1041 const managerInput = {1032 const managerInput = {
1042 sessionId: input.sessionId,1033 threadId: input.threadId,
1043 ...(input.input !== undefined ? { input: input.input } : {}),1034 ...(input.input !== undefined ? { input: input.input } : {}),
1044 ...(input.model !== undefined ? { model: input.model } : {}),1035 ...(input.model !== undefined ? { model: input.model } : {}),
1045 ...(input.effort !== undefined ? { effort: input.effort } : {}),1036 ...(input.effort !== undefined ? { effort: input.effort } : {}),
...@@ -1047,23 +1038,33 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1047,23 +1038,33 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1047 };1038 };
1048 return manager.sendTurn(managerInput);1039 return manager.sendTurn(managerInput);
1049 },1040 },
1050 catch: (cause) => toRequestError(input.sessionId, "turn/start", cause),1041 catch: (cause) => toRequestError(input.threadId, "turn/start", cause),
1051 });1042 }).pipe(
1043 Effect.map((result) => ({
1044 ...result,
1045 threadId: input.threadId,
1046 })),
1047 );
1052 });1048 });
10531049
1054 const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) =>1050 const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) =>
1055 Effect.tryPromise({1051 Effect.tryPromise({
1056 try: () => manager.interruptTurn(sessionId, turnId),1052 try: () => manager.interruptTurn(threadId, turnId),
1057 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),1053 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
1058 });1054 });
10591055
1060 const readThread: CodexAdapterShape["readThread"] = (sessionId) =>1056 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
1061 Effect.tryPromise({1057 Effect.tryPromise({
1062 try: () => manager.readThread(sessionId),1058 try: () => manager.readThread(threadId),
1063 catch: (cause) => toRequestError(sessionId, "thread/read", cause),1059 catch: (cause) => toRequestError(threadId, "thread/read", cause),
1064 });1060 }).pipe(
10651061 Effect.map((snapshot) => ({
1066 const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => {1062 threadId,
1063 turns: snapshot.turns,
1064 })),
1065 );
1066
1067 const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => {
1067 if (!Number.isInteger(numTurns) || numTurns < 1) {1068 if (!Number.isInteger(numTurns) || numTurns < 1) {
1068 return Effect.fail(1069 return Effect.fail(
1069 new ProviderAdapterValidationError({1070 new ProviderAdapterValidationError({
...@@ -1075,31 +1076,36 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1075,31 +1076,36 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1075 }1076 }
10761077
1077 return Effect.tryPromise({1078 return Effect.tryPromise({
1078 try: () => manager.rollbackThread(sessionId, numTurns),1079 try: () => manager.rollbackThread(threadId, numTurns),
1079 catch: (cause) => toRequestError(sessionId, "thread/rollback", cause),1080 catch: (cause) => toRequestError(threadId, "thread/rollback", cause),
1080 });1081 }).pipe(
1082 Effect.map((snapshot) => ({
1083 threadId,
1084 turns: snapshot.turns,
1085 })),
1086 );
1081 };1087 };
10821088
1083 const respondToRequest: CodexAdapterShape["respondToRequest"] = (1089 const respondToRequest: CodexAdapterShape["respondToRequest"] = (
1084 sessionId,1090 threadId,
1085 requestId,1091 requestId,
1086 decision,1092 decision,
1087 ) =>1093 ) =>
1088 Effect.tryPromise({1094 Effect.tryPromise({
1089 try: () => manager.respondToRequest(sessionId, requestId, decision),1095 try: () => manager.respondToRequest(threadId, requestId, decision),
1090 catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause),1096 catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause),
1091 });1097 });
10921098
1093 const stopSession: CodexAdapterShape["stopSession"] = (sessionId) =>1099 const stopSession: CodexAdapterShape["stopSession"] = (threadId) =>
1094 Effect.sync(() => {1100 Effect.sync(() => {
1095 manager.stopSession(sessionId);1101 manager.stopSession(threadId);
1096 });1102 });
10971103
1098 const listSessions: CodexAdapterShape["listSessions"] = () =>1104 const listSessions: CodexAdapterShape["listSessions"] = () =>
1099 Effect.sync(() => manager.listSessions());1105 Effect.sync(() => manager.listSessions());
11001106
1101 const hasSession: CodexAdapterShape["hasSession"] = (sessionId) =>1107 const hasSession: CodexAdapterShape["hasSession"] = (threadId) =>
1102 Effect.sync(() => manager.hasSession(sessionId));1108 Effect.sync(() => manager.hasSession(threadId));
11031109
1104 const stopAll: CodexAdapterShape["stopAll"] = () =>1110 const stopAll: CodexAdapterShape["stopAll"] = () =>
1105 Effect.sync(() => {1111 Effect.sync(() => {
...@@ -1115,33 +1121,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1115,33 +1121,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1115 if (!nativeEventLogger) {1121 if (!nativeEventLogger) {
1116 return;1122 return;
1117 }1123 }
1118 const orchestrationThreadId = yield* Effect.catch(1124 yield* nativeEventLogger.write(event, event.threadId);
1119 directory.getThreadId(event.sessionId).pipe(
1120 Effect.map((threadIdOption) =>
1121 Option.isSome(threadIdOption) ? threadIdOption.value : null,
1122 ),
1123 ),
1124 () => Effect.succeed(null),
1125 );
1126 yield* nativeEventLogger.write(event, orchestrationThreadId);
1127 });1125 });
11281126
1129 const services = yield* Effect.services<never>();1127 const services = yield* Effect.services<never>();
1130 const listener = (event: ProviderEvent) =>1128 const listener = (event: ProviderEvent) =>
1131 Effect.gen(function* () {1129 Effect.gen(function* () {
1132 yield* writeNativeEvent(event);1130 yield* writeNativeEvent(event);
1133 const runtimeEvents = mapToRuntimeEvents(event);1131 const runtimeEvents = mapToRuntimeEvents(event, event.threadId);
1134 if (runtimeEvents.length === 0) {1132 if (runtimeEvents.length === 0) {
1135 yield* Effect.logDebug("ignoring unhandled Codex provider event", {1133 yield* Effect.logDebug("ignoring unhandled Codex provider event", {
1136 method: event.method,1134 method: event.method,
1137 sessionId: event.sessionId,1135 threadId: event.threadId,
1138 threadId: event.threadId,1136 turnId: event.turnId,
1139 turnId: event.turnId,1137 itemId: event.itemId,
1140 itemId: event.itemId,1138 });
1141 });1139 return;
1142 return;1140 }
1143 }1141 yield* Queue.offerAll(runtimeEventQueue, runtimeEvents);
1144 yield* Queue.offerAll(runtimeEventQueue, runtimeEvents);
1145 }).pipe(Effect.runPromiseWith(services));1142 }).pipe(Effect.runPromiseWith(services));
1146 manager.on("event", listener);1143 manager.on("event", listener);
1147 return listener;1144 return listener;
apps/server/src/provider/Layers/CursorAdapter.test.ts+33-16
...@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";...@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
2import { PassThrough } from "node:stream";2import { PassThrough } from "node:stream";
3import readline from "node:readline";3import readline from "node:readline";
44
5import { ApprovalRequestId } from "@t3tools/contracts";5import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
6import { assert, describe, it } from "@effect/vitest";6import { assert, describe, it } from "@effect/vitest";
7import { Effect, Fiber, Stream } from "effect";7import { Effect, Fiber, Stream } from "effect";
88
...@@ -10,6 +10,10 @@ import { ProviderAdapterValidationError } from "../Errors.ts";...@@ -10,6 +10,10 @@ import { ProviderAdapterValidationError } from "../Errors.ts";
10import { CursorAdapter } from "../Services/CursorAdapter.ts";10import { CursorAdapter } from "../Services/CursorAdapter.ts";
11import { makeCursorAdapterLive } from "./CursorAdapter.ts";11import { makeCursorAdapterLive } from "./CursorAdapter.ts";
1212
13const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1");
14const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume");
15const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy");
16
13class FakeCursorAcpProcess extends EventEmitter {17class FakeCursorAcpProcess extends EventEmitter {
14 readonly stdin = new PassThrough();18 readonly stdin = new PassThrough();
15 readonly stdout = new PassThrough();19 readonly stdout = new PassThrough();
...@@ -256,7 +260,11 @@ describe("CursorAdapterLive", () => {...@@ -256,7 +260,11 @@ describe("CursorAdapterLive", () => {
256 return Effect.gen(function* () {260 return Effect.gen(function* () {
257 const adapter = yield* CursorAdapter;261 const adapter = yield* CursorAdapter;
258 const result = yield* adapter262 const result = yield* adapter
259 .startSession({ provider: "codex", runtimeMode: "full-access" })263 .startSession({
264 provider: "codex",
265 threadId: THREAD_ID,
266 runtimeMode: "full-access",
267 })
260 .pipe(Effect.result);268 .pipe(Effect.result);
261269
262 assert.equal(result._tag, "Failure");270 assert.equal(result._tag, "Failure");
...@@ -290,12 +298,13 @@ describe("CursorAdapterLive", () => {...@@ -290,12 +298,13 @@ describe("CursorAdapterLive", () => {
290298
291 const session = yield* adapter.startSession({299 const session = yield* adapter.startSession({
292 provider: "cursor",300 provider: "cursor",
301 threadId: THREAD_ID,
293 cwd: "/tmp/project",302 cwd: "/tmp/project",
294 runtimeMode: "full-access",303 runtimeMode: "full-access",
295 });304 });
296305
297 const turn = yield* adapter.sendTurn({306 const turn = yield* adapter.sendTurn({
298 sessionId: session.sessionId,307 threadId: session.threadId,
299 input: "hello",308 input: "hello",
300 attachments: [],309 attachments: [],
301 });310 });
...@@ -355,6 +364,7 @@ describe("CursorAdapterLive", () => {...@@ -355,6 +364,7 @@ describe("CursorAdapterLive", () => {
355 const adapter = yield* CursorAdapter;364 const adapter = yield* CursorAdapter;
356 yield* adapter.startSession({365 yield* adapter.startSession({
357 provider: "cursor",366 provider: "cursor",
367 threadId: THREAD_ID,
358 model: "composer-1.5",368 model: "composer-1.5",
359 runtimeMode: "full-access",369 runtimeMode: "full-access",
360 });370 });
...@@ -364,13 +374,13 @@ describe("CursorAdapterLive", () => {...@@ -364,13 +374,13 @@ describe("CursorAdapterLive", () => {
364 });374 });
365375
366 it.effect("writes provider-native observability records when enabled", () => {376 it.effect("writes provider-native observability records when enabled", () => {
367 const nativeEvents: Array<{377 const nativeEvents: Array<{
368 event?: {378 event?: {
369 provider?: string;379 provider?: string;
370 method?: string;380 method?: string;
371 sessionId?: string;381 threadId?: string;
372 };382 };
373 }> = [];383 }> = [];
374 const fake = new FakeCursorAcpProcess();384 const fake = new FakeCursorAcpProcess();
375 const layer = makeCursorAdapterLive({385 const layer = makeCursorAdapterLive({
376 createProcess: () => fake as never,386 createProcess: () => fake as never,
...@@ -388,13 +398,14 @@ describe("CursorAdapterLive", () => {...@@ -388,13 +398,14 @@ describe("CursorAdapterLive", () => {
388 const adapter = yield* CursorAdapter;398 const adapter = yield* CursorAdapter;
389 const session = yield* adapter.startSession({399 const session = yield* adapter.startSession({
390 provider: "cursor",400 provider: "cursor",
401 threadId: THREAD_ID,
391 cwd: "/tmp/project",402 cwd: "/tmp/project",
392 runtimeMode: "full-access",403 runtimeMode: "full-access",
393 });404 });
394405
395 assert.equal(nativeEvents.length > 0, true);406 assert.equal(nativeEvents.length > 0, true);
396 assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true);407 assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true);
397 assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true);408 assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true);
398 assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true);409 assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true);
399 }).pipe(Effect.provide(layer));410 }).pipe(Effect.provide(layer));
400 });411 });
...@@ -409,6 +420,7 @@ describe("CursorAdapterLive", () => {...@@ -409,6 +420,7 @@ describe("CursorAdapterLive", () => {
409 const adapter = yield* CursorAdapter;420 const adapter = yield* CursorAdapter;
410 const session = yield* adapter.startSession({421 const session = yield* adapter.startSession({
411 provider: "cursor",422 provider: "cursor",
423 threadId: RESUME_THREAD_ID,
412 cwd: "/tmp/project",424 cwd: "/tmp/project",
413 resumeCursor: {425 resumeCursor: {
414 acpSessionId: "acp-session-resume",426 acpSessionId: "acp-session-resume",
...@@ -426,7 +438,7 @@ describe("CursorAdapterLive", () => {...@@ -426,7 +438,7 @@ describe("CursorAdapterLive", () => {
426 cwd: "/tmp/project",438 cwd: "/tmp/project",
427 mcpServers: [],439 mcpServers: [],
428 });440 });
429 assert.equal(session.threadId, "acp-session-resume");441 assert.equal(session.threadId, RESUME_THREAD_ID);
430 assert.deepEqual(session.resumeCursor, {442 assert.deepEqual(session.resumeCursor, {
431 acpSessionId: "acp-session-resume",443 acpSessionId: "acp-session-resume",
432 });444 });
...@@ -443,6 +455,7 @@ describe("CursorAdapterLive", () => {...@@ -443,6 +455,7 @@ describe("CursorAdapterLive", () => {
443 const adapter = yield* CursorAdapter;455 const adapter = yield* CursorAdapter;
444 const session = yield* adapter.startSession({456 const session = yield* adapter.startSession({
445 provider: "cursor",457 provider: "cursor",
458 threadId: LEGACY_RESUME_THREAD_ID,
446 cwd: "/tmp/project",459 cwd: "/tmp/project",
447 resumeCursor: {460 resumeCursor: {
448 sessionId: "acp-session-legacy",461 sessionId: "acp-session-legacy",
...@@ -456,7 +469,7 @@ describe("CursorAdapterLive", () => {...@@ -456,7 +469,7 @@ describe("CursorAdapterLive", () => {
456 cwd: "/tmp/project",469 cwd: "/tmp/project",
457 mcpServers: [],470 mcpServers: [],
458 });471 });
459 assert.equal(session.threadId, "acp-session-legacy");472 assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID);
460 assert.deepEqual(session.resumeCursor, {473 assert.deepEqual(session.resumeCursor, {
461 acpSessionId: "acp-session-legacy",474 acpSessionId: "acp-session-legacy",
462 });475 });
...@@ -474,6 +487,7 @@ describe("CursorAdapterLive", () => {...@@ -474,6 +487,7 @@ describe("CursorAdapterLive", () => {
474487
475 const session = yield* adapter.startSession({488 const session = yield* adapter.startSession({
476 provider: "cursor",489 provider: "cursor",
490 threadId: THREAD_ID,
477 runtimeMode: "approval-required",491 runtimeMode: "approval-required",
478 });492 });
479493
...@@ -498,7 +512,7 @@ describe("CursorAdapterLive", () => {...@@ -498,7 +512,7 @@ describe("CursorAdapterLive", () => {
498 }512 }
499513
500 yield* adapter.respondToRequest(514 yield* adapter.respondToRequest(
501 session.sessionId,515 session.threadId,
502 ApprovalRequestId.makeUnsafe(runtimeRequestId),516 ApprovalRequestId.makeUnsafe(runtimeRequestId),
503 "acceptForSession",517 "acceptForSession",
504 );518 );
...@@ -528,6 +542,7 @@ describe("CursorAdapterLive", () => {...@@ -528,6 +542,7 @@ describe("CursorAdapterLive", () => {
528542
529 yield* adapter.startSession({543 yield* adapter.startSession({
530 provider: "cursor",544 provider: "cursor",
545 threadId: THREAD_ID,
531 runtimeMode: "full-access",546 runtimeMode: "full-access",
532 });547 });
533548
...@@ -562,6 +577,7 @@ describe("CursorAdapterLive", () => {...@@ -562,6 +577,7 @@ describe("CursorAdapterLive", () => {
562 const adapter = yield* CursorAdapter;577 const adapter = yield* CursorAdapter;
563 const session = yield* adapter.startSession({578 const session = yield* adapter.startSession({
564 provider: "cursor",579 provider: "cursor",
580 threadId: THREAD_ID,
565 runtimeMode: "full-access",581 runtimeMode: "full-access",
566 });582 });
567583
...@@ -569,7 +585,7 @@ describe("CursorAdapterLive", () => {...@@ -569,7 +585,7 @@ describe("CursorAdapterLive", () => {
569585
570 const result = yield* adapter586 const result = yield* adapter
571 .sendTurn({587 .sendTurn({
572 sessionId: session.sessionId,588 threadId: session.threadId,
573 input: " ",589 input: " ",
574 attachments: [],590 attachments: [],
575 })591 })
...@@ -608,11 +624,12 @@ describe("CursorAdapterLive", () => {...@@ -608,11 +624,12 @@ describe("CursorAdapterLive", () => {
608624
609 const session = yield* adapter.startSession({625 const session = yield* adapter.startSession({
610 provider: "cursor",626 provider: "cursor",
627 threadId: THREAD_ID,
611 runtimeMode: "full-access",628 runtimeMode: "full-access",
612 });629 });
613630
614 yield* adapter.sendTurn({631 yield* adapter.sendTurn({
615 sessionId: session.sessionId,632 threadId: session.threadId,
616 input: "hello",633 input: "hello",
617 attachments: [],634 attachments: [],
618 });635 });
apps/server/src/provider/Layers/CursorAdapter.ts+96-174
...@@ -16,16 +16,12 @@ import {...@@ -16,16 +16,12 @@ import {
16 type CanonicalItemType,16 type CanonicalItemType,
17 type CanonicalRequestType,17 type CanonicalRequestType,
18 ProviderItemId,18 ProviderItemId,
19 ProviderSessionId,
20 type ProviderRuntimeEvent,19 type ProviderRuntimeEvent,
21 type ProviderSession,20 type ProviderSession,
22 ProviderThreadId,
23 ProviderTurnId,
24 type ProviderTurnStartResult,21 type ProviderTurnStartResult,
25 type RuntimeMode,22 type RuntimeMode,
26 RuntimeItemId,23 RuntimeItemId,
27 RuntimeRequestId,24 RuntimeRequestId,
28 RuntimeSessionId,
29 ThreadId,25 ThreadId,
30 TurnId,26 TurnId,
31} from "@t3tools/contracts";27} from "@t3tools/contracts";
...@@ -72,7 +68,7 @@ interface PendingPermission {...@@ -72,7 +68,7 @@ interface PendingPermission {
72}68}
7369
74interface CursorTurnState {70interface CursorTurnState {
75 readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>;71 readonly turnId: TurnId;
76 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;72 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;
77 readonly startedToolCalls: Set<string>;73 readonly startedToolCalls: Set<string>;
78 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;74 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;
...@@ -87,7 +83,7 @@ interface CursorSessionContext {...@@ -87,7 +83,7 @@ interface CursorSessionContext {
87 readonly pending: Map<string, PendingRequest>;83 readonly pending: Map<string, PendingRequest>;
88 readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>;84 readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>;
89 readonly turns: Array<{85 readonly turns: Array<{
90 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;86 id: TurnId;
91 items: Array<unknown>;87 items: Array<unknown>;
92 }>;88 }>;
93 turnState: CursorTurnState | undefined;89 turnState: CursorTurnState | undefined;
...@@ -114,18 +110,6 @@ function toMessage(cause: unknown, fallback: string): string {...@@ -114,18 +110,6 @@ function toMessage(cause: unknown, fallback: string): string {
114 return fallback;110 return fallback;
115}111}
116112
117function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId {
118 return RuntimeSessionId.makeUnsafe(value);
119}
120
121function asRuntimeThreadId(value: ProviderThreadId): ThreadId {
122 return ThreadId.makeUnsafe(value);
123}
124
125function asRuntimeTurnId(value: ProviderTurnId): TurnId {
126 return TurnId.makeUnsafe(value);
127}
128
129function asRuntimeItemId(value: string): RuntimeItemId {113function asRuntimeItemId(value: string): RuntimeItemId {
130 return RuntimeItemId.makeUnsafe(value);114 return RuntimeItemId.makeUnsafe(value);
131}115}
...@@ -139,21 +123,21 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {...@@ -139,21 +123,21 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
139}123}
140124
141function toSessionError(125function toSessionError(
142 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,126 threadId: ThreadId,
143 cause: unknown,127 cause: unknown,
144): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {128): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
145 const normalized = toMessage(cause, "").toLowerCase();129 const normalized = toMessage(cause, "").toLowerCase();
146 if (normalized.includes("unknown session") || normalized.includes("not found")) {130 if (normalized.includes("unknown session") || normalized.includes("not found")) {
147 return new ProviderAdapterSessionNotFoundError({131 return new ProviderAdapterSessionNotFoundError({
148 provider: PROVIDER,132 provider: PROVIDER,
149 sessionId,133 threadId,
150 cause,134 cause,
151 });135 });
152 }136 }
153 if (normalized.includes("closed")) {137 if (normalized.includes("closed")) {
154 return new ProviderAdapterSessionClosedError({138 return new ProviderAdapterSessionClosedError({
155 provider: PROVIDER,139 provider: PROVIDER,
156 sessionId,140 threadId,
157 cause,141 cause,
158 });142 });
159 }143 }
...@@ -161,11 +145,11 @@ function toSessionError(...@@ -161,11 +145,11 @@ function toSessionError(
161}145}
162146
163function toRequestError(147function toRequestError(
164 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,148 threadId: ThreadId,
165 method: string,149 method: string,
166 cause: unknown,150 cause: unknown,
167): ProviderAdapterError {151): ProviderAdapterError {
168 const sessionError = toSessionError(sessionId, cause);152 const sessionError = toSessionError(threadId, cause);
169 if (sessionError) {153 if (sessionError) {
170 return sessionError;154 return sessionError;
171 }155 }
...@@ -336,7 +320,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -336,7 +320,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
336 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));320 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
337 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });321 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
338322
339 const sessions = new Map<ProviderSessionId, CursorSessionContext>();323 const sessions = new Map<ThreadId, CursorSessionContext>();
340 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();324 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
341325
342 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>326 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
...@@ -431,19 +415,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -431,19 +415,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
431 type: "runtime.warning",415 type: "runtime.warning",
432 eventId: stamp.eventId,416 eventId: stamp.eventId,
433 provider: PROVIDER,417 provider: PROVIDER,
434 sessionId: asRuntimeSessionId(context.session.sessionId),
435 createdAt: stamp.createdAt,418 createdAt: stamp.createdAt,
436 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),419 threadId: context.session.threadId,
437 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),420 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
438 payload: {421 payload: {
439 message,422 message,
440 ...(detail !== undefined ? { detail } : {}),423 ...(detail !== undefined ? { detail } : {}),
441 },424 },
442 providerRefs: {425 ...(context.turnState
443 providerSessionId: context.session.sessionId,426 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
444 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),427 : {}),
445 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
446 },
447 });428 });
448 });429 });
449430
...@@ -464,10 +445,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -464,10 +445,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
464 type: "item.completed",445 type: "item.completed",
465 eventId: itemStamp.eventId,446 eventId: itemStamp.eventId,
466 provider: PROVIDER,447 provider: PROVIDER,
467 sessionId: asRuntimeSessionId(context.session.sessionId),
468 createdAt: itemStamp.createdAt,448 createdAt: itemStamp.createdAt,
469 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),449 threadId: context.session.threadId,
470 turnId: asRuntimeTurnId(turnState.turnId),450 turnId: turnState.turnId,
471 itemId: asRuntimeItemId(turnState.assistantItemId),451 itemId: asRuntimeItemId(turnState.assistantItemId),
472 payload: {452 payload: {
473 itemType: "assistant_message",453 itemType: "assistant_message",
...@@ -475,9 +455,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -475,9 +455,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
475 title: "Assistant message",455 title: "Assistant message",
476 },456 },
477 providerRefs: {457 providerRefs: {
478 providerSessionId: context.session.sessionId,458 providerTurnId: String(turnState.turnId),
479 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
480 providerTurnId: turnState.turnId,
481 providerItemId: turnState.assistantItemId,459 providerItemId: turnState.assistantItemId,
482 },460 },
483 });461 });
...@@ -492,19 +470,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -492,19 +470,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
492 type: "turn.completed",470 type: "turn.completed",
493 eventId: stamp.eventId,471 eventId: stamp.eventId,
494 provider: PROVIDER,472 provider: PROVIDER,
495 sessionId: asRuntimeSessionId(context.session.sessionId),
496 createdAt: stamp.createdAt,473 createdAt: stamp.createdAt,
497 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),474 threadId: context.session.threadId,
498 turnId: asRuntimeTurnId(turnState.turnId),475 turnId: turnState.turnId,
499 payload: {476 payload: {
500 state,477 state,
501 ...(stopReason ? { stopReason } : {}),478 ...(stopReason ? { stopReason } : {}),
502 ...(errorMessage ? { errorMessage } : {}),479 ...(errorMessage ? { errorMessage } : {}),
503 },480 },
504 providerRefs: {481 providerRefs: {
505 providerSessionId: context.session.sessionId,482 providerTurnId: String(turnState.turnId),
506 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
507 providerTurnId: turnState.turnId,
508 },483 },
509 });484 });
510485
...@@ -572,10 +547,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -572,10 +547,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
572 type: "request.resolved",547 type: "request.resolved",
573 eventId: stamp.eventId,548 eventId: stamp.eventId,
574 provider: PROVIDER,549 provider: PROVIDER,
575 sessionId: asRuntimeSessionId(context.session.sessionId),
576 createdAt: stamp.createdAt,550 createdAt: stamp.createdAt,
577 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),551 threadId: context.session.threadId,
578 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),552 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
579 requestId: asRuntimeRequestId(requestId),553 requestId: asRuntimeRequestId(requestId),
580 payload: {554 payload: {
581 requestType,555 requestType,
...@@ -586,9 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -586,9 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
586 },560 },
587 },561 },
588 providerRefs: {562 providerRefs: {
589 providerSessionId: context.session.sessionId,563 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
590 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
591 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
592 providerRequestId: String(decoded.id),564 providerRequestId: String(decoded.id),
593 },565 },
594 raw: {566 raw: {
...@@ -614,10 +586,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -614,10 +586,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
614 type: "request.opened",586 type: "request.opened",
615 eventId: stamp.eventId,587 eventId: stamp.eventId,
616 provider: PROVIDER,588 provider: PROVIDER,
617 sessionId: asRuntimeSessionId(context.session.sessionId),
618 createdAt: stamp.createdAt,589 createdAt: stamp.createdAt,
619 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),590 threadId: context.session.threadId,
620 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),591 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
621 requestId: asRuntimeRequestId(requestId),592 requestId: asRuntimeRequestId(requestId),
622 payload: {593 payload: {
623 requestType,594 requestType,
...@@ -625,9 +596,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -625,9 +596,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
625 args: decoded.params,596 args: decoded.params,
626 },597 },
627 providerRefs: {598 providerRefs: {
628 providerSessionId: context.session.sessionId,599 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
629 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
630 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
631 providerRequestId: String(decoded.id),600 providerRequestId: String(decoded.id),
632 },601 },
633 raw: {602 raw: {
...@@ -659,14 +628,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -659,14 +628,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
659628
660 const base = {629 const base = {
661 provider: PROVIDER,630 provider: PROVIDER,
662 sessionId: asRuntimeSessionId(context.session.sessionId),631 threadId: context.session.threadId,
663 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),632 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
664 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),633 ...(context.turnState
665 providerRefs: {634 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
666 providerSessionId: context.session.sessionId,635 : {}),
667 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
668 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
669 },
670 raw: {636 raw: {
671 source: "cursor.acp.notification" as const,637 source: "cursor.acp.notification" as const,
672 method: decoded.method,638 method: decoded.method,
...@@ -701,7 +667,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -701,7 +667,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
701 type: "content.delta",667 type: "content.delta",
702 eventId: stamp.eventId,668 eventId: stamp.eventId,
703 createdAt: stamp.createdAt,669 createdAt: stamp.createdAt,
704 turnId: asRuntimeTurnId(context.turnState.turnId),670 turnId: context.turnState.turnId,
705 itemId: asRuntimeItemId(context.turnState.assistantItemId),671 itemId: asRuntimeItemId(context.turnState.assistantItemId),
706 payload: {672 payload: {
707 streamKind: "reasoning_text",673 streamKind: "reasoning_text",
...@@ -720,7 +686,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -720,7 +686,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
720 type: "content.delta",686 type: "content.delta",
721 eventId: stamp.eventId,687 eventId: stamp.eventId,
722 createdAt: stamp.createdAt,688 createdAt: stamp.createdAt,
723 turnId: asRuntimeTurnId(context.turnState.turnId),689 turnId: context.turnState.turnId,
724 itemId: asRuntimeItemId(context.turnState.assistantItemId),690 itemId: asRuntimeItemId(context.turnState.assistantItemId),
725 payload: {691 payload: {
726 streamKind: "assistant_text",692 streamKind: "assistant_text",
...@@ -746,7 +712,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -746,7 +712,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
746 type: "item.started",712 type: "item.started",
747 eventId: stamp.eventId,713 eventId: stamp.eventId,
748 createdAt: stamp.createdAt,714 createdAt: stamp.createdAt,
749 turnId: asRuntimeTurnId(context.turnState.turnId),715 turnId: context.turnState.turnId,
750 itemId: asRuntimeItemId(update.toolCallId),716 itemId: asRuntimeItemId(update.toolCallId),
751 payload: {717 payload: {
752 itemType,718 itemType,
...@@ -765,7 +731,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -765,7 +731,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
765 type: "item.updated",731 type: "item.updated",
766 eventId: stamp.eventId,732 eventId: stamp.eventId,
767 createdAt: stamp.createdAt,733 createdAt: stamp.createdAt,
768 turnId: asRuntimeTurnId(context.turnState.turnId),734 turnId: context.turnState.turnId,
769 itemId: asRuntimeItemId(update.toolCallId),735 itemId: asRuntimeItemId(update.toolCallId),
770 payload: {736 payload: {
771 itemType,737 itemType,
...@@ -791,7 +757,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -791,7 +757,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
791 type: eventType,757 type: eventType,
792 eventId: stamp.eventId,758 eventId: stamp.eventId,
793 createdAt: stamp.createdAt,759 createdAt: stamp.createdAt,
794 turnId: asRuntimeTurnId(context.turnState.turnId),760 turnId: context.turnState.turnId,
795 itemId: asRuntimeItemId(update.toolCallId),761 itemId: asRuntimeItemId(update.toolCallId),
796 payload: {762 payload: {
797 itemType,763 itemType,
...@@ -857,10 +823,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -857,10 +823,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
857 id: EventId.makeUnsafe(randomUUID()),823 id: EventId.makeUnsafe(randomUUID()),
858 kind: nativeKind,824 kind: nativeKind,
859 provider: PROVIDER,825 provider: PROVIDER,
860 sessionId: asRuntimeSessionId(context.session.sessionId),
861 createdAt: new Date().toISOString(),826 createdAt: new Date().toISOString(),
862 method: nativeMethod,827 method: nativeMethod,
863 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),828 threadId: context.session.threadId,
864 ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}),829 ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}),
865 payload: message,830 payload: message,
866 },831 },
...@@ -956,36 +921,31 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -956,36 +921,31 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
956921
957 if (options?.emitExitEvent !== false) {922 if (options?.emitExitEvent !== false) {
958 const stamp = yield* makeEventStamp();923 const stamp = yield* makeEventStamp();
959 yield* offerRuntimeEvent({924 yield* offerRuntimeEvent({
960 type: "session.exited",925 type: "session.exited",
961 eventId: stamp.eventId,926 eventId: stamp.eventId,
962 provider: PROVIDER,927 provider: PROVIDER,
963 sessionId: asRuntimeSessionId(context.session.sessionId),928 createdAt: stamp.createdAt,
964 createdAt: stamp.createdAt,929 threadId: context.session.threadId,
965 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),930 payload: {
966 payload: {931 reason: "Session stopped",
967 reason: "Session stopped",932 exitKind: "graceful",
968 exitKind: "graceful",933 },
969 },934 });
970 providerRefs: {935 }
971 providerSessionId: context.session.sessionId,
972 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
973 },
974 });
975 }
976936
977 sessions.delete(context.session.sessionId);937 sessions.delete(context.session.threadId);
978 });938 });
979939
980 const requireSession = (940 const requireSession = (
981 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,941 threadId: ThreadId,
982 ): Effect.Effect<CursorSessionContext, ProviderAdapterError> => {942 ): Effect.Effect<CursorSessionContext, ProviderAdapterError> => {
983 const context = sessions.get(sessionId);943 const context = sessions.get(threadId);
984 if (!context) {944 if (!context) {
985 return Effect.fail(945 return Effect.fail(
986 new ProviderAdapterSessionNotFoundError({946 new ProviderAdapterSessionNotFoundError({
987 provider: PROVIDER,947 provider: PROVIDER,
988 sessionId,948 threadId,
989 }),949 }),
990 );950 );
991 }951 }
...@@ -993,7 +953,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -993,7 +953,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
993 return Effect.fail(953 return Effect.fail(
994 new ProviderAdapterSessionClosedError({954 new ProviderAdapterSessionClosedError({
995 provider: PROVIDER,955 provider: PROVIDER,
996 sessionId,956 threadId,
997 }),957 }),
998 );958 );
999 }959 }
...@@ -1011,7 +971,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1011,7 +971,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1011 }971 }
1012972
1013 const startedAt = yield* nowIso;973 const startedAt = yield* nowIso;
1014 const sessionId = ProviderSessionId.makeUnsafe(yield* Random.nextUUIDv4);
1015 const cwd = input.cwd ?? process.cwd();974 const cwd = input.cwd ?? process.cwd();
1016 const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined;975 const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined;
1017 const binaryPath = cursorOptions?.binaryPath ?? "agent";976 const binaryPath = cursorOptions?.binaryPath ?? "agent";
...@@ -1028,7 +987,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1028,7 +987,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1028 catch: (cause) =>987 catch: (cause) =>
1029 new ProviderAdapterProcessError({988 new ProviderAdapterProcessError({
1030 provider: PROVIDER,989 provider: PROVIDER,
1031 sessionId,990 threadId: input.threadId,
1032 detail: toMessage(cause, "Failed to spawn Cursor ACP process."),991 detail: toMessage(cause, "Failed to spawn Cursor ACP process."),
1033 cause,992 cause,
1034 }),993 }),
...@@ -1037,7 +996,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1037,7 +996,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1037 const output = readline.createInterface({ input: child.stdout });996 const output = readline.createInterface({ input: child.stdout });
1038997
1039 const session: ProviderSession = {998 const session: ProviderSession = {
1040 sessionId,999 threadId: input.threadId,
1041 provider: PROVIDER,1000 provider: PROVIDER,
1042 status: "connecting",1001 status: "connecting",
1043 runtimeMode: input.runtimeMode,1002 runtimeMode: input.runtimeMode,
...@@ -1085,10 +1044,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1085,10 +1044,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1085 type: "runtime.error",1044 type: "runtime.error",
1086 eventId: stamp.eventId,1045 eventId: stamp.eventId,
1087 provider: PROVIDER,1046 provider: PROVIDER,
1088 sessionId: asRuntimeSessionId(context.session.sessionId),
1089 createdAt: stamp.createdAt,1047 createdAt: stamp.createdAt,
1090 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1048 threadId: context.session.threadId,
1091 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),1049 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
1092 payload: {1050 payload: {
1093 message: error.message || "Cursor ACP process error.",1051 message: error.message || "Cursor ACP process error.",
1094 class: "transport_error",1052 class: "transport_error",
...@@ -1118,9 +1076,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1118,9 +1076,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1118 type: "session.exited",1076 type: "session.exited",
1119 eventId: stamp.eventId,1077 eventId: stamp.eventId,
1120 provider: PROVIDER,1078 provider: PROVIDER,
1121 sessionId: asRuntimeSessionId(context.session.sessionId),
1122 createdAt: stamp.createdAt,1079 createdAt: stamp.createdAt,
1123 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1080 threadId: context.session.threadId,
1124 payload: {1081 payload: {
1125 reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,1082 reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
1126 exitKind: code === 0 ? "graceful" : "error",1083 exitKind: code === 0 ? "graceful" : "error",
...@@ -1128,19 +1085,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1128,19 +1085,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1128 },1085 },
1129 });1086 });
11301087
1131 sessions.delete(context.session.sessionId);1088 sessions.delete(context.session.threadId);
1132 }),1089 }),
1133 );1090 );
1134 });1091 });
11351092
1136 sessions.set(sessionId, context);1093 sessions.set(input.threadId, context);
11371094
1138 const initializeResult = yield* Effect.tryPromise({1095 const initializeResult = yield* Effect.tryPromise({
1139 try: async () =>1096 try: async () =>
1140 sendRequest(context, "initialize", {1097 sendRequest(context, "initialize", {
1141 protocolVersion: CURSOR_ACP_PROTOCOL_VERSION,1098 protocolVersion: CURSOR_ACP_PROTOCOL_VERSION,
1142 }),1099 }),
1143 catch: (cause) => toRequestError(sessionId, "initialize", cause),1100 catch: (cause) => toRequestError(input.threadId, "initialize", cause),
1144 });1101 });
1145 const decodedInitialize = yield* Effect.try({1102 const decodedInitialize = yield* Effect.try({
1146 try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult),1103 try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult),
...@@ -1158,14 +1115,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1158,14 +1115,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1158 type: "session.configured",1115 type: "session.configured",
1159 eventId: initStamp.eventId,1116 eventId: initStamp.eventId,
1160 provider: PROVIDER,1117 provider: PROVIDER,
1161 sessionId: asRuntimeSessionId(sessionId),
1162 createdAt: initStamp.createdAt,1118 createdAt: initStamp.createdAt,
1119 threadId: input.threadId,
1163 payload: {1120 payload: {
1164 config: decodedInitialize,1121 config: decodedInitialize,
1165 },1122 },
1166 providerRefs: {
1167 providerSessionId: sessionId,
1168 },
1169 raw: {1123 raw: {
1170 source: "cursor.acp.response",1124 source: "cursor.acp.response",
1171 method: "initialize",1125 method: "initialize",
...@@ -1179,14 +1133,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1179,14 +1133,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1179 type: "auth.status",1133 type: "auth.status",
1180 eventId: authStartStamp.eventId,1134 eventId: authStartStamp.eventId,
1181 provider: PROVIDER,1135 provider: PROVIDER,
1182 sessionId: asRuntimeSessionId(sessionId),
1183 createdAt: authStartStamp.createdAt,1136 createdAt: authStartStamp.createdAt,
1137 threadId: input.threadId,
1184 payload: {1138 payload: {
1185 isAuthenticating: true,1139 isAuthenticating: true,
1186 },1140 },
1187 providerRefs: {
1188 providerSessionId: sessionId,
1189 },
1190 raw: {1141 raw: {
1191 source: "cursor.acp.request",1142 source: "cursor.acp.request",
1192 method: "authenticate",1143 method: "authenticate",
...@@ -1196,7 +1147,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1196,7 +1147,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11961147
1197 const authenticateResult = yield* Effect.tryPromise({1148 const authenticateResult = yield* Effect.tryPromise({
1198 try: async () => sendRequest(context, "authenticate", authenticateRequest),1149 try: async () => sendRequest(context, "authenticate", authenticateRequest),
1199 catch: (cause) => toRequestError(sessionId, "authenticate", cause),1150 catch: (cause) => toRequestError(input.threadId, "authenticate", cause),
1200 }).pipe(1151 }).pipe(
1201 Effect.match({1152 Effect.match({
1202 onFailure: (error) => ({ ok: false as const, error }),1153 onFailure: (error) => ({ ok: false as const, error }),
...@@ -1209,15 +1160,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1209,15 +1160,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1209 type: "auth.status",1160 type: "auth.status",
1210 eventId: authEndStamp.eventId,1161 eventId: authEndStamp.eventId,
1211 provider: PROVIDER,1162 provider: PROVIDER,
1212 sessionId: asRuntimeSessionId(sessionId),
1213 createdAt: authEndStamp.createdAt,1163 createdAt: authEndStamp.createdAt,
1164 threadId: input.threadId,
1214 payload: {1165 payload: {
1215 isAuthenticating: false,1166 isAuthenticating: false,
1216 error: toMessage(authenticateResult.error, "Cursor authentication failed."),1167 error: toMessage(authenticateResult.error, "Cursor authentication failed."),
1217 },1168 },
1218 providerRefs: {
1219 providerSessionId: sessionId,
1220 },
1221 raw: {1169 raw: {
1222 source: "cursor.acp.response",1170 source: "cursor.acp.response",
1223 method: "authenticate",1171 method: "authenticate",
...@@ -1231,14 +1179,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1231,14 +1179,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1231 type: "auth.status",1179 type: "auth.status",
1232 eventId: authEndStamp.eventId,1180 eventId: authEndStamp.eventId,
1233 provider: PROVIDER,1181 provider: PROVIDER,
1234 sessionId: asRuntimeSessionId(sessionId),
1235 createdAt: authEndStamp.createdAt,1182 createdAt: authEndStamp.createdAt,
1183 threadId: input.threadId,
1236 payload: {1184 payload: {
1237 isAuthenticating: false,1185 isAuthenticating: false,
1238 },1186 },
1239 providerRefs: {
1240 providerSessionId: sessionId,
1241 },
1242 raw: {1187 raw: {
1243 source: "cursor.acp.response",1188 source: "cursor.acp.response",
1244 method: "authenticate",1189 method: "authenticate",
...@@ -1273,15 +1218,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1273,15 +1218,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1273 const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result);1218 const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result);
1274 return decoded.sessionId;1219 return decoded.sessionId;
1275 },1220 },
1276 catch: (cause) => toRequestError(sessionId, "session/new|session/load", cause),1221 catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause),
1277 });1222 });
12781223
1279 const threadId = ProviderThreadId.makeUnsafe(acpSessionId);
1280 context.acpSessionId = acpSessionId;1224 context.acpSessionId = acpSessionId;
1281 context.session = {1225 context.session = {
1282 ...context.session,1226 ...context.session,
1283 status: "ready",1227 status: "ready",
1284 threadId,
1285 resumeCursor: {1228 resumeCursor: {
1286 acpSessionId,1229 acpSessionId,
1287 },1230 },
...@@ -1293,14 +1236,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1293,14 +1236,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1293 type: "session.started",1236 type: "session.started",
1294 eventId: sessionStartedStamp.eventId,1237 eventId: sessionStartedStamp.eventId,
1295 provider: PROVIDER,1238 provider: PROVIDER,
1296 sessionId: asRuntimeSessionId(sessionId),
1297 createdAt: sessionStartedStamp.createdAt,1239 createdAt: sessionStartedStamp.createdAt,
1298 threadId: asRuntimeThreadId(threadId),1240 threadId: input.threadId,
1299 payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {},1241 payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {},
1300 providerRefs: {
1301 providerSessionId: sessionId,
1302 providerThreadId: threadId,
1303 },
1304 });1242 });
13051243
1306 const threadStartedStamp = yield* makeEventStamp();1244 const threadStartedStamp = yield* makeEventStamp();
...@@ -1308,15 +1246,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1308,15 +1246,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1308 type: "thread.started",1246 type: "thread.started",
1309 eventId: threadStartedStamp.eventId,1247 eventId: threadStartedStamp.eventId,
1310 provider: PROVIDER,1248 provider: PROVIDER,
1311 sessionId: asRuntimeSessionId(sessionId),
1312 createdAt: threadStartedStamp.createdAt,1249 createdAt: threadStartedStamp.createdAt,
1313 threadId: asRuntimeThreadId(threadId),1250 threadId: input.threadId,
1314 payload: {1251 payload: {
1315 providerThreadId: threadId,1252 providerThreadId: acpSessionId,
1316 },
1317 providerRefs: {
1318 providerSessionId: sessionId,
1319 providerThreadId: threadId,
1320 },1253 },
1321 });1254 });
13221255
...@@ -1325,16 +1258,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1325,16 +1258,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1325 type: "session.state.changed",1258 type: "session.state.changed",
1326 eventId: readyStamp.eventId,1259 eventId: readyStamp.eventId,
1327 provider: PROVIDER,1260 provider: PROVIDER,
1328 sessionId: asRuntimeSessionId(sessionId),
1329 createdAt: readyStamp.createdAt,1261 createdAt: readyStamp.createdAt,
1330 threadId: asRuntimeThreadId(threadId),1262 threadId: input.threadId,
1331 payload: {1263 payload: {
1332 state: "ready",1264 state: "ready",
1333 },1265 },
1334 providerRefs: {
1335 providerSessionId: sessionId,
1336 providerThreadId: threadId,
1337 },
1338 });1266 });
13391267
1340 return {1268 return {
...@@ -1344,13 +1272,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1344,13 +1272,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13441272
1345 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>1273 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
1346 Effect.gen(function* () {1274 Effect.gen(function* () {
1347 const context = yield* requireSession(input.sessionId);1275 const context = yield* requireSession(input.threadId);
13481276
1349 if (context.turnState) {1277 if (context.turnState) {
1350 return yield* new ProviderAdapterValidationError({1278 return yield* new ProviderAdapterValidationError({
1351 provider: PROVIDER,1279 provider: PROVIDER,
1352 operation: "sendTurn",1280 operation: "sendTurn",
1353 issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`,1281 issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`,
1354 });1282 });
1355 }1283 }
13561284
...@@ -1363,7 +1291,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1363,7 +1291,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1363 });1291 });
1364 }1292 }
13651293
1366 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);1294 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
1367 const turnState: CursorTurnState = {1295 const turnState: CursorTurnState = {
1368 turnId,1296 turnId,
1369 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),1297 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),
...@@ -1385,18 +1313,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1385,18 +1313,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1385 type: "turn.started",1313 type: "turn.started",
1386 eventId: startedStamp.eventId,1314 eventId: startedStamp.eventId,
1387 provider: PROVIDER,1315 provider: PROVIDER,
1388 sessionId: asRuntimeSessionId(context.session.sessionId),
1389 createdAt: startedStamp.createdAt,1316 createdAt: startedStamp.createdAt,
1390 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1317 threadId: context.session.threadId,
1391 turnId: asRuntimeTurnId(turnId),1318 turnId,
1392 payload: {1319 payload: {
1393 ...(input.model ? { model: input.model } : {}),1320 ...(input.model ? { model: input.model } : {}),
1394 ...(input.effort ? { effort: input.effort } : {}),1321 ...(input.effort ? { effort: input.effort } : {}),
1395 },1322 },
1396 providerRefs: {1323 providerRefs: {
1397 providerSessionId: context.session.sessionId,1324 providerTurnId: String(turnId),
1398 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1399 providerTurnId: turnId,
1400 },1325 },
1401 });1326 });
14021327
...@@ -1406,7 +1331,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1406,7 +1331,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1406 sessionId: context.acpSessionId,1331 sessionId: context.acpSessionId,
1407 prompt: [{ type: "text", text: promptText }],1332 prompt: [{ type: "text", text: promptText }],
1408 }),1333 }),
1409 catch: (cause) => toRequestError(input.sessionId, "session/prompt", cause),1334 catch: (cause) => toRequestError(input.threadId, "session/prompt", cause),
1410 });1335 });
14111336
1412 const promptResult = yield* Effect.try({1337 const promptResult = yield* Effect.try({
...@@ -1438,7 +1363,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1438,7 +1363,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1438 };1363 };
14391364
1440 return {1365 return {
1441 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),1366 threadId: context.session.threadId,
1442 turnId,1367 turnId,
1443 ...(context.session.resumeCursor !== undefined1368 ...(context.session.resumeCursor !== undefined
1444 ? { resumeCursor: context.session.resumeCursor }1369 ? { resumeCursor: context.session.resumeCursor }
...@@ -1446,9 +1371,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1446,9 +1371,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1446 } satisfies ProviderTurnStartResult;1371 } satisfies ProviderTurnStartResult;
1447 });1372 });
14481373
1449 const interruptTurn: CursorAdapterShape["interruptTurn"] = (sessionId, _turnId) =>1374 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) =>
1450 Effect.gen(function* () {1375 Effect.gen(function* () {
1451 const context = yield* requireSession(sessionId);1376 const context = yield* requireSession(threadId);
1452 if (!context.turnState) {1377 if (!context.turnState) {
1453 return;1378 return;
1454 }1379 }
...@@ -1456,7 +1381,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1456,7 +1381,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1456 const cancelResult = yield* Effect.tryPromise({1381 const cancelResult = yield* Effect.tryPromise({
1457 try: async () =>1382 try: async () =>
1458 sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000),1383 sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000),
1459 catch: (cause) => toRequestError(sessionId, "session/cancel", cause),1384 catch: (cause) => toRequestError(threadId, "session/cancel", cause),
1460 }).pipe(1385 }).pipe(
1461 Effect.match({1386 Effect.match({
1462 onFailure: (error) => ({ ok: false as const, error }),1387 onFailure: (error) => ({ ok: false as const, error }),
...@@ -1475,11 +1400,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1475,11 +1400,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1475 yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled");1400 yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled");
1476 });1401 });
14771402
1478 const readThread: CursorAdapterShape["readThread"] = (sessionId) =>1403 const readThread: CursorAdapterShape["readThread"] = (threadId) =>
1479 Effect.gen(function* () {1404 Effect.gen(function* () {
1480 const context = yield* requireSession(sessionId);1405 const context = yield* requireSession(threadId);
1481 return {1406 return {
1482 threadId: ProviderThreadId.makeUnsafe(context.acpSessionId),1407 threadId: context.session.threadId,
1483 turns: context.turns.map((turn) => ({1408 turns: context.turns.map((turn) => ({
1484 id: turn.id,1409 id: turn.id,
1485 items: [...turn.items],1410 items: [...turn.items],
...@@ -1487,22 +1412,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1487,22 +1412,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1487 };1412 };
1488 });1413 });
14891414
1490 const rollbackThread: CursorAdapterShape["rollbackThread"] = (sessionId, _numTurns) =>1415 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) =>
1491 Effect.fail(1416 Effect.fail(
1492 new ProviderAdapterRequestError({1417 new ProviderAdapterRequestError({
1493 provider: PROVIDER,1418 provider: PROVIDER,
1494 method: "thread/rollback",1419 method: "thread/rollback",
1495 detail: `Cursor ACP does not support thread rollback for session '${sessionId}'.`,1420 detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`,
1496 }),1421 }),
1497 );1422 );
14981423
1499 const respondToRequest: CursorAdapterShape["respondToRequest"] = (1424 const respondToRequest: CursorAdapterShape["respondToRequest"] = (
1500 sessionId,1425 threadId,
1501 requestId,1426 requestId,
1502 decision,1427 decision,
1503 ) =>1428 ) =>
1504 Effect.gen(function* () {1429 Effect.gen(function* () {
1505 const context = yield* requireSession(sessionId);1430 const context = yield* requireSession(threadId);
1506 const pending = context.pendingPermissions.get(requestId);1431 const pending = context.pendingPermissions.get(requestId);
1507 if (!pending) {1432 if (!pending) {
1508 return yield* new ProviderAdapterRequestError({1433 return yield* new ProviderAdapterRequestError({
...@@ -1540,10 +1465,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1540,10 +1465,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1540 type: "request.resolved",1465 type: "request.resolved",
1541 eventId: stamp.eventId,1466 eventId: stamp.eventId,
1542 provider: PROVIDER,1467 provider: PROVIDER,
1543 sessionId: asRuntimeSessionId(context.session.sessionId),
1544 createdAt: stamp.createdAt,1468 createdAt: stamp.createdAt,
1545 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),1469 threadId: context.session.threadId,
1546 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),1470 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
1547 requestId: asRuntimeRequestId(requestId),1471 requestId: asRuntimeRequestId(requestId),
1548 payload: {1472 payload: {
1549 requestType: pending.requestType,1473 requestType: pending.requestType,
...@@ -1553,9 +1477,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1553,9 +1477,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1553 },1477 },
1554 },1478 },
1555 providerRefs: {1479 providerRefs: {
1556 providerSessionId: context.session.sessionId,1480 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1557 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1558 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1559 providerRequestId: String(pending.jsonRpcId),1481 providerRequestId: String(pending.jsonRpcId),
1560 },1482 },
1561 raw: {1483 raw: {
...@@ -1568,9 +1490,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1568,9 +1490,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1568 });1490 });
1569 });1491 });
15701492
1571 const stopSession: CursorAdapterShape["stopSession"] = (sessionId) =>1493 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
1572 Effect.gen(function* () {1494 Effect.gen(function* () {
1573 const context = yield* requireSession(sessionId);1495 const context = yield* requireSession(threadId);
1574 yield* stopSessionInternal(context, {1496 yield* stopSessionInternal(context, {
1575 emitExitEvent: true,1497 emitExitEvent: true,
1576 });1498 });
...@@ -1579,9 +1501,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1579,9 +1501,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1579 const listSessions: CursorAdapterShape["listSessions"] = () =>1501 const listSessions: CursorAdapterShape["listSessions"] = () =>
1580 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));1502 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
15811503
1582 const hasSession: CursorAdapterShape["hasSession"] = (sessionId) =>1504 const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
1583 Effect.sync(() => {1505 Effect.sync(() => {
1584 const context = sessions.get(sessionId);1506 const context = sessions.get(threadId);
1585 return context !== undefined && !context.stopping;1507 return context !== undefined && !context.stopping;
1586 });1508 });
15871509
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-1
...@@ -28,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption...@@ -28,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption
28 const adapters =28 const adapters =
29 options?.adapters !== undefined29 options?.adapters !== undefined
30 ? options.adapters30 ? options.adapters
31 : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter];31 : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter];
32 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));32 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3333
34 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {34 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderService.test.ts+79-85
...@@ -14,10 +14,8 @@ import {...@@ -14,10 +14,8 @@ import {
14 EventId,14 EventId,
15 type ProviderKind,15 type ProviderKind,
16 ProviderSessionStartInput,16 ProviderSessionStartInput,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
20 ThreadId,17 ThreadId,
18 TurnId,
21} from "@t3tools/contracts";19} from "@t3tools/contracts";
22import { it, assert, vi } from "@effect/vitest";20import { it, assert, vi } from "@effect/vitest";
23import { assertFailure } from "@effect/vitest/utils";21import { assertFailure } from "@effect/vitest/utils";
...@@ -27,8 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -27,8 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
2725
28import {26import {
29 ProviderAdapterSessionNotFoundError,27 ProviderAdapterSessionNotFoundError,
30 ProviderSessionNotFoundError,
31 ProviderUnsupportedError,28 ProviderUnsupportedError,
29 ProviderValidationError,
32 type ProviderAdapterError,30 type ProviderAdapterError,
33} from "../Errors.ts";31} from "../Errors.ts";
34import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";32import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
...@@ -45,20 +43,17 @@ import {...@@ -45,20 +43,17 @@ import {
45 SqlitePersistenceMemory,43 SqlitePersistenceMemory,
46} from "../../persistence/Layers/Sqlite.ts";44} from "../../persistence/Layers/Sqlite.ts";
4745
48const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
49const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
50const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
51const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);46const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
52const asEventId = (value: string): EventId => EventId.makeUnsafe(value);47const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
53const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);48const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
49const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
5450
55type LegacyProviderRuntimeEvent = {51type LegacyProviderRuntimeEvent = {
56 readonly type: string;52 readonly type: string;
57 readonly eventId: EventId;53 readonly eventId: EventId;
58 readonly provider: "codex" | "claudeCode" | "cursor";54 readonly provider: "codex" | "claudeCode" | "cursor";
59 readonly sessionId: string;
60 readonly createdAt: string;55 readonly createdAt: string;
61 readonly threadId?: string | undefined;56 readonly threadId: ThreadId;
62 readonly turnId?: string | undefined;57 readonly turnId?: string | undefined;
63 readonly itemId?: string | undefined;58 readonly itemId?: string | undefined;
64 readonly requestId?: string | undefined;59 readonly requestId?: string | undefined;
...@@ -67,28 +62,23 @@ type LegacyProviderRuntimeEvent = {...@@ -67,28 +62,23 @@ type LegacyProviderRuntimeEvent = {
67};62};
6863
69function makeFakeCodexAdapter(provider: ProviderKind = "codex") {64function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
70 const sessions = new Map<ProviderSessionId, ProviderSession>();65 const sessions = new Map<ThreadId, ProviderSession>();
71 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());66 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
72 let nextSession = 1;
7367
74 const startSession = vi.fn((input: ProviderSessionStartInput) =>68 const startSession = vi.fn((input: ProviderSessionStartInput) =>
75 Effect.sync(() => {69 Effect.sync(() => {
76 const now = new Date().toISOString();70 const now = new Date().toISOString();
77 const next = nextSession;
78 const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`);
79 const session: ProviderSession = {71 const session: ProviderSession = {
80 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
81 provider,72 provider,
82 status: "ready",73 status: "ready",
83 runtimeMode: input.runtimeMode,74 runtimeMode: input.runtimeMode,
84 threadId,75 threadId: input.threadId,
85 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },76 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` },
86 cwd: input.cwd ?? process.cwd(),77 cwd: input.cwd ?? process.cwd(),
87 createdAt: now,78 createdAt: now,
88 updatedAt: now,79 updatedAt: now,
89 };80 };
90 nextSession += 1;81 sessions.set(session.threadId, session);
91 sessions.set(session.sessionId, session);
92 return session;82 return session;
93 }),83 }),
94 );84 );
...@@ -97,39 +87,39 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -97,39 +87,39 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
97 (87 (
98 input: ProviderSendTurnInput,88 input: ProviderSendTurnInput,
99 ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => {89 ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => {
100 if (!sessions.has(input.sessionId)) {90 if (!sessions.has(input.threadId)) {
101 return Effect.fail(91 return Effect.fail(
102 new ProviderAdapterSessionNotFoundError({92 new ProviderAdapterSessionNotFoundError({
103 provider,93 provider,
104 sessionId: input.sessionId,94 threadId: input.threadId,
105 }),95 }),
106 );96 );
107 }97 }
10898
109 return Effect.succeed({99 return Effect.succeed({
110 threadId: ProviderThreadId.makeUnsafe("thread-1"),100 threadId: input.threadId,
111 turnId: ProviderTurnId.makeUnsafe("turn-1"),101 turnId: TurnId.makeUnsafe(`turn-${String(input.threadId)}`),
112 });102 });
113 },103 },
114 );104 );
115105
116 const interruptTurn = vi.fn(106 const interruptTurn = vi.fn(
117 (_sessionId: string, _turnId?: string): Effect.Effect<void, ProviderAdapterError> =>107 (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect<void, ProviderAdapterError> =>
118 Effect.void,108 Effect.void,
119 );109 );
120110
121 const respondToRequest = vi.fn(111 const respondToRequest = vi.fn(
122 (112 (
123 _sessionId: string,113 _threadId: ThreadId,
124 _requestId: string,114 _requestId: string,
125 _decision: ProviderApprovalDecision,115 _decision: ProviderApprovalDecision,
126 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,116 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,
127 );117 );
128118
129 const stopSession = vi.fn(119 const stopSession = vi.fn(
130 (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>120 (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>
131 Effect.sync(() => {121 Effect.sync(() => {
132 sessions.delete(ProviderSessionId.makeUnsafe(sessionId));122 sessions.delete(threadId);
133 }),123 }),
134 );124 );
135125
...@@ -139,32 +129,31 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -139,32 +129,31 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
139 );129 );
140130
141 const hasSession = vi.fn(131 const hasSession = vi.fn(
142 (sessionId: string): Effect.Effect<boolean> =>132 (threadId: ThreadId): Effect.Effect<boolean> => Effect.succeed(sessions.has(threadId)),
143 Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))),
144 );133 );
145134
146 const readThread = vi.fn(135 const readThread = vi.fn(
147 (136 (
148 _sessionId: ProviderSessionId,137 threadId: ThreadId,
149 ): Effect.Effect<138 ): Effect.Effect<
150 {139 {
151 threadId: ProviderThreadId;140 threadId: ThreadId;
152 turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>;141 turns: ReadonlyArray<{ id: TurnId; items: readonly [] }>;
153 },142 },
154 ProviderAdapterError143 ProviderAdapterError
155 > =>144 > =>
156 Effect.succeed({145 Effect.succeed({
157 threadId: ProviderThreadId.makeUnsafe("thread-1"),146 threadId,
158 turns: [{ id: asTurnId("turn-1"), items: [] }],147 turns: [{ id: asTurnId("turn-1"), items: [] }],
159 }),148 }),
160 );149 );
161150
162 const rollbackThread = vi.fn(151 const rollbackThread = vi.fn(
163 (152 (
164 _sessionId: ProviderSessionId,153 threadId: ThreadId,
165 _numTurns: number,154 _numTurns: number,
166 ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> =>155 ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> =>
167 Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }),156 Effect.succeed({ threadId, turns: [] }),
168 );157 );
169158
170 const stopAll = vi.fn(159 const stopAll = vi.fn(
...@@ -277,7 +266,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -277,7 +266,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
277 yield* Effect.gen(function* () {266 yield* Effect.gen(function* () {
278 const directory = yield* ProviderSessionDirectory;267 const directory = yield* ProviderSessionDirectory;
279 yield* directory.upsert({268 yield* directory.upsert({
280 sessionId: asSessionId("sess-stale"),
281 provider: "codex",269 provider: "codex",
282 threadId: ThreadId.makeUnsafe("thread-stale"),270 threadId: ThreadId.makeUnsafe("thread-stale"),
283 });271 });
...@@ -294,13 +282,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -294,13 +282,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
294282
295 const persistedProvider = yield* Effect.gen(function* () {283 const persistedProvider = yield* Effect.gen(function* () {
296 const directory = yield* ProviderSessionDirectory;284 const directory = yield* ProviderSessionDirectory;
297 return yield* directory.getProvider(asSessionId("sess-stale"));285 return yield* directory.getProvider(asThreadId("thread-stale"));
298 }).pipe(Effect.provide(directoryLayer));286 }).pipe(Effect.provide(directoryLayer));
299 assert.equal(persistedProvider, "codex");287 assert.equal(persistedProvider, "codex");
300288
301 const runtime = yield* Effect.gen(function* () {289 const runtime = yield* Effect.gen(function* () {
302 const repository = yield* ProviderSessionRuntimeRepository;290 const repository = yield* ProviderSessionRuntimeRepository;
303 return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") });291 return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") });
304 }).pipe(Effect.provide(runtimeRepositoryLayer));292 }).pipe(Effect.provide(runtimeRepositoryLayer));
305 assert.equal(Option.isSome(runtime), true);293 assert.equal(Option.isSome(runtime), true);
306294
...@@ -348,10 +336,12 @@ it.effect(...@@ -348,10 +336,12 @@ it.effect(
348336
349 const startedSession = yield* Effect.gen(function* () {337 const startedSession = yield* Effect.gen(function* () {
350 const provider = yield* ProviderService;338 const provider = yield* ProviderService;
351 return yield* provider.startSession(asThreadId("thread-1"), {339 const threadId = asThreadId("thread-1");
340 return yield* provider.startSession(threadId, {
352 provider: "codex",341 provider: "codex",
353 cwd: "/tmp/project",342 cwd: "/tmp/project",
354 runtimeMode: "full-access",343 runtimeMode: "full-access",
344 threadId,
355 });345 });
356 }).pipe(Effect.provide(firstProviderLayer));346 }).pipe(Effect.provide(firstProviderLayer));
357347
...@@ -362,9 +352,7 @@ it.effect(...@@ -362,9 +352,7 @@ it.effect(
362352
363 const persistedAfterStopAll = yield* Effect.gen(function* () {353 const persistedAfterStopAll = yield* Effect.gen(function* () {
364 const repository = yield* ProviderSessionRuntimeRepository;354 const repository = yield* ProviderSessionRuntimeRepository;
365 return yield* repository.getBySessionId({355 return yield* repository.getByThreadId({ threadId: startedSession.threadId });
366 providerSessionId: startedSession.sessionId,
367 });
368 }).pipe(Effect.provide(runtimeRepositoryLayer));356 }).pipe(Effect.provide(runtimeRepositoryLayer));
369 assert.equal(Option.isSome(persistedAfterStopAll), true);357 assert.equal(Option.isSome(persistedAfterStopAll), true);
370 if (Option.isSome(persistedAfterStopAll)) {358 if (Option.isSome(persistedAfterStopAll)) {
...@@ -394,7 +382,7 @@ it.effect(...@@ -394,7 +382,7 @@ it.effect(
394 yield* Effect.gen(function* () {382 yield* Effect.gen(function* () {
395 const provider = yield* ProviderService;383 const provider = yield* ProviderService;
396 yield* provider.rollbackConversation({384 yield* provider.rollbackConversation({
397 sessionId: startedSession.sessionId,385 threadId: startedSession.threadId,
398 numTurns: 1,386 numTurns: 1,
399 });387 });
400 }).pipe(Effect.provide(secondProviderLayer));388 }).pipe(Effect.provide(secondProviderLayer));
...@@ -430,6 +418,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -430,6 +418,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
430418
431 const session = yield* provider.startSession(asThreadId("thread-1"), {419 const session = yield* provider.startSession(asThreadId("thread-1"), {
432 provider: "codex",420 provider: "codex",
421 threadId: asThreadId("thread-1"),
433 cwd: "/tmp/project",422 cwd: "/tmp/project",
434 runtimeMode: "full-access",423 runtimeMode: "full-access",
435 });424 });
...@@ -439,40 +428,43 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -439,40 +428,43 @@ routing.layer("ProviderServiceLive routing", (it) => {
439 assert.equal(sessions.length, 1);428 assert.equal(sessions.length, 1);
440429
441 yield* provider.sendTurn({430 yield* provider.sendTurn({
442 sessionId: session.sessionId,431 threadId: session.threadId,
443 input: "hello",432 input: "hello",
444 attachments: [],433 attachments: [],
445 });434 });
446 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);435 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
447436
448 yield* provider.interruptTurn({ sessionId: session.sessionId });437 yield* provider.interruptTurn({ threadId: session.threadId });
449 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]);438 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);
450439
451 yield* provider.respondToRequest({440 yield* provider.respondToRequest({
452 sessionId: session.sessionId,441 threadId: session.threadId,
453 requestId: asRequestId("req-1"),442 requestId: asRequestId("req-1"),
454 decision: "accept",443 decision: "accept",
455 });444 });
456 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [445 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [
457 [session.sessionId, asRequestId("req-1"), "accept"],446 [session.threadId, asRequestId("req-1"), "accept"],
458 ]);447 ]);
459448
460 yield* provider.rollbackConversation({449 yield* provider.rollbackConversation({
461 sessionId: session.sessionId,450 threadId: session.threadId,
462 numTurns: 0,451 numTurns: 0,
463 });452 });
464453
465 yield* provider.stopSession({ sessionId: session.sessionId });454 yield* provider.stopSession({ threadId: session.threadId });
466 const sendAfterStop = yield* Effect.result(455 const sendAfterStop = yield* Effect.result(
467 provider.sendTurn({456 provider.sendTurn({
468 sessionId: session.sessionId,457 threadId: session.threadId,
469 input: "after-stop",458 input: "after-stop",
470 attachments: [],459 attachments: [],
471 }),460 }),
472 );461 );
473 assertFailure(462 assertFailure(
474 sendAfterStop,463 sendAfterStop,
475 new ProviderSessionNotFoundError({ sessionId: session.sessionId }),464 new ProviderValidationError({
465 operation: "ProviderService.sendTurn",
466 issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`,
467 }),
476 );468 );
477 }),469 }),
478 );470 );
...@@ -483,6 +475,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -483,6 +475,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
483475
484 const session = yield* provider.startSession(asThreadId("thread-claude"), {476 const session = yield* provider.startSession(asThreadId("thread-claude"), {
485 provider: "claudeCode",477 provider: "claudeCode",
478 threadId: asThreadId("thread-claude"),
486 cwd: "/tmp/project-claude",479 cwd: "/tmp/project-claude",
487 runtimeMode: "full-access",480 runtimeMode: "full-access",
488 });481 });
...@@ -505,15 +498,16 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -505,15 +498,16 @@ routing.layer("ProviderServiceLive routing", (it) => {
505498
506 const initial = yield* provider.startSession(asThreadId("thread-1"), {499 const initial = yield* provider.startSession(asThreadId("thread-1"), {
507 provider: "codex",500 provider: "codex",
501 threadId: asThreadId("thread-1"),
508 cwd: "/tmp/project",502 cwd: "/tmp/project",
509 runtimeMode: "full-access",503 runtimeMode: "full-access",
510 });504 });
511 yield* routing.codex.stopSession(initial.sessionId);505 yield* routing.codex.stopSession(initial.threadId);
512 routing.codex.startSession.mockClear();506 routing.codex.startSession.mockClear();
513 routing.codex.rollbackThread.mockClear();507 routing.codex.rollbackThread.mockClear();
514508
515 yield* provider.rollbackConversation({509 yield* provider.rollbackConversation({
516 sessionId: initial.sessionId,510 threadId: initial.threadId,
517 numTurns: 1,511 numTurns: 1,
518 });512 });
519513
...@@ -544,6 +538,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -544,6 +538,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
544538
545 const initial = yield* provider.startSession(asThreadId("thread-1"), {539 const initial = yield* provider.startSession(asThreadId("thread-1"), {
546 provider: "codex",540 provider: "codex",
541 threadId: asThreadId("thread-1"),
547 cwd: "/tmp/project-send-turn",542 cwd: "/tmp/project-send-turn",
548 runtimeMode: "full-access",543 runtimeMode: "full-access",
549 });544 });
...@@ -553,7 +548,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -553,7 +548,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
553 routing.codex.sendTurn.mockClear();548 routing.codex.sendTurn.mockClear();
554549
555 yield* provider.sendTurn({550 yield* provider.sendTurn({
556 sessionId: initial.sessionId,551 threadId: initial.threadId,
557 input: "resume",552 input: "resume",
558 attachments: [],553 attachments: [],
559 });554 });
...@@ -583,10 +578,12 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -583,10 +578,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
583578
584 yield* provider.startSession(asThreadId("thread-1"), {579 yield* provider.startSession(asThreadId("thread-1"), {
585 provider: "codex",580 provider: "codex",
581 threadId: asThreadId("thread-1"),
586 runtimeMode: "full-access",582 runtimeMode: "full-access",
587 });583 });
588 yield* provider.startSession(asThreadId("thread-2"), {584 yield* provider.startSession(asThreadId("thread-2"), {
589 provider: "codex",585 provider: "codex",
586 threadId: asThreadId("thread-2"),
590 runtimeMode: "full-access",587 runtimeMode: "full-access",
591 });588 });
592589
...@@ -604,16 +601,17 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -604,16 +601,17 @@ routing.layer("ProviderServiceLive routing", (it) => {
604601
605 const session = yield* provider.startSession(asThreadId("thread-1"), {602 const session = yield* provider.startSession(asThreadId("thread-1"), {
606 provider: "codex",603 provider: "codex",
604 threadId: asThreadId("thread-1"),
607 runtimeMode: "full-access",605 runtimeMode: "full-access",
608 });606 });
609 yield* provider.sendTurn({607 yield* provider.sendTurn({
610 sessionId: session.sessionId,608 threadId: session.threadId,
611 input: "hello",609 input: "hello",
612 attachments: [],610 attachments: [],
613 });611 });
614612
615 const runningRuntime = yield* runtimeRepository.getBySessionId({613 const runningRuntime = yield* runtimeRepository.getByThreadId({
616 providerSessionId: session.sessionId,614 threadId: session.threadId,
617 });615 });
618 assert.equal(Option.isSome(runningRuntime), true);616 assert.equal(Option.isSome(runningRuntime), true);
619 if (Option.isSome(runningRuntime)) {617 if (Option.isSome(runningRuntime)) {
...@@ -638,8 +636,8 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -638,8 +636,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
638 }636 }
639637
640 yield* provider.stopAll();638 yield* provider.stopAll();
641 const stoppedRuntime = yield* runtimeRepository.getBySessionId({639 const stoppedRuntime = yield* runtimeRepository.getByThreadId({
642 providerSessionId: session.sessionId,640 threadId: session.threadId,
643 });641 });
644 assert.equal(Option.isSome(stoppedRuntime), true);642 assert.equal(Option.isSome(stoppedRuntime), true);
645 if (Option.isSome(stoppedRuntime)) {643 if (Option.isSome(stoppedRuntime)) {
...@@ -656,6 +654,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -656,6 +654,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
656 const provider = yield* ProviderService;654 const provider = yield* ProviderService;
657 const session = yield* provider.startSession(asThreadId("thread-1"), {655 const session = yield* provider.startSession(asThreadId("thread-1"), {
658 provider: "codex",656 provider: "codex",
657 threadId: asThreadId("thread-1"),
659 runtimeMode: "full-access",658 runtimeMode: "full-access",
660 });659 });
661660
...@@ -669,9 +668,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -669,9 +668,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
669 type: "turn.completed",668 type: "turn.completed",
670 eventId: asEventId("evt-1"),669 eventId: asEventId("evt-1"),
671 provider: "codex",670 provider: "codex",
672 sessionId: session.sessionId,
673 createdAt: new Date().toISOString(),671 createdAt: new Date().toISOString(),
674 threadId: asProviderThreadId("thread-1"),672 threadId: session.threadId,
675 turnId: asTurnId("turn-1"),673 turnId: asTurnId("turn-1"),
676 status: "completed",674 status: "completed",
677 };675 };
...@@ -694,6 +692,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -694,6 +692,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
694 const provider = yield* ProviderService;692 const provider = yield* ProviderService;
695 const session = yield* provider.startSession(asThreadId("thread-seq"), {693 const session = yield* provider.startSession(asThreadId("thread-seq"), {
696 provider: "codex",694 provider: "codex",
695 threadId: asThreadId("thread-seq"),
697 runtimeMode: "full-access",696 runtimeMode: "full-access",
698 });697 });
699698
...@@ -708,9 +707,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -708,9 +707,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
708 type: "tool.started",707 type: "tool.started",
709 eventId: asEventId("evt-seq-1"),708 eventId: asEventId("evt-seq-1"),
710 provider: "codex",709 provider: "codex",
711 sessionId: session.sessionId,
712 createdAt: new Date().toISOString(),710 createdAt: new Date().toISOString(),
713 threadId: asProviderThreadId("thread-1"),711 threadId: session.threadId,
714 turnId: asTurnId("turn-1"),712 turnId: asTurnId("turn-1"),
715 toolKind: "command",713 toolKind: "command",
716 title: "Command run",714 title: "Command run",
...@@ -719,9 +717,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -719,9 +717,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
719 type: "tool.completed",717 type: "tool.completed",
720 eventId: asEventId("evt-seq-2"),718 eventId: asEventId("evt-seq-2"),
721 provider: "codex",719 provider: "codex",
722 sessionId: session.sessionId,
723 createdAt: new Date().toISOString(),720 createdAt: new Date().toISOString(),
724 threadId: asProviderThreadId("thread-1"),721 threadId: session.threadId,
725 turnId: asTurnId("turn-1"),722 turnId: asTurnId("turn-1"),
726 toolKind: "command",723 toolKind: "command",
727 title: "Command run",724 title: "Command run",
...@@ -730,9 +727,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -730,9 +727,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
730 type: "turn.completed",727 type: "turn.completed",
731 eventId: asEventId("evt-seq-3"),728 eventId: asEventId("evt-seq-3"),
732 provider: "codex",729 provider: "codex",
733 sessionId: session.sessionId,
734 createdAt: new Date().toISOString(),730 createdAt: new Date().toISOString(),
735 threadId: asProviderThreadId("thread-1"),731 threadId: session.threadId,
736 turnId: asTurnId("turn-1"),732 turnId: asTurnId("turn-1"),
737 status: "completed",733 status: "completed",
738 });734 });
...@@ -751,6 +747,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -751,6 +747,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
751 const provider = yield* ProviderService;747 const provider = yield* ProviderService;
752 const session = yield* provider.startSession(asThreadId("thread-1"), {748 const session = yield* provider.startSession(asThreadId("thread-1"), {
753 provider: "codex",749 provider: "codex",
750 threadId: asThreadId("thread-1"),
754 runtimeMode: "full-access",751 runtimeMode: "full-access",
755 });752 });
756753
...@@ -775,9 +772,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -775,9 +772,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
775 type: "tool.completed",772 type: "tool.completed",
776 eventId: asEventId("evt-ordered-1"),773 eventId: asEventId("evt-ordered-1"),
777 provider: "codex",774 provider: "codex",
778 sessionId: session.sessionId,
779 createdAt: new Date().toISOString(),775 createdAt: new Date().toISOString(),
780 threadId: asProviderThreadId("thread-1"),776 threadId: session.threadId,
781 turnId: asTurnId("turn-1"),777 turnId: asTurnId("turn-1"),
782 toolKind: "command",778 toolKind: "command",
783 title: "Command run",779 title: "Command run",
...@@ -787,9 +783,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -787,9 +783,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
787 type: "message.delta",783 type: "message.delta",
788 eventId: asEventId("evt-ordered-2"),784 eventId: asEventId("evt-ordered-2"),
789 provider: "codex",785 provider: "codex",
790 sessionId: session.sessionId,
791 createdAt: new Date().toISOString(),786 createdAt: new Date().toISOString(),
792 threadId: asProviderThreadId("thread-1"),787 threadId: session.threadId,
793 turnId: asTurnId("turn-1"),788 turnId: asTurnId("turn-1"),
794 delta: "hello",789 delta: "hello",
795 },790 },
...@@ -797,9 +792,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -797,9 +792,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
797 type: "turn.completed",792 type: "turn.completed",
798 eventId: asEventId("evt-ordered-3"),793 eventId: asEventId("evt-ordered-3"),
799 provider: "codex",794 provider: "codex",
800 sessionId: session.sessionId,
801 createdAt: new Date().toISOString(),795 createdAt: new Date().toISOString(),
802 threadId: asProviderThreadId("thread-1"),796 threadId: session.threadId,
803 turnId: asTurnId("turn-1"),797 turnId: asTurnId("turn-1"),
804 status: "completed",798 status: "completed",
805 },799 },
...@@ -854,32 +848,32 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -854,32 +848,32 @@ validation.layer("ProviderServiceLive validation", (it) => {
854 Effect.sync(() => {848 Effect.sync(() => {
855 const now = new Date().toISOString();849 const now = new Date().toISOString();
856 return {850 return {
857 sessionId: asSessionId("sess-missing-thread"),
858 provider: "codex",851 provider: "codex",
859 status: "ready",852 status: "ready",
853 threadId: input.threadId,
860 runtimeMode: input.runtimeMode,854 runtimeMode: input.runtimeMode,
861 cwd: input.cwd ?? process.cwd(),855 cwd: input.cwd ?? process.cwd(),
862 createdAt: now,856 createdAt: now,
863 updatedAt: now,857 updatedAt: now,
864 } satisfies ProviderSession;858 } satisfies ProviderSession;
865 }),859 }),
866 );860 );
867861
868 const session = yield* provider.startSession(asThreadId("thread-missing"), {862 const session = yield* provider.startSession(asThreadId("thread-missing"), {
869 provider: "codex",863 provider: "codex",
864 threadId: asThreadId("thread-missing"),
870 cwd: "/tmp/project",865 cwd: "/tmp/project",
871 runtimeMode: "full-access",866 runtimeMode: "full-access",
872 });867 });
873868
874 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));869 assert.equal(session.threadId, asThreadId("thread-missing"));
875 assert.equal(session.threadId, undefined);
876870
877 const runtime = yield* runtimeRepository.getBySessionId({871 const runtime = yield* runtimeRepository.getByThreadId({
878 providerSessionId: session.sessionId,872 threadId: session.threadId,
879 });873 });
880 assert.equal(Option.isSome(runtime), true);874 assert.equal(Option.isSome(runtime), true);
881 if (Option.isSome(runtime)) {875 if (Option.isSome(runtime)) {
882 assert.equal(runtime.value.providerThreadId, null);876 assert.equal(runtime.value.threadId, session.threadId);
883 }877 }
884 }),878 }),
885 );879 );
apps/server/src/provider/Layers/ProviderService.ts+62-255
...@@ -11,8 +11,6 @@...@@ -11,8 +11,6 @@
11 */11 */
12import {12import {
13 NonNegativeInt,13 NonNegativeInt,
14 ProviderSessionId,
15 RuntimeSessionId,
16 ThreadId,14 ThreadId,
17 ProviderInterruptTurnInput,15 ProviderInterruptTurnInput,
18 ProviderRespondToRequestInput,16 ProviderRespondToRequestInput,
...@@ -22,14 +20,14 @@ import {...@@ -22,14 +20,14 @@ import {
22 type ProviderRuntimeEvent,20 type ProviderRuntimeEvent,
23 type ProviderSession,21 type ProviderSession,
24} from "@t3tools/contracts";22} from "@t3tools/contracts";
25import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect";23import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect";
2624
27import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";25import { ProviderValidationError } from "../Errors.ts";
28import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";26import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
29import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";27import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
30import {28import {
31 ProviderSessionDirectory,29 ProviderSessionDirectory,
32 type ProviderSessionBinding,30 type ProviderRuntimeBinding,
33} from "../Services/ProviderSessionDirectory.ts";31} from "../Services/ProviderSessionDirectory.ts";
34import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";32import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3533
...@@ -39,7 +37,7 @@ export interface ProviderServiceLiveOptions {...@@ -39,7 +37,7 @@ export interface ProviderServiceLiveOptions {
39}37}
4038
41const ProviderRollbackConversationInput = Schema.Struct({39const ProviderRollbackConversationInput = Schema.Struct({
42 sessionId: ProviderSessionId,40 threadId: ThreadId,
43 numTurns: NonNegativeInt,41 numTurns: NonNegativeInt,
44});42});
4543
...@@ -96,7 +94,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u...@@ -96,7 +94,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u
96}94}
9795
98function readPersistedCwd(96function readPersistedCwd(
99 runtimePayload: ProviderSessionBinding["runtimePayload"],97 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
100): string | undefined {98): string | undefined {
101 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {99 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
102 return undefined;100 return undefined;
...@@ -121,47 +119,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -121,47 +119,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
121 const directory = yield* ProviderSessionDirectory;119 const directory = yield* ProviderSessionDirectory;
122 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();120 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
123 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();121 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
124 const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>(
125 new Map(),
126 );
127 const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId =>
128 RuntimeSessionId.makeUnsafe(sessionId);
129 const asProviderSessionId = (sessionId: RuntimeSessionId): ProviderSessionId =>
130 ProviderSessionId.makeUnsafe(sessionId);
131
132 const canonicalizeRuntimeEventSession = (
133 event: ProviderRuntimeEvent,
134 ): Effect.Effect<ProviderRuntimeEvent> =>
135 Ref.get(routedSessionAliasesRef).pipe(
136 Effect.map((aliases) => {
137 for (const [staleSessionId, liveSessionId] of aliases) {
138 if (asRuntimeSessionId(liveSessionId) === event.sessionId) {
139 return {
140 ...event,
141 sessionId: asRuntimeSessionId(staleSessionId),
142 } satisfies ProviderRuntimeEvent;
143 }
144 }
145 return event;
146 }),
147 );
148122
149 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>123 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
150 canonicalizeRuntimeEventSession(event).pipe(124 Effect.succeed(event).pipe(
151 Effect.tap((canonicalEvent) =>125 Effect.tap((canonicalEvent) =>
152 canonicalEventLogger126 canonicalEventLogger
153 ? Effect.flatMap(127 ? canonicalEventLogger.write(canonicalEvent, null)
154 Effect.catch(
155 directory.getThreadId(asProviderSessionId(canonicalEvent.sessionId)).pipe(
156 Effect.map((threadIdOption) =>
157 Option.isSome(threadIdOption) ? threadIdOption.value : null,
158 ),
159 ),
160 () => Effect.succeed<ThreadId | null>(null),
161 ),
162 (orchestrationThreadId) =>
163 canonicalEventLogger.write(canonicalEvent, orchestrationThreadId),
164 )
165 : Effect.void,128 : Effect.void,
166 ),129 ),
167 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),130 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
...@@ -172,56 +135,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -172,56 +135,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
172 session: ProviderSession,135 session: ProviderSession,
173 threadId: ThreadId,136 threadId: ThreadId,
174 ) =>137 ) =>
175 Effect.gen(function* () {138 directory.upsert({
176 const providerThreadId = session.threadId;139 threadId,
177140 provider: session.provider,
178 yield* directory.upsert({141 runtimeMode: session.runtimeMode,
179 sessionId: session.sessionId,142 status: toRuntimeStatus(session),
180 provider: session.provider,143 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
181 threadId,144 runtimePayload: toRuntimePayloadFromSession(session),
182 ...(providerThreadId !== undefined ? { providerThreadId } : {}),
183 runtimeMode: session.runtimeMode,
184 status: toRuntimeStatus(session),
185 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
186 runtimePayload: toRuntimePayloadFromSession(session),
187 });
188
189 return providerThreadId;
190 });
191
192 const clearAliasKey = (staleSessionId: ProviderSessionId) =>
193 Ref.update(routedSessionAliasesRef, (current) => {
194 if (!current.has(staleSessionId)) {
195 return current;
196 }
197 const next = new Map(current);
198 next.delete(staleSessionId);
199 return next;
200 });
201
202 const clearAliasesReferencing = (sessionId: ProviderSessionId) =>
203 Ref.update(routedSessionAliasesRef, (current) => {
204 let changed = false;
205 const next = new Map<ProviderSessionId, ProviderSessionId>();
206 for (const [key, value] of current) {
207 if (key === sessionId || value === sessionId) {
208 changed = true;
209 continue;
210 }
211 next.set(key, value);
212 }
213 return changed ? next : current;
214 });
215
216 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>
217 Ref.update(routedSessionAliasesRef, (current) => {
218 const existing = current.get(staleSessionId);
219 if (existing === liveSessionId) {
220 return current;
221 }
222 const next = new Map(current);
223 next.set(staleSessionId, liveSessionId);
224 return next;
225 });145 });
226146
227 const providers = yield* registry.listProviders();147 const providers = yield* registry.listProviders();
...@@ -244,166 +164,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -244,166 +164,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
244 ).pipe(Effect.asVoid);164 ).pipe(Effect.asVoid);
245165
246 const recoverSessionForThread = (input: {166 const recoverSessionForThread = (input: {
247 readonly staleSessionId: ProviderSessionId;167 readonly binding: ProviderRuntimeBinding;
248 readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId };
249 readonly operation: string;168 readonly operation: string;
250 }) =>169 }) =>
251 Effect.gen(function* () {170 Effect.gen(function* () {
252 const adapter = yield* registry.getByProvider(input.binding.provider);171 const adapter = yield* registry.getByProvider(input.binding.provider);
253 const activeSessions = yield* adapter.listSessions();
254 const resumeThreadId = input.binding.providerThreadId ?? undefined;
255 const hasResumeCursor =172 const hasResumeCursor =
256 input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined;173 input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined;
257 const existing =174 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);
258 resumeThreadId === undefined175 if (hasActiveSession) {
259 ? undefined176 const activeSessions = yield* adapter.listSessions();
260 : activeSessions.find((session) => session.threadId === resumeThreadId);177 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);
261 if (existing) {178 if (existing) {
262 const existingProviderThreadId = yield* upsertSessionBinding(179 yield* upsertSessionBinding(existing, input.binding.threadId);
263 existing,180 return { adapter, session: existing } as const;
264 input.binding.threadId,
265 );
266 yield* directory.upsert({
267 sessionId: input.staleSessionId,
268 provider: existing.provider,
269 threadId: input.binding.threadId,
270 ...(existingProviderThreadId !== undefined
271 ? { providerThreadId: existingProviderThreadId }
272 : {}),
273 ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}),
274 });
275 if (existing.sessionId !== input.staleSessionId) {
276 yield* setAlias(input.staleSessionId, existing.sessionId);
277 } else {
278 yield* clearAliasKey(input.staleSessionId);
279 }181 }
280 return {
281 adapter,
282 sessionId: existing.sessionId,
283 } as const;
284 }182 }
285183
286 if (!hasResumeCursor) {184 if (!hasResumeCursor) {
287 return yield* toValidationError(185 return yield* toValidationError(
288 input.operation,186 input.operation,
289 `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`,187 `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`,
290 );188 );
291 }189 }
292190
293 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);191 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
294192
295 const resumed = yield* adapter.startSession({193 const resumed = yield* adapter.startSession({
194 threadId: input.binding.threadId,
296 provider: input.binding.provider,195 provider: input.binding.provider,
297 ...(persistedCwd ? { cwd: persistedCwd } : {}),196 ...(persistedCwd ? { cwd: persistedCwd } : {}),
298 ...(resumeThreadId ? { resumeThreadId } : {}),
299 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),197 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
300 runtimeMode: input.binding.runtimeMode ?? "full-access",198 runtimeMode: input.binding.runtimeMode ?? "full-access",
301 });199 });
302 if (resumed.provider !== adapter.provider) {200 if (resumed.provider !== adapter.provider) {
303 return yield* toValidationError(201 return yield* toValidationError(
304 input.operation,202 input.operation,
305 `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`,203 `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`,
306 );204 );
307 }205 }
308206
309 const resumedProviderThreadId = yield* upsertSessionBinding(207 yield* upsertSessionBinding(resumed, input.binding.threadId);
310 resumed,208 return { adapter, session: resumed } as const;
311 input.binding.threadId,
312 );
313
314 yield* directory.upsert({
315 sessionId: input.staleSessionId,
316 provider: resumed.provider,
317 threadId: input.binding.threadId,
318 ...(resumedProviderThreadId !== undefined
319 ? { providerThreadId: resumedProviderThreadId }
320 : {}),
321 ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}),
322 });
323
324 if (resumed.sessionId !== input.staleSessionId) {
325 yield* setAlias(input.staleSessionId, resumed.sessionId);
326 } else {
327 yield* clearAliasKey(input.staleSessionId);
328 }
329
330 return {
331 adapter,
332 sessionId: resumed.sessionId,
333 } as const;
334 });209 });
335210
336 const resolveRoutableSession = (input: {211 const resolveRoutableSession = (input: {
337 readonly sessionId: ProviderSessionId;212 readonly threadId: ThreadId;
338 readonly operation: string;213 readonly operation: string;
339 readonly allowRecovery: boolean;214 readonly allowRecovery: boolean;
340 }) =>215 }) =>
341 Effect.gen(function* () {216 Effect.gen(function* () {
342 const bindingOption = yield* directory.getBinding(input.sessionId);217 const bindingOption = yield* directory.getBinding(input.threadId);
343 const binding = Option.getOrUndefined(bindingOption);218 const binding = Option.getOrUndefined(bindingOption);
344 if (!binding) {219 if (!binding) {
345 return yield* new ProviderSessionNotFoundError({
346 sessionId: input.sessionId,
347 });
348 }
349 if (!binding.threadId) {
350 return yield* toValidationError(220 return yield* toValidationError(
351 input.operation,221 input.operation,
352 `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`,222 `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`,
353 );223 );
354 }224 }
355 const bindingWithThreadId: ProviderSessionBinding & {
356 readonly threadId: ThreadId;
357 } = {
358 ...binding,
359 threadId: binding.threadId,
360 };
361 const adapter = yield* registry.getByProvider(binding.provider);225 const adapter = yield* registry.getByProvider(binding.provider);
362226
363 const hasRequestedSession = yield* adapter.hasSession(input.sessionId);227 const hasRequestedSession = yield* adapter.hasSession(input.threadId);
364 if (hasRequestedSession) {228 if (hasRequestedSession) {
365 yield* clearAliasKey(input.sessionId);229 return { adapter, threadId: input.threadId, isActive: true } as const;
366 return {
367 adapter,
368 sessionId: input.sessionId,
369 isActive: true,
370 } as const;
371 }
372
373 const alias = yield* Ref.get(routedSessionAliasesRef).pipe(
374 Effect.map((aliases) => aliases.get(input.sessionId)),
375 );
376 if (alias) {
377 const aliasIsActive = yield* adapter.hasSession(alias);
378 if (aliasIsActive) {
379 return {
380 adapter,
381 sessionId: alias,
382 isActive: true,
383 } as const;
384 }
385 yield* clearAliasKey(input.sessionId);
386 }230 }
387231
388 if (!input.allowRecovery) {232 if (!input.allowRecovery) {
389 return {233 return { adapter, threadId: input.threadId, isActive: false } as const;
390 adapter,
391 sessionId: input.sessionId,
392 isActive: false,
393 } as const;
394 }234 }
395235
396 const recovered = yield* recoverSessionForThread({236 const recovered = yield* recoverSessionForThread({ binding, operation: input.operation });
397 staleSessionId: input.sessionId,237 return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const;
398 binding: bindingWithThreadId,
399 operation: input.operation,
400 });
401
402 return {
403 adapter: recovered.adapter,
404 sessionId: recovered.sessionId,
405 isActive: true,
406 } as const;
407 });238 });
408239
409 const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) =>240 const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) =>
...@@ -416,6 +247,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -416,6 +247,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
416247
417 const input = {248 const input = {
418 ...parsed,249 ...parsed,
250 threadId,
419 provider: parsed.provider ?? "codex",251 provider: parsed.provider ?? "codex",
420 };252 };
421 const adapter = yield* registry.getByProvider(input.provider);253 const adapter = yield* registry.getByProvider(input.provider);
...@@ -452,28 +284,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -452,28 +284,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
452 );284 );
453 }285 }
454 const routed = yield* resolveRoutableSession({286 const routed = yield* resolveRoutableSession({
455 sessionId: input.sessionId,287 threadId: input.threadId,
456 operation: "ProviderService.sendTurn",288 operation: "ProviderService.sendTurn",
457 allowRecovery: true,289 allowRecovery: true,
458 });290 });
459 const turn = yield* routed.adapter.sendTurn({291 const turn = yield* routed.adapter.sendTurn(input);
460 ...input,
461 sessionId: routed.sessionId,
462 });
463 const threadId = yield* directory
464 .getThreadId(input.sessionId)
465 .pipe(Effect.map(Option.getOrUndefined));
466 if (!threadId) {
467 return yield* toValidationError(
468 "ProviderService.sendTurn",
469 `No thread id is tracked for provider session '${input.sessionId}'.`,
470 );
471 }
472 yield* directory.upsert({292 yield* directory.upsert({
473 sessionId: input.sessionId,293 threadId: input.threadId,
474 provider: routed.adapter.provider,294 provider: routed.adapter.provider,
475 threadId,
476 ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}),
477 status: "running",295 status: "running",
478 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),296 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
479 runtimePayload: {297 runtimePayload: {
...@@ -493,11 +311,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -493,11 +311,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
493 payload: rawInput,311 payload: rawInput,
494 });312 });
495 const routed = yield* resolveRoutableSession({313 const routed = yield* resolveRoutableSession({
496 sessionId: input.sessionId,314 threadId: input.threadId,
497 operation: "ProviderService.interruptTurn",315 operation: "ProviderService.interruptTurn",
498 allowRecovery: true,316 allowRecovery: true,
499 });317 });
500 yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId);318 yield* routed.adapter.interruptTurn(routed.threadId, input.turnId);
501 });319 });
502320
503 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>321 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
...@@ -508,11 +326,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -508,11 +326,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
508 payload: rawInput,326 payload: rawInput,
509 });327 });
510 const routed = yield* resolveRoutableSession({328 const routed = yield* resolveRoutableSession({
511 sessionId: input.sessionId,329 threadId: input.threadId,
512 operation: "ProviderService.respondToRequest",330 operation: "ProviderService.respondToRequest",
513 allowRecovery: true,331 allowRecovery: true,
514 });332 });
515 yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision);333 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);
516 });334 });
517335
518 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>336 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
...@@ -523,19 +341,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -523,19 +341,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
523 payload: rawInput,341 payload: rawInput,
524 });342 });
525 const routed = yield* resolveRoutableSession({343 const routed = yield* resolveRoutableSession({
526 sessionId: input.sessionId,344 threadId: input.threadId,
527 operation: "ProviderService.stopSession",345 operation: "ProviderService.stopSession",
528 allowRecovery: false,346 allowRecovery: false,
529 });347 });
530 if (routed.isActive) {348 if (routed.isActive) {
531 yield* routed.adapter.stopSession(routed.sessionId);349 yield* routed.adapter.stopSession(routed.threadId);
532 }
533 if (routed.sessionId !== input.sessionId) {
534 yield* directory.remove(routed.sessionId);
535 yield* clearAliasesReferencing(routed.sessionId);
536 }350 }
537 yield* directory.remove(input.sessionId);351 yield* directory.remove(input.threadId);
538 yield* clearAliasesReferencing(input.sessionId);
539 });352 });
540353
541 const listSessions: ProviderServiceShape["listSessions"] = () =>354 const listSessions: ProviderServiceShape["listSessions"] = () =>
...@@ -543,39 +356,36 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -543,39 +356,36 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
543 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());356 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());
544 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);357 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
545 const persistedBindings = yield* directory358 const persistedBindings = yield* directory
546 .listSessionIds()359 .listThreadIds()
547 .pipe(360 .pipe(
548 Effect.flatMap((sessionIds) =>361 Effect.flatMap((threadIds) =>
549 Effect.forEach(362 Effect.forEach(
550 sessionIds,363 threadIds,
551 (sessionId) =>364 (threadId) =>
552 directory.getBinding(sessionId).pipe(365 directory.getBinding(threadId).pipe(
553 Effect.orElseSucceed(() => Option.none<ProviderSessionBinding>()),366 Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>()),
554 ),367 ),
555 { concurrency: "unbounded" },368 { concurrency: "unbounded" },
556 ),369 ),
557 ),370 ),
558 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>),371 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
559 );372 );
560 const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>();373 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();
561 for (const bindingOption of persistedBindings) {374 for (const bindingOption of persistedBindings) {
562 const binding = Option.getOrUndefined(bindingOption);375 const binding = Option.getOrUndefined(bindingOption);
563 if (binding) {376 if (binding) {
564 bindingsBySessionId.set(binding.sessionId, binding);377 bindingsByThreadId.set(binding.threadId, binding);
565 }378 }
566 }379 }
567380
568 return activeSessions.map((session) => {381 return activeSessions.map((session) => {
569 const binding = bindingsBySessionId.get(session.sessionId);382 const binding = bindingsByThreadId.get(session.threadId);
570 if (!binding) {383 if (!binding) {
571 return session;384 return session;
572 }385 }
573386
574 return {387 return {
575 ...session,388 ...session,
576 ...(session.threadId === undefined && binding.providerThreadId
577 ? { threadId: binding.providerThreadId }
578 : {}),
579 ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined389 ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined
580 ? { resumeCursor: binding.resumeCursor }390 ? { resumeCursor: binding.resumeCursor }
581 : {}),391 : {}),
...@@ -598,22 +408,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -598,22 +408,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
598 return;408 return;
599 }409 }
600 const routed = yield* resolveRoutableSession({410 const routed = yield* resolveRoutableSession({
601 sessionId: input.sessionId,411 threadId: input.threadId,
602 operation: "ProviderService.rollbackConversation",412 operation: "ProviderService.rollbackConversation",
603 allowRecovery: true,413 allowRecovery: true,
604 });414 });
605 yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns);415 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);
606 });416 });
607417
608 const stopAll: ProviderServiceShape["stopAll"] = () =>418 const stopAll: ProviderServiceShape["stopAll"] = () =>
609 Effect.gen(function* () {419 Effect.gen(function* () {
610 const sessionIds = yield* directory.listSessionIds();420 const threadIds = yield* directory.listThreadIds();
611 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);421 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
612 yield* Effect.forEach(sessionIds, (sessionId) =>422 yield* Effect.forEach(threadIds, (threadId) =>
613 directory.getProvider(sessionId).pipe(423 directory.getProvider(threadId).pipe(
614 Effect.flatMap((provider) =>424 Effect.flatMap((provider) =>
615 directory.upsert({425 directory.upsert({
616 sessionId,426 threadId,
617 provider,427 provider,
618 status: "stopped",428 status: "stopped",
619 runtimePayload: {429 runtimePayload: {
...@@ -625,9 +435,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -625,9 +435,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
625 ),435 ),
626 ),436 ),
627 ).pipe(Effect.asVoid);437 ).pipe(Effect.asVoid);
628 // Keep persisted session bindings so stale sessions can be resumed after
629 // process restart via providerThreadId.
630 yield* Ref.set(routedSessionAliasesRef, new Map());
631 });438 });
632439
633 return {440 return {
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+44-91
...@@ -3,7 +3,7 @@ import os from "node:os";...@@ -3,7 +3,7 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts";6import { ThreadId } from "@t3tools/contracts";
7import { it, assert } from "@effect/vitest";7import { it, assert } from "@effect/vitest";
8import { assertFailure, assertSome } from "@effect/vitest/utils";8import { assertFailure, assertSome } from "@effect/vitest/utils";
9import { Effect, Layer, Option } from "effect";9import { Effect, Layer, Option } from "effect";
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15} from "../../persistence/Layers/Sqlite.ts";15} from "../../persistence/Layers/Sqlite.ts";
16import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";16import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
17import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";17import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
18import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";18import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts";
19import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";19import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
20import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";20import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
2121
...@@ -31,74 +31,59 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli...@@ -31,74 +31,59 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
31}31}
3232
33it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {33it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {
34 it("upserts, reads, and removes session bindings", () =>34 it("upserts, reads, and removes thread bindings", () =>
35 Effect.gen(function* () {35 Effect.gen(function* () {
36 const directory = yield* ProviderSessionDirectory;36 const directory = yield* ProviderSessionDirectory;
37 const runtimeRepository = yield* ProviderSessionRuntimeRepository;37 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
3838
39 const sessionId = ProviderSessionId.makeUnsafe("sess-1");
40 const initialThreadId = ThreadId.makeUnsafe("thread-1");39 const initialThreadId = ThreadId.makeUnsafe("thread-1");
4140
42 yield* directory.upsert({41 yield* directory.upsert({
43 sessionId,
44 provider: "codex",42 provider: "codex",
45 threadId: initialThreadId,43 threadId: initialThreadId,
46 });44 });
4745
48 const provider = yield* directory.getProvider(sessionId);46 const provider = yield* directory.getProvider(initialThreadId);
49 assert.equal(provider, "codex");47 assert.equal(provider, "codex");
50 const resolvedThreadId = yield* directory.getThreadId(sessionId);48 const resolvedBinding = yield* directory.getBinding(initialThreadId);
51 assertSome(resolvedThreadId, initialThreadId);49 assertSome(resolvedBinding, {
50 threadId: initialThreadId,
51 provider: "codex",
52 });
53 if (Option.isSome(resolvedBinding)) {
54 assert.equal(resolvedBinding.value.threadId, initialThreadId);
55 }
5256
53 const nextThreadId = ThreadId.makeUnsafe("thread-2");57 const nextThreadId = ThreadId.makeUnsafe("thread-2");
5458
55 yield* directory.upsert({59 yield* directory.upsert({
56 sessionId: sessionId,
57 provider: "codex",60 provider: "codex",
58 threadId: nextThreadId,61 threadId: nextThreadId,
59 });62 });
60 const updatedThreadId = yield* directory.getThreadId(sessionId);63 const updatedBinding = yield* directory.getBinding(nextThreadId);
61 assertSome(updatedThreadId, nextThreadId);
62 const updatedBinding = yield* directory.getBinding(sessionId);
63 assert.equal(Option.isSome(updatedBinding), true);64 assert.equal(Option.isSome(updatedBinding), true);
64 if (Option.isSome(updatedBinding)) {65 if (Option.isSome(updatedBinding)) {
65 assert.equal(updatedBinding.value.threadId, nextThreadId);66 assert.equal(updatedBinding.value.threadId, nextThreadId);
66 assert.equal(updatedBinding.value.providerThreadId, null);
67 }67 }
6868
69 const runtime = yield* runtimeRepository.getBySessionId({69 const runtime = yield* runtimeRepository.getByThreadId({ threadId: nextThreadId });
70 providerSessionId: sessionId,
71 });
72 assert.equal(Option.isSome(runtime), true);70 assert.equal(Option.isSome(runtime), true);
73 if (Option.isSome(runtime)) {71 if (Option.isSome(runtime)) {
74 assert.equal(runtime.value.threadId, nextThreadId);72 assert.equal(runtime.value.threadId, nextThreadId);
75 assert.equal(runtime.value.providerThreadId, null);
76 assert.equal(runtime.value.status, "running");73 assert.equal(runtime.value.status, "running");
77 assert.equal(runtime.value.adapterKey, "codex");74 assert.equal(runtime.value.providerName, "codex");
78 }75 }
7976
80 const sessionIds = yield* directory.listSessionIds();77 const threadIds = yield* directory.listThreadIds();
81 assert.deepEqual(sessionIds, [sessionId]);78 assert.deepEqual(threadIds, [nextThreadId]);
8279
83 yield* directory.remove(sessionId);80 yield* directory.remove(nextThreadId);
84 const missingProvider = yield* directory.getProvider(sessionId).pipe(Effect.result);81 const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result);
85 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
86 }));
87
88 it("fails upsert when thread id is unavailable", () =>
89 Effect.gen(function* () {
90 const directory = yield* ProviderSessionDirectory;
91 const result = yield* Effect.result(
92 directory.upsert({
93 sessionId: ProviderSessionId.makeUnsafe("sess-no-thread"),
94 provider: "codex",
95 }),
96 );
97 assertFailure(82 assertFailure(
98 result,83 missingProvider,
99 new ProviderValidationError({84 new ProviderSessionDirectoryPersistenceError({
100 operation: "ProviderSessionDirectory.upsert",85 operation: "ProviderSessionDirectory.getProvider",
101 issue: "threadId must be a non-empty string.",86 detail: `No persisted provider binding found for thread '${nextThreadId}'.`,
102 }),87 }),
103 );88 );
104 }));89 }));
...@@ -108,15 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -108,15 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
108 const directory = yield* ProviderSessionDirectory;93 const directory = yield* ProviderSessionDirectory;
109 const runtimeRepository = yield* ProviderSessionRuntimeRepository;94 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
11095
111 const sessionId = ProviderSessionId.makeUnsafe("sess-runtime");
112 const threadId = ThreadId.makeUnsafe("thread-runtime");96 const threadId = ThreadId.makeUnsafe("thread-runtime");
113 const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime");
11497
115 yield* directory.upsert({98 yield* directory.upsert({
116 sessionId,
117 provider: "codex",99 provider: "codex",
118 threadId,100 threadId,
119 providerThreadId,
120 status: "starting",101 status: "starting",
121 resumeCursor: {102 resumeCursor: {
122 threadId: "provider-thread-runtime",103 threadId: "provider-thread-runtime",
...@@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
128 });109 });
129110
130 yield* directory.upsert({111 yield* directory.upsert({
131 sessionId,
132 provider: "codex",112 provider: "codex",
113 threadId,
133 status: "running",114 status: "running",
134 runtimePayload: {115 runtimePayload: {
135 activeTurnId: "turn-1",116 activeTurnId: "turn-1",
136 },117 },
137 });118 });
138119
139 const runtime = yield* runtimeRepository.getBySessionId({120 const runtime = yield* runtimeRepository.getByThreadId({ threadId });
140 providerSessionId: sessionId,
141 });
142 assert.equal(Option.isSome(runtime), true);121 assert.equal(Option.isSome(runtime), true);
143 if (Option.isSome(runtime)) {122 if (Option.isSome(runtime)) {
144 assert.equal(runtime.value.threadId, threadId);123 assert.equal(runtime.value.threadId, threadId);
145 assert.equal(runtime.value.providerThreadId, providerThreadId);
146 assert.equal(runtime.value.status, "running");124 assert.equal(runtime.value.status, "running");
147 assert.deepEqual(runtime.value.resumeCursor, {125 assert.deepEqual(runtime.value.resumeCursor, {
148 threadId: providerThreadId,126 threadId: "provider-thread-runtime",
149 });127 });
150 assert.deepEqual(runtime.value.runtimePayload, {128 assert.deepEqual(runtime.value.runtimePayload, {
151 cwd: "/tmp/project",129 cwd: "/tmp/project",
...@@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
155 }133 }
156 }));134 }));
157135
158 it("clears providerThreadId when explicitly set to null", () =>
159 Effect.gen(function* () {
160 const directory = yield* ProviderSessionDirectory;
161 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
162
163 const sessionId = ProviderSessionId.makeUnsafe("sess-clear-provider-thread-id");
164 const threadId = ThreadId.makeUnsafe("thread-clear-provider-thread-id");
165 const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-to-clear");
166
167 yield* directory.upsert({
168 sessionId,
169 provider: "codex",
170 threadId,
171 adapterKey: "custom-adapter",
172 providerThreadId,
173 });
174
175 yield* directory.upsert({
176 sessionId,
177 provider: "codex",
178 providerThreadId: null,
179 });
180
181 const runtime = yield* runtimeRepository.getBySessionId({
182 providerSessionId: sessionId,
183 });
184 assert.equal(Option.isSome(runtime), true);
185 if (Option.isSome(runtime)) {
186 assert.equal(runtime.value.providerThreadId, null);
187 assert.equal(runtime.value.adapterKey, "custom-adapter");
188 }
189 }));
190
191 it("rehydrates persisted mappings across layer restart", () =>136 it("rehydrates persisted mappings across layer restart", () =>
192 Effect.gen(function* () {137 Effect.gen(function* () {
193 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));138 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));
194 const dbPath = path.join(tempDir, "orchestration.sqlite");139 const dbPath = path.join(tempDir, "orchestration.sqlite");
195 const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath));140 const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath));
196141
197 const sessionId = ProviderSessionId.makeUnsafe("sess-restart");
198 const threadId = ThreadId.makeUnsafe("thread-restart");142 const threadId = ThreadId.makeUnsafe("thread-restart");
199143
200 yield* Effect.gen(function* () {144 yield* Effect.gen(function* () {
201 const directory = yield* ProviderSessionDirectory;145 const directory = yield* ProviderSessionDirectory;
202 yield* directory.upsert({146 yield* directory.upsert({
203 sessionId,
204 provider: "codex",147 provider: "codex",
205 threadId,148 threadId,
206 });149 });
...@@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
209 yield* Effect.gen(function* () {152 yield* Effect.gen(function* () {
210 const directory = yield* ProviderSessionDirectory;153 const directory = yield* ProviderSessionDirectory;
211 const sql = yield* SqlClient.SqlClient;154 const sql = yield* SqlClient.SqlClient;
212 const provider = yield* directory.getProvider(sessionId);155 const provider = yield* directory.getProvider(threadId);
213 assert.equal(provider, "codex");156 assert.equal(provider, "codex");
214157
215 const resolvedThreadId = yield* directory.getThreadId(sessionId);158 const resolvedBinding = yield* directory.getBinding(threadId);
216 assertSome(resolvedThreadId, threadId);159 assertSome(resolvedBinding, {
160 threadId,
161 provider: "codex",
162 });
163 if (Option.isSome(resolvedBinding)) {
164 assert.equal(resolvedBinding.value.threadId, threadId);
165 }
217166
218 const legacyTableRows = yield* sql<{ readonly name: string }>`167 const legacyTableRows = yield* sql<{ readonly name: string }>`
219 SELECT name168 SELECT name
...@@ -229,18 +178,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -229,18 +178,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
229 it("accepts cursor provider bindings", () =>178 it("accepts cursor provider bindings", () =>
230 Effect.gen(function* () {179 Effect.gen(function* () {
231 const directory = yield* ProviderSessionDirectory;180 const directory = yield* ProviderSessionDirectory;
232 const sessionId = ProviderSessionId.makeUnsafe("sess-cursor");
233 const threadId = ThreadId.makeUnsafe("thread-cursor");181 const threadId = ThreadId.makeUnsafe("thread-cursor");
234182
235 yield* directory.upsert({183 yield* directory.upsert({
236 sessionId,
237 provider: "cursor",184 provider: "cursor",
238 threadId,185 threadId,
239 });186 });
240187
241 const provider = yield* directory.getProvider(sessionId);188 const provider = yield* directory.getProvider(threadId);
242 assert.equal(provider, "cursor");189 assert.equal(provider, "cursor");
243 const resolvedThreadId = yield* directory.getThreadId(sessionId);190 const resolvedBinding = yield* directory.getBinding(threadId);
244 assertSome(resolvedThreadId, threadId);191 assertSome(resolvedBinding, {
192 threadId,
193 provider: "cursor",
194 });
195 if (Option.isSome(resolvedBinding)) {
196 assert.equal(resolvedBinding.value.threadId, threadId);
197 }
245 }));198 }));
246});199});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+27-64
...@@ -1,15 +1,14 @@...@@ -1,15 +1,14 @@
1import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts";1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
33
4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import {5import {
6 ProviderSessionDirectoryPersistenceError,6 ProviderSessionDirectoryPersistenceError,
7 ProviderSessionNotFoundError,
8 ProviderValidationError,7 ProviderValidationError,
9} from "../Errors.ts";8} from "../Errors.ts";
10import {9import {
11 ProviderSessionDirectory,10 ProviderSessionDirectory,
12 type ProviderSessionBinding,11 type ProviderRuntimeBinding,
13 type ProviderSessionDirectoryShape,12 type ProviderSessionDirectoryShape,
14} from "../Services/ProviderSessionDirectory.ts";13} from "../Services/ProviderSessionDirectory.ts";
1514
...@@ -49,10 +48,7 @@ function mergeRuntimePayload(...@@ -49,10 +48,7 @@ function mergeRuntimePayload(
49 return existing ?? null;48 return existing ?? null;
50 }49 }
51 if (isRecord(existing) && isRecord(next)) {50 if (isRecord(existing) && isRecord(next)) {
52 return {51 return { ...existing, ...next };
53 ...existing,
54 ...next,
55 };
56 }52 }
57 return next;53 return next;
58}54}
...@@ -60,21 +56,19 @@ function mergeRuntimePayload(...@@ -60,21 +56,19 @@ function mergeRuntimePayload(
60const makeProviderSessionDirectory = Effect.gen(function* () {56const makeProviderSessionDirectory = Effect.gen(function* () {
61 const repository = yield* ProviderSessionRuntimeRepository;57 const repository = yield* ProviderSessionRuntimeRepository;
6258
63 const getBinding = (sessionId: ProviderSessionId) =>59 const getBinding = (threadId: ThreadId) =>
64 repository.getBySessionId({ providerSessionId: sessionId }).pipe(60 repository.getByThreadId({ threadId }).pipe(
65 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),61 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")),
66 Effect.flatMap((runtime) =>62 Effect.flatMap((runtime) =>
67 Option.match(runtime, {63 Option.match(runtime, {
68 onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()),64 onNone: () => Effect.succeed(Option.none<ProviderRuntimeBinding>()),
69 onSome: (value) =>65 onSome: (value) =>
70 decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe(66 decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe(
71 Effect.map((provider) =>67 Effect.map((provider) =>
72 Option.some({68 Option.some({
73 sessionId: value.providerSessionId,
74 provider,
75 threadId: value.threadId,69 threadId: value.threadId,
70 provider,
76 adapterKey: value.adapterKey,71 adapterKey: value.adapterKey,
77 providerThreadId: value.providerThreadId,
78 runtimeMode: value.runtimeMode,72 runtimeMode: value.runtimeMode,
79 status: value.status,73 status: value.status,
80 resumeCursor: value.resumeCursor,74 resumeCursor: value.resumeCursor,
...@@ -88,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -88,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
8882
89 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {83 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {
90 const existing = yield* repository84 const existing = yield* repository
91 .getBySessionId({85 .getByThreadId({ threadId: binding.threadId })
92 providerSessionId: binding.sessionId,86 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")));
93 })
94 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")));
9587
96 const existingRuntime = Option.getOrUndefined(existing);88 const existingRuntime = Option.getOrUndefined(existing);
97 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;89 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
...@@ -105,14 +97,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -105,14 +97,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
105 const now = new Date().toISOString();97 const now = new Date().toISOString();
106 yield* repository98 yield* repository
107 .upsert({99 .upsert({
108 providerSessionId: binding.sessionId,
109 threadId: resolvedThreadId,100 threadId: resolvedThreadId,
110 providerName: binding.provider,101 providerName: binding.provider,
111 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,102 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,
112 providerThreadId:
113 binding.providerThreadId !== undefined
114 ? binding.providerThreadId
115 : (existingRuntime?.providerThreadId ?? null),
116 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",103 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
117 status: binding.status ?? existingRuntime?.status ?? "running",104 status: binding.status ?? existingRuntime?.status ?? "running",
118 lastSeenAt: now,105 lastSeenAt: now,
...@@ -128,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -128,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
128 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));115 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
129 });116 });
130117
131 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) =>118 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) =>
132 getBinding(sessionId).pipe(119 getBinding(threadId).pipe(
133 Effect.flatMap((binding) =>120 Effect.flatMap((binding) =>
134 Option.match(binding, {121 Option.match(binding, {
135 onSome: (value) => Effect.succeed(value.provider),122 onSome: (value) => Effect.succeed(value.provider),
136 onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })),123 onNone: () =>
137 }),124 Effect.fail(
138 ),125 new ProviderSessionDirectoryPersistenceError({
139 );126 operation: "ProviderSessionDirectory.getProvider",
140127 detail: `No persisted provider binding found for thread '${threadId}'.`,
141 const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) =>128 }),
142 getBinding(sessionId);129 ),
143
144 const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) =>
145 getBinding(sessionId).pipe(
146 Effect.flatMap((binding) =>
147 Option.match(binding, {
148 onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)),
149 onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })),
150 }),130 }),
151 ),131 ),
152 );132 );
153133
154 const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) =>134 const remove: ProviderSessionDirectoryShape["remove"] = (threadId) =>
155 repository135 repository
156 .deleteBySessionId({ providerSessionId: sessionId })136 .deleteByThreadId({ threadId })
157 .pipe(137 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")));
158 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
159 );
160138
161 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>139 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
162 repository.list().pipe(140 repository.list().pipe(
163 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),141 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),
164 Effect.flatMap((rows) =>142 Effect.map((rows) => rows.map((row) => row.threadId)),
165 Effect.forEach(
166 rows,
167 (row) =>
168 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(
169 Effect.map((provider) => ({
170 sessionId: row.providerSessionId,
171 provider,
172 threadId: row.threadId,
173 })),
174 ),
175 { concurrency: "unbounded" },
176 ),
177 ),
178 Effect.map((bindings) => bindings.map((binding) => binding.sessionId)),
179 );143 );
180144
181 return {145 return {
182 upsert,146 upsert,
183 getProvider,147 getProvider,
184 getBinding: getBindingBySessionId,148 getBinding,
185 getThreadId,
186 remove,149 remove,
187 listSessionIds,150 listThreadIds,
188 } satisfies ProviderSessionDirectoryShape;151 } satisfies ProviderSessionDirectoryShape;
189});152});
190153
apps/server/src/provider/Services/ProviderAdapter.ts+11-12
...@@ -14,11 +14,10 @@ import type {...@@ -14,11 +14,10 @@ import type {
14 ProviderRuntimeEvent,14 ProviderRuntimeEvent,
15 ProviderSendTurnInput,15 ProviderSendTurnInput,
16 ProviderSession,16 ProviderSession,
17 ProviderSessionId,
18 ProviderSessionStartInput,17 ProviderSessionStartInput,
19 ProviderThreadId,18 ThreadId,
20 ProviderTurnId,
21 ProviderTurnStartResult,19 ProviderTurnStartResult,
20 TurnId,
22} from "@t3tools/contracts";21} from "@t3tools/contracts";
23import type { Effect } from "effect";22import type { Effect } from "effect";
24import type { Stream } from "effect";23import type { Stream } from "effect";
...@@ -33,12 +32,12 @@ export interface ProviderAdapterCapabilities {...@@ -33,12 +32,12 @@ export interface ProviderAdapterCapabilities {
33}32}
3433
35export interface ProviderThreadTurnSnapshot {34export interface ProviderThreadTurnSnapshot {
36 readonly id: ProviderTurnId;35 readonly id: TurnId;
37 readonly items: ReadonlyArray<unknown>;36 readonly items: ReadonlyArray<unknown>;
38}37}
3938
40export interface ProviderThreadSnapshot {39export interface ProviderThreadSnapshot {
41 readonly threadId: ProviderThreadId;40 readonly threadId: ThreadId;
42 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;41 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
43}42}
4443
...@@ -67,15 +66,15 @@ export interface ProviderAdapterShape<TError> {...@@ -67,15 +66,15 @@ export interface ProviderAdapterShape<TError> {
67 * Interrupt an active turn.66 * Interrupt an active turn.
68 */67 */
69 readonly interruptTurn: (68 readonly interruptTurn: (
70 sessionId: ProviderSessionId,69 threadId: ThreadId,
71 turnId?: ProviderTurnId,70 turnId?: TurnId,
72 ) => Effect.Effect<void, TError>;71 ) => Effect.Effect<void, TError>;
7372
74 /**73 /**
75 * Respond to an interactive approval request.74 * Respond to an interactive approval request.
76 */75 */
77 readonly respondToRequest: (76 readonly respondToRequest: (
78 sessionId: ProviderSessionId,77 threadId: ThreadId,
79 requestId: ApprovalRequestId,78 requestId: ApprovalRequestId,
80 decision: ProviderApprovalDecision,79 decision: ProviderApprovalDecision,
81 ) => Effect.Effect<void, TError>;80 ) => Effect.Effect<void, TError>;
...@@ -83,7 +82,7 @@ export interface ProviderAdapterShape<TError> {...@@ -83,7 +82,7 @@ export interface ProviderAdapterShape<TError> {
83 /**82 /**
84 * Stop one provider session.83 * Stop one provider session.
85 */84 */
86 readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>;85 readonly stopSession: (threadId: ThreadId) => Effect.Effect<void, TError>;
8786
88 /**87 /**
89 * List currently active provider sessions for this adapter.88 * List currently active provider sessions for this adapter.
...@@ -93,20 +92,20 @@ export interface ProviderAdapterShape<TError> {...@@ -93,20 +92,20 @@ export interface ProviderAdapterShape<TError> {
93 /**92 /**
94 * Check whether this adapter owns an active session id.93 * Check whether this adapter owns an active session id.
95 */94 */
96 readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>;95 readonly hasSession: (threadId: ThreadId) => Effect.Effect<boolean>;
9796
98 /**97 /**
99 * Read a provider thread snapshot.98 * Read a provider thread snapshot.
100 */99 */
101 readonly readThread: (100 readonly readThread: (
102 sessionId: ProviderSessionId,101 threadId: ThreadId,
103 ) => Effect.Effect<ProviderThreadSnapshot, TError>;102 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
104103
105 /**104 /**
106 * Roll back a provider thread by N turns.105 * Roll back a provider thread by N turns.
107 */106 */
108 readonly rollbackThread: (107 readonly rollbackThread: (
109 sessionId: ProviderSessionId,108 threadId: ThreadId,
110 numTurns: number,109 numTurns: number,
111 ) => Effect.Effect<ProviderThreadSnapshot, TError>;110 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
112111
apps/server/src/provider/Services/ProviderService.ts+1-2
...@@ -18,7 +18,6 @@ import type {...@@ -18,7 +18,6 @@ import type {
18 ProviderRuntimeEvent,18 ProviderRuntimeEvent,
19 ProviderSendTurnInput,19 ProviderSendTurnInput,
20 ProviderSession,20 ProviderSession,
21 ProviderSessionId,
22 ProviderSessionStartInput,21 ProviderSessionStartInput,
23 ProviderStopSessionInput,22 ProviderStopSessionInput,
24 ThreadId,23 ThreadId,
...@@ -88,7 +87,7 @@ export interface ProviderServiceShape {...@@ -88,7 +87,7 @@ export interface ProviderServiceShape {
88 * Roll back provider conversation state by a number of turns.87 * Roll back provider conversation state by a number of turns.
89 */88 */
90 readonly rollbackConversation: (input: {89 readonly rollbackConversation: (input: {
91 readonly sessionId: ProviderSessionId;90 readonly threadId: ThreadId;
92 readonly numTurns: number;91 readonly numTurns: number;
93 }) => Effect.Effect<void, ProviderServiceError>;92 }) => Effect.Effect<void, ProviderServiceError>;
9493
apps/server/src/provider/Services/ProviderSessionDirectory.ts+10-57
...@@ -1,17 +1,6 @@...@@ -1,17 +1,6 @@
1/**
2 * ProviderSessionDirectory - Session ownership index across provider adapters.
3 *
4 * Tracks which provider owns each `sessionId` so `ProviderService` can route
5 * session-scoped calls to the correct adapter. It is metadata only and does not
6 * perform provider RPC or checkpoint operations.
7 *
8 * @module ProviderSessionDirectory
9 */
10import type {1import type {
11 ProviderKind,2 ProviderKind,
12 ProviderSessionId,
13 ProviderSessionRuntimeStatus,3 ProviderSessionRuntimeStatus,
14 ProviderThreadId,
15 RuntimeMode,4 RuntimeMode,
16 ThreadId,5 ThreadId,
17} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -20,84 +9,48 @@ import type { Effect } from "effect";...@@ -20,84 +9,48 @@ import type { Effect } from "effect";
209
21import type {10import type {
22 ProviderSessionDirectoryPersistenceError,11 ProviderSessionDirectoryPersistenceError,
23 ProviderSessionNotFoundError,
24 ProviderValidationError,12 ProviderValidationError,
25} from "../Errors.ts";13} from "../Errors.ts";
2614
27export interface ProviderSessionBinding {15export interface ProviderRuntimeBinding {
28 readonly sessionId: ProviderSessionId;16 readonly threadId: ThreadId;
29 readonly provider: ProviderKind;17 readonly provider: ProviderKind;
30 readonly threadId?: ThreadId;
31 readonly adapterKey?: string;18 readonly adapterKey?: string;
32 readonly providerThreadId?: ProviderThreadId | null;
33 readonly status?: ProviderSessionRuntimeStatus;19 readonly status?: ProviderSessionRuntimeStatus;
34 readonly resumeCursor?: unknown | null;20 readonly resumeCursor?: unknown | null;
35 readonly runtimePayload?: unknown | null;21 readonly runtimePayload?: unknown | null;
36 readonly runtimeMode?: RuntimeMode;22 readonly runtimeMode?: RuntimeMode;
37}23}
3824
39export type ProviderSessionDirectoryReadError =25export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError;
40 | ProviderSessionNotFoundError
41 | ProviderSessionDirectoryPersistenceError;
4226
43export type ProviderSessionDirectoryWriteError =27export type ProviderSessionDirectoryWriteError =
44 | ProviderValidationError28 | ProviderValidationError
45 | ProviderSessionDirectoryPersistenceError;29 | ProviderSessionDirectoryPersistenceError;
4630
47/**
48 * ProviderSessionDirectoryShape - Service API for provider session ownership metadata.
49 */
50export interface ProviderSessionDirectoryShape {31export interface ProviderSessionDirectoryShape {
51 /**
52 * Record or update ownership for one provider session.
53 *
54 * Preserves existing persisted fields when omitted and shallow-merges
55 * runtime payload objects.
56 */
57 readonly upsert: (32 readonly upsert: (
58 binding: ProviderSessionBinding,33 binding: ProviderRuntimeBinding,
59 ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;34 ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;
6035
61 /**
62 * Resolve the provider owner for a session id.
63 */
64 readonly getProvider: (36 readonly getProvider: (
65 sessionId: ProviderSessionId,37 threadId: ThreadId,
66 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;38 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
6739
68 /**
69 * Resolve the full tracked binding for a session id.
70 */
71 readonly getBinding: (40 readonly getBinding: (
72 sessionId: ProviderSessionId,41 threadId: ThreadId,
73 ) => Effect.Effect<Option.Option<ProviderSessionBinding>, ProviderSessionDirectoryReadError>;42 ) => Effect.Effect<Option.Option<ProviderRuntimeBinding>, ProviderSessionDirectoryReadError>;
74
75 /**
76 * Resolve the tracked thread id for a session, if known.
77 */
78 readonly getThreadId: (
79 sessionId: ProviderSessionId,
80 ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>;
8143
82 /**
83 * Remove a session binding.
84 */
85 readonly remove: (44 readonly remove: (
86 sessionId: ProviderSessionId,45 threadId: ThreadId,
87 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;46 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;
8847
89 /**48 readonly listThreadIds: () => Effect.Effect<
90 * List tracked session ids.49 ReadonlyArray<ThreadId>,
91 */
92 readonly listSessionIds: () => Effect.Effect<
93 ReadonlyArray<ProviderSessionId>,
94 ProviderSessionDirectoryPersistenceError50 ProviderSessionDirectoryPersistenceError
95 >;51 >;
96}52}
9753
98/**
99 * ProviderSessionDirectory - Service tag for session ownership lookup.
100 */
101export class ProviderSessionDirectory extends ServiceMap.Service<54export class ProviderSessionDirectory extends ServiceMap.Service<
102 ProviderSessionDirectory,55 ProviderSessionDirectory,
103 ProviderSessionDirectoryShape56 ProviderSessionDirectoryShape
apps/server/src/serverLayers.ts+9-7
...@@ -58,14 +58,16 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -58,14 +58,16 @@ export function makeServerProviderLayer(): Layer.Layer<
58 const codexAdapterLayer = makeCodexAdapterLive(58 const codexAdapterLayer = makeCodexAdapterLive(
59 nativeEventLogger ? { nativeEventLogger } : undefined,59 nativeEventLogger ? { nativeEventLogger } : undefined,
60 );60 );
61 const claudeAdapterLayer = makeClaudeCodeAdapterLive({61 const claudeAdapterLayer = makeClaudeCodeAdapterLive(
62 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),62 nativeEventLogger ? { nativeEventLogger } : undefined,
63 });63 );
64 const cursorAdapterLayer = makeCursorAdapterLive({64 const cursorAdapterLayer = makeCursorAdapterLive(
65 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),65 nativeEventLogger ? { nativeEventLogger } : undefined,
66 });66 );
67 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(67 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
68 Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer, cursorAdapterLayer)),68 Layer.provide(codexAdapterLayer),
69 Layer.provide(claudeAdapterLayer),
70 Layer.provide(cursorAdapterLayer),
69 Layer.provideMerge(providerSessionDirectoryLayer),71 Layer.provideMerge(providerSessionDirectoryLayer),
70 );72 );
71 return makeProviderServiceLive(73 return makeProviderServiceLive(
apps/server/src/wsServer.test.ts+11-16
...@@ -18,9 +18,8 @@ import {...@@ -18,9 +18,8 @@ import {
18 ORCHESTRATION_WS_CHANNELS,18 ORCHESTRATION_WS_CHANNELS,
19 ORCHESTRATION_WS_METHODS,19 ORCHESTRATION_WS_METHODS,
20 ProviderItemId,20 ProviderItemId,
21 ProviderSessionId,21 ThreadId,
22 ProviderThreadId,22 TurnId,
23 ProviderTurnId,
24 WS_CHANNELS,23 WS_CHANNELS,
25 WS_METHODS,24 WS_METHODS,
26 type WebSocketResponse,25 type WebSocketResponse,
...@@ -60,11 +59,9 @@ interface PendingMessages {...@@ -60,11 +59,9 @@ interface PendingMessages {
60const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();59const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
6160
62const asEventId = (value: string): EventId => EventId.makeUnsafe(value);61const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
63const asProviderSessionId = (value: string): ProviderSessionId =>
64 ProviderSessionId.makeUnsafe(value);
65const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
66const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
67const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);62const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
63const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
64const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
6865
69const defaultOpenService: OpenShape = {66const defaultOpenService: OpenShape = {
70 openBrowser: () => Effect.void,67 openBrowser: () => Effect.void,
...@@ -1146,26 +1143,24 @@ describe("WebSocket Server", () => {...@@ -1146,26 +1143,24 @@ describe("WebSocket Server", () => {
11461143
1147 it("keeps orchestration domain push behavior for provider runtime events", async () => {1144 it("keeps orchestration domain push behavior for provider runtime events", async () => {
1148 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());1145 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
1149 const sessionId = asProviderSessionId("sess-test");
1150 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {1146 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
1151 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));1147 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
1152 };1148 };
1153 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;1149 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
1154 const providerService: ProviderServiceShape = {1150 const providerService: ProviderServiceShape = {
1155 startSession: () =>1151 startSession: (threadId) =>
1156 Effect.succeed({1152 Effect.succeed({
1157 sessionId,
1158 provider: "codex",1153 provider: "codex",
1159 status: "ready",1154 status: "ready",
1160 runtimeMode: "full-access",1155 runtimeMode: "full-access",
1161 threadId: asProviderThreadId("provider-thread-1"),1156 threadId,
1162 createdAt: new Date().toISOString(),1157 createdAt: new Date().toISOString(),
1163 updatedAt: new Date().toISOString(),1158 updatedAt: new Date().toISOString(),
1164 }),1159 }),
1165 sendTurn: () =>1160 sendTurn: ({ threadId }) =>
1166 Effect.succeed({1161 Effect.succeed({
1167 threadId: asProviderThreadId("provider-thread-1"),1162 threadId,
1168 turnId: asProviderTurnId("provider-turn-1"),1163 turnId: asTurnId("provider-turn-1"),
1169 }),1164 }),
1170 interruptTurn: () => unsupported(),1165 interruptTurn: () => unsupported(),
1171 respondToRequest: () => unsupported(),1166 respondToRequest: () => unsupported(),
...@@ -1239,9 +1234,9 @@ describe("WebSocket Server", () => {...@@ -1239,9 +1234,9 @@ describe("WebSocket Server", () => {
1239 type: "content.delta",1234 type: "content.delta",
1240 eventId: asEventId("evt-ws-runtime-message-delta"),1235 eventId: asEventId("evt-ws-runtime-message-delta"),
1241 provider: "codex",1236 provider: "codex",
1242 sessionId,1237 threadId: asThreadId("thread-1"),
1243 createdAt: new Date().toISOString(),1238 createdAt: new Date().toISOString(),
1244 turnId: asProviderTurnId("turn-1"),1239 turnId: asTurnId("turn-1"),
1245 itemId: asProviderItemId("item-1"),1240 itemId: asProviderItemId("item-1"),
1246 payload: {1241 payload: {
1247 streamKind: "assistant_text",1242 streamKind: "assistant_text",
apps/web/src/components/BranchToolbar.tsx+2-3
...@@ -52,8 +52,7 @@ export default function BranchToolbar({...@@ -52,8 +52,7 @@ export default function BranchToolbar({
52 const api = readNativeApi();52 const api = readNativeApi();
53 // If the effective cwd is about to change, stop the running session so the53 // If the effective cwd is about to change, stop the running session so the
54 // next message creates a new one with the correct cwd.54 // next message creates a new one with the correct cwd.
55 const sessionId = serverThread?.session?.sessionId;55 if (serverThread?.session && worktreePath !== activeWorktreePath && api) {
56 if (sessionId && worktreePath !== activeWorktreePath && api) {
57 void api.orchestration56 void api.orchestration
58 .dispatchCommand({57 .dispatchCommand({
59 type: "thread.session.stop",58 type: "thread.session.stop",
...@@ -89,7 +88,7 @@ export default function BranchToolbar({...@@ -89,7 +88,7 @@ export default function BranchToolbar({
89 },88 },
90 [89 [
91 activeThreadId,90 activeThreadId,
92 serverThread?.session?.sessionId,91 serverThread?.session,
93 activeWorktreePath,92 activeWorktreePath,
94 hasServerThread,93 hasServerThread,
95 setThreadBranchAction,94 setThreadBranchAction,
apps/web/src/components/ChatView.tsx+2
...@@ -3324,6 +3324,8 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({...@@ -3324,6 +3324,8 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
3324 <AlertTitle className="text-xs">3324 <AlertTitle className="text-xs">
3325 {approval.requestKind === "command"3325 {approval.requestKind === "command"
3326 ? "Command approval requested"3326 ? "Command approval requested"
3327 : approval.requestKind === "file-read"
3328 ? "File-read approval requested"
3327 : "File-change approval requested"}3329 : "File-change approval requested"}
3328 </AlertTitle>3330 </AlertTitle>
3329 <AlertDescription3331 <AlertDescription
apps/web/src/session-logic.ts+8-3
...@@ -28,7 +28,7 @@ export interface WorkLogEntry {...@@ -28,7 +28,7 @@ export interface WorkLogEntry {
2828
29export interface PendingApproval {29export interface PendingApproval {
30 requestId: ApprovalRequestId;30 requestId: ApprovalRequestId;
31 requestKind: "command" | "file-change";31 requestKind: "command" | "file-read" | "file-change";
32 createdAt: string;32 createdAt: string;
33 detail?: string;33 detail?: string;
34}34}
...@@ -95,6 +95,8 @@ function requestKindFromRequestType(...@@ -95,6 +95,8 @@ function requestKindFromRequestType(
95 case "command_execution_approval":95 case "command_execution_approval":
96 case "exec_command_approval":96 case "exec_command_approval":
97 return "command";97 return "command";
98 case "file_read_approval":
99 return "file-read";
98 case "file_change_approval":100 case "file_change_approval":
99 case "apply_patch_approval":101 case "apply_patch_approval":
100 return "file-change";102 return "file-change";
...@@ -119,11 +121,14 @@ export function derivePendingApprovals(...@@ -119,11 +121,14 @@ export function derivePendingApprovals(
119 ? ApprovalRequestId.makeUnsafe(payload.requestId)121 ? ApprovalRequestId.makeUnsafe(payload.requestId)
120 : null;122 : null;
121 const requestKind =123 const requestKind =
122 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")124 payload &&
125 (payload.requestKind === "command" ||
126 payload.requestKind === "file-read" ||
127 payload.requestKind === "file-change")
123 ? payload.requestKind128 ? payload.requestKind
124 : payload129 : payload
125 ? requestKindFromRequestType(payload.requestType)130 ? requestKindFromRequestType(payload.requestType)
126 : null;131 : null;
127 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;132 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
128133
129 if (activity.kind === "approval.requested" && requestId && requestKind) {134 if (activity.kind === "approval.requested" && requestId && requestKind) {
apps/web/src/store.test.ts-4
...@@ -147,8 +147,6 @@ describe("store read model sync", () => {...@@ -147,8 +147,6 @@ describe("store read model sync", () => {
147 threadId: ThreadId.makeUnsafe("thread-1"),147 threadId: ThreadId.makeUnsafe("thread-1"),
148 status: "ready",148 status: "ready",
149 providerName: "claudeCode",149 providerName: "claudeCode",
150 providerSessionId: null,
151 providerThreadId: null,
152 runtimeMode: "approval-required",150 runtimeMode: "approval-required",
153 activeTurnId: null,151 activeTurnId: null,
154 lastError: null,152 lastError: null,
...@@ -171,8 +169,6 @@ describe("store read model sync", () => {...@@ -171,8 +169,6 @@ describe("store read model sync", () => {
171 threadId: ThreadId.makeUnsafe("thread-1"),169 threadId: ThreadId.makeUnsafe("thread-1"),
172 status: "ready",170 status: "ready",
173 providerName: "cursor",171 providerName: "cursor",
174 providerSessionId: null,
175 providerThreadId: null,
176 runtimeMode: "approval-required",172 runtimeMode: "approval-required",
177 activeTurnId: null,173 activeTurnId: null,
178 lastError: null,174 lastError: null,
apps/web/src/store.ts+1-6
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 getModelOptions,4 getModelOptions,
5 normalizeModelSlug,5 normalizeModelSlug,
6 type ProviderKind,6 type ProviderKind,
7 ProviderSessionId,
8 ThreadId,7 ThreadId,
9 type OrchestrationReadModel,8 type OrchestrationReadModel,
10 type OrchestrationSessionStatus,9 type OrchestrationSessionStatus,
...@@ -247,7 +246,7 @@ export function syncServerReadModel(...@@ -247,7 +246,7 @@ export function syncServerReadModel(
247 const existing = existingThreadById.get(thread.id);246 const existing = existingThreadById.get(thread.id);
248 return {247 return {
249 id: thread.id,248 id: thread.id,
250 codexThreadId: thread.session?.providerThreadId ?? null,249 codexThreadId: null,
251 projectId: thread.projectId,250 projectId: thread.projectId,
252 title: thread.title,251 title: thread.title,
253 model: resolveModelSlugForProvider(252 model: resolveModelSlugForProvider(
...@@ -260,13 +259,9 @@ export function syncServerReadModel(...@@ -260,13 +259,9 @@ export function syncServerReadModel(
260 runtimeMode: thread.runtimeMode,259 runtimeMode: thread.runtimeMode,
261 session: thread.session260 session: thread.session
262 ? {261 ? {
263 sessionId:
264 thread.session.providerSessionId ??
265 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
266 provider: toLegacyProvider(thread.session.providerName),262 provider: toLegacyProvider(thread.session.providerName),
267 status: toLegacySessionStatus(thread.session.status),263 status: toLegacySessionStatus(thread.session.status),
268 orchestrationStatus: thread.session.status,264 orchestrationStatus: thread.session.status,
269 threadId: thread.session.providerThreadId,
270 activeTurnId: thread.session.activeTurnId ?? undefined,265 activeTurnId: thread.session.activeTurnId ?? undefined,
271 createdAt: thread.session.updatedAt,266 createdAt: thread.session.updatedAt,
272 updatedAt: thread.session.updatedAt,267 updatedAt: thread.session.updatedAt,
apps/web/src/types.ts+1-5
...@@ -8,8 +8,6 @@ import type {...@@ -8,8 +8,6 @@ import type {
8 TurnId,8 TurnId,
9 MessageId,9 MessageId,
10 CheckpointRef,10 CheckpointRef,
11 ProviderThreadId,
12 ProviderSessionId,
13 ProviderKind,11 ProviderKind,
14} from "@t3tools/contracts";12} from "@t3tools/contracts";
1513
...@@ -75,7 +73,7 @@ export interface Project {...@@ -75,7 +73,7 @@ export interface Project {
7573
76export interface Thread {74export interface Thread {
77 id: ThreadId;75 id: ThreadId;
78 codexThreadId: ProviderThreadId | null;76 codexThreadId: string | null;
79 projectId: ProjectId;77 projectId: ProjectId;
80 title: string;78 title: string;
81 model: string;79 model: string;
...@@ -93,10 +91,8 @@ export interface Thread {...@@ -93,10 +91,8 @@ export interface Thread {
93}91}
9492
95export interface ThreadSession {93export interface ThreadSession {
96 sessionId: ProviderSessionId;
97 provider: ProviderKind;94 provider: ProviderKind;
98 status: SessionPhase | "error" | "closed";95 status: SessionPhase | "error" | "closed";
99 threadId: ProviderThreadId | null;
100 activeTurnId?: TurnId | undefined;96 activeTurnId?: TurnId | undefined;
101 createdAt: string;97 createdAt: string;
102 updatedAt: string;98 updatedAt: string;
packages/contracts/src/baseSchemas.ts-6
...@@ -28,12 +28,6 @@ export type MessageId = typeof MessageId.Type;...@@ -28,12 +28,6 @@ export type MessageId = typeof MessageId.Type;
28export const TurnId = makeEntityId("TurnId");28export const TurnId = makeEntityId("TurnId");
29export type TurnId = typeof TurnId.Type;29export type TurnId = typeof TurnId.Type;
3030
31export const ProviderSessionId = makeEntityId("ProviderSessionId");
32export type ProviderSessionId = typeof ProviderSessionId.Type;
33export const ProviderThreadId = makeEntityId("ProviderThreadId");
34export type ProviderThreadId = typeof ProviderThreadId.Type;
35export const ProviderTurnId = makeEntityId("ProviderTurnId");
36export type ProviderTurnId = typeof ProviderTurnId.Type;
37export const ProviderItemId = makeEntityId("ProviderItemId");31export const ProviderItemId = makeEntityId("ProviderItemId");
38export type ProviderItemId = typeof ProviderItemId.Type;32export type ProviderItemId = typeof ProviderItemId.Type;
39export const RuntimeSessionId = makeEntityId("RuntimeSessionId");33export const RuntimeSessionId = makeEntityId("RuntimeSessionId");
packages/contracts/src/orchestration.ts+2-9
...@@ -9,9 +9,6 @@ import {...@@ -9,9 +9,6 @@ import {
9 NonNegativeInt,9 NonNegativeInt,
10 ProjectId,10 ProjectId,
11 ProviderItemId,11 ProviderItemId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,12 ThreadId,
16 TrimmedNonEmptyString,13 TrimmedNonEmptyString,
17 TurnId,14 TurnId,
...@@ -35,7 +32,7 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";...@@ -35,7 +32,7 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
35export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);32export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
36export type RuntimeMode = typeof RuntimeMode.Type;33export type RuntimeMode = typeof RuntimeMode.Type;
37export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";34export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
38export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);35export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);
39export type ProviderRequestKind = typeof ProviderRequestKind.Type;36export type ProviderRequestKind = typeof ProviderRequestKind.Type;
40export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);37export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
41export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;38export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
...@@ -149,8 +146,6 @@ export const OrchestrationSession = Schema.Struct({...@@ -149,8 +146,6 @@ export const OrchestrationSession = Schema.Struct({
149 threadId: ThreadId,146 threadId: ThreadId,
150 status: OrchestrationSessionStatus,147 status: OrchestrationSessionStatus,
151 providerName: Schema.NullOr(TrimmedNonEmptyString),148 providerName: Schema.NullOr(TrimmedNonEmptyString),
152 providerSessionId: Schema.NullOr(ProviderSessionId),
153 providerThreadId: Schema.NullOr(ProviderThreadId),
154 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),149 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
155 activeTurnId: Schema.NullOr(TurnId),150 activeTurnId: Schema.NullOr(TurnId),
156 lastError: Schema.NullOr(TrimmedNonEmptyString),151 lastError: Schema.NullOr(TrimmedNonEmptyString),
...@@ -637,9 +632,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({...@@ -637,9 +632,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({
637});632});
638633
639export const OrchestrationEventMetadata = Schema.Struct({634export const OrchestrationEventMetadata = Schema.Struct({
640 providerSessionId: Schema.optional(ProviderSessionId),635 providerTurnId: Schema.optional(TrimmedNonEmptyString),
641 providerThreadId: Schema.optional(ProviderThreadId),
642 providerTurnId: Schema.optional(ProviderTurnId),
643 providerItemId: Schema.optional(ProviderItemId),636 providerItemId: Schema.optional(ProviderItemId),
644 adapterKey: Schema.optional(TrimmedNonEmptyString),637 adapterKey: Schema.optional(TrimmedNonEmptyString),
645 requestId: Schema.optional(ApprovalRequestId),638 requestId: Schema.optional(ApprovalRequestId),
packages/contracts/src/provider.ts+16-19
...@@ -6,10 +6,8 @@ import {...@@ -6,10 +6,8 @@ import {
6 IsoDateTime,6 IsoDateTime,
7 NonNegativeInt,7 NonNegativeInt,
8 ProviderItemId,8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ThreadId,9 ThreadId,
10 TurnId,
13} from "./baseSchemas";11} from "./baseSchemas";
14import {12import {
15 ChatAttachment,13 ChatAttachment,
...@@ -34,15 +32,14 @@ export const ProviderSessionStatus = Schema.Literals([...@@ -34,15 +32,14 @@ export const ProviderSessionStatus = Schema.Literals([
34export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;32export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;
3533
36export const ProviderSession = Schema.Struct({34export const ProviderSession = Schema.Struct({
37 sessionId: ProviderSessionId,
38 provider: ProviderKind,35 provider: ProviderKind,
39 status: ProviderSessionStatus,36 status: ProviderSessionStatus,
40 runtimeMode: RuntimeMode,37 runtimeMode: RuntimeMode,
41 cwd: Schema.optional(TrimmedNonEmptyStringSchema),38 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
42 model: Schema.optional(TrimmedNonEmptyStringSchema),39 model: Schema.optional(TrimmedNonEmptyStringSchema),
43 threadId: Schema.optional(ProviderThreadId),40 threadId: ThreadId,
44 resumeCursor: Schema.optional(Schema.Unknown),41 resumeCursor: Schema.optional(Schema.Unknown),
45 activeTurnId: Schema.optional(ProviderTurnId),42 activeTurnId: Schema.optional(TurnId),
46 createdAt: IsoDateTime,43 createdAt: IsoDateTime,
47 updatedAt: IsoDateTime,44 updatedAt: IsoDateTime,
48 lastError: Schema.optional(TrimmedNonEmptyStringSchema),45 lastError: Schema.optional(TrimmedNonEmptyStringSchema),
...@@ -75,6 +72,7 @@ export const ProviderStartOptions = Schema.Struct({...@@ -75,6 +72,7 @@ export const ProviderStartOptions = Schema.Struct({
75export type ProviderStartOptions = typeof ProviderStartOptions.Type;72export type ProviderStartOptions = typeof ProviderStartOptions.Type;
7673
77export const ProviderSessionStartInput = Schema.Struct({74export const ProviderSessionStartInput = Schema.Struct({
75 threadId: ThreadId,
78 provider: Schema.optional(ProviderKind),76 provider: Schema.optional(ProviderKind),
79 cwd: Schema.optional(TrimmedNonEmptyStringSchema),77 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
80 model: Schema.optional(TrimmedNonEmptyStringSchema),78 model: Schema.optional(TrimmedNonEmptyStringSchema),
...@@ -85,7 +83,7 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -85,7 +83,7 @@ export const ProviderSessionStartInput = Schema.Struct({
85export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;83export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
8684
87export const ProviderSendTurnInput = Schema.Struct({85export const ProviderSendTurnInput = Schema.Struct({
88 sessionId: ProviderSessionId,86 threadId: ThreadId,
89 input: Schema.optional(87 input: Schema.optional(
90 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),88 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
91 ),89 ),
...@@ -98,25 +96,25 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -98,25 +96,25 @@ export const ProviderSendTurnInput = Schema.Struct({
98export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;96export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
9997
100export const ProviderTurnStartResult = Schema.Struct({98export const ProviderTurnStartResult = Schema.Struct({
101 threadId: Schema.optional(ProviderThreadId),99 threadId: ThreadId,
102 turnId: ProviderTurnId,100 turnId: TurnId,
103 resumeCursor: Schema.optional(Schema.Unknown),101 resumeCursor: Schema.optional(Schema.Unknown),
104});102});
105export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;103export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
106104
107export const ProviderInterruptTurnInput = Schema.Struct({105export const ProviderInterruptTurnInput = Schema.Struct({
108 sessionId: ProviderSessionId,106 threadId: ThreadId,
109 turnId: Schema.optional(ProviderTurnId),107 turnId: Schema.optional(TurnId),
110});108});
111export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;109export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;
112110
113export const ProviderStopSessionInput = Schema.Struct({111export const ProviderStopSessionInput = Schema.Struct({
114 sessionId: ProviderSessionId,112 threadId: ThreadId,
115});113});
116export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;114export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;
117115
118export const ProviderListCheckpointsInput = Schema.Struct({116export const ProviderListCheckpointsInput = Schema.Struct({
119 sessionId: ProviderSessionId,117 threadId: ThreadId,
120});118});
121export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;119export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;
122120
...@@ -137,7 +135,7 @@ export const ProviderListCheckpointsResult = Schema.Struct({...@@ -137,7 +135,7 @@ export const ProviderListCheckpointsResult = Schema.Struct({
137export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;135export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;
138136
139export const ProviderRevertToCheckpointInput = Schema.Struct({137export const ProviderRevertToCheckpointInput = Schema.Struct({
140 sessionId: ProviderSessionId,138 threadId: ThreadId,
141 turnCount: NonNegativeInt,139 turnCount: NonNegativeInt,
142});140});
143export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;141export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;
...@@ -152,7 +150,7 @@ export const ProviderRevertToCheckpointResult = Schema.Struct({...@@ -152,7 +150,7 @@ export const ProviderRevertToCheckpointResult = Schema.Struct({
152export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;150export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;
153151
154export const ProviderGetCheckpointDiffInput = Schema.Struct({152export const ProviderGetCheckpointDiffInput = Schema.Struct({
155 sessionId: ProviderSessionId,153 threadId: ThreadId,
156 ...TurnCountRange.fields,154 ...TurnCountRange.fields,
157});155});
158export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;156export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;
...@@ -165,7 +163,7 @@ export const ProviderGetCheckpointDiffResult = Schema.Struct({...@@ -165,7 +163,7 @@ export const ProviderGetCheckpointDiffResult = Schema.Struct({
165export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;163export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;
166164
167export const ProviderRespondToRequestInput = Schema.Struct({165export const ProviderRespondToRequestInput = Schema.Struct({
168 sessionId: ProviderSessionId,166 threadId: ThreadId,
169 requestId: ApprovalRequestId,167 requestId: ApprovalRequestId,
170 decision: ProviderApprovalDecision,168 decision: ProviderApprovalDecision,
171});169});
...@@ -178,12 +176,11 @@ export const ProviderEvent = Schema.Struct({...@@ -178,12 +176,11 @@ export const ProviderEvent = Schema.Struct({
178 id: EventId,176 id: EventId,
179 kind: ProviderEventKind,177 kind: ProviderEventKind,
180 provider: ProviderKind,178 provider: ProviderKind,
181 sessionId: ProviderSessionId,179 threadId: ThreadId,
182 createdAt: IsoDateTime,180 createdAt: IsoDateTime,
183 method: TrimmedNonEmptyStringSchema,181 method: TrimmedNonEmptyStringSchema,
184 message: Schema.optional(TrimmedNonEmptyStringSchema),182 message: Schema.optional(TrimmedNonEmptyStringSchema),
185 threadId: Schema.optional(ProviderThreadId),183 turnId: Schema.optional(TurnId),
186 turnId: Schema.optional(ProviderTurnId),
187 itemId: Schema.optional(ProviderItemId),184 itemId: Schema.optional(ProviderItemId),
188 requestId: Schema.optional(ApprovalRequestId),185 requestId: Schema.optional(ApprovalRequestId),
189 requestKind: Schema.optional(ProviderRequestKind),186 requestKind: Schema.optional(ProviderRequestKind),
packages/contracts/src/providerRuntime.ts+10-11
...@@ -3,12 +3,8 @@ import {...@@ -3,12 +3,8 @@ import {
3 EventId,3 EventId,
4 IsoDateTime,4 IsoDateTime,
5 ProviderItemId,5 ProviderItemId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ProviderTurnId,
9 RuntimeItemId,6 RuntimeItemId,
10 RuntimeRequestId,7 RuntimeRequestId,
11 RuntimeSessionId,
12 RuntimeTaskId,8 RuntimeTaskId,
13 ThreadId,9 ThreadId,
14 TrimmedNonEmptyString,10 TrimmedNonEmptyString,
...@@ -44,9 +40,7 @@ export const ProviderRequestId = TrimmedNonEmptyStringSchema;...@@ -44,9 +40,7 @@ export const ProviderRequestId = TrimmedNonEmptyStringSchema;
44export type ProviderRequestId = typeof ProviderRequestId.Type;40export type ProviderRequestId = typeof ProviderRequestId.Type;
4541
46export const ProviderRefs = Schema.Struct({42export const ProviderRefs = Schema.Struct({
47 providerSessionId: Schema.optional(ProviderSessionId),43 providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema),
48 providerThreadId: Schema.optional(ProviderThreadId),
49 providerTurnId: Schema.optional(ProviderTurnId),
50 providerItemId: Schema.optional(ProviderItemId),44 providerItemId: Schema.optional(ProviderItemId),
51 providerRequestId: Schema.optional(ProviderRequestId),45 providerRequestId: Schema.optional(ProviderRequestId),
52});46});
...@@ -131,6 +125,7 @@ export type CanonicalItemType = typeof CanonicalItemType.Type;...@@ -131,6 +125,7 @@ export type CanonicalItemType = typeof CanonicalItemType.Type;
131125
132export const CanonicalRequestType = Schema.Literals([126export const CanonicalRequestType = Schema.Literals([
133 "command_execution_approval",127 "command_execution_approval",
128 "file_read_approval",
134 "file_change_approval",129 "file_change_approval",
135 "apply_patch_approval",130 "apply_patch_approval",
136 "exec_command_approval",131 "exec_command_approval",
...@@ -239,9 +234,8 @@ export const RuntimeErrorType = Schema.Literal("runtime.error");...@@ -239,9 +234,8 @@ export const RuntimeErrorType = Schema.Literal("runtime.error");
239export const ProviderRuntimeEventBase = Schema.Struct({234export const ProviderRuntimeEventBase = Schema.Struct({
240 eventId: EventId,235 eventId: EventId,
241 provider: ProviderKind,236 provider: ProviderKind,
242 sessionId: RuntimeSessionId,237 threadId: ThreadId,
243 createdAt: IsoDateTime,238 createdAt: IsoDateTime,
244 threadId: Schema.optional(ThreadId),
245 turnId: Schema.optional(TurnId),239 turnId: Schema.optional(TurnId),
246 itemId: Schema.optional(RuntimeItemId),240 itemId: Schema.optional(RuntimeItemId),
247 requestId: Schema.optional(RuntimeRequestId),241 requestId: Schema.optional(RuntimeRequestId),
...@@ -276,7 +270,7 @@ export const SessionExitedPayload = Schema.Struct({...@@ -276,7 +270,7 @@ export const SessionExitedPayload = Schema.Struct({
276export type SessionExitedPayload = typeof SessionExitedPayload.Type;270export type SessionExitedPayload = typeof SessionExitedPayload.Type;
277271
278export const ThreadStartedPayload = Schema.Struct({272export const ThreadStartedPayload = Schema.Struct({
279 providerThreadId: Schema.optional(ProviderThreadId),273 providerThreadId: Schema.optional(TrimmedNonEmptyStringSchema),
280});274});
281export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;275export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;
282276
...@@ -948,7 +942,12 @@ export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolv...@@ -948,7 +942,12 @@ export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolv
948export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;942export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;
949943
950// Legacy helper aliases retained for adapters/tests.944// Legacy helper aliases retained for adapters/tests.
951export const ProviderRuntimeToolKind = Schema.Literals(["command", "file-change", "other"]);945export const ProviderRuntimeToolKind = Schema.Literals([
946 "command",
947 "file-read",
948 "file-change",
949 "other",
950]);
952export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;951export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
953952
954export const ProviderRuntimeTurnStatus = RuntimeTurnState;953export const ProviderRuntimeTurnStatus = RuntimeTurnState;
provider-threadless-runtime-migration-plan.md created+281
...@@ -0,0 +1,281 @@
1# Provider Runtime Refactor Plan
2
3## Goal
4
5Remove `providerSessionId` and `providerThreadId` from the core application model and make
6provider continuity depend only on:
7
8- canonical `threadId`
9- provider kind
10- runtime mode
11- opaque `resumeCursor`
12
13Live provider session/process state becomes ephemeral and provider-private.
14
15## Desired Architecture
16
17### Durable state
18
19Persist only the data required to reconstruct a provider conversation after process restart:
20
21- `threadId`
22- `provider`
23- `runtimeMode`
24- `resumeCursor` (opaque)
25- `status`
26- `activeTurnId` / `lastError` / `updatedAt` if needed for UX
27- optional provider runtime payload only if it is required for resume or useful UX
28
29Do **not** persist:
30
31- `providerSessionId`
32- `providerThreadId`
33- live child-process/session handles
34- adapter-private routing aliases
35
36### Ephemeral state
37
38Each adapter keeps an in-memory map keyed by canonical `threadId`:
39
40- `threadId -> live provider context`
41
42Examples:
43
44- Codex child process context
45- Cursor ACP live session
46- Claude Code live subprocess context
47
48These are disposable and rebuilt from persisted `resumeCursor`.
49
50### Observability
51
52Provider-native identifiers should live in logs/runtime events only, not in the canonical DB model.
53
54Examples:
55
56- Codex thread id
57- Cursor ACP thread/session id
58- Claude conversation/thread id
59
60## High-Level Refactor Sequence
61
62### 1. Contracts cleanup
63
64Remove provider identity fields from shared contracts:
65
66- `packages/contracts/src/provider.ts`
67- `packages/contracts/src/orchestration.ts`
68- `packages/contracts/src/providerRuntime.ts`
69- related tests
70
71Planned changes:
72
73- remove `ProviderSessionId` from cross-layer service payloads where possible
74- remove `providerSessionId` / `providerThreadId` from thread session snapshot contracts
75- keep `resumeCursor` opaque
76
77### 2. Redefine thread runtime/session projection model
78
79Replace thread session projection as a provider-identity carrier with a pure UX/runtime status view.
80
81Possible projected shape:
82
83- `status`
84- `providerName`
85- `runtimeMode`
86- `activeTurnId`
87- `lastError`
88- `updatedAt`
89
90No provider-native ids in the projection.
91
92Files likely affected:
93
94- `apps/server/src/persistence/Services/ProjectionThreadSessions.ts`
95- `apps/server/src/persistence/Layers/ProjectionThreadSessions.ts`
96- `apps/server/src/orchestration/projector.ts`
97- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
98- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`
99- `apps/web/src/store.ts`
100
101### 3. Make provider service thread-keyed
102
103Refactor provider service APIs to route by canonical `threadId`, not `providerSessionId`.
104
105Methods to change:
106
107- `startSession`
108- `sendTurn`
109- `interruptTurn`
110- `respondToRequest`
111- `rollbackConversation`
112- `stopSession` / equivalent
113- `listSessions`
114
115New mental model:
116
117- orchestration calls provider service with `threadId`
118- provider service resolves/creates live adapter context for that canonical thread
119- adapter handles provider-native resume semantics internally
120
121Files likely affected:
122
123- `apps/server/src/provider/Services/ProviderService.ts`
124- `apps/server/src/provider/Layers/ProviderService.ts`
125- `apps/server/src/provider/Services/ProviderAdapter.ts`
126- all adapter implementations/tests
127
128### 4. Replace session directory with thread-keyed runtime directory
129
130Current `ProviderSessionDirectory` is centered on session ids.
131Replace with a thread-keyed directory/repository.
132
133Persist per canonical thread:
134
135- `threadId`
136- `provider`
137- `runtimeMode`
138- `resumeCursor`
139- `status`
140- optional runtime payload
141
142Likely replacement:
143
144- `ProviderThreadRuntimeDirectory` or similar
145
146Files likely affected:
147
148- `apps/server/src/provider/Services/ProviderSessionDirectory.ts`
149- `apps/server/src/provider/Layers/ProviderSessionDirectory.ts`
150- `apps/server/src/persistence/Services/ProviderSessionRuntime.ts`
151- `apps/server/src/persistence/Layers/ProviderSessionRuntime.ts`
152
153### 5. Database migration
154
155Perform a clean-slate schema migration to remove provider ids from persistence.
156
157Targets:
158
159- remove `provider_session_id`
160- remove `provider_thread_id`
161- make canonical thread id the primary runtime key where appropriate
162
163Likely tables impacted:
164
165- `provider_session_runtime`
166- `projection_thread_sessions`
167
168Because SQLite column removal is awkward, and app is still early. A destructive migration is preferred.
169Existing users will need to reset their db so migrations run from scratch.
170
171Migration should also preserve:
172
173- `resume_cursor_json`
174- `runtime_mode`
175- `runtime_payload_json`
176- status metadata
177
178### 6. Orchestration refactor
179
180Remove provider-id assumptions from orchestration.
181
182Affected areas:
183
184- `ProviderCommandReactor`
185- `ProviderRuntimeIngestion`
186- `CheckpointReactor`
187- decider/projector event payloads
188
189Rules:
190
191- orchestration should only know canonical `threadId`
192- restart/reconcile should use provider service APIs keyed by canonical thread
193- no orchestration logic should depend on provider-native ids
194
195### 7. Adapter refactor
196
197Each adapter should:
198
199- keep a `Map<ThreadId, LiveContext>` (LiveContext is a provider-specific context object, typed independently for each adapter)
200- use persisted opaque `resumeCursor` to recreate live state
201- emit provider-native ids only in runtime/log events if desired
202
203Specific adapters:
204
205- `CodexAdapter`
206- `CursorAdapter`
207- `ClaudeCodeAdapter`
208
209### 8. Runtime events and logs
210
211Keep provider-native ids in logs and runtime events only when useful for debugging.
212
213Need to review:
214
215- `packages/contracts/src/providerRuntime.ts`
216- `apps/server/src/provider/Layers/*Adapter.ts`
217- log sinks / canonical event logging
218
219Goal:
220
221- provider-native ids optional and observational
222- not required by orchestration/persistence correctness
223
224### 9. Web/client cleanup
225
226Web should consume only canonical thread status.
227
228Remove use of:
229
230- `providerSessionId`
231- `providerThreadId`
232
233Likely files:
234
235- `apps/web/src/store.ts`
236- `apps/web/src/types.ts`
237- related tests
238
239### 10. Test migration
240
241Update and simplify tests across:
242
243- provider service tests
244- orchestration tests
245- integration harnesses
246- checkpoint/runtime ingestion tests
247
248Tests should validate:
249
250- thread continuity across restart using persisted opaque `resumeCursor`
251- runtime mode switches after reopen
252- no correctness dependency on provider-native ids
253
254## Suggested Execution Order
255
2561. Add/adjust plan-approved contracts
2572. Introduce new thread-keyed provider runtime persistence alongside old code temporarily in implementation
2583. Refactor provider service/adapters to use canonical `threadId`
2594. Switch orchestration to thread-keyed APIs
2605. Switch projections/web contracts
2616. Remove old provider-id fields and dead code
2627. Run migration and clean tests
263
264## Key Invariants After Refactor
265
266- Canonical `threadId` is the only cross-layer routing key
267- `resumeCursor` is the only persisted continuity primitive
268- provider-native ids are never required for correctness
269- live provider session/process state is always reconstructible and disposable
270- restart/reopen behavior is first-class, not a stale-session special case
271
272## Open Questions
273
2741. Should `thread.session` remain in snapshots at all, or be folded into thread runtime fields?
2752. Should provider runtime payload remain persisted if not required for resume?
2763. Do we want one provider runtime row per canonical thread, or a more generic runtime-state table keyed by thread?
277
278## Recommendation
279
280Proceed with a full clean-slate migration rather than incremental compatibility shims.
281The current model is carrying too much provider-native identity into layers that should only reason about canonical threads and opaque resume state.