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 = (
196196 options?: MakeOrchestrationIntegrationHarnessOptions,
197197) =>
198198 Effect.gen(function* () {
199 const sleep = (ms: number) => Effect.sleep(ms);
200 const provider = options?.provider ?? "codex";
201 const useRealCodex = options?.realCodex === true;
202 const adapterHarness = useRealCodex
203 ? null
204 : yield* makeTestProviderAdapterHarness({
205 provider,
206 });
207 const fakeRegistry = adapterHarness
208 ? Layer.succeed(ProviderAdapterRegistry, {
209 getByProvider: (resolvedProvider) =>
210 resolvedProvider === adapterHarness.provider
211 ? Effect.succeed(adapterHarness.adapter)
212 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
213 listProviders: () => Effect.succeed([adapterHarness.provider]),
214 } as typeof ProviderAdapterRegistry.Service)
215 : null;
216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
217 const workspaceDir = path.join(rootDir, "workspace");
218 const stateDir = path.join(rootDir, "state");
219 const dbPath = path.join(stateDir, "state.sqlite");
220 fs.mkdirSync(workspaceDir, { recursive: true });
221 fs.mkdirSync(stateDir, { recursive: true });
222 initializeGitWorkspace(workspaceDir);
199 const sleep = (ms: number) => Effect.sleep(ms);
200 const provider = options?.provider ?? "codex";
201 const useRealCodex = options?.realCodex === true;
202 const adapterHarness = useRealCodex
203 ? null
204 : yield* makeTestProviderAdapterHarness({
205 provider,
206 });
207 const fakeRegistry = adapterHarness
208 ? Layer.succeed(ProviderAdapterRegistry, {
209 getByProvider: (resolvedProvider) =>
210 resolvedProvider === adapterHarness.provider
211 ? Effect.succeed(adapterHarness.adapter)
212 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
213 listProviders: () => Effect.succeed([adapterHarness.provider]),
214 } as typeof ProviderAdapterRegistry.Service)
215 : null;
216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
217 const workspaceDir = path.join(rootDir, "workspace");
218 const stateDir = path.join(rootDir, "state");
219 const dbPath = path.join(stateDir, "state.sqlite");
220 fs.mkdirSync(workspaceDir, { recursive: true });
221 fs.mkdirSync(stateDir, { recursive: true });
222 initializeGitWorkspace(workspaceDir);
223223
224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
225 const orchestrationLayer = OrchestrationEngineLive.pipe(
226 Layer.provide(OrchestrationProjectionPipelineLive),
227 Layer.provide(OrchestrationEventStoreLive),
228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
229 );
230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
231 Layer.provide(ProviderSessionRuntimeRepositoryLive),
232 );
233 const realCodexRegistry = Layer.effect(
234 ProviderAdapterRegistry,
235 Effect.gen(function* () {
236 const codexAdapter = yield* CodexAdapter;
237 return {
238 getByProvider: (resolvedProvider) =>
239 resolvedProvider === "codex"
240 ? Effect.succeed(codexAdapter)
241 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
242 listProviders: () => Effect.succeed(["codex"] as const),
243 } as typeof ProviderAdapterRegistry.Service;
244 }),
245 ).pipe(
246 Layer.provide(makeCodexAdapterLive()),
247 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
248 Layer.provideMerge(NodeServices.layer),
249 Layer.provideMerge(providerSessionDirectoryLayer),
250 );
251 const providerLayer = useRealCodex
252 ? makeProviderServiceLive().pipe(
253 Layer.provide(providerSessionDirectoryLayer),
254 Layer.provide(realCodexRegistry),
255 )
256 : makeProviderServiceLive().pipe(
257 Layer.provide(providerSessionDirectoryLayer),
258 Layer.provide(fakeRegistry!),
259 );
224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
225 const orchestrationLayer = OrchestrationEngineLive.pipe(
226 Layer.provide(OrchestrationProjectionPipelineLive),
227 Layer.provide(OrchestrationEventStoreLive),
228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
229 );
230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
231 Layer.provide(ProviderSessionRuntimeRepositoryLive),
232 );
233 const realCodexRegistry = Layer.effect(
234 ProviderAdapterRegistry,
235 Effect.gen(function* () {
236 const codexAdapter = yield* CodexAdapter;
237 return {
238 getByProvider: (resolvedProvider) =>
239 resolvedProvider === "codex"
240 ? Effect.succeed(codexAdapter)
241 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
242 listProviders: () => Effect.succeed(["codex"] as const),
243 } as typeof ProviderAdapterRegistry.Service;
244 }),
245 ).pipe(
246 Layer.provide(makeCodexAdapterLive()),
247 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
248 Layer.provideMerge(NodeServices.layer),
249 Layer.provideMerge(providerSessionDirectoryLayer),
250 );
251 const providerLayer = useRealCodex
252 ? makeProviderServiceLive().pipe(
253 Layer.provide(providerSessionDirectoryLayer),
254 Layer.provide(realCodexRegistry),
255 )
256 : makeProviderServiceLive().pipe(
257 Layer.provide(providerSessionDirectoryLayer),
258 Layer.provide(fakeRegistry!),
259 );
260260
261 const runtimeServicesLayer = Layer.mergeAll(
262 orchestrationLayer,
263 OrchestrationProjectionSnapshotQueryLive,
264 ProjectionCheckpointRepositoryLive,
265 ProjectionPendingApprovalRepositoryLive,
266 CheckpointStoreLive,
267 providerLayer,
268 );
269 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
270 Layer.provideMerge(runtimeServicesLayer),
271 );
272 const gitCoreLayer = Layer.succeed(GitCore, {
273 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
274 Effect.succeed({ branch: input.newBranch }),
275 } as unknown as GitCoreShape);
276 const textGenerationLayer = Layer.succeed(TextGeneration, {
277 generateBranchName: () => Effect.succeed({ branch: null }),
278 } as unknown as TextGenerationShape);
279 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
280 Layer.provideMerge(runtimeServicesLayer),
281 Layer.provideMerge(gitCoreLayer),
282 Layer.provideMerge(textGenerationLayer),
283 );
284 const checkpointReactorLayer = CheckpointReactorLive.pipe(
285 Layer.provideMerge(runtimeServicesLayer),
286 );
287 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
288 Layer.provideMerge(runtimeIngestionLayer),
289 Layer.provideMerge(providerCommandReactorLayer),
290 Layer.provideMerge(checkpointReactorLayer),
291 );
292 const layer = orchestrationReactorLayer.pipe(
293 Layer.provide(persistenceLayer),
294 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
295 Layer.provideMerge(NodeServices.layer),
296 );
261 const runtimeServicesLayer = Layer.mergeAll(
262 orchestrationLayer,
263 OrchestrationProjectionSnapshotQueryLive,
264 ProjectionCheckpointRepositoryLive,
265 ProjectionPendingApprovalRepositoryLive,
266 CheckpointStoreLive,
267 providerLayer,
268 );
269 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
270 Layer.provideMerge(runtimeServicesLayer),
271 );
272 const gitCoreLayer = Layer.succeed(GitCore, {
273 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
274 Effect.succeed({ branch: input.newBranch }),
275 } as unknown as GitCoreShape);
276 const textGenerationLayer = Layer.succeed(TextGeneration, {
277 generateBranchName: () => Effect.succeed({ branch: null }),
278 } as unknown as TextGenerationShape);
279 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
280 Layer.provideMerge(runtimeServicesLayer),
281 Layer.provideMerge(gitCoreLayer),
282 Layer.provideMerge(textGenerationLayer),
283 );
284 const checkpointReactorLayer = CheckpointReactorLive.pipe(
285 Layer.provideMerge(runtimeServicesLayer),
286 );
287 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
288 Layer.provideMerge(runtimeIngestionLayer),
289 Layer.provideMerge(providerCommandReactorLayer),
290 Layer.provideMerge(checkpointReactorLayer),
291 );
292 const layer = orchestrationReactorLayer.pipe(
293 Layer.provide(persistenceLayer),
294 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
295 Layer.provideMerge(NodeServices.layer),
296 );
297297
298 const runtime = ManagedRuntime.make(layer);
299 const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () =>
300 runtime.runPromise(Effect.service(OrchestrationEngineService)),
301 ).pipe(Effect.orDie);
302 const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>
303 runtime.runPromise(Effect.service(OrchestrationReactor)),
304 ).pipe(Effect.orDie);
305 const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>
306 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
307 ).pipe(Effect.orDie);
308 const providerService = yield* tryRuntimePromise("load ProviderService service", () =>
309 runtime.runPromise(Effect.service(ProviderService)),
310 ).pipe(Effect.orDie);
311 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
312 runtime.runPromise(Effect.service(CheckpointStore)),
313 ).pipe(Effect.orDie);
314 const checkpointRepository = yield* tryRuntimePromise(
315 "load ProjectionCheckpointRepository service",
316 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
317 ).pipe(Effect.orDie);
318 const pendingApprovalRepository = yield* tryRuntimePromise(
319 "load ProjectionPendingApprovalRepository service",
320 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
321 ).pipe(Effect.orDie);
298 const runtime = ManagedRuntime.make(layer);
299 const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () =>
300 runtime.runPromise(Effect.service(OrchestrationEngineService)),
301 ).pipe(Effect.orDie);
302 const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () =>
303 runtime.runPromise(Effect.service(OrchestrationReactor)),
304 ).pipe(Effect.orDie);
305 const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () =>
306 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
307 ).pipe(Effect.orDie);
308 const providerService = yield* tryRuntimePromise("load ProviderService service", () =>
309 runtime.runPromise(Effect.service(ProviderService)),
310 ).pipe(Effect.orDie);
311 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
312 runtime.runPromise(Effect.service(CheckpointStore)),
313 ).pipe(Effect.orDie);
314 const checkpointRepository = yield* tryRuntimePromise(
315 "load ProjectionCheckpointRepository service",
316 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
317 ).pipe(Effect.orDie);
318 const pendingApprovalRepository = yield* tryRuntimePromise(
319 "load ProjectionPendingApprovalRepository service",
320 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
321 ).pipe(Effect.orDie);
322322
323 const scope = yield* Scope.make("sequential");
324 yield* tryRuntimePromise("start OrchestrationReactor", () =>
325 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),
326 ).pipe(Effect.orDie);
327 yield* sleep(10);
323 const scope = yield* Scope.make("sequential");
324 yield* tryRuntimePromise("start OrchestrationReactor", () =>
325 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),
326 ).pipe(Effect.orDie);
327 yield* sleep(10);
328328
329 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
330 threadId,
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}'`,
329 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
330 threadId,
331 predicate,
344332 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"] = (
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",
347 const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = (
348 predicate,
357349 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"] = (
361 requestId,
362 predicate,
363 timeoutMs,
364 ) =>
365 waitFor(
366 pendingApprovalRepository
367 .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })
368 .pipe(
369 Effect.map((row) =>
370 Option.match(row, {
371 onNone: () => null,
372 onSome: (value) => ({
373 status: value.status,
374 decision: value.decision,
375 resolvedAt: value.resolvedAt,
360 const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = (
361 requestId,
362 predicate,
363 timeoutMs,
364 ) =>
365 waitFor(
366 pendingApprovalRepository
367 .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })
368 .pipe(
369 Effect.map((row) =>
370 Option.match(row, {
371 onNone: () => null,
372 onSome: (value) => ({
373 status: value.status,
374 decision: value.decision,
375 resolvedAt: value.resolvedAt,
376 }),
376377 }),
377 }),
378 ),
378379 ),
379 ),
380 (
381 row,
382 ): row is {
383 readonly status: "pending" | "resolved";
384 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
385 readonly resolvedAt: string | null;
386 } => row !== null && predicate(row),
387 `pending approval '${requestId}'`,
388 timeoutMs,
389 ) as Effect.Effect<
390 {
391 readonly status: "pending" | "resolved";
392 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
393 readonly resolvedAt: string | null;
394 },
395 never
396 >;
380 (
381 row,
382 ): row is {
383 readonly status: "pending" | "resolved";
384 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
385 readonly resolvedAt: string | null;
386 } => row !== null && predicate(row),
387 `pending approval '${requestId}'`,
388 timeoutMs,
389 ) as Effect.Effect<
390 {
391 readonly status: "pending" | "resolved";
392 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
393 readonly resolvedAt: string | null;
394 },
395 never
396 >;
397397
398 let disposed = false;
399 const dispose = Effect.gen(function* () {
400 if (disposed) {
401 return;
402 }
403 disposed = true;
398 let disposed = false;
399 const dispose = Effect.gen(function* () {
400 if (disposed) {
401 return;
402 }
403 disposed = true;
404404
405 const shutdown = Effect.gen(function* () {
406 const stopAllExit = yield* Effect.exit(
407 Effect.promise(() => runtime.runPromise(providerService.stopAll())),
408 );
409 const closeScopeExit = yield* Effect.exit(
410 Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))),
411 );
412 const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose()));
405 const shutdown = Effect.gen(function* () {
406 const stopAllExit = yield* Effect.exit(
407 Effect.promise(() => runtime.runPromise(providerService.stopAll())),
408 );
409 const closeScopeExit = yield* Effect.exit(Scope.close(scope, Exit.void));
410 const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose()));
413411
414 const failureCause = Exit.isFailure(stopAllExit)
415 ? stopAllExit.cause
416 : Exit.isFailure(closeScopeExit)
417 ? closeScopeExit.cause
418 : Exit.isFailure(disposeRuntimeExit)
419 ? disposeRuntimeExit.cause
420 : null;
412 const failureCause = Exit.isFailure(stopAllExit)
413 ? stopAllExit.cause
414 : Exit.isFailure(closeScopeExit)
415 ? closeScopeExit.cause
416 : Exit.isFailure(disposeRuntimeExit)
417 ? disposeRuntimeExit.cause
418 : null;
421419
422 if (failureCause) {
423 return yield* Effect.failCause(failureCause);
424 }
425 });
420 if (failureCause) {
421 return yield* Effect.failCause(failureCause);
422 }
423 });
426424
427 yield* shutdown.pipe(
428 Effect.ensuring(
429 Effect.sync(() => {
430 fs.rmSync(rootDir, { recursive: true, force: true });
431 }),
432 ),
433 );
434 });
425 yield* shutdown.pipe(
426 Effect.ensuring(
427 Effect.sync(() => {
428 fs.rmSync(rootDir, { recursive: true, force: true });
429 }),
430 ),
431 );
432 });
435433
436 return {
437 rootDir,
438 workspaceDir,
434 return {
435 rootDir,
436 workspaceDir,
439437 dbPath,
440438 adapterHarness: adapterHarness as TestProviderAdapterHarness,
441 engine,
442 snapshotQuery,
443 providerService,
444 checkpointStore,
445 checkpointRepository,
446 pendingApprovalRepository,
447 waitForThread,
448 waitForDomainEvent,
449 waitForPendingApproval,
450 dispose,
451 } satisfies OrchestrationIntegrationHarness;
452});
439 engine,
440 snapshotQuery,
441 providerService,
442 checkpointStore,
443 checkpointRepository,
444 pendingApprovalRepository,
445 waitForThread,
446 waitForDomainEvent,
447 waitForPendingApproval,
448 dispose,
449 } satisfies OrchestrationIntegrationHarness;
450 });
apps/server/integration/TestProviderAdapter.integration.ts+60-69
......@@ -7,9 +7,6 @@ import {
77 ProviderRuntimeEvent,
88 RuntimeSessionId,
99 ProviderSession,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
1310 ProviderTurnStartResult,
1411 ThreadId,
1512 TurnId,
......@@ -39,9 +36,8 @@ export type FixtureProviderRuntimeEvent = {
3936 readonly type: string;
4037 readonly eventId: EventId;
4138 readonly provider: "codex" | "claudeCode" | "cursor";
42 readonly sessionId: string;
4339 readonly createdAt: string;
44 readonly threadId?: string | undefined;
40 readonly threadId: string;
4541 readonly turnId?: string | undefined;
4642 readonly itemId?: string | undefined;
4743 readonly requestId?: string | undefined;
......@@ -184,18 +180,18 @@ export interface TestProviderAdapterHarness {
184180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
185181 readonly provider: "codex" | "claudeCode";
186182 readonly queueTurnResponse: (
187 sessionId: string,
183 threadId: ThreadId,
188184 response: TestTurnResponse,
189185 ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>;
190186 readonly queueTurnResponseForNextSession: (
191187 response: TestTurnResponse,
192188 ) => Effect.Effect<void, never>;
193189 readonly getStartCount: () => number;
194 readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>;
195 readonly getInterruptCalls: (sessionId: string) => ReadonlyArray<ProviderTurnId | undefined>;
196 readonly listActiveSessionIds: () => ReadonlyArray<ProviderSessionId>;
197 readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{
198 readonly sessionId: ProviderSessionId;
190 readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray<number>;
191 readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray<TurnId | undefined>;
192 readonly listActiveSessionIds: () => ReadonlyArray<ThreadId>;
193 readonly getApprovalResponses: (threadId: ThreadId) => ReadonlyArray<{
194 readonly threadId: ThreadId;
199195 readonly requestId: ApprovalRequestId;
200196 readonly decision: ProviderApprovalDecision;
201197 }>;
......@@ -211,19 +207,19 @@ function nowIso(): string {
211207
212208function sessionNotFound(
213209 provider: "codex" | "claudeCode",
214 sessionId: string,
210 threadId: ThreadId,
215211): ProviderAdapterSessionNotFoundError {
216212 return new ProviderAdapterSessionNotFoundError({
217213 provider,
218 sessionId,
214 threadId: String(threadId),
219215 });
220216}
221217
222218function missingSessionEffect(
223219 provider: "codex" | "claudeCode",
224 sessionId: string,
220 threadId: ThreadId,
225221): Effect.Effect<never, ProviderAdapterError> {
226 return Effect.fail(sessionNotFound(provider, sessionId));
222 return Effect.fail(sessionNotFound(provider, threadId));
227223}
228224
229225export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
......@@ -231,13 +227,13 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
231227 const provider = options?.provider ?? "codex";
232228 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
233229 let sessionCount = 0;
234 const sessions = new Map<string, SessionState>();
230 const sessions = new Map<ThreadId, SessionState>();
235231 const queuedResponsesForNextSession: TestTurnResponse[] = [];
236 const interruptCallsBySession = new Map<string, Array<ProviderTurnId | undefined>>();
232 const interruptCallsBySession = new Map<ThreadId, Array<TurnId | undefined>>();
237233 const approvalResponsesBySession = new Map<
238 string,
234 ThreadId,
239235 Array<{
240 readonly sessionId: ProviderSessionId;
236 readonly threadId: ThreadId;
241237 readonly requestId: ApprovalRequestId;
242238 readonly decision: ProviderApprovalDecision;
243239 }>
......@@ -256,23 +252,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
256252 }
257253
258254 sessionCount += 1;
259 const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`);
260 const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`);
255 const threadId = input.threadId;
261256 const createdAt = nowIso();
262257
263258 const session: ProviderSession = {
264 sessionId,
265259 provider,
266260 status: "ready",
267261 runtimeMode: input.runtimeMode,
268262 threadId,
269263 cwd: input.cwd,
270 resumeCursor: input.resumeCursor ?? { sessionId },
264 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
271265 createdAt,
272266 updatedAt: createdAt,
273267 };
274268
275 sessions.set(sessionId, {
269 sessions.set(threadId, {
276270 session,
277271 snapshot: {
278272 threadId,
......@@ -288,21 +282,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
288282
289283 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>
290284 Effect.gen(function* () {
291 const state = sessions.get(input.sessionId);
285 const state = sessions.get(input.threadId);
292286 if (!state) {
293 return yield* missingSessionEffect(provider, input.sessionId);
287 return yield* missingSessionEffect(provider, input.threadId);
294288 }
295289
296290 state.turnCount += 1;
297291 const turnCount = state.turnCount;
298 const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`);
292 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
299293
300294 const response = state.queuedResponses.shift();
301295 if (!response) {
302296 return yield* new ProviderAdapterValidationError({
303297 provider,
304298 operation: "sendTurn",
305 issue: `No queued turn response for session ${input.sessionId}.`,
299 issue: `No queued turn response for thread ${input.threadId}.`,
306300 });
307301 }
308302
......@@ -313,14 +307,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
313307 ...(fixtureEvent as Record<string, unknown>),
314308 eventId: randomUUID(),
315309 provider,
316 sessionId: RuntimeSessionId.makeUnsafe(input.sessionId),
310 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
317311 createdAt: nowIso(),
318312 };
319 if (Object.hasOwn(rawEvent, "threadId")) {
320 rawEvent.threadId = ThreadId.makeUnsafe(state.snapshot.threadId);
321 }
313 rawEvent.threadId = state.snapshot.threadId;
322314 if (Object.hasOwn(rawEvent, "turnId")) {
323 rawEvent.turnId = TurnId.makeUnsafe(turnId);
315 rawEvent.turnId = turnId;
324316 }
325317
326318 const runtimeEvent = normalizeFixtureEvent(rawEvent);
......@@ -373,10 +365,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
373365 type: "turn.completed",
374366 eventId: EventId.makeUnsafe(randomUUID()),
375367 provider,
376 sessionId: RuntimeSessionId.makeUnsafe(input.sessionId),
377368 createdAt: nowIso(),
378 threadId: ThreadId.makeUnsafe(state.snapshot.threadId),
379 turnId: TurnId.makeUnsafe(turnId),
369 threadId: state.snapshot.threadId,
370 turnId,
380371 payload: {
381372 state: "completed",
382373 },
......@@ -394,60 +385,60 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
394385 });
395386
396387 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
397 sessionId,
388 threadId,
398389 turnId,
399390 ) =>
400 sessions.has(sessionId)
391 sessions.has(threadId)
401392 ? Effect.sync(() => {
402 const existing = interruptCallsBySession.get(sessionId) ?? [];
393 const existing = interruptCallsBySession.get(threadId) ?? [];
403394 existing.push(turnId);
404 interruptCallsBySession.set(sessionId, existing);
395 interruptCallsBySession.set(threadId, existing);
405396 })
406 : missingSessionEffect(provider, sessionId);
397 : missingSessionEffect(provider, threadId);
407398
408399 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
409 sessionId,
400 threadId,
410401 requestId,
411402 decision,
412403 ) =>
413 sessions.has(sessionId)
404 sessions.has(threadId)
414405 ? Effect.sync(() => {
415 const existing = approvalResponsesBySession.get(sessionId) ?? [];
406 const existing = approvalResponsesBySession.get(threadId) ?? [];
416407 existing.push({
417 sessionId,
408 threadId,
418409 requestId,
419410 decision,
420411 });
421 approvalResponsesBySession.set(sessionId, existing);
412 approvalResponsesBySession.set(threadId, existing);
422413 })
423 : missingSessionEffect(provider, sessionId);
414 : missingSessionEffect(provider, threadId);
424415
425 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>
416 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
426417 Effect.sync(() => {
427 sessions.delete(sessionId);
418 sessions.delete(threadId);
428419 });
429420
430421 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>
431422 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
432423
433 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (sessionId) =>
434 Effect.succeed(sessions.has(sessionId));
424 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
425 Effect.succeed(sessions.has(threadId));
435426
436 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => {
437 const state = sessions.get(sessionId);
427 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
428 const state = sessions.get(threadId);
438429 if (!state) {
439 return missingSessionEffect(provider, sessionId);
430 return missingSessionEffect(provider, threadId);
440431 }
441432 return Effect.succeed(state.snapshot);
442433 };
443434
444435 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
445 sessionId,
436 threadId,
446437 numTurns,
447438 ) => {
448 const state = sessions.get(sessionId);
439 const state = sessions.get(threadId);
449440 if (!state) {
450 return missingSessionEffect(provider, sessionId);
441 return missingSessionEffect(provider, threadId);
451442 }
452443 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
453444 return Effect.fail(
......@@ -494,16 +485,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
494485 };
495486
496487 const queueTurnResponse = (
497 sessionId: string,
488 threadId: ThreadId,
498489 response: TestTurnResponse,
499490 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>
500 Effect.sync(() => sessions.get(sessionId)).pipe(
491 Effect.sync(() => sessions.get(threadId)).pipe(
501492 Effect.flatMap((state) =>
502493 state
503494 ? Effect.sync(() => {
504495 state.queuedResponses.push(response);
505496 })
506 : Effect.fail(sessionNotFound(provider, sessionId)),
497 : Effect.fail(sessionNotFound(provider, threadId)),
507498 ),
508499 );
509500
......@@ -514,8 +505,8 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
514505 queuedResponsesForNextSession.push(response);
515506 });
516507
517 const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => {
518 const state = sessions.get(sessionId);
508 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
509 const state = sessions.get(threadId);
519510 if (!state) {
520511 return [];
521512 }
......@@ -524,25 +515,25 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
524515
525516 const getStartCount = (): number => sessionCount;
526517
527 const getInterruptCalls = (sessionId: string): ReadonlyArray<ProviderTurnId | undefined> => {
528 const calls = interruptCallsBySession.get(sessionId);
518 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
519 const calls = interruptCallsBySession.get(threadId);
529520 if (!calls) {
530521 return [];
531522 }
532523 return [...calls];
533524 };
534525
535 const listActiveSessionIds = (): ReadonlyArray<ProviderSessionId> =>
536 Array.from(sessions.values(), (state) => state.session.sessionId);
526 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
527 Array.from(sessions.values(), (state) => state.session.threadId);
537528
538529 const getApprovalResponses = (
539 sessionId: string,
530 threadId: ThreadId,
540531 ): ReadonlyArray<{
541 readonly sessionId: ProviderSessionId;
532 readonly threadId: ThreadId;
542533 readonly requestId: ApprovalRequestId;
543534 readonly decision: ProviderApprovalDecision;
544535 }> => {
545 const responses = approvalResponsesBySession.get(sessionId);
536 const responses = approvalResponsesBySession.get(threadId);
546537 if (!responses) {
547538 return [];
548539 }
apps/server/integration/orchestrationEngine.integration.test.ts+72-117
......@@ -7,9 +7,6 @@ import {
77 EventId,
88 MessageId,
99 ProjectId,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
1310 ThreadId,
1411} from "@t3tools/contracts";
1512import { assert, it } from "@effect/vitest";
......@@ -27,18 +24,12 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";
2724const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2825const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
2926const 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);
3427const asApprovalRequestId = (value: string): ApprovalRequestId =>
3528 ApprovalRequestId.makeUnsafe(value);
3629
3730const PROJECT_ID = asProjectId("project-1");
3831const THREAD_ID = ThreadId.makeUnsafe("thread-1");
39const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
32const FIXTURE_TURN_ID = "fixture-turn";
4233const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
4334type IntegrationProvider = "codex" | "claudeCode";
4435
......@@ -81,7 +72,6 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr
8172 return {
8273 eventId: asEventId(eventId),
8374 provider,
84 sessionId: FIXTURE_SESSION_ID,
8575 createdAt,
8676 };
8777}
......@@ -167,20 +157,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
167157 {
168158 type: "turn.started",
169159 ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"),
170 threadId: FIXTURE_THREAD_ID,
160 threadId: THREAD_ID,
171161 turnId: FIXTURE_TURN_ID,
172162 },
173163 {
174164 type: "message.delta",
175165 ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"),
176 threadId: FIXTURE_THREAD_ID,
166 threadId: THREAD_ID,
177167 turnId: FIXTURE_TURN_ID,
178168 delta: "Single turn response.\n",
179169 },
180170 {
181171 type: "turn.completed",
182172 ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"),
183 threadId: FIXTURE_THREAD_ID,
173 threadId: THREAD_ID,
184174 turnId: FIXTURE_TURN_ID,
185175 status: "completed",
186176 },
......@@ -276,15 +266,12 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
276266 (entry) =>
277267 entry.session?.status === "ready" &&
278268 entry.session.providerName === "codex" &&
279 entry.session.providerThreadId !== null &&
280269 entry.messages.some(
281270 (message) => message.role === "assistant" && message.streaming === false,
282271 ),
283272 180_000,
284273 );
285
286 const originalProviderThreadId = firstThread.session?.providerThreadId;
287 assert.isNotNull(originalProviderThreadId);
274 assert.equal(firstThread.session?.threadId, "thread-1");
288275
289276 yield* harness.engine.dispatch({
290277 type: "thread.turn.start",
......@@ -305,15 +292,13 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
305292 (entry) =>
306293 entry.session?.status === "ready" &&
307294 entry.session.providerName === "codex" &&
308 entry.session.providerThreadId !== null &&
309295 entry.session.runtimeMode === "approval-required" &&
310296 entry.messages.some(
311297 (message) => message.role === "assistant" && message.text.includes("BETA"),
312298 ),
313299 180_000,
314300 );
315
316 assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId);
301 assert.equal(secondThread.session?.threadId, "thread-1");
317302 }),
318303 ),
319304);
......@@ -328,13 +313,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
328313 {
329314 type: "turn.started",
330315 ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"),
331 threadId: FIXTURE_THREAD_ID,
316 threadId: THREAD_ID,
332317 turnId: FIXTURE_TURN_ID,
333318 },
334319 {
335320 type: "tool.started",
336321 ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"),
337 threadId: FIXTURE_THREAD_ID,
322 threadId: THREAD_ID,
338323 turnId: FIXTURE_TURN_ID,
339324 toolKind: "command",
340325 title: "Edit file",
......@@ -343,7 +328,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
343328 {
344329 type: "tool.completed",
345330 ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"),
346 threadId: FIXTURE_THREAD_ID,
331 threadId: THREAD_ID,
347332 turnId: FIXTURE_TURN_ID,
348333 toolKind: "command",
349334 title: "Edit file",
......@@ -352,14 +337,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
352337 {
353338 type: "message.delta",
354339 ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"),
355 threadId: FIXTURE_THREAD_ID,
340 threadId: THREAD_ID,
356341 turnId: FIXTURE_TURN_ID,
357342 delta: "Updated README to v2.\n",
358343 },
359344 {
360345 type: "turn.completed",
361346 ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"),
362 threadId: FIXTURE_THREAD_ID,
347 threadId: THREAD_ID,
363348 turnId: FIXTURE_TURN_ID,
364349 status: "completed",
365350 },
......@@ -377,35 +362,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
377362 text: "Make first edit",
378363 });
379364
380 const firstTurnThread = yield* harness.waitForThread(
365 yield* harness.waitForThread(
381366 THREAD_ID,
382 (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null,
367 (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1",
383368 );
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, {
391371 events: [
392372 {
393373 type: "turn.started",
394374 ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"),
395 threadId: FIXTURE_THREAD_ID,
375 threadId: THREAD_ID,
396376 turnId: FIXTURE_TURN_ID,
397377 },
398378 {
399379 type: "message.delta",
400380 ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"),
401 threadId: FIXTURE_THREAD_ID,
381 threadId: THREAD_ID,
402382 turnId: FIXTURE_TURN_ID,
403383 delta: "Updated README to v3.\n",
404384 },
405385 {
406386 type: "turn.completed",
407387 ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"),
408 threadId: FIXTURE_THREAD_ID,
388 threadId: THREAD_ID,
409389 turnId: FIXTURE_TURN_ID,
410390 status: "completed",
411391 },
......@@ -492,13 +472,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons
492472 {
493473 type: "turn.started",
494474 ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"),
495 threadId: FIXTURE_THREAD_ID,
475 threadId: THREAD_ID,
496476 turnId: FIXTURE_TURN_ID,
497477 },
498478 {
499479 type: "approval.requested",
500480 ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"),
501 threadId: FIXTURE_THREAD_ID,
481 threadId: THREAD_ID,
502482 turnId: FIXTURE_TURN_ID,
503483 requestId: APPROVAL_REQUEST_ID,
504484 requestKind: "command",
......@@ -507,7 +487,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons
507487 {
508488 type: "turn.completed",
509489 ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"),
510 threadId: FIXTURE_THREAD_ID,
490 threadId: THREAD_ID,
511491 turnId: FIXTURE_TURN_ID,
512492 status: "completed",
513493 },
......@@ -551,9 +531,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons
551531 assert.equal(resolvedRow.status, "resolved");
552532 assert.equal(resolvedRow.decision, "accept");
553533
554 const providerSessionId = thread.session?.providerSessionId ?? "test-session-1";
555534 const approvalResponses = yield* waitForSync(
556 () => harness.adapterHarness.getApprovalResponses(providerSessionId),
535 () => harness.adapterHarness.getApprovalResponses(THREAD_ID),
557536 (responses) => responses.length === 1,
558537 "provider approval response",
559538 );
......@@ -574,13 +553,13 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
574553 {
575554 type: "turn.started",
576555 ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"),
577 threadId: FIXTURE_THREAD_ID,
556 threadId: THREAD_ID,
578557 turnId: FIXTURE_TURN_ID,
579558 },
580559 {
581560 type: "content.delta",
582561 ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"),
583 threadId: FIXTURE_THREAD_ID,
562 threadId: THREAD_ID,
584563 turnId: FIXTURE_TURN_ID,
585564 payload: {
586565 streamKind: "assistant_text",
......@@ -590,7 +569,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
590569 {
591570 type: "runtime.error",
592571 ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"),
593 threadId: FIXTURE_THREAD_ID,
572 threadId: THREAD_ID,
594573 turnId: FIXTURE_TURN_ID,
595574 payload: {
596575 message: "Sandbox command failed.",
......@@ -599,7 +578,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
599578 {
600579 type: "turn.completed",
601580 ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"),
602 threadId: FIXTURE_THREAD_ID,
581 threadId: THREAD_ID,
603582 turnId: FIXTURE_TURN_ID,
604583 payload: {
605584 state: "failed",
......@@ -653,13 +632,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
653632 {
654633 type: "turn.started",
655634 ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"),
656 threadId: FIXTURE_THREAD_ID,
635 threadId: THREAD_ID,
657636 turnId: FIXTURE_TURN_ID,
658637 },
659638 {
660639 type: "tool.started",
661640 ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"),
662 threadId: FIXTURE_THREAD_ID,
641 threadId: THREAD_ID,
663642 turnId: FIXTURE_TURN_ID,
664643 toolKind: "command",
665644 title: "Edit file",
......@@ -668,7 +647,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
668647 {
669648 type: "tool.completed",
670649 ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"),
671 threadId: FIXTURE_THREAD_ID,
650 threadId: THREAD_ID,
672651 turnId: FIXTURE_TURN_ID,
673652 toolKind: "command",
674653 title: "Edit file",
......@@ -677,14 +656,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
677656 {
678657 type: "message.delta",
679658 ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"),
680 threadId: FIXTURE_THREAD_ID,
659 threadId: THREAD_ID,
681660 turnId: FIXTURE_TURN_ID,
682661 delta: "Updated README to v2.\n",
683662 },
684663 {
685664 type: "turn.completed",
686665 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),
687 threadId: FIXTURE_THREAD_ID,
666 threadId: THREAD_ID,
688667 turnId: FIXTURE_TURN_ID,
689668 status: "completed",
690669 },
......@@ -701,28 +680,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
701680 text: "First edit",
702681 });
703682
704 const firstTurnThread = yield* harness.waitForThread(
683 yield* harness.waitForThread(
705684 THREAD_ID,
706 (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1,
685 (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1,
707686 );
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, {
715689 events: [
716690 {
717691 type: "turn.started",
718692 ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"),
719 threadId: FIXTURE_THREAD_ID,
693 threadId: THREAD_ID,
720694 turnId: FIXTURE_TURN_ID,
721695 },
722696 {
723697 type: "tool.started",
724698 ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"),
725 threadId: FIXTURE_THREAD_ID,
699 threadId: THREAD_ID,
726700 turnId: FIXTURE_TURN_ID,
727701 toolKind: "command",
728702 title: "Edit file",
......@@ -731,7 +705,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
731705 {
732706 type: "tool.completed",
733707 ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"),
734 threadId: FIXTURE_THREAD_ID,
708 threadId: THREAD_ID,
735709 turnId: FIXTURE_TURN_ID,
736710 toolKind: "command",
737711 title: "Edit file",
......@@ -740,14 +714,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
740714 {
741715 type: "message.delta",
742716 ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"),
743 threadId: FIXTURE_THREAD_ID,
717 threadId: THREAD_ID,
744718 turnId: FIXTURE_TURN_ID,
745719 delta: "Updated README to v3.\n",
746720 },
747721 {
748722 type: "turn.completed",
749723 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),
750 threadId: FIXTURE_THREAD_ID,
724 threadId: THREAD_ID,
751725 turnId: FIXTURE_TURN_ID,
752726 status: "completed",
753727 },
......@@ -816,7 +790,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
816790 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
817791 false,
818792 );
819 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
793 assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]);
820794
821795 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
822796 threadId: THREAD_ID,
......@@ -874,20 +848,20 @@ it.live("starts a claudeCode session on first turn when provider is requested",
874848 {
875849 type: "turn.started",
876850 ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"),
877 threadId: FIXTURE_THREAD_ID,
851 threadId: THREAD_ID,
878852 turnId: FIXTURE_TURN_ID,
879853 },
880854 {
881855 type: "message.delta",
882856 ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"),
883 threadId: FIXTURE_THREAD_ID,
857 threadId: THREAD_ID,
884858 turnId: FIXTURE_TURN_ID,
885859 delta: "Claude first turn.\n",
886860 },
887861 {
888862 type: "turn.completed",
889863 ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"),
890 threadId: FIXTURE_THREAD_ID,
864 threadId: THREAD_ID,
891865 turnId: FIXTURE_TURN_ID,
892866 status: "completed",
893867 },
......@@ -928,20 +902,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
928902 {
929903 type: "turn.started",
930904 ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"),
931 threadId: FIXTURE_THREAD_ID,
905 threadId: THREAD_ID,
932906 turnId: FIXTURE_TURN_ID,
933907 },
934908 {
935909 type: "message.delta",
936910 ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"),
937 threadId: FIXTURE_THREAD_ID,
911 threadId: THREAD_ID,
938912 turnId: FIXTURE_TURN_ID,
939913 delta: "Turn before restart.\n",
940914 },
941915 {
942916 type: "turn.completed",
943917 ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"),
944 threadId: FIXTURE_THREAD_ID,
918 threadId: THREAD_ID,
945919 turnId: FIXTURE_TURN_ID,
946920 status: "completed",
947921 },
......@@ -956,16 +930,10 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
956930 provider: "claudeCode",
957931 });
958932
959 const firstThread = yield* harness.waitForThread(
933 yield* harness.waitForThread(
960934 THREAD_ID,
961 (entry) =>
962 entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null,
935 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1",
963936 );
964 const staleSessionId = firstThread.session?.providerSessionId;
965 assert.equal(staleSessionId !== null, true);
966 if (!staleSessionId) {
967 return;
968 }
969937
970938 yield* harness.providerService.stopAll();
971939 yield* waitForSync(
......@@ -979,20 +947,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
979947 {
980948 type: "turn.started",
981949 ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"),
982 threadId: FIXTURE_THREAD_ID,
950 threadId: THREAD_ID,
983951 turnId: FIXTURE_TURN_ID,
984952 },
985953 {
986954 type: "message.delta",
987955 ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"),
988 threadId: FIXTURE_THREAD_ID,
956 threadId: THREAD_ID,
989957 turnId: FIXTURE_TURN_ID,
990958 delta: "Turn after restart.\n",
991959 },
992960 {
993961 type: "turn.completed",
994962 ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"),
995 threadId: FIXTURE_THREAD_ID,
963 threadId: THREAD_ID,
996964 turnId: FIXTURE_TURN_ID,
997965 status: "completed",
998966 },
......@@ -1021,7 +989,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res
1021989 !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
1022990 );
1023991 assert.equal(recoveredThread.session?.providerName, "claudeCode");
1024 assert.equal(recoveredThread.session?.providerSessionId, staleSessionId);
992 assert.equal(recoveredThread.session?.threadId, "thread-1");
1025993 }),
1026994 "claudeCode",
1027995 ),
......@@ -1038,13 +1006,13 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
10381006 {
10391007 type: "turn.started",
10401008 ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"),
1041 threadId: FIXTURE_THREAD_ID,
1009 threadId: THREAD_ID,
10421010 turnId: FIXTURE_TURN_ID,
10431011 },
10441012 {
10451013 type: "approval.requested",
10461014 ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"),
1047 threadId: FIXTURE_THREAD_ID,
1015 threadId: THREAD_ID,
10481016 turnId: FIXTURE_TURN_ID,
10491017 requestId: APPROVAL_REQUEST_ID,
10501018 requestKind: "command",
......@@ -1053,7 +1021,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
10531021 {
10541022 type: "turn.completed",
10551023 ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"),
1056 threadId: FIXTURE_THREAD_ID,
1024 threadId: THREAD_ID,
10571025 turnId: FIXTURE_TURN_ID,
10581026 status: "completed",
10591027 },
......@@ -1071,11 +1039,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
10711039 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
10721040 entry.activities.some((activity) => activity.kind === "approval.requested"),
10731041 );
1074 const providerSessionId = thread.session?.providerSessionId;
1075 assert.equal(providerSessionId !== null, true);
1076 if (!providerSessionId) {
1077 return;
1078 }
1042 assert.equal(thread.session?.threadId, "thread-1");
10791043
10801044 yield* harness.engine.dispatch({
10811045 type: "thread.approval.respond",
......@@ -1092,7 +1056,7 @@ it.live("forwards claudeCode approval responses to the provider session", () =>
10921056 );
10931057
10941058 const approvalResponses = yield* waitForSync(
1095 () => harness.adapterHarness.getApprovalResponses(providerSessionId),
1059 () => harness.adapterHarness.getApprovalResponses(THREAD_ID),
10961060 (responses) => responses.length === 1,
10971061 "claude provider approval response",
10981062 );
......@@ -1113,20 +1077,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
11131077 {
11141078 type: "turn.started",
11151079 ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"),
1116 threadId: FIXTURE_THREAD_ID,
1080 threadId: THREAD_ID,
11171081 turnId: FIXTURE_TURN_ID,
11181082 },
11191083 {
11201084 type: "message.delta",
11211085 ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"),
1122 threadId: FIXTURE_THREAD_ID,
1086 threadId: THREAD_ID,
11231087 turnId: FIXTURE_TURN_ID,
11241088 delta: "Long running output.\n",
11251089 },
11261090 {
11271091 type: "turn.completed",
11281092 ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"),
1129 threadId: FIXTURE_THREAD_ID,
1093 threadId: THREAD_ID,
11301094 turnId: FIXTURE_TURN_ID,
11311095 status: "completed",
11321096 },
......@@ -1143,13 +1107,9 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
11431107
11441108 const thread = yield* harness.waitForThread(
11451109 THREAD_ID,
1146 (entry) => entry.session?.providerSessionId !== null,
1110 (entry) => entry.session?.threadId === "thread-1",
11471111 );
1148 const providerSessionId = thread.session?.providerSessionId;
1149 assert.equal(providerSessionId !== null, true);
1150 if (!providerSessionId) {
1151 return;
1152 }
1112 assert.equal(thread.session?.threadId, "thread-1");
11531113
11541114 yield* harness.engine.dispatch({
11551115 type: "thread.turn.interrupt",
......@@ -1160,7 +1120,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
11601120 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested");
11611121
11621122 const interruptCalls = yield* waitForSync(
1163 () => harness.adapterHarness.getInterruptCalls(providerSessionId),
1123 () => harness.adapterHarness.getInterruptCalls(THREAD_ID),
11641124 (calls) => calls.length === 1,
11651125 "claude provider interrupt call",
11661126 );
......@@ -1181,20 +1141,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
11811141 {
11821142 type: "turn.started",
11831143 ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"),
1184 threadId: FIXTURE_THREAD_ID,
1144 threadId: THREAD_ID,
11851145 turnId: FIXTURE_TURN_ID,
11861146 },
11871147 {
11881148 type: "message.delta",
11891149 ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"),
1190 threadId: FIXTURE_THREAD_ID,
1150 threadId: THREAD_ID,
11911151 turnId: FIXTURE_TURN_ID,
11921152 delta: "README -> v2\n",
11931153 },
11941154 {
11951155 type: "turn.completed",
11961156 ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"),
1197 threadId: FIXTURE_THREAD_ID,
1157 threadId: THREAD_ID,
11981158 turnId: FIXTURE_TURN_ID,
11991159 status: "completed",
12001160 },
......@@ -1213,35 +1173,30 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
12131173 provider: "claudeCode",
12141174 });
12151175
1216 const firstThread = yield* harness.waitForThread(
1176 yield* harness.waitForThread(
12171177 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",
12191179 );
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, {
12271182 events: [
12281183 {
12291184 type: "turn.started",
12301185 ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"),
1231 threadId: FIXTURE_THREAD_ID,
1186 threadId: THREAD_ID,
12321187 turnId: FIXTURE_TURN_ID,
12331188 },
12341189 {
12351190 type: "message.delta",
12361191 ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"),
1237 threadId: FIXTURE_THREAD_ID,
1192 threadId: THREAD_ID,
12381193 turnId: FIXTURE_TURN_ID,
12391194 delta: "README -> v3\n",
12401195 },
12411196 {
12421197 type: "turn.completed",
12431198 ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"),
1244 threadId: FIXTURE_THREAD_ID,
1199 threadId: THREAD_ID,
12451200 turnId: FIXTURE_TURN_ID,
12461201 status: "completed",
12471202 },
......@@ -1289,7 +1244,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", (
12891244 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
12901245 false,
12911246 );
1292 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
1247 assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]);
12931248 }),
12941249 "claudeCode",
12951250 ),
apps/server/integration/providerService.integration.test.ts+16-12
......@@ -1,4 +1,4 @@
1import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
22import { ThreadId } from "@t3tools/contracts";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { it, assert } from "@effect/vitest";
......@@ -93,18 +93,18 @@ const collectEventsDuring = <A, E, R>(
9393const runTurn = (input: {
9494 readonly provider: ProviderServiceShape;
9595 readonly harness: TestProviderAdapterHarness;
96 readonly sessionId: ProviderSessionId;
96 readonly threadId: ThreadId;
9797 readonly userText: string;
9898 readonly response: TestTurnResponse;
9999}) =>
100100 Effect.gen(function* () {
101 yield* input.harness.queueTurnResponse(input.sessionId, input.response);
101 yield* input.harness.queueTurnResponse(input.threadId, input.response);
102102
103103 return yield* collectEventsDuring(
104104 input.provider.streamEvents,
105105 input.response.events.length,
106106 input.provider.sendTurn({
107 sessionId: input.sessionId,
107 threadId: input.threadId,
108108 input: input.userText,
109109 attachments: [],
110110 }),
......@@ -120,6 +120,7 @@ it.effect("replays typed runtime fixture events", () =>
120120 const session = yield* provider.startSession(
121121 ThreadId.makeUnsafe("thread-integration-typed"),
122122 {
123 threadId: ThreadId.makeUnsafe("thread-integration-typed"),
123124 provider: "codex",
124125 cwd: fixture.cwd,
125126 runtimeMode: "full-access",
......@@ -130,7 +131,7 @@ it.effect("replays typed runtime fixture events", () =>
130131 const observedEvents = yield* runTurn({
131132 provider,
132133 harness: fixture.harness,
133 sessionId: session.sessionId,
134 threadId: session.threadId,
134135 userText: "hello",
135136 response: { events: codexTurnTextFixture },
136137 });
......@@ -154,6 +155,7 @@ it.effect("replays file-changing fixture turn events", () =>
154155 const session = yield* provider.startSession(
155156 ThreadId.makeUnsafe("thread-integration-tools"),
156157 {
158 threadId: ThreadId.makeUnsafe("thread-integration-tools"),
157159 provider: "codex",
158160 cwd: fixture.cwd,
159161 runtimeMode: "full-access",
......@@ -164,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () =>
164166 const observedEvents = yield* runTurn({
165167 provider,
166168 harness: fixture.harness,
167 sessionId: session.sessionId,
169 threadId: session.threadId,
168170 userText: "make a small change",
169171 response: {
170172 events: codexTurnToolFixture,
......@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194 const session = yield* provider.startSession(
193195 ThreadId.makeUnsafe("thread-integration-multi"),
194196 {
197 threadId: ThreadId.makeUnsafe("thread-integration-multi"),
195198 provider: "codex",
196199 cwd: fixture.cwd,
197200 runtimeMode: "full-access",
......@@ -202,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
202205 const firstTurnEvents = yield* runTurn({
203206 provider,
204207 harness: fixture.harness,
205 sessionId: session.sessionId,
208 threadId: session.threadId,
206209 userText: "turn 1",
207210 response: {
208211 events: codexTurnToolFixture,
......@@ -218,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
218221 const secondTurnEvents = yield* runTurn({
219222 provider,
220223 harness: fixture.harness,
221 sessionId: session.sessionId,
224 threadId: session.threadId,
222225 userText: "turn 2 approval",
223226 response: {
224227 events: codexTurnApprovalFixture,
......@@ -245,6 +248,7 @@ it.effect("rolls back provider conversation state only", () =>
245248 const session = yield* provider.startSession(
246249 ThreadId.makeUnsafe("thread-integration-rollback"),
247250 {
251 threadId: ThreadId.makeUnsafe("thread-integration-rollback"),
248252 provider: "codex",
249253 cwd: fixture.cwd,
250254 runtimeMode: "full-access",
......@@ -255,7 +259,7 @@ it.effect("rolls back provider conversation state only", () =>
255259 yield* runTurn({
256260 provider,
257261 harness: fixture.harness,
258 sessionId: session.sessionId,
262 threadId: session.threadId,
259263 userText: "turn 1",
260264 response: {
261265 events: codexTurnToolFixture,
......@@ -267,7 +271,7 @@ it.effect("rolls back provider conversation state only", () =>
267271 yield* runTurn({
268272 provider,
269273 harness: fixture.harness,
270 sessionId: session.sessionId,
274 threadId: session.threadId,
271275 userText: "turn 2 approval",
272276 response: {
273277 events: codexTurnApprovalFixture,
......@@ -277,11 +281,11 @@ it.effect("rolls back provider conversation state only", () =>
277281 });
278282
279283 yield* provider.rollbackConversation({
280 sessionId: session.sessionId,
284 threadId: session.threadId,
281285 numTurns: 1,
282286 });
283287
284 const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId);
288 const rollbackCalls = fixture.harness.getRollbackCalls(session.threadId);
285289 assert.deepEqual(rollbackCalls, [1]);
286290
287291 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";
33import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
44import os from "node:os";
55import path from "node:path";
6import { ProviderSessionId } from "@t3tools/contracts";
6import { ThreadId } from "@t3tools/contracts";
77
88import {
99 CodexAppServerManager,
......@@ -12,7 +12,7 @@ import {
1212 normalizeCodexModelSlug,
1313} from "./codexAppServerManager";
1414
15const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
15const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
1616
1717function createSendTurnHarness() {
1818 const manager = new CodexAppServerManager();
......@@ -168,6 +168,7 @@ describe("startSession", () => {
168168 try {
169169 await expect(
170170 manager.startSession({
171 threadId: asThreadId("thread-1"),
171172 provider: "codex",
172173 runtimeMode: "full-access",
173174 }),
......@@ -191,7 +192,7 @@ describe("sendTurn", () => {
191192 createSendTurnHarness();
192193
193194 const result = await manager.sendTurn({
194 sessionId: asSessionId("sess_1"),
195 threadId: asThreadId("thread_1"),
195196 input: "Inspect this image",
196197 attachments: [
197198 {
......@@ -207,7 +208,7 @@ describe("sendTurn", () => {
207208 threadId: "thread_1",
208209 turnId: "turn_1",
209210 });
210 expect(requireSession).toHaveBeenCalledWith("sess_1");
211 expect(requireSession).toHaveBeenCalledWith("thread_1");
211212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
212213 threadId: "thread_1",
213214 input: [
......@@ -234,7 +235,7 @@ describe("sendTurn", () => {
234235 const { manager, context, sendRequest } = createSendTurnHarness();
235236
236237 await manager.sendTurn({
237 sessionId: asSessionId("sess_1"),
238 threadId: asThreadId("thread_1"),
238239 attachments: [
239240 {
240241 type: "image",
......@@ -259,7 +260,7 @@ describe("sendTurn", () => {
259260
260261 await expect(
261262 manager.sendTurn({
262 sessionId: asSessionId("sess_1"),
263 threadId: asThreadId("thread_1"),
263264 }),
264265 ).rejects.toThrow("Turn input must include text or attachments.");
265266 });
......@@ -280,9 +281,9 @@ describe("thread checkpoint control", () => {
280281 },
281282 });
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");
286287 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
287288 threadId: "thread_1",
288289 includeTurns: true,
......@@ -310,7 +311,7 @@ describe("thread checkpoint control", () => {
310311 ],
311312 });
312313
313 const result = await manager.readThread(asSessionId("sess_1"));
314 const result = await manager.readThread(asThreadId("thread_1"));
314315
315316 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
316317 threadId: "thread_1",
......@@ -336,7 +337,7 @@ describe("thread checkpoint control", () => {
336337 },
337338 });
338339
339 const result = await manager.rollbackThread(asSessionId("sess_1"), 2);
340 const result = await manager.rollbackThread(asThreadId("thread_1"), 2);
340341
341342 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
342343 threadId: "thread_1",
......@@ -364,6 +365,7 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
364365
365366 try {
366367 const firstSession = await manager.startSession({
368 threadId: asThreadId("thread-live"),
367369 provider: "codex",
368370 cwd: workspaceDir,
369371 runtimeMode: "full-access",
......@@ -378,24 +380,25 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
378380 });
379381
380382 const firstTurn = await manager.sendTurn({
381 sessionId: firstSession.sessionId,
383 threadId: firstSession.threadId,
382384 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383385 });
384386
385387 expect(firstTurn.threadId).toBe(firstSession.threadId);
386388
387389 await vi.waitFor(async () => {
388 const snapshot = await manager.readThread(firstSession.sessionId);
390 const snapshot = await manager.readThread(firstSession.threadId);
389391 expect(snapshot.turns.length).toBeGreaterThan(0);
390392 }, { timeout: 120_000, interval: 1_000 });
391393
392 const firstSnapshot = await manager.readThread(firstSession.sessionId);
394 const firstSnapshot = await manager.readThread(firstSession.threadId);
393395 const originalThreadId = firstSnapshot.threadId;
394396 const originalTurnCount = firstSnapshot.turns.length;
395397
396 manager.stopSession(firstSession.sessionId);
398 manager.stopSession(firstSession.threadId);
397399
398400 const resumedSession = await manager.startSession({
401 threadId: firstSession.threadId,
399402 provider: "codex",
400403 cwd: workspaceDir,
401404 runtimeMode: "approval-required",
......@@ -412,17 +415,17 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
412415
413416 expect(resumedSession.threadId).toBe(originalThreadId);
414417
415 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId);
418 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);
416419 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417420 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418421
419422 await manager.sendTurn({
420 sessionId: resumedSession.sessionId,
423 threadId: resumedSession.threadId,
421424 input: `Reply with exactly the word BETA ${randomUUID()}`,
422425 });
423426
424427 await vi.waitFor(async () => {
425 const snapshot = await manager.readThread(resumedSession.sessionId);
428 const snapshot = await manager.readThread(resumedSession.threadId);
426429 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427430 }, { timeout: 120_000, interval: 1_000 });
428431 } finally {
apps/server/src/codexAppServerManager.ts+118-108
......@@ -8,9 +8,8 @@ import {
88 EventId,
99 ProviderItemId,
1010 ProviderRequestKind,
11 ProviderSessionId,
12 ProviderThreadId,
13 ProviderTurnId,
11 ThreadId,
12 TurnId,
1413 normalizeModelSlug,
1514 type ProviderApprovalDecision,
1615 type ProviderEvent,
......@@ -18,7 +17,7 @@ import {
1817 type ProviderSessionStartInput,
1918 type ProviderTurnStartResult,
2019} from "@t3tools/contracts";
21import { Effect } from "effect";
20import { Effect, ServiceMap } from "effect";
2221
2322type PendingRequestKey = string;
2423
......@@ -34,8 +33,8 @@ interface PendingApprovalRequest {
3433 jsonRpcId: string | number;
3534 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
3635 requestKind: ProviderRequestKind;
37 threadId?: ProviderThreadId;
38 turnId?: ProviderTurnId;
36 threadId: ThreadId;
37 turnId?: TurnId;
3938 itemId?: ProviderItemId;
4039}
4140
......@@ -72,7 +71,7 @@ interface JsonRpcNotification {
7271}
7372
7473export interface CodexAppServerSendTurnInput {
75 readonly sessionId: ProviderSessionId;
74 readonly threadId: ThreadId;
7675 readonly input?: string;
7776 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
7877 readonly model?: string;
......@@ -80,12 +79,12 @@ export interface CodexAppServerSendTurnInput {
8079}
8180
8281export interface CodexThreadTurnSnapshot {
83 id: ProviderTurnId;
82 id: TurnId;
8483 items: unknown[];
8584}
8685
8786export interface CodexThreadSnapshot {
88 threadId: ProviderThreadId;
87 threadId: string;
8988 turns: CodexThreadTurnSnapshot[];
9089}
9190
......@@ -191,10 +190,16 @@ export interface CodexAppServerManagerEvents {
191190}
192191
193192export 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
196201 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
197 const sessionId = ProviderSessionId.makeUnsafe(randomUUID());
202 const threadId = input.threadId;
198203 const now = new Date().toISOString();
199204 let context: CodexSessionContext | undefined;
200205
......@@ -202,12 +207,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
202207 const resolvedCwd = input.cwd ?? process.cwd();
203208
204209 const session: ProviderSession = {
205 sessionId,
206210 provider: "codex",
207211 status: "connecting",
208212 runtimeMode: input.runtimeMode,
209213 model: normalizeCodexModelSlug(input.model),
210214 cwd: resolvedCwd,
215 threadId,
211216 createdAt: now,
212217 updatedAt: now,
213218 };
......@@ -236,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
236241 stopping: false,
237242 };
238243
239 this.sessions.set(sessionId, context);
244 this.sessions.set(threadId, context);
240245 this.attachProcessListeners(context);
241246
242247 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
......@@ -274,12 +279,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
274279 : "Starting a new Codex thread.",
275280 );
276281 await Effect.logInfo("codex app-server opening thread", {
277 sessionId,
282 threadId,
278283 requestedRuntimeMode: input.runtimeMode,
279284 requestedModel: normalizedModel ?? null,
280285 requestedCwd: resolvedCwd,
281286 resumeThreadId: resumeThreadId ?? null,
282 }).pipe(Effect.runPromise);
287 }).pipe(this.runPromise);
283288
284289 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
285290 let threadOpenResponse: unknown;
......@@ -298,12 +303,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
298303 error instanceof Error ? error.message : "Codex thread resume failed.",
299304 );
300305 await Effect.logWarning("codex app-server thread resume failed", {
301 sessionId,
306 threadId,
302307 requestedRuntimeMode: input.runtimeMode,
303308 resumeThreadId,
304309 recoverable: false,
305310 cause: error instanceof Error ? error.message : String(error),
306 }).pipe(Effect.runPromise);
311 }).pipe(this.runPromise);
307312 throw error;
308313 }
309314
......@@ -314,12 +319,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
314319 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,
315320 );
316321 await Effect.logWarning("codex app-server thread resume fell back to fresh start", {
317 sessionId,
322 threadId,
318323 requestedRuntimeMode: input.runtimeMode,
319324 resumeThreadId,
320325 recoverable: true,
321326 cause: error instanceof Error ? error.message : String(error),
322 }).pipe(Effect.runPromise);
327 }).pipe(this.runPromise);
323328 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
324329 }
325330 } else {
......@@ -334,22 +339,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
334339 if (!threadIdRaw) {
335340 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
336341 }
337 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
342 const providerThreadId = threadIdRaw;
338343
339344 this.updateSession(context, {
340345 status: "ready",
341 threadId,
342 resumeCursor: { threadId },
346 resumeCursor: { threadId: providerThreadId },
343347 });
344 this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`);
348 this.emitLifecycleEvent(
349 context,
350 "session/threadOpenResolved",
351 `Codex ${threadOpenMethod} resolved.`,
352 );
345353 await Effect.logInfo("codex app-server thread open resolved", {
346 sessionId,
354 threadId,
347355 threadOpenMethod,
348356 requestedResumeThreadId: resumeThreadId ?? null,
349 resolvedThreadId: threadId,
357 resolvedThreadId: providerThreadId,
350358 requestedRuntimeMode: input.runtimeMode,
351 }).pipe(Effect.runPromise);
352 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);
359 }).pipe(this.runPromise);
360 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${providerThreadId}`);
353361 return { ...context.session };
354362 } catch (error) {
355363 const message = error instanceof Error ? error.message : "Failed to start Codex session.";
......@@ -359,13 +367,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
359367 lastError: message,
360368 });
361369 this.emitErrorEvent(context, "session/startFailed", message);
362 this.stopSession(sessionId);
370 this.stopSession(threadId);
363371 } else {
364372 this.emitEvent({
365373 id: EventId.makeUnsafe(randomUUID()),
366374 kind: "error",
367375 provider: "codex",
368 sessionId,
376 threadId,
369377 createdAt: new Date().toISOString(),
370378 method: "session/startFailed",
371379 message,
......@@ -376,10 +384,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
376384 }
377385
378386 async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
379 const context = this.requireSession(input.sessionId);
380 if (!context.session.threadId) {
381 throw new Error("Session is missing a thread id.");
382 }
387 const context = this.requireSession(input.threadId);
383388
384389 const turnInput: Array<
385390 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
......@@ -403,15 +408,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
403408 throw new Error("Turn input must include text or attachments.");
404409 }
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 }
406419 const turnStartParams: {
407 threadId: ProviderThreadId;
420 threadId: string;
408421 input: Array<
409422 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
410423 >;
411424 model?: string;
412425 effort?: string;
413426 } = {
414 threadId: context.session.threadId,
427 threadId: providerThreadId,
415428 input: turnInput,
416429 };
417430 const normalizedModel = normalizeCodexModelSlug(input.model);
......@@ -429,7 +442,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
429442 if (!turnIdRaw) {
430443 throw new Error("turn/start response did not include a turn id.");
431444 }
432 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
445 const turnId = TurnId.makeUnsafe(turnIdRaw);
433446
434447 this.updateSession(context, {
435448 status: "running",
......@@ -448,49 +461,59 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
448461 };
449462 }
450463
451 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
452 const context = this.requireSession(sessionId);
464 async interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise<void> {
465 const context = this.requireSession(threadId);
453466 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) {
456474 return;
457475 }
458476
459477 await this.sendRequest(context, "turn/interrupt", {
460 threadId: context.session.threadId,
478 threadId: providerThreadId,
461479 turnId: effectiveTurnId,
462480 });
463481 }
464482
465 async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> {
466 const context = this.requireSession(sessionId);
467 const threadId = context.session.threadId;
468 if (!threadId) {
469 throw new Error("Session is missing a thread id.");
483 async readThread(threadId: ThreadId): Promise<CodexThreadSnapshot> {
484 const context = this.requireSession(threadId);
485 const providerThreadId = readResumeThreadId({
486 threadId: context.session.threadId,
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.");
470492 }
471493
472494 const response = await this.sendRequest(context, "thread/read", {
473 threadId,
495 threadId: providerThreadId,
474496 includeTurns: true,
475497 });
476498 return this.parseThreadSnapshot("thread/read", response);
477499 }
478500
479 async rollbackThread(
480 sessionId: ProviderSessionId,
481 numTurns: number,
482 ): Promise<CodexThreadSnapshot> {
483 const context = this.requireSession(sessionId);
484 const threadId = context.session.threadId;
485 if (!threadId) {
486 throw new Error("Session is missing a thread id.");
501 async rollbackThread(threadId: ThreadId, numTurns: number): Promise<CodexThreadSnapshot> {
502 const context = this.requireSession(threadId);
503 const providerThreadId = readResumeThreadId({
504 threadId: context.session.threadId,
505 runtimeMode: context.session.runtimeMode,
506 resumeCursor: context.session.resumeCursor,
507 });
508 if (!providerThreadId) {
509 throw new Error("Session is missing a provider resume thread id.");
487510 }
488511 if (!Number.isInteger(numTurns) || numTurns < 1) {
489512 throw new Error("numTurns must be an integer >= 1.");
490513 }
491514
492515 const response = await this.sendRequest(context, "thread/rollback", {
493 threadId,
516 threadId: providerThreadId,
494517 numTurns,
495518 });
496519 this.updateSession(context, {
......@@ -501,11 +524,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
501524 }
502525
503526 async respondToRequest(
504 sessionId: ProviderSessionId,
527 threadId: ThreadId,
505528 requestId: ApprovalRequestId,
506529 decision: ProviderApprovalDecision,
507530 ): Promise<void> {
508 const context = this.requireSession(sessionId);
531 const context = this.requireSession(threadId);
509532 const pendingRequest = context.pendingApprovals.get(requestId);
510533 if (!pendingRequest) {
511534 throw new Error(`Unknown pending approval request: ${requestId}`);
......@@ -523,10 +546,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
523546 id: EventId.makeUnsafe(randomUUID()),
524547 kind: "notification",
525548 provider: "codex",
526 sessionId: context.session.sessionId,
549 threadId: context.session.threadId,
527550 createdAt: new Date().toISOString(),
528551 method: "item/requestApproval/decision",
529 threadId: pendingRequest.threadId,
530552 turnId: pendingRequest.turnId,
531553 itemId: pendingRequest.itemId,
532554 requestId: pendingRequest.requestId,
......@@ -539,8 +561,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
539561 });
540562 }
541563
542 stopSession(sessionId: ProviderSessionId): void {
543 const context = this.sessions.get(sessionId);
564 stopSession(threadId: ThreadId): void {
565 const context = this.sessions.get(threadId);
544566 if (!context) {
545567 return;
546568 }
......@@ -565,7 +587,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
565587 activeTurnId: undefined,
566588 });
567589 this.emitLifecycleEvent(context, "session/closed", "Session stopped");
568 this.sessions.delete(sessionId);
590 this.sessions.delete(threadId);
569591 }
570592
571593 listSessions(): ProviderSession[] {
......@@ -574,24 +596,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
574596 }));
575597 }
576598
577 hasSession(sessionId: ProviderSessionId): boolean {
578 return this.sessions.has(sessionId);
599 hasSession(threadId: ThreadId): boolean {
600 return this.sessions.has(threadId);
579601 }
580602
581603 stopAll(): void {
582 for (const sessionId of this.sessions.keys()) {
583 this.stopSession(sessionId);
604 for (const threadId of this.sessions.keys()) {
605 this.stopSession(threadId);
584606 }
585607 }
586608
587 private requireSession(sessionId: ProviderSessionId): CodexSessionContext {
588 const context = this.sessions.get(sessionId);
609 private requireSession(threadId: ThreadId): CodexSessionContext {
610 const context = this.sessions.get(threadId);
589611 if (!context) {
590 throw new Error(`Unknown session: ${sessionId}`);
612 throw new Error(`Unknown session for thread: ${threadId}`);
591613 }
592614
593615 if (context.session.status === "closed") {
594 throw new Error(`Session is closed: ${sessionId}`);
616 throw new Error(`Session is closed for thread: ${threadId}`);
595617 }
596618
597619 return context;
......@@ -636,7 +658,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
636658 lastError: code === 0 ? context.session.lastError : message,
637659 });
638660 this.emitLifecycleEvent(context, "session/exited", message);
639 this.sessions.delete(context.session.sessionId);
661 this.sessions.delete(context.session.threadId);
640662 });
641663 }
642664
......@@ -698,10 +720,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
698720 id: EventId.makeUnsafe(randomUUID()),
699721 kind: "notification",
700722 provider: "codex",
701 sessionId: context.session.sessionId,
723 threadId: context.session.threadId,
702724 createdAt: new Date().toISOString(),
703725 method: notification.method,
704 threadId: route.threadId,
705726 turnId: route.turnId,
706727 itemId: route.itemId,
707728 textDelta,
......@@ -709,19 +730,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
709730 });
710731
711732 if (notification.method === "thread/started") {
712 const threadId = toProviderThreadId(
733 const providerThreadId = normalizeProviderThreadId(
713734 this.readString(this.readObject(notification.params)?.thread, "id"),
714735 );
715 if (threadId) {
716 this.updateSession(context, { threadId });
736 if (providerThreadId) {
737 this.updateSession(context, { resumeCursor: { threadId: providerThreadId } });
717738 }
718739 return;
719740 }
720741
721742 if (notification.method === "turn/started") {
722 const turnId = toProviderTurnId(
723 this.readString(this.readObject(notification.params)?.turn, "id"),
724 );
743 const turnId = toTurnId(this.readString(this.readObject(notification.params)?.turn, "id"));
725744 this.updateSession(context, {
726745 status: "running",
727746 activeTurnId: turnId,
......@@ -766,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
766785 ? "item/commandExecution/requestApproval"
767786 : "item/fileChange/requestApproval",
768787 requestKind,
769 ...(route.threadId ? { threadId: route.threadId } : {}),
788 threadId: context.session.threadId,
770789 ...(route.turnId ? { turnId: route.turnId } : {}),
771790 ...(route.itemId ? { itemId: route.itemId } : {}),
772791 };
......@@ -777,10 +796,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
777796 id: EventId.makeUnsafe(randomUUID()),
778797 kind: "request",
779798 provider: "codex",
780 sessionId: context.session.sessionId,
799 threadId: context.session.threadId,
781800 createdAt: new Date().toISOString(),
782801 method: request.method,
783 threadId: route.threadId,
784802 turnId: route.turnId,
785803 itemId: route.itemId,
786804 requestId,
......@@ -872,7 +890,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
872890 id: EventId.makeUnsafe(randomUUID()),
873891 kind: "session",
874892 provider: "codex",
875 sessionId: context.session.sessionId,
893 threadId: context.session.threadId,
876894 createdAt: new Date().toISOString(),
877895 method,
878896 message,
......@@ -884,7 +902,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
884902 id: EventId.makeUnsafe(randomUUID()),
885903 kind: "error",
886904 provider: "codex",
887 sessionId: context.session.sessionId,
905 threadId: context.session.threadId,
888906 createdAt: new Date().toISOString(),
889907 method,
890908 message,
......@@ -908,6 +926,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
908926 return "command";
909927 }
910928
929 if (method === "item/fileRead/requestApproval") {
930 return "file-read";
931 }
932
911933 if (method === "item/fileChange/requestApproval") {
912934 return "file-change";
913935 }
......@@ -923,14 +945,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
923945 if (!threadIdRaw) {
924946 throw new Error(`${method} response did not include a thread id.`);
925947 }
926 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
927
928948 const turnsRaw =
929949 this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
930950 const turns = turnsRaw.map((turnValue, index) => {
931951 const turn = this.readObject(turnValue);
932952 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;
933 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
953 const turnId = TurnId.makeUnsafe(turnIdRaw);
934954 const items = this.readArray(turn, "items") ?? [];
935955 return {
936956 id: turnId,
......@@ -939,7 +959,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
939959 });
940960
941961 return {
942 threadId,
962 threadId: threadIdRaw,
943963 turns,
944964 };
945965 }
......@@ -977,31 +997,21 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
977997 }
978998
979999 private readRouteFields(params: unknown): {
980 threadId?: ProviderThreadId;
981 turnId?: ProviderTurnId;
1000 turnId?: TurnId;
9821001 itemId?: ProviderItemId;
9831002 } {
9841003 const route: {
985 threadId?: ProviderThreadId;
986 turnId?: ProviderTurnId;
1004 turnId?: TurnId;
9871005 itemId?: ProviderItemId;
9881006 } = {};
9891007
990 const threadId = toProviderThreadId(
991 this.readString(params, "threadId") ??
992 this.readString(this.readObject(params, "thread"), "id"),
993 );
994 const turnId = toProviderTurnId(
1008 const turnId = toTurnId(
9951009 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"),
9961010 );
9971011 const itemId = toProviderItemId(
9981012 this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"),
9991013 );
10001014
1001 if (threadId) {
1002 route.threadId = threadId;
1003 }
1004
10051015 if (turnId) {
10061016 route.turnId = turnId;
10071017 }
......@@ -1065,8 +1075,8 @@ function brandIfNonEmpty<T extends string>(
10651075 return normalized?.length ? maker(normalized) : undefined;
10661076}
10671077
1068function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined {
1069 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
1078function normalizeProviderThreadId(value: string | undefined): string | undefined {
1079 return brandIfNonEmpty(value, (normalized) => normalized);
10701080}
10711081
10721082function readCodexProviderOptions(input: ProviderSessionStartInput): {
......@@ -1083,20 +1093,20 @@ function readCodexProviderOptions(input: ProviderSessionStartInput): {
10831093 };
10841094}
10851095
1086function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined {
1096function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
10871097 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
10881098 return undefined;
10891099 }
10901100 const rawThreadId = (resumeCursor as Record<string, unknown>).threadId;
1091 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined;
1101 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;
10921102}
10931103
1094function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
1104function readResumeThreadId(input: ProviderSessionStartInput): string | undefined {
10951105 return readResumeCursorThreadId(input.resumeCursor);
10961106}
10971107
1098function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
1099 return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe);
1108function toTurnId(value: string | undefined): TurnId | undefined {
1109 return brandIfNonEmpty(value, TurnId.makeUnsafe);
11001110}
11011111
11021112function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+43-71
......@@ -9,9 +9,6 @@ import {
99 EventId,
1010 MessageId,
1111 ProjectId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
1512 ThreadId,
1613 TurnId,
1714} from "@t3tools/contracts";
......@@ -40,18 +37,14 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
4037import { ServerConfig } from "../../config.ts";
4138
4239const 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);
4640const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4741
4842type LegacyProviderRuntimeEvent = {
4943 readonly type: string;
5044 readonly eventId: EventId;
5145 readonly provider: "codex" | "claudeCode" | "cursor";
52 readonly sessionId: string;
5346 readonly createdAt: string;
54 readonly threadId?: string | undefined;
47 readonly threadId: ThreadId;
5548 readonly turnId?: string | undefined;
5649 readonly itemId?: string | undefined;
5750 readonly requestId?: string | undefined;
......@@ -68,7 +61,7 @@ function createProviderServiceHarness(
6861 const now = new Date().toISOString();
6962 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
7063 const rollbackConversation = vi.fn(
71 (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void,
64 (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void,
7265 );
7366
7467 const unsupported = <A>() =>
......@@ -77,11 +70,10 @@ function createProviderServiceHarness(
7770 hasSession
7871 ? Effect.succeed([
7972 {
80 sessionId: asSessionId("sess-1"),
8173 provider: providerName,
8274 status: "ready",
8375 runtimeMode: "full-access",
84 threadId: asProviderThreadId("provider-thread-1"),
76 threadId: ThreadId.makeUnsafe("thread-1"),
8577 cwd: sessionCwd,
8678 createdAt: now,
8779 updatedAt: now,
......@@ -344,8 +336,6 @@ describe("CheckpointReactor", () => {
344336 threadId: ThreadId.makeUnsafe("thread-1"),
345337 status: "ready",
346338 providerName: "codex",
347 providerSessionId: asSessionId("sess-1"),
348 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
349339 runtimeMode: "approval-required",
350340 activeTurnId: null,
351341 lastError: null,
......@@ -359,10 +349,10 @@ describe("CheckpointReactor", () => {
359349 type: "turn.started",
360350 eventId: EventId.makeUnsafe("evt-turn-started-1"),
361351 provider: "codex",
362 sessionId: asSessionId("sess-1"),
352
363353 createdAt: new Date().toISOString(),
364 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
365 turnId: asProviderTurnId("turn-1"),
354 threadId: ThreadId.makeUnsafe("thread-1"),
355 turnId: asTurnId("turn-1"),
366356 });
367357 await waitForGitRefExists(
368358 harness.cwd,
......@@ -374,10 +364,10 @@ describe("CheckpointReactor", () => {
374364 type: "turn.completed",
375365 eventId: EventId.makeUnsafe("evt-turn-completed-1"),
376366 provider: "codex",
377 sessionId: asSessionId("sess-1"),
367
378368 createdAt: new Date().toISOString(),
379 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
380 turnId: asProviderTurnId("turn-1"),
369 threadId: ThreadId.makeUnsafe("thread-1"),
370 turnId: asTurnId("turn-1"),
381371 payload: { state: "completed" },
382372 });
383373
......@@ -422,8 +412,6 @@ describe("CheckpointReactor", () => {
422412 threadId: ThreadId.makeUnsafe("thread-1"),
423413 status: "running",
424414 providerName: "codex",
425 providerSessionId: asSessionId("sess-1"),
426 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
427415 runtimeMode: "approval-required",
428416 activeTurnId: asTurnId("turn-main"),
429417 lastError: null,
......@@ -437,10 +425,10 @@ describe("CheckpointReactor", () => {
437425 type: "turn.started",
438426 eventId: EventId.makeUnsafe("evt-turn-started-main"),
439427 provider: "codex",
440 sessionId: asSessionId("sess-1"),
428
441429 createdAt: new Date().toISOString(),
442 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
443 turnId: asProviderTurnId("turn-main"),
430 threadId: ThreadId.makeUnsafe("thread-1"),
431 turnId: asTurnId("turn-main"),
444432 });
445433 await waitForGitRefExists(
446434 harness.cwd,
......@@ -453,10 +441,10 @@ describe("CheckpointReactor", () => {
453441 type: "turn.completed",
454442 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),
455443 provider: "codex",
456 sessionId: asSessionId("sess-1"),
444
457445 createdAt: new Date().toISOString(),
458 threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"),
459 turnId: asProviderTurnId("turn-aux"),
446 threadId: ThreadId.makeUnsafe("thread-1"),
447 turnId: asTurnId("turn-aux"),
460448 payload: { state: "completed" },
461449 });
462450
......@@ -471,10 +459,10 @@ describe("CheckpointReactor", () => {
471459 type: "turn.completed",
472460 eventId: EventId.makeUnsafe("evt-turn-completed-main"),
473461 provider: "codex",
474 sessionId: asSessionId("sess-1"),
462
475463 createdAt: new Date().toISOString(),
476 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
477 turnId: asProviderTurnId("turn-main"),
464 threadId: ThreadId.makeUnsafe("thread-1"),
465 turnId: asTurnId("turn-main"),
478466 payload: { state: "completed" },
479467 });
480468
......@@ -501,8 +489,6 @@ describe("CheckpointReactor", () => {
501489 threadId: ThreadId.makeUnsafe("thread-1"),
502490 status: "ready",
503491 providerName: "claudeCode",
504 providerSessionId: asSessionId("sess-1"),
505 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
506492 runtimeMode: "approval-required",
507493 activeTurnId: null,
508494 lastError: null,
......@@ -516,10 +502,10 @@ describe("CheckpointReactor", () => {
516502 type: "turn.started",
517503 eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),
518504 provider: "claudeCode",
519 sessionId: asSessionId("sess-1"),
505
520506 createdAt: new Date().toISOString(),
521 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
522 turnId: asProviderTurnId("turn-claude-1"),
507 threadId: ThreadId.makeUnsafe("thread-1"),
508 turnId: asTurnId("turn-claude-1"),
523509 });
524510 await waitForGitRefExists(
525511 harness.cwd,
......@@ -531,10 +517,10 @@ describe("CheckpointReactor", () => {
531517 type: "turn.completed",
532518 eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),
533519 provider: "claudeCode",
534 sessionId: asSessionId("sess-1"),
520
535521 createdAt: new Date().toISOString(),
536 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
537 turnId: asProviderTurnId("turn-claude-1"),
522 threadId: ThreadId.makeUnsafe("thread-1"),
523 turnId: asTurnId("turn-claude-1"),
538524 payload: { state: "completed" },
539525 });
540526
......@@ -563,8 +549,6 @@ describe("CheckpointReactor", () => {
563549 threadId: ThreadId.makeUnsafe("thread-1"),
564550 status: "ready",
565551 providerName: "codex",
566 providerSessionId: asSessionId("sess-1"),
567 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
568552 runtimeMode: "approval-required",
569553 activeTurnId: null,
570554 lastError: null,
......@@ -578,10 +562,10 @@ describe("CheckpointReactor", () => {
578562 type: "turn.completed",
579563 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),
580564 provider: "codex",
581 sessionId: asSessionId("sess-1"),
565
582566 createdAt: new Date().toISOString(),
583 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
584 turnId: asProviderTurnId("turn-missing-baseline"),
567 threadId: ThreadId.makeUnsafe("thread-1"),
568 turnId: asTurnId("turn-missing-baseline"),
585569 payload: { state: "completed" },
586570 });
587571
......@@ -652,8 +636,6 @@ describe("CheckpointReactor", () => {
652636 threadId: ThreadId.makeUnsafe("thread-1"),
653637 status: "running",
654638 providerName: "codex",
655 providerSessionId: asSessionId("sess-missing"),
656 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),
657639 runtimeMode: "approval-required",
658640 activeTurnId: asTurnId("turn-missing-cwd"),
659641 lastError: null,
......@@ -668,10 +650,10 @@ describe("CheckpointReactor", () => {
668650 type: "turn.completed",
669651 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
670652 provider: "codex",
671 sessionId: asSessionId("sess-missing"),
653
672654 createdAt: new Date().toISOString(),
673 threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"),
674 turnId: asProviderTurnId("turn-missing-cwd"),
655 threadId: ThreadId.makeUnsafe("thread-1"),
656 turnId: asTurnId("turn-missing-cwd"),
675657 payload: { state: "completed" },
676658 });
677659
......@@ -701,8 +683,6 @@ describe("CheckpointReactor", () => {
701683 threadId: ThreadId.makeUnsafe("thread-1"),
702684 status: "ready",
703685 providerName: "codex",
704 providerSessionId: asSessionId("sess-1"),
705 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
706686 runtimeMode: "approval-required",
707687 activeTurnId: null,
708688 lastError: null,
......@@ -716,10 +696,10 @@ describe("CheckpointReactor", () => {
716696 type: "checkpoint.captured",
717697 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
718698 provider: "codex",
719 sessionId: asSessionId("sess-1"),
699
720700 createdAt: new Date().toISOString(),
721 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
722 turnId: asProviderTurnId("turn-3"),
701 threadId: ThreadId.makeUnsafe("thread-1"),
702 turnId: asTurnId("turn-3"),
723703 turnCount: 3,
724704 status: "completed",
725705 });
......@@ -753,8 +733,6 @@ describe("CheckpointReactor", () => {
753733 threadId: ThreadId.makeUnsafe("thread-1"),
754734 status: "ready",
755735 providerName: "codex",
756 providerSessionId: asSessionId("sess-1"),
757 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
758736 runtimeMode: "approval-required",
759737 activeTurnId: null,
760738 lastError: null,
......@@ -768,10 +746,10 @@ describe("CheckpointReactor", () => {
768746 type: "turn.completed",
769747 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
770748 provider: "codex",
771 sessionId: asSessionId("sess-1"),
749
772750 createdAt: new Date().toISOString(),
773 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
774 turnId: asProviderTurnId("turn-runtime-failure"),
751 threadId: ThreadId.makeUnsafe("thread-1"),
752 turnId: asTurnId("turn-runtime-failure"),
775753 payload: { state: "completed" },
776754 });
777755
......@@ -779,10 +757,10 @@ describe("CheckpointReactor", () => {
779757 type: "turn.started",
780758 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
781759 provider: "codex",
782 sessionId: asSessionId("sess-1"),
760
783761 createdAt: new Date().toISOString(),
784 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
785 turnId: asProviderTurnId("turn-after-runtime-failure"),
762 threadId: ThreadId.makeUnsafe("thread-1"),
763 turnId: asTurnId("turn-after-runtime-failure"),
786764 });
787765
788766 await waitForGitRefExists(
......@@ -807,8 +785,6 @@ describe("CheckpointReactor", () => {
807785 threadId: ThreadId.makeUnsafe("thread-1"),
808786 status: "ready",
809787 providerName: "codex",
810 providerSessionId: asSessionId("sess-1"),
811 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
812788 runtimeMode: "approval-required",
813789 activeTurnId: null,
814790 lastError: null,
......@@ -865,7 +841,7 @@ describe("CheckpointReactor", () => {
865841 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
866842 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
867843 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
868 sessionId: asSessionId("sess-1"),
844
869845 numTurns: 1,
870846 });
871847 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
......@@ -887,8 +863,6 @@ describe("CheckpointReactor", () => {
887863 threadId: ThreadId.makeUnsafe("thread-1"),
888864 status: "ready",
889865 providerName: "claudeCode",
890 providerSessionId: asSessionId("sess-1"),
891 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
892866 runtimeMode: "approval-required",
893867 activeTurnId: null,
894868 lastError: null,
......@@ -940,7 +914,7 @@ describe("CheckpointReactor", () => {
940914 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
941915 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
942916 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
943 sessionId: asSessionId("sess-1"),
917
944918 numTurns: 1,
945919 });
946920 });
......@@ -958,8 +932,6 @@ describe("CheckpointReactor", () => {
958932 threadId: ThreadId.makeUnsafe("thread-1"),
959933 status: "ready",
960934 providerName: "codex",
961 providerSessionId: asSessionId("sess-1"),
962 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
963935 runtimeMode: "approval-required",
964936 activeTurnId: null,
965937 lastError: null,
......@@ -1032,11 +1004,11 @@ describe("CheckpointReactor", () => {
10321004
10331005 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
10341006 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
1035 sessionId: asSessionId("sess-1"),
1007
10361008 numTurns: 1,
10371009 });
10381010 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({
1039 sessionId: asSessionId("sess-1"),
1011
10401012 numTurns: 1,
10411013 });
10421014 });
apps/server/src/orchestration/Layers/CheckpointReactor.ts+27-71
......@@ -2,13 +2,10 @@ import {
22 CommandId,
33 EventId,
44 MessageId,
5 ProviderSessionId,
6 ProviderThreadId,
75 ThreadId,
86 TurnId,
97 type OrchestrationEvent,
108 type ProviderRuntimeEvent,
11 RuntimeSessionId,
129} from "@t3tools/contracts";
1310import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
1411
......@@ -38,14 +35,6 @@ function toTurnId(value: string | undefined): TurnId | null {
3835 return value === undefined ? null : TurnId.makeUnsafe(String(value));
3936}
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
4938function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
5039 if (left === null || left === undefined || right === null || right === undefined) {
5140 return false;
......@@ -100,107 +89,75 @@ const make = Effect.gen(function* () {
10089 });
10190
10291 const appendCaptureFailureActivity = (input: {
103 readonly sessionId: ProviderSessionId;
92 readonly threadId: ThreadId;
10493 readonly turnId: TurnId | null;
10594 readonly detail: string;
10695 readonly createdAt: string;
10796 }) =>
108 Effect.gen(function* () {
109 const readModel = yield* orchestrationEngine.getReadModel();
110 const thread = readModel.threads.find(
111 (entry) => entry.session?.providerSessionId === input.sessionId,
112 );
113 if (!thread) {
114 return;
115 }
116 yield* orchestrationEngine.dispatch({
117 type: "thread.activity.append",
118 commandId: serverCommandId("checkpoint-capture-failure"),
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,
97 orchestrationEngine.dispatch({
98 type: "thread.activity.append",
99 commandId: serverCommandId("checkpoint-capture-failure"),
100 threadId: input.threadId,
101 activity: {
102 id: EventId.makeUnsafe(crypto.randomUUID()),
103 tone: "error",
104 kind: "checkpoint.capture.failed",
105 summary: "Checkpoint capture failed",
106 payload: {
107 detail: input.detail,
130108 },
109 turnId: input.turnId,
131110 createdAt: input.createdAt,
132 });
111 },
112 createdAt: input.createdAt,
133113 });
134114
135115 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (
136116 threadId: ThreadId,
137117 ): Effect.fn.Return<
138 Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }>
118 Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>
139119 > {
140120 const readModel = yield* orchestrationEngine.getReadModel();
141121 const thread = readModel.threads.find((entry) => entry.id === threadId);
142 const projectedSessionId = thread?.session?.providerSessionId ?? null;
143 const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined;
144122
145123 const sessions = yield* providerService.listSessions();
146124
147125 const findSessionWithCwd = (
148126 session: (typeof sessions)[number] | undefined,
149 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {
127 ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => {
150128 if (!session?.cwd) {
151129 return Option.none();
152130 }
153 return Option.some({ sessionId: session.sessionId, cwd: session.cwd });
131 return Option.some({ threadId: session.threadId, cwd: session.cwd });
154132 };
155133
156 if (projectedSessionId !== null) {
157 const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId);
134 if (thread) {
135 const projectedSession = sessions.find(
136 (session) => session.threadId === thread.id,
137 );
158138 const fromProjected = findSessionWithCwd(projectedSession);
159139 if (Option.isSome(fromProjected)) {
160140 return fromProjected;
161141 }
162142 }
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
174144 return Option.none();
175145 });
176146
177147 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
178148 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
179149 ) {
180 const providerSessionId = toProviderSessionId(event.sessionId);
181150 const turnId = toTurnId(event.turnId);
182151 if (!turnId) {
183152 return;
184153 }
185154
186155 const readModel = yield* orchestrationEngine.getReadModel();
187 const thread = readModel.threads.find(
188 (entry) => entry.session?.providerSessionId === providerSessionId,
189 );
156 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
190157 if (!thread) {
191158 return;
192159 }
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
204161 // When a primary turn is active, only that turn may produce completion checkpoints.
205162 if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) {
206163 return;
......@@ -272,7 +229,7 @@ const make = Effect.gen(function* () {
272229 ),
273230 Effect.tapError((error) =>
274231 appendCaptureFailureActivity({
275 sessionId: providerSessionId,
232 threadId: thread.id,
276233 turnId,
277234 detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`,
278235 createdAt: event.createdAt,
......@@ -332,7 +289,6 @@ const make = Effect.gen(function* () {
332289 const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* (
333290 event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>,
334291 ) {
335 const providerSessionId = toProviderSessionId(event.sessionId);
336292 const turnId = toTurnId(event.turnId);
337293 if (!turnId) {
338294 return;
......@@ -340,7 +296,7 @@ const make = Effect.gen(function* () {
340296
341297 const readModel = yield* orchestrationEngine.getReadModel();
342298 const thread = readModel.threads.find(
343 (entry) => entry.session?.providerSessionId === providerSessionId,
299 (entry) => entry.id === event.threadId,
344300 );
345301 if (!thread) {
346302 return;
......@@ -520,7 +476,7 @@ const make = Effect.gen(function* () {
520476 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
521477 if (rolledBackTurns > 0) {
522478 yield* providerService.rollbackConversation({
523 sessionId: sessionRuntime.value.sessionId,
479 threadId: sessionRuntime.value.threadId,
524480 numTurns: rolledBackTurns,
525481 });
526482 }
......@@ -588,7 +544,7 @@ const make = Effect.gen(function* () {
588544 yield* captureCheckpointFromTurnCompletion(event).pipe(
589545 Effect.catch((error) =>
590546 appendCaptureFailureActivity({
591 sessionId: toProviderSessionId(event.sessionId),
547 threadId: event.threadId,
592548 turnId,
593549 detail: error.message,
594550 createdAt: new Date().toISOString(),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+1-8
......@@ -4,8 +4,6 @@ import {
44 CorrelationId,
55 EventId,
66 MessageId,
7 ProviderSessionId,
8 ProviderThreadId,
97 ProjectId,
108 ThreadId,
119 TurnId,
......@@ -1771,18 +1769,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
17711769 commandId: CommandId.makeUnsafe("cmd-restart-2"),
17721770 causationEventId: null,
17731771 correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),
1774 metadata: {
1775 providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
1776 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
1777 },
1772 metadata: {},
17781773 payload: {
17791774 threadId,
17801775 session: {
17811776 threadId,
17821777 status: "running",
17831778 providerName: "codex",
1784 providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
1785 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
17861779 runtimeMode: "approval-required",
17871780 activeTurnId: turnId,
17881781 lastError: null,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts-2
......@@ -665,8 +665,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
665665 threadId: event.payload.threadId,
666666 status: event.payload.session.status,
667667 providerName: event.payload.session.providerName,
668 providerSessionId: event.payload.session.providerSessionId,
669 providerThreadId: event.payload.session.providerThreadId,
670668 runtimeMode: event.payload.session.runtimeMode,
671669 activeTurnId: event.payload.session.activeTurnId,
672670 lastError: event.payload.session.lastError,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts-7
......@@ -3,8 +3,6 @@ import {
33 EventId,
44 MessageId,
55 ProjectId,
6 ProviderSessionId,
7 ProviderThreadId,
86 ThreadId,
97 TurnId,
108} from "@t3tools/contracts";
......@@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
2220const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2321const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
2422const 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
2924const projectionSnapshotLayer = it.layer(
3025 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
......@@ -290,8 +285,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
290285 threadId: ThreadId.makeUnsafe("thread-1"),
291286 status: "running",
292287 providerName: "codex",
293 providerSessionId: asProviderSessionId("provider-session-1"),
294 providerThreadId: asProviderThreadId("provider-thread-1"),
295288 runtimeMode: "approval-required",
296289 activeTurnId: asTurnId("turn-1"),
297290 lastError: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts-2
......@@ -461,8 +461,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
461461 threadId: row.threadId,
462462 status: row.status,
463463 providerName: row.providerName,
464 providerSessionId: row.providerSessionId,
465 providerThreadId: row.providerThreadId,
466464 runtimeMode: row.runtimeMode,
467465 activeTurnId: row.activeTurnId,
468466 lastError: row.lastError,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+31-39
......@@ -9,9 +9,6 @@ import {
99 EventId,
1010 MessageId,
1111 ProjectId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
1512 ThreadId,
1613 TurnId,
1714} from "@t3tools/contracts";
......@@ -38,8 +35,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
3835import * as NodeServices from "@effect/platform-node/NodeServices";
3936
4037const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
41const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
42const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
4338const asApprovalRequestId = (value: string): ApprovalRequestId =>
4439 ApprovalRequestId.makeUnsafe(value);
4540const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
......@@ -110,8 +105,14 @@ describe("ProviderCommandReactor", () => {
110105 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"
111106 ? input.model
112107 : 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}`);
113115 const session: ProviderSession = {
114 sessionId: asSessionId(`sess-${sessionIndex}`),
115116 provider,
116117 status: "ready" as const,
117118 runtimeMode:
......@@ -122,7 +123,7 @@ describe("ProviderCommandReactor", () => {
122123 ? input.runtimeMode
123124 : "full-access",
124125 ...(model !== undefined ? { model } : {}),
125 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
126 threadId,
126127 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
127128 createdAt: now,
128129 updatedAt: now,
......@@ -132,22 +133,22 @@ describe("ProviderCommandReactor", () => {
132133 });
133134 const sendTurn = vi.fn((_: unknown) =>
134135 Effect.succeed({
135 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
136 turnId: asProviderTurnId("provider-turn-1"),
136 threadId: ThreadId.makeUnsafe("thread-1"),
137 turnId: asTurnId("turn-1"),
137138 }),
138139 );
139140 const interruptTurn = vi.fn((_: unknown) => Effect.void);
140141 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
141142 const stopSession = vi.fn((input: unknown) =>
142143 Effect.sync(() => {
143 const sessionId =
144 typeof input === "object" && input !== null && "sessionId" in input
145 ? (input as { sessionId?: ProviderSessionId }).sessionId
144 const threadId =
145 typeof input === "object" && input !== null && "threadId" in input
146 ? (input as { threadId?: ThreadId }).threadId
146147 : undefined;
147 if (!sessionId) {
148 if (!threadId) {
148149 return;
149150 }
150 const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId);
151 const index = runtimeSessions.findIndex((session) => session.threadId === threadId);
151152 if (index >= 0) {
152153 runtimeSessions.splice(index, 1);
153154 }
......@@ -284,7 +285,7 @@ describe("ProviderCommandReactor", () => {
284285
285286 const readModel = await Effect.runPromise(harness.engine.getReadModel());
286287 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");
288289 expect(thread?.session?.runtimeMode).toBe("approval-required");
289290 });
290291
......@@ -321,7 +322,7 @@ describe("ProviderCommandReactor", () => {
321322 const readModel = await Effect.runPromise(harness.engine.getReadModel());
322323 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
323324 expect(thread?.session?.providerName).toBe("claudeCode");
324 expect(thread?.session?.providerSessionId).toBe("sess-1");
325 expect(thread?.session?.threadId).toBe("thread-1");
325326 });
326327
327328 it("starts first turn with cursor provider when provider is specified", async () => {
......@@ -357,7 +358,7 @@ describe("ProviderCommandReactor", () => {
357358 const readModel = await Effect.runPromise(harness.engine.getReadModel());
358359 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
359360 expect(thread?.session?.providerName).toBe("cursor");
360 expect(thread?.session?.providerSessionId).toBe("sess-1");
361 expect(thread?.session?.threadId).toBe("thread-1");
361362 });
362363
363364 it("reuses the same provider session when runtime mode is unchanged", async () => {
......@@ -500,7 +501,7 @@ describe("ProviderCommandReactor", () => {
500501 expect(harness.stopSession.mock.calls.length).toBe(0);
501502 expect(harness.startSession.mock.calls.length).toBe(1);
502503 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
503 sessionId: asSessionId("sess-1"),
504 threadId: ThreadId.makeUnsafe("thread-1"),
504505 model: "gpt-5.3-codex",
505506 });
506507 });
......@@ -554,8 +555,6 @@ describe("ProviderCommandReactor", () => {
554555 return thread?.runtimeMode === "approval-required";
555556 });
556557 await waitFor(() => harness.startSession.mock.calls.length === 2);
557 await waitFor(() => harness.stopSession.mock.calls.length === 1);
558
559558 await Effect.runPromise(
560559 harness.engine.dispatch({
561560 type: "thread.turn.start",
......@@ -574,18 +573,19 @@ describe("ProviderCommandReactor", () => {
574573
575574 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);
578577 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
578 threadId: ThreadId.makeUnsafe("thread-1"),
579579 resumeCursor: { opaque: "cursor-1" },
580580 runtimeMode: "approval-required",
581581 });
582582 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
583 sessionId: asSessionId("sess-2"),
583 threadId: ThreadId.makeUnsafe("thread-1"),
584584 });
585585
586586 const readModel = await Effect.runPromise(harness.engine.getReadModel());
587587 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");
589589 expect(thread?.session?.runtimeMode).toBe("approval-required");
590590 });
591591
......@@ -629,12 +629,12 @@ describe("ProviderCommandReactor", () => {
629629 }),
630630 );
631631
632 await waitFor(() => harness.stopSession.mock.calls.length === 1);
633632 await waitFor(() => harness.startSession.mock.calls.length === 2);
634633 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);
637636 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
637 threadId: ThreadId.makeUnsafe("thread-1"),
638638 provider: "claudeCode",
639639 runtimeMode: "approval-required",
640640 });
......@@ -642,7 +642,7 @@ describe("ProviderCommandReactor", () => {
642642
643643 const readModel = await Effect.runPromise(harness.engine.getReadModel());
644644 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");
646646 expect(thread?.session?.providerName).toBe("claudeCode");
647647 expect(thread?.session?.runtimeMode).toBe("approval-required");
648648 });
......@@ -707,7 +707,7 @@ describe("ProviderCommandReactor", () => {
707707
708708 const readModel = await Effect.runPromise(harness.engine.getReadModel());
709709 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");
711711 expect(thread?.session?.runtimeMode).toBe("full-access");
712712 });
713713
......@@ -724,8 +724,6 @@ describe("ProviderCommandReactor", () => {
724724 threadId: ThreadId.makeUnsafe("thread-1"),
725725 status: "running",
726726 providerName: "codex",
727 providerSessionId: asSessionId("sess-1"),
728 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
729727 runtimeMode: "approval-required",
730728 activeTurnId: asTurnId("turn-1"),
731729 lastError: null,
......@@ -747,7 +745,8 @@ describe("ProviderCommandReactor", () => {
747745
748746 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);
749747 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
750 sessionId: "sess-1",
748 threadId: "thread-1",
749 turnId: "turn-1",
751750 });
752751 });
753752
......@@ -764,8 +763,6 @@ describe("ProviderCommandReactor", () => {
764763 threadId: ThreadId.makeUnsafe("thread-1"),
765764 status: "running",
766765 providerName: "codex",
767 providerSessionId: asSessionId("sess-1"),
768 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
769766 runtimeMode: "approval-required",
770767 activeTurnId: null,
771768 lastError: null,
......@@ -788,7 +785,7 @@ describe("ProviderCommandReactor", () => {
788785
789786 await waitFor(() => harness.respondToRequest.mock.calls.length === 1);
790787 expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({
791 sessionId: "sess-1",
788 threadId: "thread-1",
792789 requestId: "approval-request-1",
793790 decision: "accept",
794791 });
......@@ -816,8 +813,6 @@ describe("ProviderCommandReactor", () => {
816813 threadId: ThreadId.makeUnsafe("thread-1"),
817814 status: "running",
818815 providerName: "cursor",
819 providerSessionId: asSessionId("sess-1"),
820 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
821816 runtimeMode: "approval-required",
822817 activeTurnId: null,
823818 lastError: null,
......@@ -901,8 +896,6 @@ describe("ProviderCommandReactor", () => {
901896 threadId: ThreadId.makeUnsafe("thread-1"),
902897 status: "ready",
903898 providerName: "codex",
904 providerSessionId: asSessionId("sess-1"),
905 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
906899 runtimeMode: "approval-required",
907900 activeTurnId: null,
908901 lastError: null,
......@@ -926,8 +919,7 @@ describe("ProviderCommandReactor", () => {
926919 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
927920 expect(thread?.session).not.toBeNull();
928921 expect(thread?.session?.status).toBe("stopped");
929 expect(thread?.session?.providerSessionId).toBeNull();
930 expect(thread?.session?.providerThreadId).toBeNull();
922 expect(thread?.session?.threadId).toBe("thread-1");
931923 expect(thread?.session?.activeTurnId).toBeNull();
932924 });
933925});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+26-45
......@@ -4,9 +4,8 @@ import {
44 EventId,
55 type OrchestrationEvent,
66 type ProviderKind,
7 type ProviderSessionId,
87 type OrchestrationSession,
9 type ThreadId,
8 ThreadId,
109 type ProviderSession,
1110 type RuntimeMode,
1211 type TurnId,
......@@ -220,9 +219,9 @@ const make = Effect.gen(function* () {
220219 projects: readModel.projects,
221220 });
222221
223 const resolveActiveSession = (sessionId: ProviderSessionId) =>
222 const resolveActiveSession = (threadId: ThreadId) =>
224223 providerService.listSessions().pipe(
225 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),
224 Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)),
226225 );
227226
228227 const startProviderSession = (input?: {
......@@ -230,6 +229,7 @@ const make = Effect.gen(function* () {
230229 readonly provider?: ProviderKind;
231230 }) =>
232231 providerService.startSession(threadId, {
232 threadId,
233233 ...(input?.provider ?? preferredProvider
234234 ? { provider: input?.provider ?? preferredProvider }
235235 : {}),
......@@ -246,8 +246,6 @@ const make = Effect.gen(function* () {
246246 threadId,
247247 status: mapProviderSessionStatusToOrchestrationStatus(session.status),
248248 providerName: session.provider,
249 providerSessionId: session.sessionId,
250 providerThreadId: session.threadId ?? null,
251249 runtimeMode: desiredRuntimeMode,
252250 // Provider turn ids are not orchestration turn ids.
253251 activeTurnId: null,
......@@ -257,11 +255,12 @@ const make = Effect.gen(function* () {
257255 createdAt,
258256 });
259257
260 const existingSessionId = thread.session?.providerSessionId;
261 if (existingSessionId) {
258 const existingSessionThreadId =
259 thread.session && thread.session.status !== "stopped" ? thread.id : null;
260 if (existingSessionThreadId) {
262261 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
263262 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
264 const activeSession = yield* resolveActiveSession(existingSessionId);
263 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
265264 const sessionModelSwitch =
266265 currentProvider === undefined
267266 ? "in-session"
......@@ -271,12 +270,8 @@ const make = Effect.gen(function* () {
271270 const shouldRestartForModelChange =
272271 modelChanged && sessionModelSwitch === "restart-session";
273272
274 if (
275 !runtimeModeChanged &&
276 !providerChanged &&
277 !shouldRestartForModelChange
278 ) {
279 return existingSessionId;
273 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {
274 return existingSessionThreadId;
280275 }
281276
282277 const resumeCursor =
......@@ -285,7 +280,7 @@ const make = Effect.gen(function* () {
285280 : (activeSession?.resumeCursor ?? undefined);
286281 yield* Effect.logInfo("provider command reactor restarting provider session", {
287282 threadId,
288 existingSessionId,
283 existingSessionThreadId,
289284 currentProvider,
290285 desiredProvider: options?.provider ?? currentProvider,
291286 currentRuntimeMode: thread.session?.runtimeMode,
......@@ -302,30 +297,20 @@ const make = Effect.gen(function* () {
302297 });
303298 yield* Effect.logInfo("provider command reactor restarted provider session", {
304299 threadId,
305 previousSessionId: existingSessionId,
306 restartedSessionId: restartedSession.sessionId,
300 previousSessionId: existingSessionThreadId,
301 restartedSessionThreadId: restartedSession.threadId,
307302 provider: restartedSession.provider,
308 providerThreadId: restartedSession.threadId,
309303 runtimeMode: restartedSession.runtimeMode,
310304 });
311305 yield* bindSessionToThread(restartedSession);
312 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
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;
306 return restartedSession.threadId;
322307 }
323308
324309 const startedSession = yield* startProviderSession(
325310 options?.provider !== undefined ? { provider: options.provider } : undefined,
326311 );
327312 yield* bindSessionToThread(startedSession);
328 return startedSession.sessionId;
313 return startedSession.threadId;
329314 });
330315
331316 const sendTurnForThread = Effect.fnUntraced(function* (input: {
......@@ -341,14 +326,14 @@ const make = Effect.gen(function* () {
341326 if (!thread) {
342327 return;
343328 }
344 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {
329 yield* ensureSessionForThread(input.threadId, input.createdAt, {
345330 ...(input.provider !== undefined ? { provider: input.provider } : {}),
346331 ...(input.model !== undefined ? { model: input.model } : {}),
347332 });
348333 const normalizedInput = toNonEmptyProviderInput(input.messageText);
349334 const normalizedAttachments = input.attachments ?? [];
350335 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)),
352337 );
353338 const sessionModelSwitch =
354339 activeSession === undefined
......@@ -358,7 +343,7 @@ const make = Effect.gen(function* () {
358343 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
359344
360345 yield* providerService.sendTurn({
361 sessionId,
346 threadId: input.threadId,
362347 ...(normalizedInput ? { input: normalizedInput } : {}),
363348 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
364349 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
......@@ -487,8 +472,8 @@ const make = Effect.gen(function* () {
487472 if (!thread) {
488473 return;
489474 }
490 const sessionId = thread.session?.providerSessionId;
491 if (!sessionId) {
475 const hasSession = thread.session && thread.session.status !== "stopped";
476 if (!hasSession) {
492477 return yield* appendProviderFailureActivity({
493478 threadId: event.payload.threadId,
494479 kind: "provider.turn.interrupt.failed",
......@@ -500,7 +485,7 @@ const make = Effect.gen(function* () {
500485 }
501486
502487 // 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 });
504489 });
505490
506491 const processApprovalResponseRequested = Effect.fnUntraced(function* (
......@@ -510,8 +495,8 @@ const make = Effect.gen(function* () {
510495 if (!thread) {
511496 return;
512497 }
513 const sessionId = thread.session?.providerSessionId;
514 if (!sessionId) {
498 const hasSession = thread.session && thread.session.status !== "stopped";
499 if (!hasSession) {
515500 return yield* appendProviderFailureActivity({
516501 threadId: event.payload.threadId,
517502 kind: "provider.approval.respond.failed",
......@@ -525,7 +510,7 @@ const make = Effect.gen(function* () {
525510
526511 yield* providerService
527512 .respondToRequest({
528 sessionId,
513 threadId: event.payload.threadId,
529514 requestId: event.payload.requestId,
530515 decision: event.payload.decision,
531516 })
......@@ -559,10 +544,8 @@ const make = Effect.gen(function* () {
559544 }
560545
561546 const now = event.payload.createdAt;
562 const sessionId = thread.session?.providerSessionId;
563
564 if (sessionId) {
565 yield* providerService.stopSession({ sessionId });
547 if (thread.session && thread.session.status !== "stopped") {
548 yield* providerService.stopSession({ threadId: thread.id });
566549 }
567550
568551 yield* setThreadSession({
......@@ -571,8 +554,6 @@ const make = Effect.gen(function* () {
571554 threadId: thread.id,
572555 status: "stopped",
573556 providerName: thread.session?.providerName ?? null,
574 providerSessionId: null,
575 providerThreadId: null,
576557 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
577558 activeTurnId: null,
578559 lastError: thread.session?.lastError ?? null,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+89-113
......@@ -6,10 +6,8 @@ import {
66 MessageId,
77 ProjectId,
88 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
129 ThreadId,
10 TurnId,
1311} from "@t3tools/contracts";
1412import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
1513import { afterEach, describe, expect, it } from "vitest";
......@@ -33,20 +31,18 @@ import { ServerConfig } from "../../config.ts";
3331import * as NodeServices from "@effect/platform-node/NodeServices";
3432
3533const 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);
3934const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
4035const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
4136const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
37const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
38const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4239
4340type LegacyProviderRuntimeEvent = {
4441 readonly type: string;
4542 readonly eventId: EventId;
4643 readonly provider: "codex" | "claudeCode" | "cursor";
47 readonly sessionId: string;
4844 readonly createdAt: string;
49 readonly threadId?: string | undefined;
45 readonly threadId: ThreadId;
5046 readonly turnId?: string | undefined;
5147 readonly itemId?: string | undefined;
5248 readonly requestId?: string | undefined;
......@@ -182,8 +178,6 @@ describe("ProviderRuntimeIngestion", () => {
182178 threadId: ThreadId.makeUnsafe("thread-1"),
183179 status: "ready",
184180 providerName: "codex",
185 providerSessionId: asSessionId("sess-1"),
186 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
187181 runtimeMode: "approval-required",
188182 activeTurnId: null,
189183 updatedAt: createdAt,
......@@ -207,9 +201,9 @@ describe("ProviderRuntimeIngestion", () => {
207201 type: "turn.started",
208202 eventId: asEventId("evt-turn-started"),
209203 provider: "codex",
210 sessionId: asSessionId("sess-1"),
204 threadId: asThreadId("thread-1"),
211205 createdAt: now,
212 turnId: asProviderTurnId("turn-1"),
206 turnId: asTurnId("turn-1"),
213207 });
214208
215209 await waitForThread(
......@@ -221,9 +215,9 @@ describe("ProviderRuntimeIngestion", () => {
221215 type: "turn.completed",
222216 eventId: asEventId("evt-turn-completed"),
223217 provider: "codex",
224 sessionId: asSessionId("sess-1"),
218 threadId: asThreadId("thread-1"),
225219 createdAt: new Date().toISOString(),
226 turnId: asProviderTurnId("turn-1"),
220 turnId: asTurnId("turn-1"),
227221 payload: {
228222 state: "failed",
229223 errorMessage: "turn failed",
......@@ -249,9 +243,8 @@ describe("ProviderRuntimeIngestion", () => {
249243 type: "session.state.changed",
250244 eventId: asEventId("evt-session-state-waiting"),
251245 provider: "codex",
252 sessionId: asSessionId("sess-1"),
246 threadId: asThreadId("thread-1"),
253247 createdAt: waitingAt,
254 threadId: asProviderThreadId("provider-thread-1"),
255248 payload: {
256249 state: "waiting",
257250 reason: "awaiting approval",
......@@ -269,9 +262,8 @@ describe("ProviderRuntimeIngestion", () => {
269262 type: "session.state.changed",
270263 eventId: asEventId("evt-session-state-error"),
271264 provider: "codex",
272 sessionId: asSessionId("sess-1"),
265 threadId: asThreadId("thread-1"),
273266 createdAt: new Date().toISOString(),
274 threadId: asProviderThreadId("provider-thread-1"),
275267 payload: {
276268 state: "error",
277269 reason: "provider crashed",
......@@ -292,9 +284,8 @@ describe("ProviderRuntimeIngestion", () => {
292284 type: "session.state.changed",
293285 eventId: asEventId("evt-session-state-stopped"),
294286 provider: "codex",
295 sessionId: asSessionId("sess-1"),
287 threadId: asThreadId("thread-1"),
296288 createdAt: new Date().toISOString(),
297 threadId: asProviderThreadId("provider-thread-1"),
298289 payload: {
299290 state: "stopped",
300291 },
......@@ -314,9 +305,8 @@ describe("ProviderRuntimeIngestion", () => {
314305 type: "session.state.changed",
315306 eventId: asEventId("evt-session-state-ready"),
316307 provider: "codex",
317 sessionId: asSessionId("sess-1"),
308 threadId: asThreadId("thread-1"),
318309 createdAt: new Date().toISOString(),
319 threadId: asProviderThreadId("provider-thread-1"),
320310 payload: {
321311 state: "ready",
322312 },
......@@ -341,10 +331,9 @@ describe("ProviderRuntimeIngestion", () => {
341331 type: "turn.started",
342332 eventId: asEventId("evt-turn-started-midturn-lifecycle"),
343333 provider: "codex",
344 sessionId: asSessionId("sess-1"),
345334 createdAt: now,
346 threadId: asProviderThreadId("provider-thread-1"),
347 turnId: asProviderTurnId("turn-midturn-lifecycle"),
335 threadId: asThreadId("thread-1"),
336 turnId: asTurnId("turn-midturn-lifecycle"),
348337 });
349338
350339 await waitForThread(
......@@ -358,17 +347,15 @@ describe("ProviderRuntimeIngestion", () => {
358347 type: "thread.started",
359348 eventId: asEventId("evt-thread-started-midturn-lifecycle"),
360349 provider: "codex",
361 sessionId: asSessionId("sess-1"),
362350 createdAt: new Date().toISOString(),
363 threadId: asProviderThreadId("provider-thread-1"),
351 threadId: asThreadId("thread-1"),
364352 });
365353 harness.emit({
366354 type: "session.started",
367355 eventId: asEventId("evt-session-started-midturn-lifecycle"),
368356 provider: "codex",
369 sessionId: asSessionId("sess-1"),
370357 createdAt: new Date().toISOString(),
371 threadId: asProviderThreadId("provider-thread-1"),
358 threadId: asThreadId("thread-1"),
372359 });
373360
374361 await Effect.runPromise(Effect.sleep("40 millis"));
......@@ -381,10 +368,9 @@ describe("ProviderRuntimeIngestion", () => {
381368 type: "turn.completed",
382369 eventId: asEventId("evt-turn-completed-midturn-lifecycle"),
383370 provider: "codex",
384 sessionId: asSessionId("sess-1"),
385371 createdAt: new Date().toISOString(),
386 threadId: asProviderThreadId("provider-thread-1"),
387 turnId: asProviderTurnId("turn-midturn-lifecycle"),
372 threadId: asThreadId("thread-1"),
373 turnId: asTurnId("turn-midturn-lifecycle"),
388374 status: "completed",
389375 });
390376
......@@ -402,10 +388,9 @@ describe("ProviderRuntimeIngestion", () => {
402388 type: "turn.started",
403389 eventId: asEventId("evt-turn-started-primary"),
404390 provider: "codex",
405 sessionId: asSessionId("sess-1"),
406391 createdAt: now,
407 threadId: asProviderThreadId("provider-thread-1"),
408 turnId: asProviderTurnId("turn-primary"),
392 threadId: asThreadId("thread-1"),
393 turnId: asTurnId("turn-primary"),
409394 });
410395
411396 await waitForThread(
......@@ -418,10 +403,9 @@ describe("ProviderRuntimeIngestion", () => {
418403 type: "turn.completed",
419404 eventId: asEventId("evt-turn-completed-aux"),
420405 provider: "codex",
421 sessionId: asSessionId("sess-1"),
422406 createdAt: new Date().toISOString(),
423 threadId: asProviderThreadId("provider-thread-aux"),
424 turnId: asProviderTurnId("turn-aux"),
407 threadId: asThreadId("thread-1"),
408 turnId: asTurnId("turn-aux"),
425409 status: "completed",
426410 });
427411
......@@ -437,10 +421,9 @@ describe("ProviderRuntimeIngestion", () => {
437421 type: "turn.completed",
438422 eventId: asEventId("evt-turn-completed-primary"),
439423 provider: "codex",
440 sessionId: asSessionId("sess-1"),
441424 createdAt: new Date().toISOString(),
442 threadId: asProviderThreadId("provider-thread-1"),
443 turnId: asProviderTurnId("turn-primary"),
425 threadId: asThreadId("thread-1"),
426 turnId: asTurnId("turn-primary"),
444427 status: "completed",
445428 });
446429
......@@ -463,8 +446,6 @@ describe("ProviderRuntimeIngestion", () => {
463446 threadId: ThreadId.makeUnsafe("thread-1"),
464447 status: "ready",
465448 providerName: "claudeCode",
466 providerSessionId: asSessionId("sess-1"),
467 providerThreadId: asProviderThreadId("claude-thread-placeholder"),
468449 runtimeMode: "approval-required",
469450 activeTurnId: null,
470451 updatedAt: seededAt,
......@@ -478,10 +459,9 @@ describe("ProviderRuntimeIngestion", () => {
478459 type: "turn.started",
479460 eventId: asEventId("evt-turn-started-claude-placeholder"),
480461 provider: "claudeCode",
481 sessionId: asSessionId("sess-1"),
482462 createdAt: new Date().toISOString(),
483 threadId: asProviderThreadId("provider-thread-real"),
484 turnId: asProviderTurnId("turn-claude-placeholder"),
463 threadId: asThreadId("thread-1"),
464 turnId: asTurnId("turn-claude-placeholder"),
485465 });
486466
487467 await waitForThread(
......@@ -495,10 +475,9 @@ describe("ProviderRuntimeIngestion", () => {
495475 type: "turn.completed",
496476 eventId: asEventId("evt-turn-completed-claude-placeholder"),
497477 provider: "claudeCode",
498 sessionId: asSessionId("sess-1"),
499478 createdAt: new Date().toISOString(),
500 threadId: asProviderThreadId("provider-thread-real"),
501 turnId: asProviderTurnId("turn-claude-placeholder"),
479 threadId: asThreadId("thread-1"),
480 turnId: asTurnId("turn-claude-placeholder"),
502481 status: "completed",
503482 });
504483
......@@ -516,9 +495,9 @@ describe("ProviderRuntimeIngestion", () => {
516495 type: "turn.started",
517496 eventId: asEventId("evt-turn-started-guarded"),
518497 provider: "codex",
519 sessionId: asSessionId("sess-1"),
520498 createdAt: now,
521 turnId: asProviderTurnId("turn-guarded-main"),
499 threadId: asThreadId("thread-1"),
500 turnId: asTurnId("turn-guarded-main"),
522501 });
523502
524503 await waitForThread(
......@@ -532,9 +511,9 @@ describe("ProviderRuntimeIngestion", () => {
532511 type: "turn.completed",
533512 eventId: asEventId("evt-turn-completed-guarded-other"),
534513 provider: "codex",
535 sessionId: asSessionId("sess-1"),
536514 createdAt: new Date().toISOString(),
537 turnId: asProviderTurnId("turn-guarded-other"),
515 threadId: asThreadId("thread-1"),
516 turnId: asTurnId("turn-guarded-other"),
538517 status: "completed",
539518 });
540519
......@@ -550,9 +529,9 @@ describe("ProviderRuntimeIngestion", () => {
550529 type: "turn.completed",
551530 eventId: asEventId("evt-turn-completed-guarded-main"),
552531 provider: "codex",
553 sessionId: asSessionId("sess-1"),
554532 createdAt: new Date().toISOString(),
555 turnId: asProviderTurnId("turn-guarded-main"),
533 threadId: asThreadId("thread-1"),
534 turnId: asTurnId("turn-guarded-main"),
556535 status: "completed",
557536 });
558537
......@@ -570,9 +549,9 @@ describe("ProviderRuntimeIngestion", () => {
570549 type: "content.delta",
571550 eventId: asEventId("evt-message-delta-1"),
572551 provider: "codex",
573 sessionId: asSessionId("sess-1"),
574552 createdAt: now,
575 turnId: asProviderTurnId("turn-2"),
553 threadId: asThreadId("thread-1"),
554 turnId: asTurnId("turn-2"),
576555 itemId: asItemId("item-1"),
577556 payload: {
578557 streamKind: "assistant_text",
......@@ -583,9 +562,9 @@ describe("ProviderRuntimeIngestion", () => {
583562 type: "content.delta",
584563 eventId: asEventId("evt-message-delta-2"),
585564 provider: "codex",
586 sessionId: asSessionId("sess-1"),
587565 createdAt: now,
588 turnId: asProviderTurnId("turn-2"),
566 threadId: asThreadId("thread-1"),
567 turnId: asTurnId("turn-2"),
589568 itemId: asItemId("item-1"),
590569 payload: {
591570 streamKind: "assistant_text",
......@@ -596,9 +575,9 @@ describe("ProviderRuntimeIngestion", () => {
596575 type: "item.completed",
597576 eventId: asEventId("evt-message-completed"),
598577 provider: "codex",
599 sessionId: asSessionId("sess-1"),
600578 createdAt: now,
601 turnId: asProviderTurnId("turn-2"),
579 threadId: asThreadId("thread-1"),
580 turnId: asTurnId("turn-2"),
602581 itemId: asItemId("item-1"),
603582 payload: {
604583 itemType: "assistant_message",
......@@ -627,9 +606,9 @@ describe("ProviderRuntimeIngestion", () => {
627606 type: "item.completed",
628607 eventId: asEventId("evt-assistant-item-completed-no-delta"),
629608 provider: "codex",
630 sessionId: asSessionId("sess-1"),
631609 createdAt: now,
632 turnId: asProviderTurnId("turn-no-delta"),
610 threadId: asThreadId("thread-1"),
611 turnId: asTurnId("turn-no-delta"),
633612 itemId: asItemId("item-no-delta"),
634613 payload: {
635614 itemType: "assistant_message",
......@@ -659,9 +638,9 @@ describe("ProviderRuntimeIngestion", () => {
659638 type: "turn.started",
660639 eventId: asEventId("evt-turn-started-buffered"),
661640 provider: "codex",
662 sessionId: asSessionId("sess-1"),
663641 createdAt: now,
664 turnId: asProviderTurnId("turn-buffered"),
642 threadId: asThreadId("thread-1"),
643 turnId: asTurnId("turn-buffered"),
665644 });
666645 await waitForThread(
667646 harness.engine,
......@@ -673,9 +652,9 @@ describe("ProviderRuntimeIngestion", () => {
673652 type: "content.delta",
674653 eventId: asEventId("evt-message-delta-buffered"),
675654 provider: "codex",
676 sessionId: asSessionId("sess-1"),
677655 createdAt: now,
678 turnId: asProviderTurnId("turn-buffered"),
656 threadId: asThreadId("thread-1"),
657 turnId: asTurnId("turn-buffered"),
679658 itemId: asItemId("item-buffered"),
680659 payload: {
681660 streamKind: "assistant_text",
......@@ -698,9 +677,9 @@ describe("ProviderRuntimeIngestion", () => {
698677 type: "item.completed",
699678 eventId: asEventId("evt-message-completed-buffered"),
700679 provider: "codex",
701 sessionId: asSessionId("sess-1"),
702680 createdAt: now,
703 turnId: asProviderTurnId("turn-buffered"),
681 threadId: asThreadId("thread-1"),
682 turnId: asTurnId("turn-buffered"),
704683 itemId: asItemId("item-buffered"),
705684 payload: {
706685 itemType: "assistant_message",
......@@ -747,9 +726,9 @@ describe("ProviderRuntimeIngestion", () => {
747726 type: "turn.started",
748727 eventId: asEventId("evt-turn-started-streaming-mode"),
749728 provider: "codex",
750 sessionId: asSessionId("sess-1"),
751729 createdAt: now,
752 turnId: asProviderTurnId("turn-streaming-mode"),
730 threadId: asThreadId("thread-1"),
731 turnId: asTurnId("turn-streaming-mode"),
753732 });
754733 await waitForThread(
755734 harness.engine,
......@@ -762,9 +741,9 @@ describe("ProviderRuntimeIngestion", () => {
762741 type: "content.delta",
763742 eventId: asEventId("evt-message-delta-streaming-mode"),
764743 provider: "codex",
765 sessionId: asSessionId("sess-1"),
766744 createdAt: now,
767 turnId: asProviderTurnId("turn-streaming-mode"),
745 threadId: asThreadId("thread-1"),
746 turnId: asTurnId("turn-streaming-mode"),
768747 itemId: asItemId("item-streaming-mode"),
769748 payload: {
770749 streamKind: "assistant_text",
......@@ -789,9 +768,9 @@ describe("ProviderRuntimeIngestion", () => {
789768 type: "item.completed",
790769 eventId: asEventId("evt-message-completed-streaming-mode"),
791770 provider: "codex",
792 sessionId: asSessionId("sess-1"),
793771 createdAt: now,
794 turnId: asProviderTurnId("turn-streaming-mode"),
772 threadId: asThreadId("thread-1"),
773 turnId: asTurnId("turn-streaming-mode"),
795774 itemId: asItemId("item-streaming-mode"),
796775 payload: {
797776 itemType: "assistant_message",
......@@ -821,9 +800,9 @@ describe("ProviderRuntimeIngestion", () => {
821800 type: "turn.started",
822801 eventId: asEventId("evt-turn-started-buffer-spill"),
823802 provider: "codex",
824 sessionId: asSessionId("sess-1"),
825803 createdAt: now,
826 turnId: asProviderTurnId("turn-buffer-spill"),
804 threadId: asThreadId("thread-1"),
805 turnId: asTurnId("turn-buffer-spill"),
827806 });
828807 await waitForThread(
829808 harness.engine,
......@@ -836,9 +815,9 @@ describe("ProviderRuntimeIngestion", () => {
836815 type: "content.delta",
837816 eventId: asEventId("evt-message-delta-buffer-spill"),
838817 provider: "codex",
839 sessionId: asSessionId("sess-1"),
840818 createdAt: now,
841 turnId: asProviderTurnId("turn-buffer-spill"),
819 threadId: asThreadId("thread-1"),
820 turnId: asTurnId("turn-buffer-spill"),
842821 itemId: asItemId("item-buffer-spill"),
843822 payload: {
844823 streamKind: "assistant_text",
......@@ -849,9 +828,9 @@ describe("ProviderRuntimeIngestion", () => {
849828 type: "item.completed",
850829 eventId: asEventId("evt-message-completed-buffer-spill"),
851830 provider: "codex",
852 sessionId: asSessionId("sess-1"),
853831 createdAt: now,
854 turnId: asProviderTurnId("turn-buffer-spill"),
832 threadId: asThreadId("thread-1"),
833 turnId: asTurnId("turn-buffer-spill"),
855834 itemId: asItemId("item-buffer-spill"),
856835 payload: {
857836 itemType: "assistant_message",
......@@ -881,9 +860,9 @@ describe("ProviderRuntimeIngestion", () => {
881860 type: "turn.started",
882861 eventId: asEventId("evt-turn-started-for-complete-dedup"),
883862 provider: "codex",
884 sessionId: asSessionId("sess-1"),
885863 createdAt: now,
886 turnId: asProviderTurnId("turn-complete-dedup"),
864 threadId: asThreadId("thread-1"),
865 turnId: asTurnId("turn-complete-dedup"),
887866 });
888867
889868 await waitForThread(
......@@ -897,9 +876,9 @@ describe("ProviderRuntimeIngestion", () => {
897876 type: "content.delta",
898877 eventId: asEventId("evt-message-delta-for-complete-dedup"),
899878 provider: "codex",
900 sessionId: asSessionId("sess-1"),
901879 createdAt: now,
902 turnId: asProviderTurnId("turn-complete-dedup"),
880 threadId: asThreadId("thread-1"),
881 turnId: asTurnId("turn-complete-dedup"),
903882 itemId: asItemId("item-complete-dedup"),
904883 payload: {
905884 streamKind: "assistant_text",
......@@ -910,9 +889,9 @@ describe("ProviderRuntimeIngestion", () => {
910889 type: "item.completed",
911890 eventId: asEventId("evt-message-completed-for-complete-dedup"),
912891 provider: "codex",
913 sessionId: asSessionId("sess-1"),
914892 createdAt: now,
915 turnId: asProviderTurnId("turn-complete-dedup"),
893 threadId: asThreadId("thread-1"),
894 turnId: asTurnId("turn-complete-dedup"),
916895 itemId: asItemId("item-complete-dedup"),
917896 payload: {
918897 itemType: "assistant_message",
......@@ -923,9 +902,9 @@ describe("ProviderRuntimeIngestion", () => {
923902 type: "turn.completed",
924903 eventId: asEventId("evt-turn-completed-for-complete-dedup"),
925904 provider: "codex",
926 sessionId: asSessionId("sess-1"),
927905 createdAt: now,
928 turnId: asProviderTurnId("turn-complete-dedup"),
906 threadId: asThreadId("thread-1"),
907 turnId: asTurnId("turn-complete-dedup"),
929908 payload: {
930909 state: "completed",
931910 },
......@@ -967,8 +946,8 @@ describe("ProviderRuntimeIngestion", () => {
967946 type: "request.opened",
968947 eventId: asEventId("evt-request-opened"),
969948 provider: "codex",
970 sessionId: asSessionId("sess-1"),
971949 createdAt: now,
950 threadId: asThreadId("thread-1"),
972951 requestId: ApprovalRequestId.makeUnsafe("req-open"),
973952 payload: {
974953 requestType: "command_execution_approval",
......@@ -980,8 +959,8 @@ describe("ProviderRuntimeIngestion", () => {
980959 type: "request.resolved",
981960 eventId: asEventId("evt-request-resolved"),
982961 provider: "codex",
983 sessionId: asSessionId("sess-1"),
984962 createdAt: now,
963 threadId: asThreadId("thread-1"),
985964 requestId: ApprovalRequestId.makeUnsafe("req-open"),
986965 payload: {
987966 requestType: "command_execution_approval",
......@@ -1033,9 +1012,9 @@ describe("ProviderRuntimeIngestion", () => {
10331012 type: "runtime.error",
10341013 eventId: asEventId("evt-runtime-error"),
10351014 provider: "codex",
1036 sessionId: asSessionId("sess-1"),
10371015 createdAt: now,
1038 turnId: asProviderTurnId("turn-3"),
1016 threadId: asThreadId("thread-1"),
1017 turnId: asTurnId("turn-3"),
10391018 payload: {
10401019 message: "runtime exploded",
10411020 },
......@@ -1060,26 +1039,24 @@ describe("ProviderRuntimeIngestion", () => {
10601039 type: "session.started",
10611040 eventId: asEventId("evt-session-started"),
10621041 provider: "codex",
1063 sessionId: asSessionId("sess-1"),
10641042 createdAt: now,
1065 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
1043 threadId: asThreadId("thread-1"),
10661044 message: "session started",
10671045 });
10681046 harness.emit({
10691047 type: "thread.started",
10701048 eventId: asEventId("evt-thread-started"),
10711049 provider: "codex",
1072 sessionId: asSessionId("sess-1"),
10731050 createdAt: now,
1074 threadId: ProviderThreadId.makeUnsafe("provider-thread-2"),
1051 threadId: asThreadId("thread-1"),
10751052 });
10761053 harness.emit({
10771054 type: "item.started",
10781055 eventId: asEventId("evt-tool-started"),
10791056 provider: "codex",
1080 sessionId: asSessionId("sess-1"),
10811057 createdAt: now,
1082 turnId: asProviderTurnId("turn-9"),
1058 threadId: asThreadId("thread-1"),
1059 turnId: asTurnId("turn-9"),
10831060 payload: {
10841061 itemType: "command_execution",
10851062 status: "in_progress",
......@@ -1114,9 +1091,8 @@ describe("ProviderRuntimeIngestion", () => {
11141091 type: "thread.metadata.updated",
11151092 eventId: asEventId("evt-thread-metadata-updated"),
11161093 provider: "codex",
1117 sessionId: asSessionId("sess-1"),
11181094 createdAt: now,
1119 threadId: asProviderThreadId("provider-thread-1"),
1095 threadId: asThreadId("thread-1"),
11201096 payload: {
11211097 name: "Renamed by provider",
11221098 metadata: { source: "provider" },
......@@ -1127,9 +1103,9 @@ describe("ProviderRuntimeIngestion", () => {
11271103 type: "turn.plan.updated",
11281104 eventId: asEventId("evt-turn-plan-updated"),
11291105 provider: "codex",
1130 sessionId: asSessionId("sess-1"),
11311106 createdAt: now,
1132 turnId: asProviderTurnId("turn-p1"),
1107 threadId: asThreadId("thread-1"),
1108 turnId: asTurnId("turn-p1"),
11331109 payload: {
11341110 explanation: "Working through the plan",
11351111 plan: [
......@@ -1143,9 +1119,9 @@ describe("ProviderRuntimeIngestion", () => {
11431119 type: "item.updated",
11441120 eventId: asEventId("evt-item-updated"),
11451121 provider: "codex",
1146 sessionId: asSessionId("sess-1"),
11471122 createdAt: now,
1148 turnId: asProviderTurnId("turn-p1"),
1123 threadId: asThreadId("thread-1"),
1124 turnId: asTurnId("turn-p1"),
11491125 itemId: asItemId("item-p1-tool"),
11501126 payload: {
11511127 itemType: "command_execution",
......@@ -1160,9 +1136,9 @@ describe("ProviderRuntimeIngestion", () => {
11601136 type: "runtime.warning",
11611137 eventId: asEventId("evt-runtime-warning"),
11621138 provider: "codex",
1163 sessionId: asSessionId("sess-1"),
11641139 createdAt: now,
1165 turnId: asProviderTurnId("turn-p1"),
1140 threadId: asThreadId("thread-1"),
1141 turnId: asTurnId("turn-p1"),
11661142 payload: {
11671143 message: "Provider got slow",
11681144 detail: { latencyMs: 1500 },
......@@ -1173,9 +1149,9 @@ describe("ProviderRuntimeIngestion", () => {
11731149 type: "turn.diff.updated",
11741150 eventId: asEventId("evt-turn-diff-updated"),
11751151 provider: "codex",
1176 sessionId: asSessionId("sess-1"),
11771152 createdAt: now,
1178 turnId: asProviderTurnId("turn-p1"),
1153 threadId: asThreadId("thread-1"),
1154 turnId: asTurnId("turn-p1"),
11791155 itemId: asItemId("item-p1-assistant"),
11801156 payload: {
11811157 unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n",
......@@ -1249,9 +1225,9 @@ describe("ProviderRuntimeIngestion", () => {
12491225 type: "content.delta",
12501226 eventId: asEventId("evt-invalid-delta"),
12511227 provider: "codex",
1252 sessionId: asSessionId("sess-1"),
12531228 createdAt: now,
1254 turnId: asProviderTurnId("turn-invalid"),
1229 threadId: asThreadId("thread-1"),
1230 turnId: asTurnId("turn-invalid"),
12551231 itemId: asItemId("item-invalid"),
12561232 payload: {
12571233 streamKind: "assistant_text",
......@@ -1263,9 +1239,9 @@ describe("ProviderRuntimeIngestion", () => {
12631239 type: "runtime.error",
12641240 eventId: asEventId("evt-runtime-error-after-failure"),
12651241 provider: "codex",
1266 sessionId: asSessionId("sess-1"),
12671242 createdAt: new Date().toISOString(),
1268 turnId: asProviderTurnId("turn-after-failure"),
1243 threadId: asThreadId("thread-1"),
1244 turnId: asTurnId("turn-after-failure"),
12691245 payload: {
12701246 message: "runtime still processed",
12711247 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+33-95
......@@ -5,13 +5,10 @@ import {
55 MessageId,
66 type OrchestrationEvent,
77 CheckpointRef,
8 ProviderThreadId,
9 type ThreadId,
8 ThreadId,
109 TurnId,
1110 type OrchestrationThreadActivity,
1211 type ProviderRuntimeEvent,
13 ProviderSessionId,
14 RuntimeSessionId,
1512} from "@t3tools/contracts";
1613import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";
1714
......@@ -22,7 +19,7 @@ import {
2219 type ProviderRuntimeIngestionShape,
2320} from "../Services/ProviderRuntimeIngestion.ts";
2421
25const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;
22const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
2623const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
2724 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
2825
......@@ -53,14 +50,6 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined {
5350 return value === undefined ? undefined : TurnId.makeUnsafe(String(value));
5451}
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
6453function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined {
6554 return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value);
6655}
......@@ -72,13 +61,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine
7261 return left === right;
7362}
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
8264function truncateDetail(value: string, limit = 180): string {
8365 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
8466}
......@@ -148,11 +130,13 @@ function orchestrationSessionStatusFromRuntimeState(
148130
149131function requestKindFromCanonicalRequestType(
150132 requestType: string | undefined,
151): "command" | "file-change" | undefined {
133): "command" | "file-read" | "file-change" | undefined {
152134 switch (requestType) {
153135 case "command_execution_approval":
154136 case "exec_command_approval":
155137 return "command";
138 case "file_read_approval":
139 return "file-read";
156140 case "file_change_approval":
157141 case "apply_patch_approval":
158142 return "file-change";
......@@ -194,6 +178,8 @@ function runtimeEventToActivities(
194178 summary:
195179 requestKind === "command"
196180 ? "Command approval requested"
181 : requestKind === "file-read"
182 ? "File-read approval requested"
197183 : requestKind === "file-change"
198184 ? "File-change approval requested"
199185 : "Approval requested",
......@@ -380,15 +366,15 @@ const make = Effect.gen(function* () {
380366 });
381367
382368 const rememberAssistantMessageId = (
383 sessionId: ProviderSessionId,
369 threadId: ThreadId,
384370 turnId: TurnId,
385371 messageId: MessageId,
386372 ) =>
387 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(
373 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
388374 Effect.flatMap((existingIds) =>
389375 Cache.set(
390376 turnMessageIdsByTurnKey,
391 providerTurnKey(sessionId, turnId),
377 providerTurnKey(threadId, turnId),
392378 Option.match(existingIds, {
393379 onNone: () => new Set([messageId]),
394380 onSome: (ids) => {
......@@ -402,11 +388,11 @@ const make = Effect.gen(function* () {
402388 );
403389
404390 const forgetAssistantMessageId = (
405 sessionId: ProviderSessionId,
391 threadId: ThreadId,
406392 turnId: TurnId,
407393 messageId: MessageId,
408394 ) =>
409 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(
395 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
410396 Effect.flatMap((existingIds) =>
411397 Option.match(existingIds, {
412398 onNone: () => Effect.void,
......@@ -414,23 +400,23 @@ const make = Effect.gen(function* () {
414400 const nextIds = new Set(ids);
415401 nextIds.delete(messageId);
416402 if (nextIds.size === 0) {
417 return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId));
403 return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
418404 }
419 return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds);
405 return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds);
420406 },
421407 }),
422408 ),
423409 );
424410
425 const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) =>
426 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe(
411 const getAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
412 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
427413 Effect.map((existingIds) =>
428414 Option.getOrElse(existingIds, (): Set<MessageId> => new Set<MessageId>()),
429415 ),
430416 );
431417
432 const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) =>
433 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId));
418 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
419 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
434420
435421 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
436422 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
......@@ -505,9 +491,9 @@ const make = Effect.gen(function* () {
505491 yield* clearAssistantMessageState(input.messageId);
506492 });
507493
508 const clearTurnStateForSession = (sessionId: ProviderSessionId) =>
494 const clearTurnStateForSession = (threadId: ThreadId) =>
509495 Effect.gen(function* () {
510 const prefix = `${sessionId}:`;
496 const prefix = `${threadId}:`;
511497 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
512498 yield* Effect.forEach(
513499 turnKeys,
......@@ -532,29 +518,14 @@ const make = Effect.gen(function* () {
532518
533519 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
534520 Effect.gen(function* () {
535 const providerSessionId = toProviderSessionId(event.sessionId);
536521 const readModel = yield* orchestrationEngine.getReadModel();
537 const thread = readModel.threads.find(
538 (entry) => entry.session?.providerSessionId === providerSessionId,
539 );
522 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
540523 if (!thread) return;
541524
542525 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);
551526 const eventTurnId = toTurnId(event.turnId);
552527 const activeTurnId = thread.session?.activeTurnId ?? null;
553528
554 const matchesThreadScope =
555 eventProviderThreadId === null ||
556 scopedSessionProviderThreadId === null ||
557 sameId(eventProviderThreadId, scopedSessionProviderThreadId);
558529 const conflictsWithActiveTurn =
559530 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
560531 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
......@@ -568,27 +539,10 @@ const make = Effect.gen(function* () {
568539 return true;
569540 case "session.started":
570541 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 }
582542 return true;
583543 case "turn.started":
584 if (!matchesThreadScope) {
585 return false;
586 }
587544 return !conflictsWithActiveTurn;
588545 case "turn.completed":
589 if (!matchesThreadScope) {
590 return false;
591 }
592546 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
593547 return false;
594548 }
......@@ -617,12 +571,6 @@ const make = Effect.gen(function* () {
617571 : event.type === "turn.completed" || event.type === "session.exited"
618572 ? null
619573 : activeTurnId;
620 const providerThreadIdFromEvent =
621 event.type === "thread.started"
622 ? toProviderThreadId(event.threadId)
623 : toProviderThreadId(event.threadId);
624 const providerThreadId =
625 providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null;
626574 const status = (() => {
627575 switch (event.type) {
628576 case "session.state.changed":
......@@ -658,8 +606,6 @@ const make = Effect.gen(function* () {
658606 threadId: thread.id,
659607 status,
660608 providerName: event.provider,
661 providerSessionId,
662 providerThreadId,
663609 runtimeMode: thread.session?.runtimeMode ?? "full-access",
664610 activeTurnId: nextActiveTurnId,
665611 lastError,
......@@ -677,11 +623,11 @@ const make = Effect.gen(function* () {
677623
678624 if (assistantDelta && assistantDelta.length > 0) {
679625 const assistantMessageId = MessageId.makeUnsafe(
680 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
626 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
681627 );
682628 const turnId = toTurnId(event.turnId);
683629 if (turnId) {
684 yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId);
630 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
685631 }
686632
687633 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
......@@ -714,7 +660,7 @@ const make = Effect.gen(function* () {
714660 const assistantCompletion =
715661 event.type === "item.completed" && event.payload.itemType === "assistant_message"
716662 ? {
717 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`),
663 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),
718664 fallbackText: event.payload.detail,
719665 }
720666 : undefined;
......@@ -723,7 +669,7 @@ const make = Effect.gen(function* () {
723669 const assistantMessageId = assistantCompletion.messageId;
724670 const turnId = toTurnId(event.turnId);
725671 if (turnId) {
726 yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId);
672 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
727673 }
728674
729675 yield* finalizeAssistantMessage({
......@@ -740,14 +686,14 @@ const make = Effect.gen(function* () {
740686 });
741687
742688 if (turnId) {
743 yield* forgetAssistantMessageId(providerSessionId, turnId, assistantMessageId);
689 yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);
744690 }
745691 }
746692
747693 if (event.type === "turn.completed") {
748694 const turnId = toTurnId(event.turnId);
749695 if (turnId) {
750 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(providerSessionId, turnId);
696 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
751697 yield* Effect.forEach(
752698 assistantMessageIds,
753699 (assistantMessageId) =>
......@@ -762,12 +708,12 @@ const make = Effect.gen(function* () {
762708 }),
763709 { concurrency: 1 },
764710 ).pipe(Effect.asVoid);
765 yield* clearAssistantMessageIdsForTurn(providerSessionId, turnId);
711 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
766712 }
767713 }
768714
769715 if (event.type === "session.exited") {
770 yield* clearTurnStateForSession(providerSessionId);
716 yield* clearTurnStateForSession(thread.id);
771717 }
772718
773719 if (event.type === "runtime.error") {
......@@ -775,15 +721,9 @@ const make = Effect.gen(function* () {
775721
776722 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
777723 ? true
778 : matchesThreadScope &&
779 (activeTurnId === null ||
780 eventTurnId === undefined ||
781 sameId(activeTurnId, eventTurnId));
782
783 const providerThreadId =
784 event.threadId !== undefined
785 ? ProviderThreadId.makeUnsafe(event.threadId)
786 : (thread.session?.providerThreadId ?? null);
724 : activeTurnId === null ||
725 eventTurnId === undefined ||
726 sameId(activeTurnId, eventTurnId);
787727
788728 if (shouldApplyRuntimeError) {
789729 yield* orchestrationEngine.dispatch({
......@@ -794,8 +734,6 @@ const make = Effect.gen(function* () {
794734 threadId: thread.id,
795735 status: "error",
796736 providerName: event.provider,
797 providerSessionId,
798 providerThreadId,
799737 runtimeMode: thread.session?.runtimeMode ?? "full-access",
800738 activeTurnId: eventTurnId ?? null,
801739 lastError: runtimeErrorMessage,
......@@ -819,7 +757,7 @@ const make = Effect.gen(function* () {
819757 const turnId = toTurnId(event.turnId);
820758 if (turnId) {
821759 const assistantMessageId = MessageId.makeUnsafe(
822 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
760 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
823761 );
824762 yield* orchestrationEngine.dispatch({
825763 type: "thread.turn.diff.complete",
apps/server/src/orchestration/decider.ts+1-8
......@@ -391,14 +391,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
391391 aggregateId: command.threadId,
392392 occurredAt: command.createdAt,
393393 commandId: command.commandId,
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 },
394 metadata: {},
402395 }),
403396 type: "thread.session-set",
404397 payload: {
apps/server/src/persistence/Layers/OrchestrationEventStore.ts-2
......@@ -74,8 +74,6 @@ function inferActorKind(
7474 return "server";
7575 }
7676 if (
77 event.metadata.providerSessionId !== undefined ||
78 event.metadata.providerThreadId !== undefined ||
7977 event.metadata.providerTurnId !== undefined ||
8078 event.metadata.providerItemId !== undefined ||
8179 event.metadata.adapterKey !== undefined
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts-8
......@@ -23,8 +23,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
2323 thread_id,
2424 status,
2525 provider_name,
26 provider_session_id,
27 provider_thread_id,
2826 runtime_mode,
2927 active_turn_id,
3028 last_error,
......@@ -34,8 +32,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
3432 ${row.threadId},
3533 ${row.status},
3634 ${row.providerName},
37 ${row.providerSessionId},
38 ${row.providerThreadId},
3935 ${row.runtimeMode},
4036 ${row.activeTurnId},
4137 ${row.lastError},
......@@ -45,8 +41,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
4541 DO UPDATE SET
4642 status = excluded.status,
4743 provider_name = excluded.provider_name,
48 provider_session_id = excluded.provider_session_id,
49 provider_thread_id = excluded.provider_thread_id,
5044 runtime_mode = excluded.runtime_mode,
5145 active_turn_id = excluded.active_turn_id,
5246 last_error = excluded.last_error,
......@@ -63,8 +57,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
6357 thread_id AS "threadId",
6458 status,
6559 provider_name AS "providerName",
66 provider_session_id AS "providerSessionId",
67 provider_thread_id AS "providerThreadId",
6860 runtime_mode AS "runtimeMode",
6961 active_turn_id AS "activeTurnId",
7062 last_error AS "lastError",
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+20-30
......@@ -1,4 +1,4 @@
1import { ProviderSessionId } from "@t3tools/contracts";
1import { ThreadId } from "@t3tools/contracts";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33import * as SqlSchema from "effect/unstable/sql/SqlSchema";
44import { Effect, Layer, Option, Schema, Struct } from "effect";
......@@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(
2424const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);
2525
2626const GetRuntimeRequestSchema = Schema.Struct({
27 providerSessionId: ProviderSessionId,
27 threadId: ThreadId,
2828});
2929
3030const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
......@@ -44,11 +44,9 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
4444 execute: (runtime) =>
4545 sql`
4646 INSERT INTO provider_session_runtime (
47 provider_session_id,
4847 thread_id,
4948 provider_name,
5049 adapter_key,
51 provider_thread_id,
5250 runtime_mode,
5351 status,
5452 last_seen_at,
......@@ -56,23 +54,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
5654 runtime_payload_json
5755 )
5856 VALUES (
59 ${runtime.providerSessionId},
6057 ${runtime.threadId},
6158 ${runtime.providerName},
6259 ${runtime.adapterKey},
63 ${runtime.providerThreadId},
6460 ${runtime.runtimeMode},
6561 ${runtime.status},
6662 ${runtime.lastSeenAt},
6763 ${runtime.resumeCursor},
6864 ${runtime.runtimePayload}
6965 )
70 ON CONFLICT (provider_session_id)
66 ON CONFLICT (thread_id)
7167 DO UPDATE SET
72 thread_id = excluded.thread_id,
7368 provider_name = excluded.provider_name,
7469 adapter_key = excluded.adapter_key,
75 provider_thread_id = excluded.provider_thread_id,
7670 runtime_mode = excluded.runtime_mode,
7771 status = excluded.status,
7872 last_seen_at = excluded.last_seen_at,
......@@ -81,24 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
8175 `,
8276 });
8377
84 const getRuntimeRowBySessionId = SqlSchema.findOneOption({
78 const getRuntimeRowByThreadId = SqlSchema.findOneOption({
8579 Request: GetRuntimeRequestSchema,
8680 Result: ProviderSessionRuntimeDbRowSchema,
87 execute: ({ providerSessionId }) =>
81 execute: ({ threadId }) =>
8882 sql`
8983 SELECT
90 provider_session_id AS "providerSessionId",
9184 thread_id AS "threadId",
9285 provider_name AS "providerName",
9386 adapter_key AS "adapterKey",
94 provider_thread_id AS "providerThreadId",
9587 runtime_mode AS "runtimeMode",
9688 status,
9789 last_seen_at AS "lastSeenAt",
9890 resume_cursor_json AS "resumeCursor",
9991 runtime_payload_json AS "runtimePayload"
10092 FROM provider_session_runtime
101 WHERE provider_session_id = ${providerSessionId}
93 WHERE thread_id = ${threadId}
10294 `,
10395 });
10496
......@@ -108,27 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
108100 execute: () =>
109101 sql`
110102 SELECT
111 provider_session_id AS "providerSessionId",
112103 thread_id AS "threadId",
113104 provider_name AS "providerName",
114105 adapter_key AS "adapterKey",
115 provider_thread_id AS "providerThreadId",
116106 runtime_mode AS "runtimeMode",
117107 status,
118108 last_seen_at AS "lastSeenAt",
119109 resume_cursor_json AS "resumeCursor",
120110 runtime_payload_json AS "runtimePayload"
121111 FROM provider_session_runtime
122 ORDER BY last_seen_at ASC, provider_session_id ASC
112 ORDER BY last_seen_at ASC, thread_id ASC
123113 `,
124114 });
125115
126 const deleteRuntimeBySessionId = SqlSchema.void({
116 const deleteRuntimeByThreadId = SqlSchema.void({
127117 Request: DeleteRuntimeRequestSchema,
128 execute: ({ providerSessionId }) =>
118 execute: ({ threadId }) =>
129119 sql`
130120 DELETE FROM provider_session_runtime
131 WHERE provider_session_id = ${providerSessionId}
121 WHERE thread_id = ${threadId}
132122 `,
133123 });
134124
......@@ -142,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
142132 ),
143133 );
144134
145 const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) =>
146 getRuntimeRowBySessionId(input).pipe(
135 const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) =>
136 getRuntimeRowByThreadId(input).pipe(
147137 Effect.mapError(
148138 toPersistenceSqlOrDecodeError(
149 "ProviderSessionRuntimeRepository.getBySessionId:query",
150 "ProviderSessionRuntimeRepository.getBySessionId:decodeRow",
139 "ProviderSessionRuntimeRepository.getByThreadId:query",
140 "ProviderSessionRuntimeRepository.getByThreadId:decodeRow",
151141 ),
152142 ),
153143 Effect.flatMap((runtimeRowOption) =>
......@@ -157,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
157147 decodeRuntime(row).pipe(
158148 Effect.mapError(
159149 toPersistenceDecodeError(
160 "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime",
150 "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime",
161151 ),
162152 ),
163153 Effect.map((runtime) => Option.some(runtime)),
......@@ -188,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
188178 ),
189179 );
190180
191 const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) =>
192 deleteRuntimeBySessionId(input).pipe(
181 const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) =>
182 deleteRuntimeByThreadId(input).pipe(
193183 Effect.mapError(
194 toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"),
184 toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"),
195185 ),
196186 );
197187
198188 return {
199189 upsert,
200 getBySessionId,
190 getByThreadId,
201191 list,
202 deleteBySessionId,
192 deleteByThreadId,
203193 } satisfies ProviderSessionRuntimeRepositoryShape;
204194});
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* () {
66
77 yield* sql`
88 CREATE TABLE IF NOT EXISTS provider_session_runtime (
9 provider_session_id TEXT PRIMARY KEY,
10 thread_id TEXT NOT NULL,
9 thread_id TEXT PRIMARY KEY,
1110 provider_name TEXT NOT NULL,
1211 adapter_key TEXT NOT NULL,
13 provider_thread_id TEXT,
12 runtime_mode TEXT NOT NULL DEFAULT 'full-access',
1413 status TEXT NOT NULL,
1514 last_seen_at TEXT NOT NULL,
1615 resume_cursor_json TEXT,
......@@ -18,11 +17,6 @@ export default Effect.gen(function* () {
1817 )
1918 `;
2019
21 yield* sql`
22 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread
23 ON provider_session_runtime(thread_id)
24 `;
25
2620 yield* sql`
2721 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status
2822 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";
22import * as Effect from "effect/Effect";
33
44export default Effect.gen(function* () {
5 const sql = 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 `;
5 yield* SqlClient.SqlClient;
176});
apps/server/src/persistence/Services/ProjectionThreadSessions.ts-4
......@@ -10,8 +10,6 @@ import {
1010 RuntimeMode,
1111 IsoDateTime,
1212 OrchestrationSessionStatus,
13 ProviderSessionId,
14 ProviderThreadId,
1513 ThreadId,
1614 TurnId,
1715} from "@t3tools/contracts";
......@@ -24,8 +22,6 @@ export const ProjectionThreadSession = Schema.Struct({
2422 threadId: ThreadId,
2523 status: OrchestrationSessionStatus,
2624 providerName: Schema.NullOr(Schema.String),
27 providerSessionId: Schema.NullOr(ProviderSessionId),
28 providerThreadId: Schema.NullOr(ProviderThreadId),
2925 runtimeMode: RuntimeMode,
3026 activeTurnId: Schema.NullOr(TurnId),
3127 lastError: Schema.NullOr(Schema.String),
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+7-15
......@@ -7,9 +7,7 @@
77 */
88import {
99 IsoDateTime,
10 ProviderSessionId,
1110 ProviderSessionRuntimeStatus,
12 ProviderThreadId,
1311 RuntimeMode,
1412 ThreadId,
1513} from "@t3tools/contracts";
......@@ -19,11 +17,9 @@ import type { Effect } from "effect";
1917import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";
2018
2119export const ProviderSessionRuntime = Schema.Struct({
22 providerSessionId: ProviderSessionId,
2320 threadId: ThreadId,
2421 providerName: Schema.String,
2522 adapterKey: Schema.String,
26 providerThreadId: Schema.NullOr(ProviderThreadId),
2723 runtimeMode: RuntimeMode,
2824 status: ProviderSessionRuntimeStatus,
2925 lastSeenAt: IsoDateTime,
......@@ -32,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({
3228});
3329export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type;
3430
35export const GetProviderSessionRuntimeInput = Schema.Struct({
36 providerSessionId: ProviderSessionId,
37});
31export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
3832export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type;
3933
40export const DeleteProviderSessionRuntimeInput = Schema.Struct({
41 providerSessionId: ProviderSessionId,
42});
34export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
4335export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
4436
4537/**
......@@ -49,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape {
4941 /**
5042 * Insert or replace a provider runtime row.
5143 *
52 * Upserts by `providerSessionId`, including JSON payload/cursor fields.
44 * Upserts by canonical `threadId`, including JSON payload/cursor fields.
5345 */
5446 readonly upsert: (
5547 runtime: ProviderSessionRuntime,
5648 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
5749
5850 /**
59 * Read provider runtime state by provider session id.
51 * Read provider runtime state by canonical thread id.
6052 */
61 readonly getBySessionId: (
53 readonly getByThreadId: (
6254 input: GetProviderSessionRuntimeInput,
6355 ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>;
6456
......@@ -73,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape {
7365 >;
7466
7567 /**
76 * Delete provider runtime state by provider session id.
68 * Delete provider runtime state by canonical thread id.
7769 */
78 readonly deleteBySessionId: (
70 readonly deleteByThreadId: (
7971 input: DeleteProviderSessionRuntimeInput,
8072 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
8173}
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
2626 "ProviderAdapterSessionNotFoundError",
2727 {
2828 provider: Schema.String,
29 sessionId: Schema.String,
29 threadId: Schema.String,
3030 cause: Schema.optional(Schema.Defect),
3131 },
3232) {
3333 override get message(): string {
34 return `Unknown ${this.provider} adapter session: ${this.sessionId}`;
34 return `Unknown ${this.provider} adapter thread: ${this.threadId}`;
3535 }
3636}
3737
......@@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P
4242 "ProviderAdapterSessionClosedError",
4343 {
4444 provider: Schema.String,
45 sessionId: Schema.String,
45 threadId: Schema.String,
4646 cause: Schema.optional(Schema.Defect),
4747 },
4848) {
4949 override get message(): string {
50 return `${this.provider} adapter session is closed: ${this.sessionId}`;
50 return `${this.provider} adapter thread is closed: ${this.threadId}`;
5151 }
5252}
5353
......@@ -75,13 +75,13 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide
7575 "ProviderAdapterProcessError",
7676 {
7777 provider: Schema.String,
78 sessionId: Schema.String,
78 threadId: Schema.String,
7979 detail: Schema.String,
8080 cause: Schema.optional(Schema.Defect),
8181 },
8282) {
8383 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}`;
8585 }
8686}
8787
......@@ -122,12 +122,12 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn
122122export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<ProviderSessionNotFoundError>()(
123123 "ProviderSessionNotFoundError",
124124 {
125 sessionId: Schema.String,
125 threadId: Schema.String,
126126 cause: Schema.optional(Schema.Defect),
127127 },
128128) {
129129 override get message(): string {
130 return `Unknown provider session: ${this.sessionId}`;
130 return `Unknown provider thread: ${this.threadId}`;
131131 }
132132}
133133
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+34-18
......@@ -5,7 +5,7 @@ import type {
55 SDKMessage,
66 SDKUserMessage,
77} from "@anthropic-ai/claude-agent-sdk";
8import { ApprovalRequestId, type ProviderTurnId } from "@t3tools/contracts";
8import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
99import { assert, describe, it } from "@effect/vitest";
1010import { Effect, Fiber, Random, Stream } from "effect";
1111
......@@ -161,13 +161,17 @@ function makeDeterministicRandomService(seed = 0x1234_5678): {
161161 };
162162}
163163
164
165const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
166const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
167
164168describe("ClaudeCodeAdapterLive", () => {
165169 it.effect("returns validation error for non-claudeCode provider on startSession", () => {
166170 const harness = makeHarness();
167171 return Effect.gen(function* () {
168172 const adapter = yield* ClaudeCodeAdapter;
169173 const result = yield* adapter
170 .startSession({ provider: "codex", runtimeMode: "full-access" })
174 .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" })
171175 .pipe(Effect.result);
172176
173177 assert.equal(result._tag, "Failure");
......@@ -193,6 +197,7 @@ describe("ClaudeCodeAdapterLive", () => {
193197 return Effect.gen(function* () {
194198 const adapter = yield* ClaudeCodeAdapter;
195199 yield* adapter.startSession({
200 threadId: THREAD_ID,
196201 provider: "claudeCode",
197202 runtimeMode: "full-access",
198203 });
......@@ -211,6 +216,7 @@ describe("ClaudeCodeAdapterLive", () => {
211216 return Effect.gen(function* () {
212217 const adapter = yield* ClaudeCodeAdapter;
213218 yield* adapter.startSession({
219 threadId: THREAD_ID,
214220 provider: "claudeCode",
215221 runtimeMode: "full-access",
216222 providerOptions: {
......@@ -240,13 +246,14 @@ describe("ClaudeCodeAdapterLive", () => {
240246 );
241247
242248 const session = yield* adapter.startSession({
249 threadId: THREAD_ID,
243250 provider: "claudeCode",
244251 model: "claude-sonnet-4-5",
245252 runtimeMode: "full-access",
246253 });
247254
248255 const turn = yield* adapter.sendTurn({
249 sessionId: session.sessionId,
256 threadId: session.threadId,
250257 input: "hello",
251258 attachments: [],
252259 });
......@@ -376,12 +383,13 @@ describe("ClaudeCodeAdapterLive", () => {
376383 );
377384
378385 const session = yield* adapter.startSession({
386 threadId: THREAD_ID,
379387 provider: "claudeCode",
380388 runtimeMode: "full-access",
381389 });
382390
383391 const turn = yield* adapter.sendTurn({
384 sessionId: session.sessionId,
392 threadId: session.threadId,
385393 input: "hello",
386394 attachments: [],
387395 });
......@@ -464,12 +472,13 @@ describe("ClaudeCodeAdapterLive", () => {
464472 );
465473
466474 const session = yield* adapter.startSession({
475 threadId: THREAD_ID,
467476 provider: "claudeCode",
468477 runtimeMode: "full-access",
469478 });
470479
471480 const turn = yield* adapter.sendTurn({
472 sessionId: session.sessionId,
481 threadId: session.threadId,
473482 input: "hello",
474483 attachments: [],
475484 });
......@@ -533,13 +542,14 @@ describe("ClaudeCodeAdapterLive", () => {
533542 );
534543
535544 const session = yield* adapter.startSession({
545 threadId: THREAD_ID,
536546 provider: "claudeCode",
537547 runtimeMode: "full-access",
538548 });
539549 assert.equal(session.threadId, undefined);
540550
541551 const turn = yield* adapter.sendTurn({
542 sessionId: session.sessionId,
552 threadId: session.threadId,
543553 input: "hello",
544554 attachments: [],
545555 });
......@@ -602,6 +612,7 @@ describe("ClaudeCodeAdapterLive", () => {
602612 const adapter = yield* ClaudeCodeAdapter;
603613
604614 const session = yield* adapter.startSession({
615 threadId: THREAD_ID,
605616 provider: "claudeCode",
606617 runtimeMode: "approval-required",
607618 });
......@@ -647,7 +658,7 @@ describe("ClaudeCodeAdapterLive", () => {
647658 }
648659
649660 yield* adapter.respondToRequest(
650 session.sessionId,
661 session.threadId,
651662 ApprovalRequestId.makeUnsafe(runtimeRequestId),
652663 "accept",
653664 );
......@@ -678,6 +689,7 @@ describe("ClaudeCodeAdapterLive", () => {
678689 const adapter = yield* ClaudeCodeAdapter;
679690
680691 const session = yield* adapter.startSession({
692 threadId: RESUME_THREAD_ID,
681693 provider: "claudeCode",
682694 resumeCursor: {
683695 threadId: "resume-thread-1",
......@@ -711,6 +723,7 @@ describe("ClaudeCodeAdapterLive", () => {
711723 const adapter = yield* ClaudeCodeAdapter;
712724
713725 const session = yield* adapter.startSession({
726 threadId: THREAD_ID,
714727 provider: "claudeCode",
715728 runtimeMode: "full-access",
716729 });
......@@ -734,12 +747,13 @@ describe("ClaudeCodeAdapterLive", () => {
734747 const adapter = yield* ClaudeCodeAdapter;
735748
736749 const session = yield* adapter.startSession({
750 threadId: THREAD_ID,
737751 provider: "claudeCode",
738752 runtimeMode: "full-access",
739753 });
740754
741755 const firstTurn = yield* adapter.sendTurn({
742 sessionId: session.sessionId,
756 threadId: session.threadId,
743757 input: "first",
744758 attachments: [],
745759 });
......@@ -765,7 +779,7 @@ describe("ClaudeCodeAdapterLive", () => {
765779 }
766780
767781 const secondTurn = yield* adapter.sendTurn({
768 sessionId: session.sessionId,
782 threadId: session.threadId,
769783 input: "second",
770784 attachments: [],
771785 });
......@@ -790,16 +804,16 @@ describe("ClaudeCodeAdapterLive", () => {
790804 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
791805 }
792806
793 const threadBeforeRollback = yield* adapter.readThread(session.sessionId);
807 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
794808 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);
797811 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);
801815 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);
803817 }).pipe(
804818 Effect.provideService(Random.Random, makeDeterministicRandomService()),
805819 Effect.provide(harness.layer),
......@@ -812,11 +826,12 @@ describe("ClaudeCodeAdapterLive", () => {
812826 const adapter = yield* ClaudeCodeAdapter;
813827
814828 const session = yield* adapter.startSession({
829 threadId: THREAD_ID,
815830 provider: "claudeCode",
816831 runtimeMode: "full-access",
817832 });
818833 yield* adapter.sendTurn({
819 sessionId: session.sessionId,
834 threadId: session.threadId,
820835 input: "hello",
821836 model: "claude-opus-4-6",
822837 attachments: [],
......@@ -834,7 +849,7 @@ describe("ClaudeCodeAdapterLive", () => {
834849 event?: {
835850 provider?: string;
836851 method?: string;
837 sessionId?: string;
852 threadId?: string;
838853 turnId?: string;
839854 };
840855 }> = [];
......@@ -852,11 +867,12 @@ describe("ClaudeCodeAdapterLive", () => {
852867 const adapter = yield* ClaudeCodeAdapter;
853868
854869 const session = yield* adapter.startSession({
870 threadId: THREAD_ID,
855871 provider: "claudeCode",
856872 runtimeMode: "full-access",
857873 });
858874 const turn = yield* adapter.sendTurn({
859 sessionId: session.sessionId,
875 threadId: session.threadId,
860876 input: "hello",
861877 attachments: [],
862878 });
......@@ -895,7 +911,7 @@ describe("ClaudeCodeAdapterLive", () => {
895911
896912 assert.equal(nativeEvents.length > 0, true);
897913 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);
899915 assert.equal(
900916 nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)),
901917 true,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+135-202
......@@ -27,13 +27,9 @@ import {
2727 type ProviderRuntimeEvent,
2828 type ProviderRuntimeTurnStatus,
2929 type ProviderSendTurnInput,
30 ProviderSessionId,
3130 type ProviderSession,
32 ProviderThreadId,
33 ProviderTurnId,
3431 RuntimeItemId,
3532 RuntimeRequestId,
36 RuntimeSessionId,
3733 RuntimeTaskId,
3834 ThreadId,
3935 TurnId,
......@@ -63,14 +59,14 @@ type PromptQueueItem =
6359 };
6460
6561interface ClaudeResumeState {
66 readonly threadId?: ProviderThreadId;
62 readonly threadId?: ThreadId;
6763 readonly resume?: string;
6864 readonly resumeSessionAt?: string;
6965 readonly turnCount?: number;
7066}
7167
7268interface ClaudeTurnState {
73 readonly turnId: ProviderTurnId;
69 readonly turnId: TurnId;
7470 readonly assistantItemId: string;
7571 readonly startedAt: string;
7672 readonly items: Array<unknown>;
......@@ -102,13 +98,13 @@ interface ClaudeSessionContext {
10298 resumeSessionId: string | undefined;
10399 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
104100 readonly turns: Array<{
105 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;
101 id: TurnId;
106102 items: Array<unknown>;
107103 }>;
108104 readonly inFlightTools: Map<number, ToolInFlight>;
109105 turnState: ClaudeTurnState | undefined;
110106 lastAssistantUuid: string | undefined;
111 lastThreadStartedId: ProviderThreadId | undefined;
107 lastThreadStartedId: string | undefined;
112108 stopped: boolean;
113109}
114110
......@@ -144,22 +140,14 @@ function toMessage(cause: unknown, fallback: string): string {
144140 return fallback;
145141}
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
159143function asRuntimeItemId(value: string): RuntimeItemId {
160144 return RuntimeItemId.makeUnsafe(value);
161145}
162146
147function asCanonicalTurnId(value: TurnId): TurnId {
148 return value;
149}
150
163151function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
164152 return RuntimeRequestId.makeUnsafe(value);
165153}
......@@ -192,7 +180,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
192180 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
193181 const threadId =
194182 threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate)
195 ? threadIdCandidate
183 ? ThreadId.makeUnsafe(threadIdCandidate)
196184 : undefined;
197185 const resumeCandidate =
198186 typeof cursor.resume === "string"
......@@ -206,7 +194,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
206194 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
207195
208196 return {
209 ...(threadId ? { threadId: ProviderThreadId.makeUnsafe(threadId) } : {}),
197 ...(threadId ? { threadId } : {}),
210198 ...(resume ? { resume } : {}),
211199 ...(resumeSessionAt ? { resumeSessionAt } : {}),
212200 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
......@@ -243,6 +231,10 @@ function classifyToolItemType(toolName: string): CanonicalItemType {
243231}
244232
245233function 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 }
246238 return classifyToolItemType(toolName) === "command_execution"
247239 ? "command_execution_approval"
248240 : "file_change_approval";
......@@ -324,6 +316,12 @@ function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoni
324316 return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text";
325317}
326318
319function providerThreadRef(
320 context: ClaudeSessionContext,
321): { readonly providerThreadId: string } | {} {
322 return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {};
323}
324
327325function extractAssistantText(message: SDKMessage): string {
328326 if (message.type !== "assistant") {
329327 return "";
......@@ -353,21 +351,21 @@ function extractAssistantText(message: SDKMessage): string {
353351}
354352
355353function toSessionError(
356 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
354 threadId: ThreadId,
357355 cause: unknown,
358356): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
359357 const normalized = toMessage(cause, "").toLowerCase();
360358 if (normalized.includes("unknown session") || normalized.includes("not found")) {
361359 return new ProviderAdapterSessionNotFoundError({
362360 provider: PROVIDER,
363 sessionId,
361 threadId,
364362 cause,
365363 });
366364 }
367365 if (normalized.includes("closed")) {
368366 return new ProviderAdapterSessionClosedError({
369367 provider: PROVIDER,
370 sessionId,
368 threadId,
371369 cause,
372370 });
373371 }
......@@ -375,11 +373,11 @@ function toSessionError(
375373}
376374
377375function toRequestError(
378 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
376 threadId: ThreadId,
379377 method: string,
380378 cause: unknown,
381379): ProviderAdapterError {
382 const sessionError = toSessionError(sessionId, cause);
380 const sessionError = toSessionError(threadId, cause);
383381 if (sessionError) {
384382 return sessionError;
385383 }
......@@ -469,7 +467,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
469467 readonly options: ClaudeQueryOptions;
470468 }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime);
471469
472 const sessions = new Map<ProviderSessionId, ClaudeSessionContext>();
470 const sessions = new Map<ThreadId, ClaudeSessionContext>();
473471 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
474472
475473 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
......@@ -502,11 +500,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
502500 : crypto.randomUUID(),
503501 kind: "notification",
504502 provider: PROVIDER,
505 sessionId: asRuntimeSessionId(context.session.sessionId),
506503 createdAt: observedAt,
507504 method: sdkNativeMethod(message),
508 ...(typeof message.session_id === "string" ? { threadId: message.session_id } : {}),
509 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
505 ...(typeof message.session_id === "string"
506 ? { providerThreadId: message.session_id }
507 : {}),
508 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
510509 ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}),
511510 payload: message,
512511 },
......@@ -518,9 +517,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
518517 const snapshotThread = (
519518 context: ClaudeSessionContext,
520519 ): Effect.Effect<{
521 threadId: ReturnType<typeof ProviderThreadId.makeUnsafe>;
520 threadId: ThreadId;
522521 turns: ReadonlyArray<{
523 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;
522 id: TurnId;
524523 items: ReadonlyArray<unknown>;
525524 }>;
526525 }, ProviderAdapterValidationError> =>
......@@ -569,19 +568,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
569568 if (typeof message.session_id !== "string" || message.session_id.length === 0) {
570569 return;
571570 }
572 const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id);
571 const nextThreadId = message.session_id;
573572 context.resumeSessionId = message.session_id;
574 const changed = context.session.threadId !== nextThreadId;
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 }
573 yield* updateResumeCursor(context);
585574
586575 if (context.lastThreadStartedId !== nextThreadId) {
587576 context.lastThreadStartedId = nextThreadId;
......@@ -590,16 +579,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
590579 type: "thread.started",
591580 eventId: stamp.eventId,
592581 provider: PROVIDER,
593 sessionId: asRuntimeSessionId(context.session.sessionId),
594582 createdAt: stamp.createdAt,
595 threadId: asRuntimeThreadId(nextThreadId),
583 threadId: context.session.threadId,
596584 payload: {
597585 providerThreadId: nextThreadId,
598586 },
599 providerRefs: {
600 providerSessionId: context.session.sessionId,
601 providerThreadId: nextThreadId,
602 },
587 providerRefs: {},
603588 raw: {
604589 source: "claude.sdk.message",
605590 method: "claude/thread/started",
......@@ -626,19 +611,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
626611 type: "runtime.error",
627612 eventId: stamp.eventId,
628613 provider: PROVIDER,
629 sessionId: asRuntimeSessionId(context.session.sessionId),
630614 createdAt: stamp.createdAt,
631 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
632 ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}),
615 threadId: context.session.threadId,
616 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
633617 payload: {
634618 message,
635619 class: "provider_error",
636620 ...(cause !== undefined ? { detail: cause } : {}),
637621 },
638622 providerRefs: {
639 providerSessionId: context.session.sessionId,
640 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
641 ...(turnState ? { providerTurnId: turnState.turnId } : {}),
623 ...providerThreadRef(context),
624 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
642625 },
643626 });
644627 });
......@@ -655,18 +638,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
655638 type: "runtime.warning",
656639 eventId: stamp.eventId,
657640 provider: PROVIDER,
658 sessionId: asRuntimeSessionId(context.session.sessionId),
659641 createdAt: stamp.createdAt,
660 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
661 ...(turnState ? { turnId: asRuntimeTurnId(turnState.turnId) } : {}),
642 threadId: context.session.threadId,
643 ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}),
662644 payload: {
663645 message,
664646 ...(detail !== undefined ? { detail } : {}),
665647 },
666648 providerRefs: {
667 providerSessionId: context.session.sessionId,
668 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
669 ...(turnState ? { providerTurnId: turnState.turnId } : {}),
649 ...providerThreadRef(context),
650 ...(turnState ? { providerTurnId: String(turnState.turnId) } : {}),
670651 },
671652 });
672653 });
......@@ -685,9 +666,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
685666 type: "turn.completed",
686667 eventId: stamp.eventId,
687668 provider: PROVIDER,
688 sessionId: asRuntimeSessionId(context.session.sessionId),
689669 createdAt: stamp.createdAt,
690 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
670 threadId: context.session.threadId,
691671 payload: {
692672 state: status,
693673 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
......@@ -698,10 +678,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
698678 : {}),
699679 ...(errorMessage ? { errorMessage } : {}),
700680 },
701 providerRefs: {
702 providerSessionId: context.session.sessionId,
703 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
704 },
681 providerRefs: {},
705682 });
706683 return;
707684 }
......@@ -713,19 +690,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
713690 type: "content.delta",
714691 eventId: deltaStamp.eventId,
715692 provider: PROVIDER,
716 sessionId: asRuntimeSessionId(context.session.sessionId),
717693 createdAt: deltaStamp.createdAt,
718 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
719 turnId: asRuntimeTurnId(turnState.turnId),
694 threadId: context.session.threadId,
695 turnId: turnState.turnId,
720696 itemId: asRuntimeItemId(turnState.assistantItemId),
721697 payload: {
722698 streamKind: "assistant_text",
723699 delta: turnState.fallbackAssistantText,
724700 },
725701 providerRefs: {
726 providerSessionId: context.session.sessionId,
727 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
728 providerTurnId: turnState.turnId,
702 ...providerThreadRef(context),
703 providerTurnId: String(turnState.turnId),
729704 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
730705 },
731706 });
......@@ -736,19 +711,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
736711 type: "item.completed",
737712 eventId: stamp.eventId,
738713 provider: PROVIDER,
739 sessionId: asRuntimeSessionId(context.session.sessionId),
740714 createdAt: stamp.createdAt,
741715 itemId: asRuntimeItemId(turnState.assistantItemId),
742 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
743 turnId: asRuntimeTurnId(turnState.turnId),
716 threadId: context.session.threadId,
717 turnId: turnState.turnId,
744718 payload: {
745719 itemType: "assistant_message",
746720 status: "completed",
747721 title: "Assistant message",
748722 },
749723 providerRefs: {
750 providerSessionId: context.session.sessionId,
751 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
724 ...providerThreadRef(context),
752725 providerTurnId: turnState.turnId,
753726 providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId),
754727 },
......@@ -765,10 +738,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
765738 type: "turn.completed",
766739 eventId: stamp.eventId,
767740 provider: PROVIDER,
768 sessionId: asRuntimeSessionId(context.session.sessionId),
769741 createdAt: stamp.createdAt,
770 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
771 turnId: asRuntimeTurnId(turnState.turnId),
742 threadId: context.session.threadId,
743 turnId: turnState.turnId,
772744 payload: {
773745 state: status,
774746 ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}),
......@@ -780,8 +752,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
780752 ...(errorMessage ? { errorMessage } : {}),
781753 },
782754 providerRefs: {
783 providerSessionId: context.session.sessionId,
784 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
755 ...providerThreadRef(context),
785756 providerTurnId: turnState.turnId,
786757 },
787758 });
......@@ -826,18 +797,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
826797 type: "content.delta",
827798 eventId: stamp.eventId,
828799 provider: PROVIDER,
829 sessionId: asRuntimeSessionId(context.session.sessionId),
830800 createdAt: stamp.createdAt,
831 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
832 turnId: asRuntimeTurnId(context.turnState.turnId),
801 threadId: context.session.threadId,
802 turnId: context.turnState.turnId,
833803 itemId: asRuntimeItemId(context.turnState.assistantItemId),
834804 payload: {
835805 streamKind: streamKindFromDeltaType(event.delta.type),
836806 delta: event.delta.text,
837807 },
838808 providerRefs: {
839 providerSessionId: context.session.sessionId,
840 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
809 ...providerThreadRef(context),
841810 providerTurnId: context.turnState.turnId,
842811 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
843812 },
......@@ -884,10 +853,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
884853 type: "item.started",
885854 eventId: stamp.eventId,
886855 provider: PROVIDER,
887 sessionId: asRuntimeSessionId(context.session.sessionId),
888 createdAt: stamp.createdAt,
889 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
890 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
856 createdAt: stamp.createdAt,
857 threadId: context.session.threadId,
858 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
891859 itemId: asRuntimeItemId(tool.itemId),
892860 payload: {
893861 itemType: tool.itemType,
......@@ -900,9 +868,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
900868 },
901869 },
902870 providerRefs: {
903 providerSessionId: context.session.sessionId,
904 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
905 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
871 ...providerThreadRef(context),
872 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
906873 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
907874 },
908875 raw: {
......@@ -927,10 +894,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
927894 type: "item.completed",
928895 eventId: stamp.eventId,
929896 provider: PROVIDER,
930 sessionId: asRuntimeSessionId(context.session.sessionId),
931 createdAt: stamp.createdAt,
932 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
933 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
897 createdAt: stamp.createdAt,
898 threadId: context.session.threadId,
899 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
934900 itemId: asRuntimeItemId(tool.itemId),
935901 payload: {
936902 itemType: tool.itemType,
......@@ -939,9 +905,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
939905 ...(tool.detail ? { detail: tool.detail } : {}),
940906 },
941907 providerRefs: {
942 providerSessionId: context.session.sessionId,
943 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
944 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
908 ...providerThreadRef(context),
909 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
945910 providerItemId: ProviderItemId.makeUnsafe(tool.itemId),
946911 },
947912 raw: {
......@@ -980,10 +945,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
980945 type: "item.updated",
981946 eventId: stamp.eventId,
982947 provider: PROVIDER,
983 sessionId: asRuntimeSessionId(context.session.sessionId),
984948 createdAt: stamp.createdAt,
985 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
986 turnId: asRuntimeTurnId(context.turnState.turnId),
949 threadId: context.session.threadId,
950 turnId: context.turnState.turnId,
987951 itemId: asRuntimeItemId(context.turnState.assistantItemId),
988952 payload: {
989953 itemType: "assistant_message",
......@@ -992,8 +956,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
992956 data: message.message,
993957 },
994958 providerRefs: {
995 providerSessionId: context.session.sessionId,
996 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
959 ...providerThreadRef(context),
997960 providerTurnId: context.turnState.turnId,
998961 providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId),
999962 },
......@@ -1041,13 +1004,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
10411004 const base = {
10421005 eventId: stamp.eventId,
10431006 provider: PROVIDER,
1044 sessionId: asRuntimeSessionId(context.session.sessionId),
10451007 createdAt: stamp.createdAt,
1046 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1047 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1008 threadId: context.session.threadId,
1009 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
10481010 providerRefs: {
1049 providerSessionId: context.session.sessionId,
1050 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1011 ...providerThreadRef(context),
10511012 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
10521013 },
10531014 raw: {
......@@ -1202,13 +1163,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
12021163 const base = {
12031164 eventId: stamp.eventId,
12041165 provider: PROVIDER,
1205 sessionId: asRuntimeSessionId(context.session.sessionId),
12061166 createdAt: stamp.createdAt,
1207 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1208 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1167 threadId: context.session.threadId,
1168 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
12091169 providerRefs: {
1210 providerSessionId: context.session.sessionId,
1211 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1170 ...providerThreadRef(context),
12121171 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
12131172 },
12141173 raw: {
......@@ -1284,6 +1243,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
12841243 case "stream_event":
12851244 yield* handleStreamEvent(context, message);
12861245 return;
1246 case "user":
1247 return;
12871248 case "assistant":
12881249 yield* handleAssistantMessage(context, message);
12891250 return;
......@@ -1341,19 +1302,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
13411302 type: "request.resolved",
13421303 eventId: stamp.eventId,
13431304 provider: PROVIDER,
1344 sessionId: asRuntimeSessionId(context.session.sessionId),
1345 createdAt: stamp.createdAt,
1346 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1347 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1305 createdAt: stamp.createdAt,
1306 threadId: context.session.threadId,
1307 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
13481308 requestId: asRuntimeRequestId(requestId),
13491309 payload: {
13501310 requestType: pending.requestType,
13511311 decision: "cancel",
13521312 },
13531313 providerRefs: {
1354 providerSessionId: context.session.sessionId,
1355 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1356 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1314 ...providerThreadRef(context),
1315 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
13571316 providerRequestId: requestId,
13581317 },
13591318 });
......@@ -1382,32 +1341,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
13821341 type: "session.exited",
13831342 eventId: stamp.eventId,
13841343 provider: PROVIDER,
1385 sessionId: asRuntimeSessionId(context.session.sessionId),
1386 createdAt: stamp.createdAt,
1387 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1344 createdAt: stamp.createdAt,
1345 threadId: context.session.threadId,
13881346 payload: {
13891347 reason: "Session stopped",
13901348 exitKind: "graceful",
13911349 },
1392 providerRefs: {
1393 providerSessionId: context.session.sessionId,
1394 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1395 },
1350 providerRefs: {},
13961351 });
13971352 }
13981353
1399 sessions.delete(context.session.sessionId);
1354 sessions.delete(context.session.threadId);
14001355 });
14011356
14021357 const requireSession = (
1403 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
1358 threadId: ThreadId,
14041359 ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => {
1405 const context = sessions.get(sessionId);
1360 const context = sessions.get(threadId);
14061361 if (!context) {
14071362 return Effect.fail(
14081363 new ProviderAdapterSessionNotFoundError({
14091364 provider: PROVIDER,
1410 sessionId,
1365 threadId,
14111366 }),
14121367 );
14131368 }
......@@ -1415,7 +1370,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
14151370 return Effect.fail(
14161371 new ProviderAdapterSessionClosedError({
14171372 provider: PROVIDER,
1418 sessionId,
1373 threadId,
14191374 }),
14201375 );
14211376 }
......@@ -1433,11 +1388,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
14331388 }
14341389
14351390 const startedAt = yield* nowIso;
1436 const sessionId = ProviderSessionId.makeUnsafe(
1437 `claude-session-${yield* Random.nextUUIDv4}`,
1438 );
14391391 const resumeState = readClaudeResumeState(input.resumeCursor);
1440 const threadId = resumeState?.threadId;
1392 const threadId = input.threadId;
14411393
14421394 const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
14431395 const prompt = Stream.fromQueue(promptQueue).pipe(
......@@ -1488,10 +1440,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
14881440 type: "request.opened",
14891441 eventId: requestedStamp.eventId,
14901442 provider: PROVIDER,
1491 sessionId: asRuntimeSessionId(context.session.sessionId),
1492 createdAt: requestedStamp.createdAt,
1493 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1494 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1443 createdAt: requestedStamp.createdAt,
1444 threadId: context.session.threadId,
1445 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
14951446 requestId: asRuntimeRequestId(requestId),
14961447 payload: {
14971448 requestType,
......@@ -1503,11 +1454,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
15031454 },
15041455 },
15051456 providerRefs: {
1506 providerSessionId: context.session.sessionId,
1507 ...(context.session.threadId
1457 ...(context.session.threadId
15081458 ? { providerThreadId: context.session.threadId }
15091459 : {}),
1510 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1460 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
15111461 providerRequestId: requestId,
15121462 },
15131463 raw: {
......@@ -1542,21 +1492,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
15421492 type: "request.resolved",
15431493 eventId: resolvedStamp.eventId,
15441494 provider: PROVIDER,
1545 sessionId: asRuntimeSessionId(context.session.sessionId),
1546 createdAt: resolvedStamp.createdAt,
1547 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1548 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1495 createdAt: resolvedStamp.createdAt,
1496 threadId: context.session.threadId,
1497 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
15491498 requestId: asRuntimeRequestId(requestId),
15501499 payload: {
15511500 requestType,
15521501 decision,
15531502 },
15541503 providerRefs: {
1555 providerSessionId: context.session.sessionId,
1556 ...(context.session.threadId
1504 ...(context.session.threadId
15571505 ? { providerThreadId: context.session.threadId }
15581506 : {}),
1559 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1507 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
15601508 providerRequestId: requestId,
15611509 },
15621510 raw: {
......@@ -1623,14 +1571,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
16231571 catch: (cause) =>
16241572 new ProviderAdapterProcessError({
16251573 provider: PROVIDER,
1626 sessionId,
1574 threadId,
16271575 detail: toMessage(cause, "Failed to start Claude runtime session."),
16281576 cause,
16291577 }),
16301578 });
16311579
16321580 const session: ProviderSession = {
1633 sessionId,
1581 threadId,
16341582 provider: PROVIDER,
16351583 status: "ready",
16361584 runtimeMode: input.runtimeMode,
......@@ -1664,21 +1612,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
16641612 stopped: false,
16651613 };
16661614 yield* Ref.set(contextRef, context);
1667 sessions.set(sessionId, context);
1615 sessions.set(threadId, context);
16681616
16691617 const sessionStartedStamp = yield* makeEventStamp();
16701618 yield* offerRuntimeEvent({
16711619 type: "session.started",
16721620 eventId: sessionStartedStamp.eventId,
16731621 provider: PROVIDER,
1674 sessionId: asRuntimeSessionId(sessionId),
16751622 createdAt: sessionStartedStamp.createdAt,
1676 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),
1623 threadId,
16771624 payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {},
1678 providerRefs: {
1679 providerSessionId: sessionId,
1680 ...(threadId ? { providerThreadId: threadId } : {}),
1681 },
1625 providerRefs: {},
16821626 });
16831627
16841628 const configuredStamp = yield* makeEventStamp();
......@@ -1686,9 +1630,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
16861630 type: "session.configured",
16871631 eventId: configuredStamp.eventId,
16881632 provider: PROVIDER,
1689 sessionId: asRuntimeSessionId(sessionId),
16901633 createdAt: configuredStamp.createdAt,
1691 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),
1634 threadId,
16921635 payload: {
16931636 config: {
16941637 ...(input.model ? { model: input.model } : {}),
......@@ -1699,10 +1642,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
16991642 : {}),
17001643 },
17011644 },
1702 providerRefs: {
1703 providerSessionId: sessionId,
1704 ...(threadId ? { providerThreadId: threadId } : {}),
1705 },
1645 providerRefs: {},
17061646 });
17071647
17081648 const readyStamp = yield* makeEventStamp();
......@@ -1710,16 +1650,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17101650 type: "session.state.changed",
17111651 eventId: readyStamp.eventId,
17121652 provider: PROVIDER,
1713 sessionId: asRuntimeSessionId(sessionId),
17141653 createdAt: readyStamp.createdAt,
1715 ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}),
1654 threadId,
17161655 payload: {
17171656 state: "ready",
17181657 },
1719 providerRefs: {
1720 providerSessionId: sessionId,
1721 ...(threadId ? { providerThreadId: threadId } : {}),
1722 },
1658 providerRefs: {},
17231659 });
17241660
17251661 Effect.runFork(runSdkStream(context));
......@@ -1731,24 +1667,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17311667
17321668 const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
17331669 Effect.gen(function* () {
1734 const context = yield* requireSession(input.sessionId);
1670 const context = yield* requireSession(input.threadId);
17351671
17361672 if (context.turnState) {
17371673 return yield* new ProviderAdapterValidationError({
17381674 provider: PROVIDER,
17391675 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}'.`,
17411677 });
17421678 }
17431679
17441680 if (input.model) {
17451681 yield* Effect.tryPromise({
17461682 try: () => context.query.setModel(input.model),
1747 catch: (cause) => toRequestError(input.sessionId, "turn/setModel", cause),
1683 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
17481684 });
17491685 }
17501686
1751 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);
1687 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
17521688 const turnState: ClaudeTurnState = {
17531689 turnId,
17541690 assistantItemId: yield* Random.nextUUIDv4,
......@@ -1773,18 +1709,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17731709 type: "turn.started",
17741710 eventId: turnStartedStamp.eventId,
17751711 provider: PROVIDER,
1776 sessionId: asRuntimeSessionId(context.session.sessionId),
17771712 createdAt: turnStartedStamp.createdAt,
1778 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1779 turnId: asRuntimeTurnId(turnId),
1713 threadId: context.session.threadId,
1714 turnId,
17801715 payload: {
17811716 ...(input.model ? { model: input.model } : {}),
17821717 ...(input.effort ? { effort: input.effort } : {}),
17831718 },
17841719 providerRefs: {
1785 providerSessionId: context.session.sessionId,
1786 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1787 providerTurnId: turnId,
1720 providerTurnId: String(turnId),
17881721 },
17891722 });
17901723
......@@ -1793,10 +1726,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17931726 yield* Queue.offer(context.promptQueue, {
17941727 type: "message",
17951728 message,
1796 }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)));
1729 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
17971730
17981731 return {
1799 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
1732 threadId: context.session.threadId,
18001733 turnId,
18011734 ...(context.session.resumeCursor !== undefined
18021735 ? { resumeCursor: context.session.resumeCursor }
......@@ -1804,24 +1737,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18041737 };
18051738 });
18061739
1807 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, _turnId) =>
1740 const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) =>
18081741 Effect.gen(function* () {
1809 const context = yield* requireSession(sessionId);
1742 const context = yield* requireSession(threadId);
18101743 yield* Effect.tryPromise({
18111744 try: () => context.query.interrupt(),
1812 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),
1745 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
18131746 });
18141747 });
18151748
1816 const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) =>
1749 const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) =>
18171750 Effect.gen(function* () {
1818 const context = yield* requireSession(sessionId);
1751 const context = yield* requireSession(threadId);
18191752 return yield* snapshotThread(context);
18201753 });
18211754
1822 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) =>
1755 const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) =>
18231756 Effect.gen(function* () {
1824 const context = yield* requireSession(sessionId);
1757 const context = yield* requireSession(threadId);
18251758 const nextLength = Math.max(0, context.turns.length - numTurns);
18261759 context.turns.splice(nextLength);
18271760 yield* updateResumeCursor(context);
......@@ -1829,12 +1762,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18291762 });
18301763
18311764 const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
1832 sessionId,
1765 threadId,
18331766 requestId,
18341767 decision,
18351768 ) =>
18361769 Effect.gen(function* () {
1837 const context = yield* requireSession(sessionId);
1770 const context = yield* requireSession(threadId);
18381771 const pending = context.pendingApprovals.get(requestId);
18391772 if (!pending) {
18401773 return yield* new ProviderAdapterRequestError({
......@@ -1848,9 +1781,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18481781 yield* Deferred.succeed(pending.decision, decision);
18491782 });
18501783
1851 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) =>
1784 const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
18521785 Effect.gen(function* () {
1853 const context = yield* requireSession(sessionId);
1786 const context = yield* requireSession(threadId);
18541787 yield* stopSessionInternal(context, {
18551788 emitExitEvent: true,
18561789 });
......@@ -1859,9 +1792,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18591792 const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>
18601793 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
18611794
1862 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) =>
1795 const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) =>
18631796 Effect.sync(() => {
1864 const context = sessions.get(sessionId);
1797 const context = sessions.get(threadId);
18651798 return context !== undefined && !context.stopped;
18661799 });
18671800
apps/server/src/provider/Layers/CodexAdapter.test.ts+31-34
......@@ -4,12 +4,11 @@ import {
44 ProviderItemId,
55 type ProviderApprovalDecision,
66 type ProviderEvent,
7 ProviderSessionId,
87 type ProviderSession,
98 type ProviderSessionStartInput,
10 ProviderThreadId,
11 ProviderTurnId,
129 type ProviderTurnStartResult,
10 ThreadId,
11 TurnId,
1312} from "@t3tools/contracts";
1413import * as NodeServices from "@effect/platform-node/NodeServices";
1514import { afterAll, assert, it, vi } from "@effect/vitest";
......@@ -27,8 +26,8 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";
2726import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2827import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2928
30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
31const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
29const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
30const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3231const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
3332const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
3433
......@@ -37,11 +36,10 @@ class FakeCodexManager extends CodexAppServerManager {
3736 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
3837 const now = new Date().toISOString();
3938 return {
40 sessionId: asSessionId("sess-1"),
4139 provider: "codex",
4240 status: "ready",
4341 runtimeMode: input.runtimeMode,
44 threadId: ProviderThreadId.makeUnsafe("thread-1"),
42 threadId: input.threadId,
4543 cwd: input.cwd,
4644 createdAt: now,
4745 updatedAt: now,
......@@ -51,28 +49,28 @@ class FakeCodexManager extends CodexAppServerManager {
5149
5250 public sendTurnImpl = vi.fn(
5351 async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({
54 threadId: ProviderThreadId.makeUnsafe("thread-1"),
52 threadId: asThreadId("thread-1"),
5553 turnId: asTurnId("turn-1"),
5654 }),
5755 );
5856
5957 public interruptTurnImpl = vi.fn(
60 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,
58 async (_threadId: ThreadId, _turnId?: TurnId): Promise<void> => undefined,
6159 );
6260
63 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
64 threadId: ProviderThreadId.makeUnsafe("thread-1"),
61 public readThreadImpl = vi.fn(async (_threadId: ThreadId) => ({
62 threadId: asThreadId("thread-1"),
6563 turns: [],
6664 }));
6765
68 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({
69 threadId: ProviderThreadId.makeUnsafe("thread-1"),
66 public rollbackThreadImpl = vi.fn(async (_threadId: ThreadId, _numTurns: number) => ({
67 threadId: asThreadId("thread-1"),
7068 turns: [],
7169 }));
7270
7371 public respondToRequestImpl = vi.fn(
7472 async (
75 _sessionId: ProviderSessionId,
73 _threadId: ThreadId,
7674 _requestId: ApprovalRequestId,
7775 _decision: ProviderApprovalDecision,
7876 ): Promise<void> => undefined,
......@@ -88,33 +86,33 @@ class FakeCodexManager extends CodexAppServerManager {
8886 return this.sendTurnImpl(input);
8987 }
9088
91 override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
92 return this.interruptTurnImpl(sessionId, turnId);
89 override interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise<void> {
90 return this.interruptTurnImpl(threadId, turnId);
9391 }
9492
95 override readThread(sessionId: ProviderSessionId) {
96 return this.readThreadImpl(sessionId);
93 override readThread(threadId: ThreadId) {
94 return this.readThreadImpl(threadId);
9795 }
9896
99 override rollbackThread(sessionId: ProviderSessionId, numTurns: number) {
100 return this.rollbackThreadImpl(sessionId, numTurns);
97 override rollbackThread(threadId: ThreadId, numTurns: number) {
98 return this.rollbackThreadImpl(threadId, numTurns);
10199 }
102100
103101 override respondToRequest(
104 sessionId: ProviderSessionId,
102 threadId: ThreadId,
105103 requestId: ApprovalRequestId,
106104 decision: ProviderApprovalDecision,
107105 ): Promise<void> {
108 return this.respondToRequestImpl(sessionId, requestId, decision);
106 return this.respondToRequestImpl(threadId, requestId, decision);
109107 }
110108
111 override stopSession(_sessionId: ProviderSessionId): void {}
109 override stopSession(_threadId: ThreadId): void {}
112110
113111 override listSessions(): ProviderSession[] {
114112 return [];
115113 }
116114
117 override hasSession(_sessionId: ProviderSessionId): boolean {
115 override hasSession(_threadId: ThreadId): boolean {
118116 return false;
119117 }
120118
......@@ -128,9 +126,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
128126 getProvider: () =>
129127 Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
130128 getBinding: () => Effect.succeed(Option.none()),
131 getThreadId: () => Effect.succeed(Option.none()),
132129 remove: () => Effect.void,
133 listSessionIds: () => Effect.succeed([]),
130 listThreadIds: () => Effect.succeed([]),
134131});
135132
136133const validationManager = new FakeCodexManager();
......@@ -149,6 +146,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
149146 const result = yield* adapter
150147 .startSession({
151148 provider: "claudeCode",
149 threadId: asThreadId("thread-1"),
152150 runtimeMode: "full-access",
153151 })
154152 .pipe(Effect.result);
......@@ -184,7 +182,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
184182 const adapter = yield* CodexAdapter;
185183 const result = yield* adapter
186184 .sendTurn({
187 sessionId: asSessionId("sess-missing"),
185 threadId: asThreadId("sess-missing"),
188186 input: "hello",
189187 attachments: [],
190188 })
......@@ -200,7 +198,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
200198 return;
201199 }
202200 assert.equal(result.failure.provider, "codex");
203 assert.equal(result.failure.sessionId, "sess-missing");
201 assert.equal(result.failure.threadId, "sess-missing");
204202 assert.instanceOf(result.failure.cause, Error);
205203 }),
206204 );
......@@ -225,10 +223,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
225223 id: asEventId("evt-msg-complete"),
226224 kind: "notification",
227225 provider: "codex",
228 sessionId: asSessionId("sess-1"),
229226 createdAt: new Date().toISOString(),
230227 method: "item/completed",
231 threadId: ProviderThreadId.makeUnsafe("thread-1"),
228 threadId: asThreadId("thread-1"),
232229 turnId: asTurnId("turn-1"),
233230 itemId: asItemId("msg_1"),
234231 payload: {
......@@ -265,7 +262,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
265262 id: asEventId("evt-session-closed"),
266263 kind: "session",
267264 provider: "codex",
268 sessionId: asSessionId("sess-1"),
265 threadId: asThreadId("thread-1"),
269266 createdAt: new Date().toISOString(),
270267 method: "session/closed",
271268 message: "Session stopped",
......@@ -282,7 +279,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
282279 if (firstEvent.value.type !== "session.exited") {
283280 return;
284281 }
285 assert.equal(firstEvent.value.sessionId, "sess-1");
282 assert.equal(firstEvent.value.threadId, "thread-1");
286283 assert.equal(firstEvent.value.payload.reason, "Session stopped");
287284 }),
288285 );
......@@ -296,7 +293,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
296293 id: asEventId("evt-request-resolved"),
297294 kind: "notification",
298295 provider: "codex",
299 sessionId: asSessionId("sess-1"),
296 threadId: asThreadId("thread-1"),
300297 createdAt: new Date().toISOString(),
301298 method: "serverRequest/resolved",
302299 requestId: ApprovalRequestId.makeUnsafe("req-1"),
......@@ -334,7 +331,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
334331 id: asEventId("evt-windows-sandbox-failed"),
335332 kind: "notification",
336333 provider: "codex",
337 sessionId: asSessionId("sess-1"),
334 threadId: asThreadId("thread-1"),
338335 createdAt: new Date().toISOString(),
339336 method: "windowsSandbox/setupCompleted",
340337 message: "Sandbox setup failed",
apps/server/src/provider/Layers/CodexAdapter.ts+150-153
......@@ -13,16 +13,11 @@ import {
1313 type ProviderRuntimeEvent,
1414 RuntimeItemId,
1515 RuntimeRequestId,
16 RuntimeSessionId,
1716 ProviderApprovalDecision,
1817 ProviderItemId,
19 ProviderSessionId,
20 ProviderThreadId,
21 ProviderTurnId,
2218 ThreadId,
23 TurnId,
2419} from "@t3tools/contracts";
25import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect";
20import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect";
2621
2722import {
2823 ProviderAdapterProcessError,
......@@ -33,7 +28,6 @@ import {
3328 type ProviderAdapterError,
3429} from "../Errors.ts";
3530import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
36import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
3731import { CodexAppServerManager } from "../../codexAppServerManager.ts";
3832import { resolveAttachmentPath } from "../../attachmentStore.ts";
3933import { ServerConfig } from "../../config.ts";
......@@ -43,7 +37,7 @@ const PROVIDER = "codex" as const;
4337
4438export interface CodexAdapterLiveOptions {
4539 readonly manager?: CodexAppServerManager;
46 readonly makeManager?: () => CodexAppServerManager;
40 readonly makeManager?: (services?: ServiceMap.ServiceMap<never>) => CodexAppServerManager;
4741 readonly nativeEventLogPath?: string;
4842 readonly nativeEventLogger?: EventNdjsonLogger;
4943}
......@@ -56,33 +50,29 @@ function toMessage(cause: unknown, fallback: string): string {
5650}
5751
5852function toSessionError(
59 sessionId: ProviderSessionId,
53 threadId: ThreadId,
6054 cause: unknown,
6155): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
6256 const normalized = toMessage(cause, "").toLowerCase();
6357 if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) {
6458 return new ProviderAdapterSessionNotFoundError({
6559 provider: PROVIDER,
66 sessionId,
60 threadId,
6761 cause,
6862 });
6963 }
7064 if (normalized.includes("session is closed")) {
7165 return new ProviderAdapterSessionClosedError({
7266 provider: PROVIDER,
73 sessionId,
67 threadId,
7468 cause,
7569 });
7670 }
7771 return undefined;
7872}
7973
80function toRequestError(
81 sessionId: ProviderSessionId,
82 method: string,
83 cause: unknown,
84): ProviderAdapterError {
85 const sessionError = toSessionError(sessionId, cause);
74function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError {
75 const sessionError = toSessionError(threadId, cause);
8676 if (sessionError) {
8777 return sessionError;
8878 }
......@@ -139,7 +129,8 @@ function toCanonicalItemType(raw: unknown): CanonicalItemType {
139129 if (type.includes("reasoning") || type.includes("thought")) return "reasoning";
140130 if (type.includes("plan") || type.includes("todo")) return "plan";
141131 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";
143134 if (type.includes("mcp")) return "mcp_tool_call";
144135 if (type.includes("dynamic tool")) return "dynamic_tool_call";
145136 if (type.includes("collab")) return "collab_agent_tool_call";
......@@ -211,6 +202,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {
211202 switch (method) {
212203 case "item/commandExecution/requestApproval":
213204 return "command_execution_approval";
205 case "item/fileRead/requestApproval":
206 return "file_read_approval";
214207 case "item/fileChange/requestApproval":
215208 return "file_change_approval";
216209 case "applyPatchApproval":
......@@ -232,6 +225,8 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
232225 switch (kind) {
233226 case "command":
234227 return "command_execution_approval";
228 case "file-read":
229 return "file_read_approval";
235230 case "file-change":
236231 return "file_change_approval";
237232 default:
......@@ -239,7 +234,9 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
239234 }
240235}
241236
242function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | undefined): CanonicalRequestType {
237function toRequestTypeFromResolvedPayload(
238 payload: Record<string, unknown> | undefined,
239): CanonicalRequestType {
243240 const request = asObject(payload?.request);
244241 const method = asString(request?.method) ?? asString(payload?.method);
245242 if (method) {
......@@ -252,13 +249,9 @@ function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | und
252249 return "unknown";
253250}
254251
255function toThreadState(value: unknown):
256 | "active"
257 | "idle"
258 | "archived"
259 | "closed"
260 | "compacted"
261 | "error" {
252function toThreadState(
253 value: unknown,
254): "active" | "idle" | "archived" | "closed" | "compacted" | "error" {
262255 switch (value) {
263256 case "idle":
264257 return "idle";
......@@ -278,7 +271,13 @@ function toThreadState(value: unknown):
278271
279272function contentStreamKindFromMethod(
280273 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" {
282281 switch (method) {
283282 case "item/agentMessage/delta":
284283 return "assistant_text";
......@@ -297,18 +296,6 @@ function contentStreamKindFromMethod(
297296 }
298297}
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
312299function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId {
313300 return RuntimeItemId.makeUnsafe(itemId);
314301}
......@@ -321,29 +308,28 @@ function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent[
321308 return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification";
322309}
323310
324function providerRefsFromEvent(event: ProviderEvent): ProviderRuntimeEvent["providerRefs"] | undefined {
325 const refs: Record<string, string> = {
326 providerSessionId: event.sessionId,
327 };
328 if (event.threadId) refs.providerThreadId = event.threadId;
311function providerRefsFromEvent(
312 event: ProviderEvent,
313): ProviderRuntimeEvent["providerRefs"] | undefined {
314 const refs: Record<string, string> = {};
329315 if (event.turnId) refs.providerTurnId = event.turnId;
330316 if (event.itemId) refs.providerItemId = event.itemId;
331317 if (event.requestId) refs.providerRequestId = event.requestId;
332318
333 return Object.keys(refs).length > 0
334 ? (refs as ProviderRuntimeEvent["providerRefs"])
335 : undefined;
319 return Object.keys(refs).length > 0 ? (refs as ProviderRuntimeEvent["providerRefs"]) : undefined;
336320}
337321
338function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "type" | "payload"> {
322function runtimeEventBase(
323 event: ProviderEvent,
324 canonicalThreadId: ThreadId,
325): Omit<ProviderRuntimeEvent, "type" | "payload"> {
339326 const refs = providerRefsFromEvent(event);
340327 return {
341328 eventId: event.id,
342329 provider: event.provider,
343 sessionId: asRuntimeSessionId(event.sessionId),
330 threadId: canonicalThreadId,
344331 createdAt: event.createdAt,
345 ...(event.threadId ? { threadId: asRuntimeThreadId(event.threadId) } : {}),
346 ...(event.turnId ? { turnId: asRuntimeTurnId(event.turnId) } : {}),
332 ...(event.turnId ? { turnId: event.turnId } : {}),
347333 ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}),
348334 ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}),
349335 ...(refs ? { providerRefs: refs } : {}),
......@@ -357,6 +343,7 @@ function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "typ
357343
358344function mapItemLifecycle(
359345 event: ProviderEvent,
346 canonicalThreadId: ThreadId,
360347 lifecycle: "item.started" | "item.updated" | "item.completed",
361348): ProviderRuntimeEvent | undefined {
362349 const payload = asObject(event.payload);
......@@ -380,7 +367,7 @@ function mapItemLifecycle(
380367 : undefined;
381368
382369 return {
383 ...runtimeEventBase(event),
370 ...runtimeEventBase(event, canonicalThreadId),
384371 type: lifecycle,
385372 payload: {
386373 itemType,
......@@ -392,7 +379,10 @@ function mapItemLifecycle(
392379 };
393380}
394381
395function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntimeEvent> {
382function mapToRuntimeEvents(
383 event: ProviderEvent,
384 canonicalThreadId: ThreadId,
385): ReadonlyArray<ProviderRuntimeEvent> {
396386 const payload = asObject(event.payload);
397387 const turn = asObject(payload?.turn);
398388
......@@ -402,7 +392,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
402392 }
403393 return [
404394 {
405 ...runtimeEventBase(event),
395 ...runtimeEventBase(event, canonicalThreadId),
406396 type: "runtime.error",
407397 payload: {
408398 message: event.message,
......@@ -418,7 +408,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
418408 asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt);
419409 return [
420410 {
421 ...runtimeEventBase(event),
411 ...runtimeEventBase(event, canonicalThreadId),
422412 type: "request.opened",
423413 payload: {
424414 requestType: toRequestTypeFromMethod(event.method),
......@@ -437,7 +427,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
437427 : toRequestTypeFromMethod(event.method);
438428 return [
439429 {
440 ...runtimeEventBase(event),
430 ...runtimeEventBase(event, canonicalThreadId),
441431 type: "request.resolved",
442432 payload: {
443433 requestType,
......@@ -451,7 +441,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
451441 if (event.method === "session/connecting") {
452442 return [
453443 {
454 ...runtimeEventBase(event),
444 ...runtimeEventBase(event, canonicalThreadId),
455445 type: "session.state.changed",
456446 payload: {
457447 state: "starting",
......@@ -464,7 +454,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
464454 if (event.method === "session/ready") {
465455 return [
466456 {
467 ...runtimeEventBase(event),
457 ...runtimeEventBase(event, canonicalThreadId),
468458 type: "session.state.changed",
469459 payload: {
470460 state: "ready",
......@@ -477,7 +467,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
477467 if (event.method === "session/started") {
478468 return [
479469 {
480 ...runtimeEventBase(event),
470 ...runtimeEventBase(event, canonicalThreadId),
481471 type: "session.started",
482472 payload: {
483473 ...(event.message ? { message: event.message } : {}),
......@@ -490,7 +480,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
490480 if (event.method === "session/exited" || event.method === "session/closed") {
491481 return [
492482 {
493 ...runtimeEventBase(event),
483 ...runtimeEventBase(event, canonicalThreadId),
494484 type: "session.exited",
495485 payload: {
496486 ...(event.message ? { reason: event.message } : {}),
......@@ -502,15 +492,13 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
502492
503493 if (event.method === "thread/started") {
504494 const payloadThreadId = asString(asObject(payload?.thread)?.id);
505 const providerThreadId =
506 event.threadId ??
507 (payloadThreadId ? ProviderThreadId.makeUnsafe(payloadThreadId) : undefined);
495 const providerThreadId = payloadThreadId ?? asString(payload?.threadId);
508496 if (!providerThreadId) {
509497 return [];
510498 }
511499 return [
512500 {
513 ...runtimeEventBase(event),
501 ...runtimeEventBase(event, canonicalThreadId),
514502 type: "thread.started",
515503 payload: {
516504 providerThreadId,
......@@ -528,8 +516,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
528516 ) {
529517 return [
530518 {
531 ...runtimeEventBase(event),
532519 type: "thread.state.changed",
520 ...runtimeEventBase(event, canonicalThreadId),
533521 payload: {
534522 state:
535523 event.method === "thread/archived"
......@@ -548,8 +536,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
548536 if (event.method === "thread/name/updated") {
549537 return [
550538 {
551 ...runtimeEventBase(event),
552539 type: "thread.metadata.updated",
540 ...runtimeEventBase(event, canonicalThreadId),
553541 payload: {
554542 ...(asString(payload?.threadName) ? { name: asString(payload?.threadName) } : {}),
555543 ...(event.payload !== undefined ? { metadata: asObject(event.payload) } : {}),
......@@ -561,8 +549,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
561549 if (event.method === "thread/tokenUsage/updated") {
562550 return [
563551 {
564 ...runtimeEventBase(event),
565552 type: "thread.token-usage.updated",
553 ...runtimeEventBase(event, canonicalThreadId),
566554 payload: {
567555 usage: event.payload ?? {},
568556 },
......@@ -571,16 +559,14 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
571559 }
572560
573561 if (event.method === "turn/started") {
574 const payloadTurnId = asString(turn?.id);
575 const turnId =
576 event.turnId ?? (payloadTurnId ? ProviderTurnId.makeUnsafe(payloadTurnId) : undefined);
562 const turnId = event.turnId;
577563 if (!turnId) {
578564 return [];
579565 }
580566 return [
581567 {
582 ...runtimeEventBase(event),
583 turnId: asRuntimeTurnId(turnId),
568 ...runtimeEventBase(event, canonicalThreadId),
569 turnId,
584570 type: "turn.started",
585571 payload: {
586572 ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}),
......@@ -594,7 +580,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
594580 const errorMessage = asString(asObject(turn?.error)?.message);
595581 return [
596582 {
597 ...runtimeEventBase(event),
583 ...runtimeEventBase(event, canonicalThreadId),
598584 type: "turn.completed",
599585 payload: {
600586 state: toTurnStatus(turn?.status),
......@@ -613,7 +599,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
613599 if (event.method === "turn/aborted") {
614600 return [
615601 {
616 ...runtimeEventBase(event),
602 ...runtimeEventBase(event, canonicalThreadId),
617603 type: "turn.aborted",
618604 payload: {
619605 reason: event.message ?? "Turn aborted",
......@@ -626,10 +612,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
626612 const steps = Array.isArray(payload?.plan) ? payload.plan : [];
627613 return [
628614 {
629 ...runtimeEventBase(event),
615 ...runtimeEventBase(event, canonicalThreadId),
630616 type: "turn.plan.updated",
631617 payload: {
632 ...(asString(payload?.explanation) ? { explanation: asString(payload?.explanation) } : {}),
618 ...(asString(payload?.explanation)
619 ? { explanation: asString(payload?.explanation) }
620 : {}),
633621 plan: steps
634622 .map((entry) => asObject(entry))
635623 .filter((entry): entry is Record<string, unknown> => entry !== undefined)
......@@ -648,7 +636,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
648636 if (event.method === "turn/diff/updated") {
649637 return [
650638 {
651 ...runtimeEventBase(event),
639 ...runtimeEventBase(event, canonicalThreadId),
652640 type: "turn.diff.updated",
653641 payload: {
654642 unifiedDiff:
......@@ -662,12 +650,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
662650 }
663651
664652 if (event.method === "item/started") {
665 const started = mapItemLifecycle(event, "item.started");
653 const started = mapItemLifecycle(event, canonicalThreadId, "item.started");
666654 return started ? [started] : [];
667655 }
668656
669657 if (event.method === "item/completed") {
670 const completed = mapItemLifecycle(event, "item.completed");
658 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");
671659 return completed ? [completed] : [];
672660 }
673661
......@@ -675,7 +663,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
675663 event.method === "item/reasoning/summaryPartAdded" ||
676664 event.method === "item/commandExecution/terminalInteraction"
677665 ) {
678 const updated = mapItemLifecycle(event, "item.updated");
666 const updated = mapItemLifecycle(event, canonicalThreadId, "item.updated");
679667 return updated ? [updated] : [];
680668 }
681669
......@@ -697,13 +685,17 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
697685 }
698686 return [
699687 {
700 ...runtimeEventBase(event),
688 ...runtimeEventBase(event, canonicalThreadId),
701689 type: "content.delta",
702690 payload: {
703691 streamKind: contentStreamKindFromMethod(event.method),
704692 delta,
705 ...(typeof payload?.contentIndex === "number" ? { contentIndex: payload.contentIndex } : {}),
706 ...(typeof payload?.summaryIndex === "number" ? { summaryIndex: payload.summaryIndex } : {}),
693 ...(typeof payload?.contentIndex === "number"
694 ? { contentIndex: payload.contentIndex }
695 : {}),
696 ...(typeof payload?.summaryIndex === "number"
697 ? { summaryIndex: payload.summaryIndex }
698 : {}),
707699 },
708700 },
709701 ];
......@@ -712,7 +704,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
712704 if (event.method === "item/mcpToolCall/progress") {
713705 return [
714706 {
715 ...runtimeEventBase(event),
707 ...runtimeEventBase(event, canonicalThreadId),
716708 type: "tool.progress",
717709 payload: {
718710 ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}),
......@@ -735,7 +727,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
735727 : "unknown";
736728 return [
737729 {
738 ...runtimeEventBase(event),
730 ...runtimeEventBase(event, canonicalThreadId),
739731 type: "request.resolved",
740732 payload: {
741733 requestType,
......@@ -748,8 +740,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
748740 if (event.method === "model/rerouted") {
749741 return [
750742 {
751 ...runtimeEventBase(event),
752743 type: "model.rerouted",
744 ...runtimeEventBase(event, canonicalThreadId),
753745 payload: {
754746 fromModel: asString(payload?.fromModel) ?? "unknown",
755747 toModel: asString(payload?.toModel) ?? "unknown",
......@@ -762,8 +754,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
762754 if (event.method === "deprecationNotice") {
763755 return [
764756 {
765 ...runtimeEventBase(event),
766757 type: "deprecation.notice",
758 ...runtimeEventBase(event, canonicalThreadId),
767759 payload: {
768760 summary: asString(payload?.summary) ?? "Deprecation notice",
769761 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),
......@@ -775,8 +767,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
775767 if (event.method === "configWarning") {
776768 return [
777769 {
778 ...runtimeEventBase(event),
779770 type: "config.warning",
771 ...runtimeEventBase(event, canonicalThreadId),
780772 payload: {
781773 summary: asString(payload?.summary) ?? "Configuration warning",
782774 ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}),
......@@ -790,8 +782,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
790782 if (event.method === "account/updated") {
791783 return [
792784 {
793 ...runtimeEventBase(event),
794785 type: "account.updated",
786 ...runtimeEventBase(event, canonicalThreadId),
795787 payload: {
796788 account: event.payload ?? {},
797789 },
......@@ -802,8 +794,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
802794 if (event.method === "account/rateLimits/updated") {
803795 return [
804796 {
805 ...runtimeEventBase(event),
806797 type: "account.rate-limits.updated",
798 ...runtimeEventBase(event, canonicalThreadId),
807799 payload: {
808800 rateLimits: event.payload ?? {},
809801 },
......@@ -814,8 +806,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
814806 if (event.method === "mcpServer/oauthLogin/completed") {
815807 return [
816808 {
817 ...runtimeEventBase(event),
818809 type: "mcp.oauth.completed",
810 ...runtimeEventBase(event, canonicalThreadId),
819811 payload: {
820812 success: payload?.success === true,
821813 ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}),
......@@ -829,8 +821,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
829821 const realtimeSessionId = asString(payload?.realtimeSessionId);
830822 return [
831823 {
832 ...runtimeEventBase(event),
833824 type: "thread.realtime.started",
825 ...runtimeEventBase(event, canonicalThreadId),
834826 payload: {
835827 realtimeSessionId,
836828 },
......@@ -841,8 +833,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
841833 if (event.method === "thread/realtime/itemAdded") {
842834 return [
843835 {
844 ...runtimeEventBase(event),
845836 type: "thread.realtime.item-added",
837 ...runtimeEventBase(event, canonicalThreadId),
846838 payload: {
847839 item: event.payload ?? {},
848840 },
......@@ -853,8 +845,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
853845 if (event.method === "thread/realtime/outputAudio/delta") {
854846 return [
855847 {
856 ...runtimeEventBase(event),
857848 type: "thread.realtime.audio.delta",
849 ...runtimeEventBase(event, canonicalThreadId),
858850 payload: {
859851 audio: event.payload ?? {},
860852 },
......@@ -866,8 +858,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
866858 const message = asString(payload?.message) ?? event.message ?? "Realtime error";
867859 return [
868860 {
869 ...runtimeEventBase(event),
870861 type: "thread.realtime.error",
862 ...runtimeEventBase(event, canonicalThreadId),
871863 payload: {
872864 message,
873865 },
......@@ -878,8 +870,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
878870 if (event.method === "thread/realtime/closed") {
879871 return [
880872 {
881 ...runtimeEventBase(event),
882873 type: "thread.realtime.closed",
874 ...runtimeEventBase(event, canonicalThreadId),
883875 payload: {
884876 reason: event.message,
885877 },
......@@ -892,8 +884,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
892884 asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error";
893885 return [
894886 {
895 ...runtimeEventBase(event),
896887 type: "runtime.error",
888 ...runtimeEventBase(event, canonicalThreadId),
897889 payload: {
898890 message,
899891 class: "provider_error",
......@@ -906,8 +898,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
906898 if (event.method === "windows/worldWritableWarning") {
907899 return [
908900 {
909 ...runtimeEventBase(event),
910901 type: "runtime.warning",
902 ...runtimeEventBase(event, canonicalThreadId),
911903 payload: {
912904 message: event.message ?? "Windows world-writable warning",
913905 ...(event.payload !== undefined ? { detail: event.payload } : {}),
......@@ -924,8 +916,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
924916
925917 return [
926918 {
927 ...runtimeEventBase(event),
928919 type: "session.state.changed",
920 ...runtimeEventBase(event, canonicalThreadId),
929921 payload: {
930922 state: success === false ? "error" : "ready",
931923 reason: success === false ? failureMessage : successMessage,
......@@ -935,8 +927,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
935927 ...(success === false
936928 ? [
937929 {
938 ...runtimeEventBase(event),
939930 type: "runtime.warning" as const,
931 ...runtimeEventBase(event, canonicalThreadId),
940932 payload: {
941933 message: failureMessage,
942934 ...(event.payload !== undefined ? { detail: event.payload } : {}),
......@@ -954,7 +946,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
954946 Effect.gen(function* () {
955947 const fileSystem = yield* FileSystem.FileSystem;
956948 const serverConfig = yield* Effect.service(ServerConfig);
957 const directory = yield* ProviderSessionDirectory;
958949 const nativeEventLogger =
959950 options?.nativeEventLogger ??
960951 (options?.nativeEventLogPath !== undefined
......@@ -964,14 +955,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
964955 : undefined);
965956
966957 const manager = yield* Effect.acquireRelease(
967 Effect.sync(() => {
958 Effect.gen(function* () {
968959 if (options?.manager) {
969960 return options.manager;
970961 }
971 if (options?.makeManager) {
972 return options.makeManager();
973 }
974 return new CodexAppServerManager();
962 const services = yield* Effect.services<never>();
963 return options?.makeManager?.(services) ?? new CodexAppServerManager(services);
975964 }),
976965 (manager) =>
977966 Effect.sync(() => {
......@@ -999,11 +988,13 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
999988 catch: (cause) =>
1000989 new ProviderAdapterProcessError({
1001990 provider: PROVIDER,
1002 sessionId: "pending",
991 threadId: input.threadId,
1003992 detail: toMessage(cause, "Failed to start Codex adapter session."),
1004993 cause,
1005994 }),
1006 });
995 }).pipe(
996 Effect.map((session) => session),
997 );
1007998 };
1008999
10091000 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>
......@@ -1018,7 +1009,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10181009 });
10191010 if (!attachmentPath) {
10201011 return yield* toRequestError(
1021 input.sessionId,
1012 input.threadId,
10221013 "turn/start",
10231014 new Error(`Invalid attachment id '${attachment.id}'.`),
10241015 );
......@@ -1026,7 +1017,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10261017 const bytes = yield* fileSystem
10271018 .readFile(attachmentPath)
10281019 .pipe(
1029 Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)),
1020 Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)),
10301021 );
10311022 return {
10321023 type: "image" as const,
......@@ -1039,7 +1030,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10391030 return yield* Effect.tryPromise({
10401031 try: () => {
10411032 const managerInput = {
1042 sessionId: input.sessionId,
1033 threadId: input.threadId,
10431034 ...(input.input !== undefined ? { input: input.input } : {}),
10441035 ...(input.model !== undefined ? { model: input.model } : {}),
10451036 ...(input.effort !== undefined ? { effort: input.effort } : {}),
......@@ -1047,23 +1038,33 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10471038 };
10481039 return manager.sendTurn(managerInput);
10491040 },
1050 catch: (cause) => toRequestError(input.sessionId, "turn/start", cause),
1051 });
1041 catch: (cause) => toRequestError(input.threadId, "turn/start", cause),
1042 }).pipe(
1043 Effect.map((result) => ({
1044 ...result,
1045 threadId: input.threadId,
1046 })),
1047 );
10521048 });
10531049
1054 const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) =>
1050 const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) =>
10551051 Effect.tryPromise({
1056 try: () => manager.interruptTurn(sessionId, turnId),
1057 catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause),
1052 try: () => manager.interruptTurn(threadId, turnId),
1053 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
10581054 });
10591055
1060 const readThread: CodexAdapterShape["readThread"] = (sessionId) =>
1056 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
10611057 Effect.tryPromise({
1062 try: () => manager.readThread(sessionId),
1063 catch: (cause) => toRequestError(sessionId, "thread/read", cause),
1064 });
1065
1066 const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => {
1058 try: () => manager.readThread(threadId),
1059 catch: (cause) => toRequestError(threadId, "thread/read", cause),
1060 }).pipe(
1061 Effect.map((snapshot) => ({
1062 threadId,
1063 turns: snapshot.turns,
1064 })),
1065 );
1066
1067 const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => {
10671068 if (!Number.isInteger(numTurns) || numTurns < 1) {
10681069 return Effect.fail(
10691070 new ProviderAdapterValidationError({
......@@ -1075,31 +1076,36 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10751076 }
10761077
10771078 return Effect.tryPromise({
1078 try: () => manager.rollbackThread(sessionId, numTurns),
1079 catch: (cause) => toRequestError(sessionId, "thread/rollback", cause),
1080 });
1079 try: () => manager.rollbackThread(threadId, numTurns),
1080 catch: (cause) => toRequestError(threadId, "thread/rollback", cause),
1081 }).pipe(
1082 Effect.map((snapshot) => ({
1083 threadId,
1084 turns: snapshot.turns,
1085 })),
1086 );
10811087 };
10821088
10831089 const respondToRequest: CodexAdapterShape["respondToRequest"] = (
1084 sessionId,
1090 threadId,
10851091 requestId,
10861092 decision,
10871093 ) =>
10881094 Effect.tryPromise({
1089 try: () => manager.respondToRequest(sessionId, requestId, decision),
1090 catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause),
1095 try: () => manager.respondToRequest(threadId, requestId, decision),
1096 catch: (cause) => toRequestError(threadId, "item/requestApproval/decision", cause),
10911097 });
10921098
1093 const stopSession: CodexAdapterShape["stopSession"] = (sessionId) =>
1099 const stopSession: CodexAdapterShape["stopSession"] = (threadId) =>
10941100 Effect.sync(() => {
1095 manager.stopSession(sessionId);
1101 manager.stopSession(threadId);
10961102 });
10971103
10981104 const listSessions: CodexAdapterShape["listSessions"] = () =>
10991105 Effect.sync(() => manager.listSessions());
11001106
1101 const hasSession: CodexAdapterShape["hasSession"] = (sessionId) =>
1102 Effect.sync(() => manager.hasSession(sessionId));
1107 const hasSession: CodexAdapterShape["hasSession"] = (threadId) =>
1108 Effect.sync(() => manager.hasSession(threadId));
11031109
11041110 const stopAll: CodexAdapterShape["stopAll"] = () =>
11051111 Effect.sync(() => {
......@@ -1115,33 +1121,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
11151121 if (!nativeEventLogger) {
11161122 return;
11171123 }
1118 const orchestrationThreadId = yield* Effect.catch(
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);
1124 yield* nativeEventLogger.write(event, event.threadId);
11271125 });
11281126
11291127 const services = yield* Effect.services<never>();
11301128 const listener = (event: ProviderEvent) =>
11311129 Effect.gen(function* () {
11321130 yield* writeNativeEvent(event);
1133 const runtimeEvents = mapToRuntimeEvents(event);
1134 if (runtimeEvents.length === 0) {
1135 yield* Effect.logDebug("ignoring unhandled Codex provider event", {
1136 method: event.method,
1137 sessionId: event.sessionId,
1138 threadId: event.threadId,
1139 turnId: event.turnId,
1140 itemId: event.itemId,
1141 });
1142 return;
1143 }
1144 yield* Queue.offerAll(runtimeEventQueue, runtimeEvents);
1131 const runtimeEvents = mapToRuntimeEvents(event, event.threadId);
1132 if (runtimeEvents.length === 0) {
1133 yield* Effect.logDebug("ignoring unhandled Codex provider event", {
1134 method: event.method,
1135 threadId: event.threadId,
1136 turnId: event.turnId,
1137 itemId: event.itemId,
1138 });
1139 return;
1140 }
1141 yield* Queue.offerAll(runtimeEventQueue, runtimeEvents);
11451142 }).pipe(Effect.runPromiseWith(services));
11461143 manager.on("event", listener);
11471144 return listener;
apps/server/src/provider/Layers/CursorAdapter.test.ts+33-16
......@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
22import { PassThrough } from "node:stream";
33import readline from "node:readline";
44
5import { ApprovalRequestId } from "@t3tools/contracts";
5import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
66import { assert, describe, it } from "@effect/vitest";
77import { Effect, Fiber, Stream } from "effect";
88
......@@ -10,6 +10,10 @@ import { ProviderAdapterValidationError } from "../Errors.ts";
1010import { CursorAdapter } from "../Services/CursorAdapter.ts";
1111import { 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
1317class FakeCursorAcpProcess extends EventEmitter {
1418 readonly stdin = new PassThrough();
1519 readonly stdout = new PassThrough();
......@@ -256,7 +260,11 @@ describe("CursorAdapterLive", () => {
256260 return Effect.gen(function* () {
257261 const adapter = yield* CursorAdapter;
258262 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 })
260268 .pipe(Effect.result);
261269
262270 assert.equal(result._tag, "Failure");
......@@ -290,12 +298,13 @@ describe("CursorAdapterLive", () => {
290298
291299 const session = yield* adapter.startSession({
292300 provider: "cursor",
301 threadId: THREAD_ID,
293302 cwd: "/tmp/project",
294303 runtimeMode: "full-access",
295304 });
296305
297306 const turn = yield* adapter.sendTurn({
298 sessionId: session.sessionId,
307 threadId: session.threadId,
299308 input: "hello",
300309 attachments: [],
301310 });
......@@ -355,6 +364,7 @@ describe("CursorAdapterLive", () => {
355364 const adapter = yield* CursorAdapter;
356365 yield* adapter.startSession({
357366 provider: "cursor",
367 threadId: THREAD_ID,
358368 model: "composer-1.5",
359369 runtimeMode: "full-access",
360370 });
......@@ -364,13 +374,13 @@ describe("CursorAdapterLive", () => {
364374 });
365375
366376 it.effect("writes provider-native observability records when enabled", () => {
367 const nativeEvents: Array<{
368 event?: {
369 provider?: string;
370 method?: string;
371 sessionId?: string;
372 };
373 }> = [];
377 const nativeEvents: Array<{
378 event?: {
379 provider?: string;
380 method?: string;
381 threadId?: string;
382 };
383 }> = [];
374384 const fake = new FakeCursorAcpProcess();
375385 const layer = makeCursorAdapterLive({
376386 createProcess: () => fake as never,
......@@ -388,13 +398,14 @@ describe("CursorAdapterLive", () => {
388398 const adapter = yield* CursorAdapter;
389399 const session = yield* adapter.startSession({
390400 provider: "cursor",
401 threadId: THREAD_ID,
391402 cwd: "/tmp/project",
392403 runtimeMode: "full-access",
393404 });
394405
395406 assert.equal(nativeEvents.length > 0, true);
396407 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);
398409 assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true);
399410 }).pipe(Effect.provide(layer));
400411 });
......@@ -409,6 +420,7 @@ describe("CursorAdapterLive", () => {
409420 const adapter = yield* CursorAdapter;
410421 const session = yield* adapter.startSession({
411422 provider: "cursor",
423 threadId: RESUME_THREAD_ID,
412424 cwd: "/tmp/project",
413425 resumeCursor: {
414426 acpSessionId: "acp-session-resume",
......@@ -426,7 +438,7 @@ describe("CursorAdapterLive", () => {
426438 cwd: "/tmp/project",
427439 mcpServers: [],
428440 });
429 assert.equal(session.threadId, "acp-session-resume");
441 assert.equal(session.threadId, RESUME_THREAD_ID);
430442 assert.deepEqual(session.resumeCursor, {
431443 acpSessionId: "acp-session-resume",
432444 });
......@@ -443,6 +455,7 @@ describe("CursorAdapterLive", () => {
443455 const adapter = yield* CursorAdapter;
444456 const session = yield* adapter.startSession({
445457 provider: "cursor",
458 threadId: LEGACY_RESUME_THREAD_ID,
446459 cwd: "/tmp/project",
447460 resumeCursor: {
448461 sessionId: "acp-session-legacy",
......@@ -456,7 +469,7 @@ describe("CursorAdapterLive", () => {
456469 cwd: "/tmp/project",
457470 mcpServers: [],
458471 });
459 assert.equal(session.threadId, "acp-session-legacy");
472 assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID);
460473 assert.deepEqual(session.resumeCursor, {
461474 acpSessionId: "acp-session-legacy",
462475 });
......@@ -474,6 +487,7 @@ describe("CursorAdapterLive", () => {
474487
475488 const session = yield* adapter.startSession({
476489 provider: "cursor",
490 threadId: THREAD_ID,
477491 runtimeMode: "approval-required",
478492 });
479493
......@@ -498,7 +512,7 @@ describe("CursorAdapterLive", () => {
498512 }
499513
500514 yield* adapter.respondToRequest(
501 session.sessionId,
515 session.threadId,
502516 ApprovalRequestId.makeUnsafe(runtimeRequestId),
503517 "acceptForSession",
504518 );
......@@ -528,6 +542,7 @@ describe("CursorAdapterLive", () => {
528542
529543 yield* adapter.startSession({
530544 provider: "cursor",
545 threadId: THREAD_ID,
531546 runtimeMode: "full-access",
532547 });
533548
......@@ -562,6 +577,7 @@ describe("CursorAdapterLive", () => {
562577 const adapter = yield* CursorAdapter;
563578 const session = yield* adapter.startSession({
564579 provider: "cursor",
580 threadId: THREAD_ID,
565581 runtimeMode: "full-access",
566582 });
567583
......@@ -569,7 +585,7 @@ describe("CursorAdapterLive", () => {
569585
570586 const result = yield* adapter
571587 .sendTurn({
572 sessionId: session.sessionId,
588 threadId: session.threadId,
573589 input: " ",
574590 attachments: [],
575591 })
......@@ -608,11 +624,12 @@ describe("CursorAdapterLive", () => {
608624
609625 const session = yield* adapter.startSession({
610626 provider: "cursor",
627 threadId: THREAD_ID,
611628 runtimeMode: "full-access",
612629 });
613630
614631 yield* adapter.sendTurn({
615 sessionId: session.sessionId,
632 threadId: session.threadId,
616633 input: "hello",
617634 attachments: [],
618635 });
apps/server/src/provider/Layers/CursorAdapter.ts+96-174
......@@ -16,16 +16,12 @@ import {
1616 type CanonicalItemType,
1717 type CanonicalRequestType,
1818 ProviderItemId,
19 ProviderSessionId,
2019 type ProviderRuntimeEvent,
2120 type ProviderSession,
22 ProviderThreadId,
23 ProviderTurnId,
2421 type ProviderTurnStartResult,
2522 type RuntimeMode,
2623 RuntimeItemId,
2724 RuntimeRequestId,
28 RuntimeSessionId,
2925 ThreadId,
3026 TurnId,
3127} from "@t3tools/contracts";
......@@ -72,7 +68,7 @@ interface PendingPermission {
7268}
7369
7470interface CursorTurnState {
75 readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>;
71 readonly turnId: TurnId;
7672 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;
7773 readonly startedToolCalls: Set<string>;
7874 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;
......@@ -87,7 +83,7 @@ interface CursorSessionContext {
8783 readonly pending: Map<string, PendingRequest>;
8884 readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>;
8985 readonly turns: Array<{
90 id: ReturnType<typeof ProviderTurnId.makeUnsafe>;
86 id: TurnId;
9187 items: Array<unknown>;
9288 }>;
9389 turnState: CursorTurnState | undefined;
......@@ -114,18 +110,6 @@ function toMessage(cause: unknown, fallback: string): string {
114110 return fallback;
115111}
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
129113function asRuntimeItemId(value: string): RuntimeItemId {
130114 return RuntimeItemId.makeUnsafe(value);
131115}
......@@ -139,21 +123,21 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
139123}
140124
141125function toSessionError(
142 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
126 threadId: ThreadId,
143127 cause: unknown,
144128): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
145129 const normalized = toMessage(cause, "").toLowerCase();
146130 if (normalized.includes("unknown session") || normalized.includes("not found")) {
147131 return new ProviderAdapterSessionNotFoundError({
148132 provider: PROVIDER,
149 sessionId,
133 threadId,
150134 cause,
151135 });
152136 }
153137 if (normalized.includes("closed")) {
154138 return new ProviderAdapterSessionClosedError({
155139 provider: PROVIDER,
156 sessionId,
140 threadId,
157141 cause,
158142 });
159143 }
......@@ -161,11 +145,11 @@ function toSessionError(
161145}
162146
163147function toRequestError(
164 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
148 threadId: ThreadId,
165149 method: string,
166150 cause: unknown,
167151): ProviderAdapterError {
168 const sessionError = toSessionError(sessionId, cause);
152 const sessionError = toSessionError(threadId, cause);
169153 if (sessionError) {
170154 return sessionError;
171155 }
......@@ -336,7 +320,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
336320 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
337321 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
338322
339 const sessions = new Map<ProviderSessionId, CursorSessionContext>();
323 const sessions = new Map<ThreadId, CursorSessionContext>();
340324 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
341325
342326 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
......@@ -431,19 +415,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
431415 type: "runtime.warning",
432416 eventId: stamp.eventId,
433417 provider: PROVIDER,
434 sessionId: asRuntimeSessionId(context.session.sessionId),
435418 createdAt: stamp.createdAt,
436 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
437 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
419 threadId: context.session.threadId,
420 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
438421 payload: {
439422 message,
440423 ...(detail !== undefined ? { detail } : {}),
441424 },
442 providerRefs: {
443 providerSessionId: context.session.sessionId,
444 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
445 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
446 },
425 ...(context.turnState
426 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
427 : {}),
447428 });
448429 });
449430
......@@ -464,10 +445,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
464445 type: "item.completed",
465446 eventId: itemStamp.eventId,
466447 provider: PROVIDER,
467 sessionId: asRuntimeSessionId(context.session.sessionId),
468448 createdAt: itemStamp.createdAt,
469 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
470 turnId: asRuntimeTurnId(turnState.turnId),
449 threadId: context.session.threadId,
450 turnId: turnState.turnId,
471451 itemId: asRuntimeItemId(turnState.assistantItemId),
472452 payload: {
473453 itemType: "assistant_message",
......@@ -475,9 +455,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
475455 title: "Assistant message",
476456 },
477457 providerRefs: {
478 providerSessionId: context.session.sessionId,
479 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
480 providerTurnId: turnState.turnId,
458 providerTurnId: String(turnState.turnId),
481459 providerItemId: turnState.assistantItemId,
482460 },
483461 });
......@@ -492,19 +470,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
492470 type: "turn.completed",
493471 eventId: stamp.eventId,
494472 provider: PROVIDER,
495 sessionId: asRuntimeSessionId(context.session.sessionId),
496473 createdAt: stamp.createdAt,
497 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
498 turnId: asRuntimeTurnId(turnState.turnId),
474 threadId: context.session.threadId,
475 turnId: turnState.turnId,
499476 payload: {
500477 state,
501478 ...(stopReason ? { stopReason } : {}),
502479 ...(errorMessage ? { errorMessage } : {}),
503480 },
504481 providerRefs: {
505 providerSessionId: context.session.sessionId,
506 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
507 providerTurnId: turnState.turnId,
482 providerTurnId: String(turnState.turnId),
508483 },
509484 });
510485
......@@ -572,10 +547,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
572547 type: "request.resolved",
573548 eventId: stamp.eventId,
574549 provider: PROVIDER,
575 sessionId: asRuntimeSessionId(context.session.sessionId),
576550 createdAt: stamp.createdAt,
577 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
578 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
551 threadId: context.session.threadId,
552 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
579553 requestId: asRuntimeRequestId(requestId),
580554 payload: {
581555 requestType,
......@@ -586,9 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
586560 },
587561 },
588562 providerRefs: {
589 providerSessionId: context.session.sessionId,
590 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
591 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
563 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
592564 providerRequestId: String(decoded.id),
593565 },
594566 raw: {
......@@ -614,10 +586,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
614586 type: "request.opened",
615587 eventId: stamp.eventId,
616588 provider: PROVIDER,
617 sessionId: asRuntimeSessionId(context.session.sessionId),
618589 createdAt: stamp.createdAt,
619 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
620 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
590 threadId: context.session.threadId,
591 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
621592 requestId: asRuntimeRequestId(requestId),
622593 payload: {
623594 requestType,
......@@ -625,9 +596,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
625596 args: decoded.params,
626597 },
627598 providerRefs: {
628 providerSessionId: context.session.sessionId,
629 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
630 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
599 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
631600 providerRequestId: String(decoded.id),
632601 },
633602 raw: {
......@@ -659,14 +628,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
659628
660629 const base = {
661630 provider: PROVIDER,
662 sessionId: asRuntimeSessionId(context.session.sessionId),
663 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
664 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
665 providerRefs: {
666 providerSessionId: context.session.sessionId,
667 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
668 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
669 },
631 threadId: context.session.threadId,
632 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
633 ...(context.turnState
634 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
635 : {}),
670636 raw: {
671637 source: "cursor.acp.notification" as const,
672638 method: decoded.method,
......@@ -701,7 +667,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
701667 type: "content.delta",
702668 eventId: stamp.eventId,
703669 createdAt: stamp.createdAt,
704 turnId: asRuntimeTurnId(context.turnState.turnId),
670 turnId: context.turnState.turnId,
705671 itemId: asRuntimeItemId(context.turnState.assistantItemId),
706672 payload: {
707673 streamKind: "reasoning_text",
......@@ -720,7 +686,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
720686 type: "content.delta",
721687 eventId: stamp.eventId,
722688 createdAt: stamp.createdAt,
723 turnId: asRuntimeTurnId(context.turnState.turnId),
689 turnId: context.turnState.turnId,
724690 itemId: asRuntimeItemId(context.turnState.assistantItemId),
725691 payload: {
726692 streamKind: "assistant_text",
......@@ -746,7 +712,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
746712 type: "item.started",
747713 eventId: stamp.eventId,
748714 createdAt: stamp.createdAt,
749 turnId: asRuntimeTurnId(context.turnState.turnId),
715 turnId: context.turnState.turnId,
750716 itemId: asRuntimeItemId(update.toolCallId),
751717 payload: {
752718 itemType,
......@@ -765,7 +731,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
765731 type: "item.updated",
766732 eventId: stamp.eventId,
767733 createdAt: stamp.createdAt,
768 turnId: asRuntimeTurnId(context.turnState.turnId),
734 turnId: context.turnState.turnId,
769735 itemId: asRuntimeItemId(update.toolCallId),
770736 payload: {
771737 itemType,
......@@ -791,7 +757,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
791757 type: eventType,
792758 eventId: stamp.eventId,
793759 createdAt: stamp.createdAt,
794 turnId: asRuntimeTurnId(context.turnState.turnId),
760 turnId: context.turnState.turnId,
795761 itemId: asRuntimeItemId(update.toolCallId),
796762 payload: {
797763 itemType,
......@@ -857,10 +823,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
857823 id: EventId.makeUnsafe(randomUUID()),
858824 kind: nativeKind,
859825 provider: PROVIDER,
860 sessionId: asRuntimeSessionId(context.session.sessionId),
861826 createdAt: new Date().toISOString(),
862827 method: nativeMethod,
863 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
828 threadId: context.session.threadId,
864829 ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}),
865830 payload: message,
866831 },
......@@ -956,36 +921,31 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
956921
957922 if (options?.emitExitEvent !== false) {
958923 const stamp = yield* makeEventStamp();
959 yield* offerRuntimeEvent({
960 type: "session.exited",
961 eventId: stamp.eventId,
962 provider: PROVIDER,
963 sessionId: asRuntimeSessionId(context.session.sessionId),
964 createdAt: stamp.createdAt,
965 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
966 payload: {
967 reason: "Session stopped",
968 exitKind: "graceful",
969 },
970 providerRefs: {
971 providerSessionId: context.session.sessionId,
972 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
973 },
974 });
975 }
924 yield* offerRuntimeEvent({
925 type: "session.exited",
926 eventId: stamp.eventId,
927 provider: PROVIDER,
928 createdAt: stamp.createdAt,
929 threadId: context.session.threadId,
930 payload: {
931 reason: "Session stopped",
932 exitKind: "graceful",
933 },
934 });
935 }
976936
977 sessions.delete(context.session.sessionId);
937 sessions.delete(context.session.threadId);
978938 });
979939
980940 const requireSession = (
981 sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>,
941 threadId: ThreadId,
982942 ): Effect.Effect<CursorSessionContext, ProviderAdapterError> => {
983 const context = sessions.get(sessionId);
943 const context = sessions.get(threadId);
984944 if (!context) {
985945 return Effect.fail(
986946 new ProviderAdapterSessionNotFoundError({
987947 provider: PROVIDER,
988 sessionId,
948 threadId,
989949 }),
990950 );
991951 }
......@@ -993,7 +953,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
993953 return Effect.fail(
994954 new ProviderAdapterSessionClosedError({
995955 provider: PROVIDER,
996 sessionId,
956 threadId,
997957 }),
998958 );
999959 }
......@@ -1011,7 +971,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1011971 }
1012972
1013973 const startedAt = yield* nowIso;
1014 const sessionId = ProviderSessionId.makeUnsafe(yield* Random.nextUUIDv4);
1015974 const cwd = input.cwd ?? process.cwd();
1016975 const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined;
1017976 const binaryPath = cursorOptions?.binaryPath ?? "agent";
......@@ -1028,7 +987,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1028987 catch: (cause) =>
1029988 new ProviderAdapterProcessError({
1030989 provider: PROVIDER,
1031 sessionId,
990 threadId: input.threadId,
1032991 detail: toMessage(cause, "Failed to spawn Cursor ACP process."),
1033992 cause,
1034993 }),
......@@ -1037,7 +996,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1037996 const output = readline.createInterface({ input: child.stdout });
1038997
1039998 const session: ProviderSession = {
1040 sessionId,
999 threadId: input.threadId,
10411000 provider: PROVIDER,
10421001 status: "connecting",
10431002 runtimeMode: input.runtimeMode,
......@@ -1085,10 +1044,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
10851044 type: "runtime.error",
10861045 eventId: stamp.eventId,
10871046 provider: PROVIDER,
1088 sessionId: asRuntimeSessionId(context.session.sessionId),
10891047 createdAt: stamp.createdAt,
1090 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1091 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1048 threadId: context.session.threadId,
1049 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
10921050 payload: {
10931051 message: error.message || "Cursor ACP process error.",
10941052 class: "transport_error",
......@@ -1118,9 +1076,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11181076 type: "session.exited",
11191077 eventId: stamp.eventId,
11201078 provider: PROVIDER,
1121 sessionId: asRuntimeSessionId(context.session.sessionId),
11221079 createdAt: stamp.createdAt,
1123 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1080 threadId: context.session.threadId,
11241081 payload: {
11251082 reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
11261083 exitKind: code === 0 ? "graceful" : "error",
......@@ -1128,19 +1085,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11281085 },
11291086 });
11301087
1131 sessions.delete(context.session.sessionId);
1088 sessions.delete(context.session.threadId);
11321089 }),
11331090 );
11341091 });
11351092
1136 sessions.set(sessionId, context);
1093 sessions.set(input.threadId, context);
11371094
11381095 const initializeResult = yield* Effect.tryPromise({
11391096 try: async () =>
11401097 sendRequest(context, "initialize", {
11411098 protocolVersion: CURSOR_ACP_PROTOCOL_VERSION,
11421099 }),
1143 catch: (cause) => toRequestError(sessionId, "initialize", cause),
1100 catch: (cause) => toRequestError(input.threadId, "initialize", cause),
11441101 });
11451102 const decodedInitialize = yield* Effect.try({
11461103 try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult),
......@@ -1158,14 +1115,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11581115 type: "session.configured",
11591116 eventId: initStamp.eventId,
11601117 provider: PROVIDER,
1161 sessionId: asRuntimeSessionId(sessionId),
11621118 createdAt: initStamp.createdAt,
1119 threadId: input.threadId,
11631120 payload: {
11641121 config: decodedInitialize,
11651122 },
1166 providerRefs: {
1167 providerSessionId: sessionId,
1168 },
11691123 raw: {
11701124 source: "cursor.acp.response",
11711125 method: "initialize",
......@@ -1179,14 +1133,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11791133 type: "auth.status",
11801134 eventId: authStartStamp.eventId,
11811135 provider: PROVIDER,
1182 sessionId: asRuntimeSessionId(sessionId),
11831136 createdAt: authStartStamp.createdAt,
1137 threadId: input.threadId,
11841138 payload: {
11851139 isAuthenticating: true,
11861140 },
1187 providerRefs: {
1188 providerSessionId: sessionId,
1189 },
11901141 raw: {
11911142 source: "cursor.acp.request",
11921143 method: "authenticate",
......@@ -1196,7 +1147,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
11961147
11971148 const authenticateResult = yield* Effect.tryPromise({
11981149 try: async () => sendRequest(context, "authenticate", authenticateRequest),
1199 catch: (cause) => toRequestError(sessionId, "authenticate", cause),
1150 catch: (cause) => toRequestError(input.threadId, "authenticate", cause),
12001151 }).pipe(
12011152 Effect.match({
12021153 onFailure: (error) => ({ ok: false as const, error }),
......@@ -1209,15 +1160,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
12091160 type: "auth.status",
12101161 eventId: authEndStamp.eventId,
12111162 provider: PROVIDER,
1212 sessionId: asRuntimeSessionId(sessionId),
12131163 createdAt: authEndStamp.createdAt,
1164 threadId: input.threadId,
12141165 payload: {
12151166 isAuthenticating: false,
12161167 error: toMessage(authenticateResult.error, "Cursor authentication failed."),
12171168 },
1218 providerRefs: {
1219 providerSessionId: sessionId,
1220 },
12211169 raw: {
12221170 source: "cursor.acp.response",
12231171 method: "authenticate",
......@@ -1231,14 +1179,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
12311179 type: "auth.status",
12321180 eventId: authEndStamp.eventId,
12331181 provider: PROVIDER,
1234 sessionId: asRuntimeSessionId(sessionId),
12351182 createdAt: authEndStamp.createdAt,
1183 threadId: input.threadId,
12361184 payload: {
12371185 isAuthenticating: false,
12381186 },
1239 providerRefs: {
1240 providerSessionId: sessionId,
1241 },
12421187 raw: {
12431188 source: "cursor.acp.response",
12441189 method: "authenticate",
......@@ -1273,15 +1218,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
12731218 const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result);
12741219 return decoded.sessionId;
12751220 },
1276 catch: (cause) => toRequestError(sessionId, "session/new|session/load", cause),
1221 catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause),
12771222 });
12781223
1279 const threadId = ProviderThreadId.makeUnsafe(acpSessionId);
12801224 context.acpSessionId = acpSessionId;
12811225 context.session = {
12821226 ...context.session,
12831227 status: "ready",
1284 threadId,
12851228 resumeCursor: {
12861229 acpSessionId,
12871230 },
......@@ -1293,14 +1236,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
12931236 type: "session.started",
12941237 eventId: sessionStartedStamp.eventId,
12951238 provider: PROVIDER,
1296 sessionId: asRuntimeSessionId(sessionId),
12971239 createdAt: sessionStartedStamp.createdAt,
1298 threadId: asRuntimeThreadId(threadId),
1240 threadId: input.threadId,
12991241 payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {},
1300 providerRefs: {
1301 providerSessionId: sessionId,
1302 providerThreadId: threadId,
1303 },
13041242 });
13051243
13061244 const threadStartedStamp = yield* makeEventStamp();
......@@ -1308,15 +1246,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13081246 type: "thread.started",
13091247 eventId: threadStartedStamp.eventId,
13101248 provider: PROVIDER,
1311 sessionId: asRuntimeSessionId(sessionId),
13121249 createdAt: threadStartedStamp.createdAt,
1313 threadId: asRuntimeThreadId(threadId),
1250 threadId: input.threadId,
13141251 payload: {
1315 providerThreadId: threadId,
1316 },
1317 providerRefs: {
1318 providerSessionId: sessionId,
1319 providerThreadId: threadId,
1252 providerThreadId: acpSessionId,
13201253 },
13211254 });
13221255
......@@ -1325,16 +1258,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13251258 type: "session.state.changed",
13261259 eventId: readyStamp.eventId,
13271260 provider: PROVIDER,
1328 sessionId: asRuntimeSessionId(sessionId),
13291261 createdAt: readyStamp.createdAt,
1330 threadId: asRuntimeThreadId(threadId),
1262 threadId: input.threadId,
13311263 payload: {
13321264 state: "ready",
13331265 },
1334 providerRefs: {
1335 providerSessionId: sessionId,
1336 providerThreadId: threadId,
1337 },
13381266 });
13391267
13401268 return {
......@@ -1344,13 +1272,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13441272
13451273 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
13461274 Effect.gen(function* () {
1347 const context = yield* requireSession(input.sessionId);
1275 const context = yield* requireSession(input.threadId);
13481276
13491277 if (context.turnState) {
13501278 return yield* new ProviderAdapterValidationError({
13511279 provider: PROVIDER,
13521280 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}'.`,
13541282 });
13551283 }
13561284
......@@ -1363,7 +1291,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13631291 });
13641292 }
13651293
1366 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);
1294 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
13671295 const turnState: CursorTurnState = {
13681296 turnId,
13691297 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),
......@@ -1385,18 +1313,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13851313 type: "turn.started",
13861314 eventId: startedStamp.eventId,
13871315 provider: PROVIDER,
1388 sessionId: asRuntimeSessionId(context.session.sessionId),
13891316 createdAt: startedStamp.createdAt,
1390 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1391 turnId: asRuntimeTurnId(turnId),
1317 threadId: context.session.threadId,
1318 turnId,
13921319 payload: {
13931320 ...(input.model ? { model: input.model } : {}),
13941321 ...(input.effort ? { effort: input.effort } : {}),
13951322 },
13961323 providerRefs: {
1397 providerSessionId: context.session.sessionId,
1398 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1399 providerTurnId: turnId,
1324 providerTurnId: String(turnId),
14001325 },
14011326 });
14021327
......@@ -1406,7 +1331,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14061331 sessionId: context.acpSessionId,
14071332 prompt: [{ type: "text", text: promptText }],
14081333 }),
1409 catch: (cause) => toRequestError(input.sessionId, "session/prompt", cause),
1334 catch: (cause) => toRequestError(input.threadId, "session/prompt", cause),
14101335 });
14111336
14121337 const promptResult = yield* Effect.try({
......@@ -1438,7 +1363,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14381363 };
14391364
14401365 return {
1441 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
1366 threadId: context.session.threadId,
14421367 turnId,
14431368 ...(context.session.resumeCursor !== undefined
14441369 ? { resumeCursor: context.session.resumeCursor }
......@@ -1446,9 +1371,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14461371 } satisfies ProviderTurnStartResult;
14471372 });
14481373
1449 const interruptTurn: CursorAdapterShape["interruptTurn"] = (sessionId, _turnId) =>
1374 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) =>
14501375 Effect.gen(function* () {
1451 const context = yield* requireSession(sessionId);
1376 const context = yield* requireSession(threadId);
14521377 if (!context.turnState) {
14531378 return;
14541379 }
......@@ -1456,7 +1381,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14561381 const cancelResult = yield* Effect.tryPromise({
14571382 try: async () =>
14581383 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),
14601385 }).pipe(
14611386 Effect.match({
14621387 onFailure: (error) => ({ ok: false as const, error }),
......@@ -1475,11 +1400,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14751400 yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled");
14761401 });
14771402
1478 const readThread: CursorAdapterShape["readThread"] = (sessionId) =>
1403 const readThread: CursorAdapterShape["readThread"] = (threadId) =>
14791404 Effect.gen(function* () {
1480 const context = yield* requireSession(sessionId);
1405 const context = yield* requireSession(threadId);
14811406 return {
1482 threadId: ProviderThreadId.makeUnsafe(context.acpSessionId),
1407 threadId: context.session.threadId,
14831408 turns: context.turns.map((turn) => ({
14841409 id: turn.id,
14851410 items: [...turn.items],
......@@ -1487,22 +1412,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
14871412 };
14881413 });
14891414
1490 const rollbackThread: CursorAdapterShape["rollbackThread"] = (sessionId, _numTurns) =>
1415 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) =>
14911416 Effect.fail(
14921417 new ProviderAdapterRequestError({
14931418 provider: PROVIDER,
14941419 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}'.`,
14961421 }),
14971422 );
14981423
14991424 const respondToRequest: CursorAdapterShape["respondToRequest"] = (
1500 sessionId,
1425 threadId,
15011426 requestId,
15021427 decision,
15031428 ) =>
15041429 Effect.gen(function* () {
1505 const context = yield* requireSession(sessionId);
1430 const context = yield* requireSession(threadId);
15061431 const pending = context.pendingPermissions.get(requestId);
15071432 if (!pending) {
15081433 return yield* new ProviderAdapterRequestError({
......@@ -1540,10 +1465,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
15401465 type: "request.resolved",
15411466 eventId: stamp.eventId,
15421467 provider: PROVIDER,
1543 sessionId: asRuntimeSessionId(context.session.sessionId),
15441468 createdAt: stamp.createdAt,
1545 ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})),
1546 ...((context.turnState ? { turnId: asRuntimeTurnId(context.turnState.turnId) } : {})),
1469 threadId: context.session.threadId,
1470 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
15471471 requestId: asRuntimeRequestId(requestId),
15481472 payload: {
15491473 requestType: pending.requestType,
......@@ -1553,9 +1477,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
15531477 },
15541478 },
15551479 providerRefs: {
1556 providerSessionId: context.session.sessionId,
1557 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1558 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1480 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
15591481 providerRequestId: String(pending.jsonRpcId),
15601482 },
15611483 raw: {
......@@ -1568,9 +1490,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
15681490 });
15691491 });
15701492
1571 const stopSession: CursorAdapterShape["stopSession"] = (sessionId) =>
1493 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
15721494 Effect.gen(function* () {
1573 const context = yield* requireSession(sessionId);
1495 const context = yield* requireSession(threadId);
15741496 yield* stopSessionInternal(context, {
15751497 emitExitEvent: true,
15761498 });
......@@ -1579,9 +1501,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
15791501 const listSessions: CursorAdapterShape["listSessions"] = () =>
15801502 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
15811503
1582 const hasSession: CursorAdapterShape["hasSession"] = (sessionId) =>
1504 const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
15831505 Effect.sync(() => {
1584 const context = sessions.get(sessionId);
1506 const context = sessions.get(threadId);
15851507 return context !== undefined && !context.stopping;
15861508 });
15871509
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-1
......@@ -28,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption
2828 const adapters =
2929 options?.adapters !== undefined
3030 ? options.adapters
31 : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter];
31 : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter];
3232 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3333
3434 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderService.test.ts+79-85
......@@ -14,10 +14,8 @@ import {
1414 EventId,
1515 type ProviderKind,
1616 ProviderSessionStartInput,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
2017 ThreadId,
18 TurnId,
2119} from "@t3tools/contracts";
2220import { it, assert, vi } from "@effect/vitest";
2321import { assertFailure } from "@effect/vitest/utils";
......@@ -27,8 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
2725
2826import {
2927 ProviderAdapterSessionNotFoundError,
30 ProviderSessionNotFoundError,
3128 ProviderUnsupportedError,
29 ProviderValidationError,
3230 type ProviderAdapterError,
3331} from "../Errors.ts";
3432import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
......@@ -45,20 +43,17 @@ import {
4543 SqlitePersistenceMemory,
4644} 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);
5146const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
5247const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
5348const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
49const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
5450
5551type LegacyProviderRuntimeEvent = {
5652 readonly type: string;
5753 readonly eventId: EventId;
5854 readonly provider: "codex" | "claudeCode" | "cursor";
59 readonly sessionId: string;
6055 readonly createdAt: string;
61 readonly threadId?: string | undefined;
56 readonly threadId: ThreadId;
6257 readonly turnId?: string | undefined;
6358 readonly itemId?: string | undefined;
6459 readonly requestId?: string | undefined;
......@@ -67,28 +62,23 @@ type LegacyProviderRuntimeEvent = {
6762};
6863
6964function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
70 const sessions = new Map<ProviderSessionId, ProviderSession>();
65 const sessions = new Map<ThreadId, ProviderSession>();
7166 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
72 let nextSession = 1;
7367
7468 const startSession = vi.fn((input: ProviderSessionStartInput) =>
7569 Effect.sync(() => {
7670 const now = new Date().toISOString();
77 const next = nextSession;
78 const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`);
7971 const session: ProviderSession = {
80 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
8172 provider,
8273 status: "ready",
8374 runtimeMode: input.runtimeMode,
84 threadId,
85 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },
75 threadId: input.threadId,
76 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${String(input.threadId)}` },
8677 cwd: input.cwd ?? process.cwd(),
8778 createdAt: now,
8879 updatedAt: now,
8980 };
90 nextSession += 1;
91 sessions.set(session.sessionId, session);
81 sessions.set(session.threadId, session);
9282 return session;
9383 }),
9484 );
......@@ -97,39 +87,39 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
9787 (
9888 input: ProviderSendTurnInput,
9989 ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => {
100 if (!sessions.has(input.sessionId)) {
90 if (!sessions.has(input.threadId)) {
10191 return Effect.fail(
10292 new ProviderAdapterSessionNotFoundError({
10393 provider,
104 sessionId: input.sessionId,
94 threadId: input.threadId,
10595 }),
10696 );
10797 }
10898
10999 return Effect.succeed({
110 threadId: ProviderThreadId.makeUnsafe("thread-1"),
111 turnId: ProviderTurnId.makeUnsafe("turn-1"),
100 threadId: input.threadId,
101 turnId: TurnId.makeUnsafe(`turn-${String(input.threadId)}`),
112102 });
113103 },
114104 );
115105
116106 const interruptTurn = vi.fn(
117 (_sessionId: string, _turnId?: string): Effect.Effect<void, ProviderAdapterError> =>
107 (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect<void, ProviderAdapterError> =>
118108 Effect.void,
119109 );
120110
121111 const respondToRequest = vi.fn(
122112 (
123 _sessionId: string,
113 _threadId: ThreadId,
124114 _requestId: string,
125115 _decision: ProviderApprovalDecision,
126116 ): Effect.Effect<void, ProviderAdapterError> => Effect.void,
127117 );
128118
129119 const stopSession = vi.fn(
130 (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>
120 (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> =>
131121 Effect.sync(() => {
132 sessions.delete(ProviderSessionId.makeUnsafe(sessionId));
122 sessions.delete(threadId);
133123 }),
134124 );
135125
......@@ -139,32 +129,31 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
139129 );
140130
141131 const hasSession = vi.fn(
142 (sessionId: string): Effect.Effect<boolean> =>
143 Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))),
132 (threadId: ThreadId): Effect.Effect<boolean> => Effect.succeed(sessions.has(threadId)),
144133 );
145134
146135 const readThread = vi.fn(
147136 (
148 _sessionId: ProviderSessionId,
137 threadId: ThreadId,
149138 ): Effect.Effect<
150139 {
151 threadId: ProviderThreadId;
152 turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>;
140 threadId: ThreadId;
141 turns: ReadonlyArray<{ id: TurnId; items: readonly [] }>;
153142 },
154143 ProviderAdapterError
155144 > =>
156145 Effect.succeed({
157 threadId: ProviderThreadId.makeUnsafe("thread-1"),
146 threadId,
158147 turns: [{ id: asTurnId("turn-1"), items: [] }],
159148 }),
160149 );
161150
162151 const rollbackThread = vi.fn(
163152 (
164 _sessionId: ProviderSessionId,
153 threadId: ThreadId,
165154 _numTurns: number,
166 ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> =>
167 Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }),
155 ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> =>
156 Effect.succeed({ threadId, turns: [] }),
168157 );
169158
170159 const stopAll = vi.fn(
......@@ -277,7 +266,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
277266 yield* Effect.gen(function* () {
278267 const directory = yield* ProviderSessionDirectory;
279268 yield* directory.upsert({
280 sessionId: asSessionId("sess-stale"),
281269 provider: "codex",
282270 threadId: ThreadId.makeUnsafe("thread-stale"),
283271 });
......@@ -294,13 +282,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
294282
295283 const persistedProvider = yield* Effect.gen(function* () {
296284 const directory = yield* ProviderSessionDirectory;
297 return yield* directory.getProvider(asSessionId("sess-stale"));
285 return yield* directory.getProvider(asThreadId("thread-stale"));
298286 }).pipe(Effect.provide(directoryLayer));
299287 assert.equal(persistedProvider, "codex");
300288
301289 const runtime = yield* Effect.gen(function* () {
302290 const repository = yield* ProviderSessionRuntimeRepository;
303 return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") });
291 return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") });
304292 }).pipe(Effect.provide(runtimeRepositoryLayer));
305293 assert.equal(Option.isSome(runtime), true);
306294
......@@ -348,10 +336,12 @@ it.effect(
348336
349337 const startedSession = yield* Effect.gen(function* () {
350338 const provider = yield* ProviderService;
351 return yield* provider.startSession(asThreadId("thread-1"), {
339 const threadId = asThreadId("thread-1");
340 return yield* provider.startSession(threadId, {
352341 provider: "codex",
353342 cwd: "/tmp/project",
354343 runtimeMode: "full-access",
344 threadId,
355345 });
356346 }).pipe(Effect.provide(firstProviderLayer));
357347
......@@ -362,9 +352,7 @@ it.effect(
362352
363353 const persistedAfterStopAll = yield* Effect.gen(function* () {
364354 const repository = yield* ProviderSessionRuntimeRepository;
365 return yield* repository.getBySessionId({
366 providerSessionId: startedSession.sessionId,
367 });
355 return yield* repository.getByThreadId({ threadId: startedSession.threadId });
368356 }).pipe(Effect.provide(runtimeRepositoryLayer));
369357 assert.equal(Option.isSome(persistedAfterStopAll), true);
370358 if (Option.isSome(persistedAfterStopAll)) {
......@@ -394,7 +382,7 @@ it.effect(
394382 yield* Effect.gen(function* () {
395383 const provider = yield* ProviderService;
396384 yield* provider.rollbackConversation({
397 sessionId: startedSession.sessionId,
385 threadId: startedSession.threadId,
398386 numTurns: 1,
399387 });
400388 }).pipe(Effect.provide(secondProviderLayer));
......@@ -430,6 +418,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
430418
431419 const session = yield* provider.startSession(asThreadId("thread-1"), {
432420 provider: "codex",
421 threadId: asThreadId("thread-1"),
433422 cwd: "/tmp/project",
434423 runtimeMode: "full-access",
435424 });
......@@ -439,40 +428,43 @@ routing.layer("ProviderServiceLive routing", (it) => {
439428 assert.equal(sessions.length, 1);
440429
441430 yield* provider.sendTurn({
442 sessionId: session.sessionId,
431 threadId: session.threadId,
443432 input: "hello",
444433 attachments: [],
445434 });
446435 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
447436
448 yield* provider.interruptTurn({ sessionId: session.sessionId });
449 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]);
437 yield* provider.interruptTurn({ threadId: session.threadId });
438 assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]);
450439
451440 yield* provider.respondToRequest({
452 sessionId: session.sessionId,
441 threadId: session.threadId,
453442 requestId: asRequestId("req-1"),
454443 decision: "accept",
455444 });
456445 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [
457 [session.sessionId, asRequestId("req-1"), "accept"],
446 [session.threadId, asRequestId("req-1"), "accept"],
458447 ]);
459448
460449 yield* provider.rollbackConversation({
461 sessionId: session.sessionId,
450 threadId: session.threadId,
462451 numTurns: 0,
463452 });
464453
465 yield* provider.stopSession({ sessionId: session.sessionId });
454 yield* provider.stopSession({ threadId: session.threadId });
466455 const sendAfterStop = yield* Effect.result(
467456 provider.sendTurn({
468 sessionId: session.sessionId,
457 threadId: session.threadId,
469458 input: "after-stop",
470459 attachments: [],
471460 }),
472461 );
473462 assertFailure(
474463 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 }),
476468 );
477469 }),
478470 );
......@@ -483,6 +475,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
483475
484476 const session = yield* provider.startSession(asThreadId("thread-claude"), {
485477 provider: "claudeCode",
478 threadId: asThreadId("thread-claude"),
486479 cwd: "/tmp/project-claude",
487480 runtimeMode: "full-access",
488481 });
......@@ -505,15 +498,16 @@ routing.layer("ProviderServiceLive routing", (it) => {
505498
506499 const initial = yield* provider.startSession(asThreadId("thread-1"), {
507500 provider: "codex",
501 threadId: asThreadId("thread-1"),
508502 cwd: "/tmp/project",
509503 runtimeMode: "full-access",
510504 });
511 yield* routing.codex.stopSession(initial.sessionId);
505 yield* routing.codex.stopSession(initial.threadId);
512506 routing.codex.startSession.mockClear();
513507 routing.codex.rollbackThread.mockClear();
514508
515509 yield* provider.rollbackConversation({
516 sessionId: initial.sessionId,
510 threadId: initial.threadId,
517511 numTurns: 1,
518512 });
519513
......@@ -544,6 +538,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
544538
545539 const initial = yield* provider.startSession(asThreadId("thread-1"), {
546540 provider: "codex",
541 threadId: asThreadId("thread-1"),
547542 cwd: "/tmp/project-send-turn",
548543 runtimeMode: "full-access",
549544 });
......@@ -553,7 +548,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
553548 routing.codex.sendTurn.mockClear();
554549
555550 yield* provider.sendTurn({
556 sessionId: initial.sessionId,
551 threadId: initial.threadId,
557552 input: "resume",
558553 attachments: [],
559554 });
......@@ -583,10 +578,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
583578
584579 yield* provider.startSession(asThreadId("thread-1"), {
585580 provider: "codex",
581 threadId: asThreadId("thread-1"),
586582 runtimeMode: "full-access",
587583 });
588584 yield* provider.startSession(asThreadId("thread-2"), {
589585 provider: "codex",
586 threadId: asThreadId("thread-2"),
590587 runtimeMode: "full-access",
591588 });
592589
......@@ -604,16 +601,17 @@ routing.layer("ProviderServiceLive routing", (it) => {
604601
605602 const session = yield* provider.startSession(asThreadId("thread-1"), {
606603 provider: "codex",
604 threadId: asThreadId("thread-1"),
607605 runtimeMode: "full-access",
608606 });
609607 yield* provider.sendTurn({
610 sessionId: session.sessionId,
608 threadId: session.threadId,
611609 input: "hello",
612610 attachments: [],
613611 });
614612
615 const runningRuntime = yield* runtimeRepository.getBySessionId({
616 providerSessionId: session.sessionId,
613 const runningRuntime = yield* runtimeRepository.getByThreadId({
614 threadId: session.threadId,
617615 });
618616 assert.equal(Option.isSome(runningRuntime), true);
619617 if (Option.isSome(runningRuntime)) {
......@@ -638,8 +636,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
638636 }
639637
640638 yield* provider.stopAll();
641 const stoppedRuntime = yield* runtimeRepository.getBySessionId({
642 providerSessionId: session.sessionId,
639 const stoppedRuntime = yield* runtimeRepository.getByThreadId({
640 threadId: session.threadId,
643641 });
644642 assert.equal(Option.isSome(stoppedRuntime), true);
645643 if (Option.isSome(stoppedRuntime)) {
......@@ -656,6 +654,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
656654 const provider = yield* ProviderService;
657655 const session = yield* provider.startSession(asThreadId("thread-1"), {
658656 provider: "codex",
657 threadId: asThreadId("thread-1"),
659658 runtimeMode: "full-access",
660659 });
661660
......@@ -669,9 +668,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
669668 type: "turn.completed",
670669 eventId: asEventId("evt-1"),
671670 provider: "codex",
672 sessionId: session.sessionId,
673671 createdAt: new Date().toISOString(),
674 threadId: asProviderThreadId("thread-1"),
672 threadId: session.threadId,
675673 turnId: asTurnId("turn-1"),
676674 status: "completed",
677675 };
......@@ -694,6 +692,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
694692 const provider = yield* ProviderService;
695693 const session = yield* provider.startSession(asThreadId("thread-seq"), {
696694 provider: "codex",
695 threadId: asThreadId("thread-seq"),
697696 runtimeMode: "full-access",
698697 });
699698
......@@ -708,9 +707,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
708707 type: "tool.started",
709708 eventId: asEventId("evt-seq-1"),
710709 provider: "codex",
711 sessionId: session.sessionId,
712710 createdAt: new Date().toISOString(),
713 threadId: asProviderThreadId("thread-1"),
711 threadId: session.threadId,
714712 turnId: asTurnId("turn-1"),
715713 toolKind: "command",
716714 title: "Command run",
......@@ -719,9 +717,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
719717 type: "tool.completed",
720718 eventId: asEventId("evt-seq-2"),
721719 provider: "codex",
722 sessionId: session.sessionId,
723720 createdAt: new Date().toISOString(),
724 threadId: asProviderThreadId("thread-1"),
721 threadId: session.threadId,
725722 turnId: asTurnId("turn-1"),
726723 toolKind: "command",
727724 title: "Command run",
......@@ -730,9 +727,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
730727 type: "turn.completed",
731728 eventId: asEventId("evt-seq-3"),
732729 provider: "codex",
733 sessionId: session.sessionId,
734730 createdAt: new Date().toISOString(),
735 threadId: asProviderThreadId("thread-1"),
731 threadId: session.threadId,
736732 turnId: asTurnId("turn-1"),
737733 status: "completed",
738734 });
......@@ -751,6 +747,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
751747 const provider = yield* ProviderService;
752748 const session = yield* provider.startSession(asThreadId("thread-1"), {
753749 provider: "codex",
750 threadId: asThreadId("thread-1"),
754751 runtimeMode: "full-access",
755752 });
756753
......@@ -775,9 +772,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
775772 type: "tool.completed",
776773 eventId: asEventId("evt-ordered-1"),
777774 provider: "codex",
778 sessionId: session.sessionId,
779775 createdAt: new Date().toISOString(),
780 threadId: asProviderThreadId("thread-1"),
776 threadId: session.threadId,
781777 turnId: asTurnId("turn-1"),
782778 toolKind: "command",
783779 title: "Command run",
......@@ -787,9 +783,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
787783 type: "message.delta",
788784 eventId: asEventId("evt-ordered-2"),
789785 provider: "codex",
790 sessionId: session.sessionId,
791786 createdAt: new Date().toISOString(),
792 threadId: asProviderThreadId("thread-1"),
787 threadId: session.threadId,
793788 turnId: asTurnId("turn-1"),
794789 delta: "hello",
795790 },
......@@ -797,9 +792,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
797792 type: "turn.completed",
798793 eventId: asEventId("evt-ordered-3"),
799794 provider: "codex",
800 sessionId: session.sessionId,
801795 createdAt: new Date().toISOString(),
802 threadId: asProviderThreadId("thread-1"),
796 threadId: session.threadId,
803797 turnId: asTurnId("turn-1"),
804798 status: "completed",
805799 },
......@@ -854,32 +848,32 @@ validation.layer("ProviderServiceLive validation", (it) => {
854848 Effect.sync(() => {
855849 const now = new Date().toISOString();
856850 return {
857 sessionId: asSessionId("sess-missing-thread"),
858851 provider: "codex",
859852 status: "ready",
853 threadId: input.threadId,
860854 runtimeMode: input.runtimeMode,
861 cwd: input.cwd ?? process.cwd(),
862 createdAt: now,
863 updatedAt: now,
864 } satisfies ProviderSession;
865 }),
855 cwd: input.cwd ?? process.cwd(),
856 createdAt: now,
857 updatedAt: now,
858 } satisfies ProviderSession;
859 }),
866860 );
867861
868862 const session = yield* provider.startSession(asThreadId("thread-missing"), {
869863 provider: "codex",
864 threadId: asThreadId("thread-missing"),
870865 cwd: "/tmp/project",
871866 runtimeMode: "full-access",
872867 });
873868
874 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));
875 assert.equal(session.threadId, undefined);
869 assert.equal(session.threadId, asThreadId("thread-missing"));
876870
877 const runtime = yield* runtimeRepository.getBySessionId({
878 providerSessionId: session.sessionId,
871 const runtime = yield* runtimeRepository.getByThreadId({
872 threadId: session.threadId,
879873 });
880874 assert.equal(Option.isSome(runtime), true);
881875 if (Option.isSome(runtime)) {
882 assert.equal(runtime.value.providerThreadId, null);
876 assert.equal(runtime.value.threadId, session.threadId);
883877 }
884878 }),
885879 );
apps/server/src/provider/Layers/ProviderService.ts+62-255
......@@ -11,8 +11,6 @@
1111 */
1212import {
1313 NonNegativeInt,
14 ProviderSessionId,
15 RuntimeSessionId,
1614 ThreadId,
1715 ProviderInterruptTurnInput,
1816 ProviderRespondToRequestInput,
......@@ -22,14 +20,14 @@ import {
2220 type ProviderRuntimeEvent,
2321 type ProviderSession,
2422} 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";
2826import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
2927import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
3028import {
3129 ProviderSessionDirectory,
32 type ProviderSessionBinding,
30 type ProviderRuntimeBinding,
3331} from "../Services/ProviderSessionDirectory.ts";
3432import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3533
......@@ -39,7 +37,7 @@ export interface ProviderServiceLiveOptions {
3937}
4038
4139const ProviderRollbackConversationInput = Schema.Struct({
42 sessionId: ProviderSessionId,
40 threadId: ThreadId,
4341 numTurns: NonNegativeInt,
4442});
4543
......@@ -96,7 +94,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u
9694}
9795
9896function readPersistedCwd(
99 runtimePayload: ProviderSessionBinding["runtimePayload"],
97 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
10098): string | undefined {
10199 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
102100 return undefined;
......@@ -121,47 +119,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
121119 const directory = yield* ProviderSessionDirectory;
122120 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
123121 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
149123 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
150 canonicalizeRuntimeEventSession(event).pipe(
124 Effect.succeed(event).pipe(
151125 Effect.tap((canonicalEvent) =>
152126 canonicalEventLogger
153 ? Effect.flatMap(
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 )
127 ? canonicalEventLogger.write(canonicalEvent, null)
165128 : Effect.void,
166129 ),
167130 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
......@@ -172,56 +135,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
172135 session: ProviderSession,
173136 threadId: ThreadId,
174137 ) =>
175 Effect.gen(function* () {
176 const providerThreadId = session.threadId;
177
178 yield* directory.upsert({
179 sessionId: session.sessionId,
180 provider: session.provider,
181 threadId,
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;
138 directory.upsert({
139 threadId,
140 provider: session.provider,
141 runtimeMode: session.runtimeMode,
142 status: toRuntimeStatus(session),
143 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
144 runtimePayload: toRuntimePayloadFromSession(session),
225145 });
226146
227147 const providers = yield* registry.listProviders();
......@@ -244,166 +164,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
244164 ).pipe(Effect.asVoid);
245165
246166 const recoverSessionForThread = (input: {
247 readonly staleSessionId: ProviderSessionId;
248 readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId };
167 readonly binding: ProviderRuntimeBinding;
249168 readonly operation: string;
250169 }) =>
251170 Effect.gen(function* () {
252171 const adapter = yield* registry.getByProvider(input.binding.provider);
253 const activeSessions = yield* adapter.listSessions();
254 const resumeThreadId = input.binding.providerThreadId ?? undefined;
255172 const hasResumeCursor =
256173 input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined;
257 const existing =
258 resumeThreadId === undefined
259 ? undefined
260 : activeSessions.find((session) => session.threadId === resumeThreadId);
261 if (existing) {
262 const existingProviderThreadId = yield* upsertSessionBinding(
263 existing,
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);
174 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);
175 if (hasActiveSession) {
176 const activeSessions = yield* adapter.listSessions();
177 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);
178 if (existing) {
179 yield* upsertSessionBinding(existing, input.binding.threadId);
180 return { adapter, session: existing } as const;
279181 }
280 return {
281 adapter,
282 sessionId: existing.sessionId,
283 } as const;
284182 }
285183
286184 if (!hasResumeCursor) {
287185 return yield* toValidationError(
288186 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.`,
290188 );
291189 }
292190
293191 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
294192
295193 const resumed = yield* adapter.startSession({
194 threadId: input.binding.threadId,
296195 provider: input.binding.provider,
297196 ...(persistedCwd ? { cwd: persistedCwd } : {}),
298 ...(resumeThreadId ? { resumeThreadId } : {}),
299197 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
300198 runtimeMode: input.binding.runtimeMode ?? "full-access",
301199 });
302200 if (resumed.provider !== adapter.provider) {
303201 return yield* toValidationError(
304202 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}'.`,
306204 );
307205 }
308206
309 const resumedProviderThreadId = yield* upsertSessionBinding(
310 resumed,
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;
207 yield* upsertSessionBinding(resumed, input.binding.threadId);
208 return { adapter, session: resumed } as const;
334209 });
335210
336211 const resolveRoutableSession = (input: {
337 readonly sessionId: ProviderSessionId;
212 readonly threadId: ThreadId;
338213 readonly operation: string;
339214 readonly allowRecovery: boolean;
340215 }) =>
341216 Effect.gen(function* () {
342 const bindingOption = yield* directory.getBinding(input.sessionId);
217 const bindingOption = yield* directory.getBinding(input.threadId);
343218 const binding = Option.getOrUndefined(bindingOption);
344219 if (!binding) {
345 return yield* new ProviderSessionNotFoundError({
346 sessionId: input.sessionId,
347 });
348 }
349 if (!binding.threadId) {
350220 return yield* toValidationError(
351221 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.`,
353223 );
354224 }
355 const bindingWithThreadId: ProviderSessionBinding & {
356 readonly threadId: ThreadId;
357 } = {
358 ...binding,
359 threadId: binding.threadId,
360 };
361225 const adapter = yield* registry.getByProvider(binding.provider);
362226
363 const hasRequestedSession = yield* adapter.hasSession(input.sessionId);
227 const hasRequestedSession = yield* adapter.hasSession(input.threadId);
364228 if (hasRequestedSession) {
365 yield* clearAliasKey(input.sessionId);
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);
229 return { adapter, threadId: input.threadId, isActive: true } as const;
386230 }
387231
388232 if (!input.allowRecovery) {
389 return {
390 adapter,
391 sessionId: input.sessionId,
392 isActive: false,
393 } as const;
233 return { adapter, threadId: input.threadId, isActive: false } as const;
394234 }
395235
396 const recovered = yield* recoverSessionForThread({
397 staleSessionId: input.sessionId,
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;
236 const recovered = yield* recoverSessionForThread({ binding, operation: input.operation });
237 return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const;
407238 });
408239
409240 const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) =>
......@@ -416,6 +247,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
416247
417248 const input = {
418249 ...parsed,
250 threadId,
419251 provider: parsed.provider ?? "codex",
420252 };
421253 const adapter = yield* registry.getByProvider(input.provider);
......@@ -452,28 +284,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
452284 );
453285 }
454286 const routed = yield* resolveRoutableSession({
455 sessionId: input.sessionId,
287 threadId: input.threadId,
456288 operation: "ProviderService.sendTurn",
457289 allowRecovery: true,
458290 });
459 const turn = yield* routed.adapter.sendTurn({
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 }
291 const turn = yield* routed.adapter.sendTurn(input);
472292 yield* directory.upsert({
473 sessionId: input.sessionId,
293 threadId: input.threadId,
474294 provider: routed.adapter.provider,
475 threadId,
476 ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}),
477295 status: "running",
478296 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
479297 runtimePayload: {
......@@ -493,11 +311,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
493311 payload: rawInput,
494312 });
495313 const routed = yield* resolveRoutableSession({
496 sessionId: input.sessionId,
314 threadId: input.threadId,
497315 operation: "ProviderService.interruptTurn",
498316 allowRecovery: true,
499317 });
500 yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId);
318 yield* routed.adapter.interruptTurn(routed.threadId, input.turnId);
501319 });
502320
503321 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
......@@ -508,11 +326,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
508326 payload: rawInput,
509327 });
510328 const routed = yield* resolveRoutableSession({
511 sessionId: input.sessionId,
329 threadId: input.threadId,
512330 operation: "ProviderService.respondToRequest",
513331 allowRecovery: true,
514332 });
515 yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision);
333 yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision);
516334 });
517335
518336 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
......@@ -523,19 +341,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
523341 payload: rawInput,
524342 });
525343 const routed = yield* resolveRoutableSession({
526 sessionId: input.sessionId,
344 threadId: input.threadId,
527345 operation: "ProviderService.stopSession",
528346 allowRecovery: false,
529347 });
530348 if (routed.isActive) {
531 yield* routed.adapter.stopSession(routed.sessionId);
532 }
533 if (routed.sessionId !== input.sessionId) {
534 yield* directory.remove(routed.sessionId);
535 yield* clearAliasesReferencing(routed.sessionId);
349 yield* routed.adapter.stopSession(routed.threadId);
536350 }
537 yield* directory.remove(input.sessionId);
538 yield* clearAliasesReferencing(input.sessionId);
351 yield* directory.remove(input.threadId);
539352 });
540353
541354 const listSessions: ProviderServiceShape["listSessions"] = () =>
......@@ -543,39 +356,36 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
543356 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());
544357 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
545358 const persistedBindings = yield* directory
546 .listSessionIds()
359 .listThreadIds()
547360 .pipe(
548 Effect.flatMap((sessionIds) =>
361 Effect.flatMap((threadIds) =>
549362 Effect.forEach(
550 sessionIds,
551 (sessionId) =>
552 directory.getBinding(sessionId).pipe(
553 Effect.orElseSucceed(() => Option.none<ProviderSessionBinding>()),
363 threadIds,
364 (threadId) =>
365 directory.getBinding(threadId).pipe(
366 Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>()),
554367 ),
555368 { concurrency: "unbounded" },
556369 ),
557370 ),
558 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>),
371 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
559372 );
560 const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>();
373 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();
561374 for (const bindingOption of persistedBindings) {
562375 const binding = Option.getOrUndefined(bindingOption);
563376 if (binding) {
564 bindingsBySessionId.set(binding.sessionId, binding);
377 bindingsByThreadId.set(binding.threadId, binding);
565378 }
566379 }
567380
568381 return activeSessions.map((session) => {
569 const binding = bindingsBySessionId.get(session.sessionId);
382 const binding = bindingsByThreadId.get(session.threadId);
570383 if (!binding) {
571384 return session;
572385 }
573386
574387 return {
575388 ...session,
576 ...(session.threadId === undefined && binding.providerThreadId
577 ? { threadId: binding.providerThreadId }
578 : {}),
579389 ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined
580390 ? { resumeCursor: binding.resumeCursor }
581391 : {}),
......@@ -598,22 +408,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
598408 return;
599409 }
600410 const routed = yield* resolveRoutableSession({
601 sessionId: input.sessionId,
411 threadId: input.threadId,
602412 operation: "ProviderService.rollbackConversation",
603413 allowRecovery: true,
604414 });
605 yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns);
415 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);
606416 });
607417
608418 const stopAll: ProviderServiceShape["stopAll"] = () =>
609419 Effect.gen(function* () {
610 const sessionIds = yield* directory.listSessionIds();
420 const threadIds = yield* directory.listThreadIds();
611421 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
612 yield* Effect.forEach(sessionIds, (sessionId) =>
613 directory.getProvider(sessionId).pipe(
422 yield* Effect.forEach(threadIds, (threadId) =>
423 directory.getProvider(threadId).pipe(
614424 Effect.flatMap((provider) =>
615425 directory.upsert({
616 sessionId,
426 threadId,
617427 provider,
618428 status: "stopped",
619429 runtimePayload: {
......@@ -625,9 +435,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
625435 ),
626436 ),
627437 ).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());
631438 });
632439
633440 return {
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+44-91
......@@ -3,7 +3,7 @@ import os from "node:os";
33import path from "node:path";
44
55import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts";
6import { ThreadId } from "@t3tools/contracts";
77import { it, assert } from "@effect/vitest";
88import { assertFailure, assertSome } from "@effect/vitest/utils";
99import { Effect, Layer, Option } from "effect";
......@@ -15,7 +15,7 @@ import {
1515} from "../../persistence/Layers/Sqlite.ts";
1616import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
1717import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
18import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
18import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts";
1919import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2020import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
2121
......@@ -31,74 +31,59 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
3131}
3232
3333it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {
34 it("upserts, reads, and removes session bindings", () =>
34 it("upserts, reads, and removes thread bindings", () =>
3535 Effect.gen(function* () {
3636 const directory = yield* ProviderSessionDirectory;
3737 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
3838
39 const sessionId = ProviderSessionId.makeUnsafe("sess-1");
4039 const initialThreadId = ThreadId.makeUnsafe("thread-1");
4140
4241 yield* directory.upsert({
43 sessionId,
4442 provider: "codex",
4543 threadId: initialThreadId,
4644 });
4745
48 const provider = yield* directory.getProvider(sessionId);
46 const provider = yield* directory.getProvider(initialThreadId);
4947 assert.equal(provider, "codex");
50 const resolvedThreadId = yield* directory.getThreadId(sessionId);
51 assertSome(resolvedThreadId, initialThreadId);
48 const resolvedBinding = yield* directory.getBinding(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
5357 const nextThreadId = ThreadId.makeUnsafe("thread-2");
5458
5559 yield* directory.upsert({
56 sessionId: sessionId,
5760 provider: "codex",
5861 threadId: nextThreadId,
5962 });
60 const updatedThreadId = yield* directory.getThreadId(sessionId);
61 assertSome(updatedThreadId, nextThreadId);
62 const updatedBinding = yield* directory.getBinding(sessionId);
63 const updatedBinding = yield* directory.getBinding(nextThreadId);
6364 assert.equal(Option.isSome(updatedBinding), true);
6465 if (Option.isSome(updatedBinding)) {
6566 assert.equal(updatedBinding.value.threadId, nextThreadId);
66 assert.equal(updatedBinding.value.providerThreadId, null);
6767 }
6868
69 const runtime = yield* runtimeRepository.getBySessionId({
70 providerSessionId: sessionId,
71 });
69 const runtime = yield* runtimeRepository.getByThreadId({ threadId: nextThreadId });
7270 assert.equal(Option.isSome(runtime), true);
7371 if (Option.isSome(runtime)) {
7472 assert.equal(runtime.value.threadId, nextThreadId);
75 assert.equal(runtime.value.providerThreadId, null);
7673 assert.equal(runtime.value.status, "running");
77 assert.equal(runtime.value.adapterKey, "codex");
74 assert.equal(runtime.value.providerName, "codex");
7875 }
7976
80 const sessionIds = yield* directory.listSessionIds();
81 assert.deepEqual(sessionIds, [sessionId]);
77 const threadIds = yield* directory.listThreadIds();
78 assert.deepEqual(threadIds, [nextThreadId]);
8279
83 yield* directory.remove(sessionId);
84 const missingProvider = yield* directory.getProvider(sessionId).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 );
80 yield* directory.remove(nextThreadId);
81 const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result);
9782 assertFailure(
98 result,
99 new ProviderValidationError({
100 operation: "ProviderSessionDirectory.upsert",
101 issue: "threadId must be a non-empty string.",
83 missingProvider,
84 new ProviderSessionDirectoryPersistenceError({
85 operation: "ProviderSessionDirectory.getProvider",
86 detail: `No persisted provider binding found for thread '${nextThreadId}'.`,
10287 }),
10388 );
10489 }));
......@@ -108,15 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
10893 const directory = yield* ProviderSessionDirectory;
10994 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
11095
111 const sessionId = ProviderSessionId.makeUnsafe("sess-runtime");
11296 const threadId = ThreadId.makeUnsafe("thread-runtime");
113 const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime");
11497
11598 yield* directory.upsert({
116 sessionId,
11799 provider: "codex",
118100 threadId,
119 providerThreadId,
120101 status: "starting",
121102 resumeCursor: {
122103 threadId: "provider-thread-runtime",
......@@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
128109 });
129110
130111 yield* directory.upsert({
131 sessionId,
132112 provider: "codex",
113 threadId,
133114 status: "running",
134115 runtimePayload: {
135116 activeTurnId: "turn-1",
136117 },
137118 });
138119
139 const runtime = yield* runtimeRepository.getBySessionId({
140 providerSessionId: sessionId,
141 });
120 const runtime = yield* runtimeRepository.getByThreadId({ threadId });
142121 assert.equal(Option.isSome(runtime), true);
143122 if (Option.isSome(runtime)) {
144123 assert.equal(runtime.value.threadId, threadId);
145 assert.equal(runtime.value.providerThreadId, providerThreadId);
146124 assert.equal(runtime.value.status, "running");
147125 assert.deepEqual(runtime.value.resumeCursor, {
148 threadId: providerThreadId,
126 threadId: "provider-thread-runtime",
149127 });
150128 assert.deepEqual(runtime.value.runtimePayload, {
151129 cwd: "/tmp/project",
......@@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
155133 }
156134 }));
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
191136 it("rehydrates persisted mappings across layer restart", () =>
192137 Effect.gen(function* () {
193138 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));
194139 const dbPath = path.join(tempDir, "orchestration.sqlite");
195140 const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath));
196141
197 const sessionId = ProviderSessionId.makeUnsafe("sess-restart");
198142 const threadId = ThreadId.makeUnsafe("thread-restart");
199143
200144 yield* Effect.gen(function* () {
201145 const directory = yield* ProviderSessionDirectory;
202146 yield* directory.upsert({
203 sessionId,
204147 provider: "codex",
205148 threadId,
206149 });
......@@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
209152 yield* Effect.gen(function* () {
210153 const directory = yield* ProviderSessionDirectory;
211154 const sql = yield* SqlClient.SqlClient;
212 const provider = yield* directory.getProvider(sessionId);
155 const provider = yield* directory.getProvider(threadId);
213156 assert.equal(provider, "codex");
214157
215 const resolvedThreadId = yield* directory.getThreadId(sessionId);
216 assertSome(resolvedThreadId, threadId);
158 const resolvedBinding = yield* directory.getBinding(threadId);
159 assertSome(resolvedBinding, {
160 threadId,
161 provider: "codex",
162 });
163 if (Option.isSome(resolvedBinding)) {
164 assert.equal(resolvedBinding.value.threadId, threadId);
165 }
217166
218167 const legacyTableRows = yield* sql<{ readonly name: string }>`
219168 SELECT name
......@@ -229,18 +178,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
229178 it("accepts cursor provider bindings", () =>
230179 Effect.gen(function* () {
231180 const directory = yield* ProviderSessionDirectory;
232 const sessionId = ProviderSessionId.makeUnsafe("sess-cursor");
233181 const threadId = ThreadId.makeUnsafe("thread-cursor");
234182
235183 yield* directory.upsert({
236 sessionId,
237184 provider: "cursor",
238185 threadId,
239186 });
240187
241 const provider = yield* directory.getProvider(sessionId);
188 const provider = yield* directory.getProvider(threadId);
242189 assert.equal(provider, "cursor");
243 const resolvedThreadId = yield* directory.getThreadId(sessionId);
244 assertSome(resolvedThreadId, threadId);
190 const resolvedBinding = yield* directory.getBinding(threadId);
191 assertSome(resolvedBinding, {
192 threadId,
193 provider: "cursor",
194 });
195 if (Option.isSome(resolvedBinding)) {
196 assert.equal(resolvedBinding.value.threadId, threadId);
197 }
245198 }));
246199});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+27-64
......@@ -1,15 +1,14 @@
1import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts";
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
22import { Effect, Layer, Option } from "effect";
33
44import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
55import {
66 ProviderSessionDirectoryPersistenceError,
7 ProviderSessionNotFoundError,
87 ProviderValidationError,
98} from "../Errors.ts";
109import {
1110 ProviderSessionDirectory,
12 type ProviderSessionBinding,
11 type ProviderRuntimeBinding,
1312 type ProviderSessionDirectoryShape,
1413} from "../Services/ProviderSessionDirectory.ts";
1514
......@@ -49,10 +48,7 @@ function mergeRuntimePayload(
4948 return existing ?? null;
5049 }
5150 if (isRecord(existing) && isRecord(next)) {
52 return {
53 ...existing,
54 ...next,
55 };
51 return { ...existing, ...next };
5652 }
5753 return next;
5854}
......@@ -60,21 +56,19 @@ function mergeRuntimePayload(
6056const makeProviderSessionDirectory = Effect.gen(function* () {
6157 const repository = yield* ProviderSessionRuntimeRepository;
6258
63 const getBinding = (sessionId: ProviderSessionId) =>
64 repository.getBySessionId({ providerSessionId: sessionId }).pipe(
65 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),
59 const getBinding = (threadId: ThreadId) =>
60 repository.getByThreadId({ threadId }).pipe(
61 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getByThreadId")),
6662 Effect.flatMap((runtime) =>
6763 Option.match(runtime, {
68 onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()),
64 onNone: () => Effect.succeed(Option.none<ProviderRuntimeBinding>()),
6965 onSome: (value) =>
7066 decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe(
7167 Effect.map((provider) =>
7268 Option.some({
73 sessionId: value.providerSessionId,
74 provider,
7569 threadId: value.threadId,
70 provider,
7671 adapterKey: value.adapterKey,
77 providerThreadId: value.providerThreadId,
7872 runtimeMode: value.runtimeMode,
7973 status: value.status,
8074 resumeCursor: value.resumeCursor,
......@@ -88,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
8882
8983 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {
9084 const existing = yield* repository
91 .getBySessionId({
92 providerSessionId: binding.sessionId,
93 })
94 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")));
85 .getByThreadId({ threadId: binding.threadId })
86 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId")));
9587
9688 const existingRuntime = Option.getOrUndefined(existing);
9789 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
......@@ -105,14 +97,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
10597 const now = new Date().toISOString();
10698 yield* repository
10799 .upsert({
108 providerSessionId: binding.sessionId,
109100 threadId: resolvedThreadId,
110101 providerName: binding.provider,
111102 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,
112 providerThreadId:
113 binding.providerThreadId !== undefined
114 ? binding.providerThreadId
115 : (existingRuntime?.providerThreadId ?? null),
116103 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
117104 status: binding.status ?? existingRuntime?.status ?? "running",
118105 lastSeenAt: now,
......@@ -128,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
128115 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
129116 });
130117
131 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) =>
132 getBinding(sessionId).pipe(
118 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) =>
119 getBinding(threadId).pipe(
133120 Effect.flatMap((binding) =>
134121 Option.match(binding, {
135122 onSome: (value) => Effect.succeed(value.provider),
136 onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })),
137 }),
138 ),
139 );
140
141 const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) =>
142 getBinding(sessionId);
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 })),
123 onNone: () =>
124 Effect.fail(
125 new ProviderSessionDirectoryPersistenceError({
126 operation: "ProviderSessionDirectory.getProvider",
127 detail: `No persisted provider binding found for thread '${threadId}'.`,
128 }),
129 ),
150130 }),
151131 ),
152132 );
153133
154 const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) =>
134 const remove: ProviderSessionDirectoryShape["remove"] = (threadId) =>
155135 repository
156 .deleteBySessionId({ providerSessionId: sessionId })
157 .pipe(
158 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
159 );
136 .deleteByThreadId({ threadId })
137 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")));
160138
161 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>
139 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
162140 repository.list().pipe(
163 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),
164 Effect.flatMap((rows) =>
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)),
141 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")),
142 Effect.map((rows) => rows.map((row) => row.threadId)),
179143 );
180144
181145 return {
182146 upsert,
183147 getProvider,
184 getBinding: getBindingBySessionId,
185 getThreadId,
148 getBinding,
186149 remove,
187 listSessionIds,
150 listThreadIds,
188151 } satisfies ProviderSessionDirectoryShape;
189152});
190153
apps/server/src/provider/Services/ProviderAdapter.ts+11-12
......@@ -14,11 +14,10 @@ import type {
1414 ProviderRuntimeEvent,
1515 ProviderSendTurnInput,
1616 ProviderSession,
17 ProviderSessionId,
1817 ProviderSessionStartInput,
19 ProviderThreadId,
20 ProviderTurnId,
18 ThreadId,
2119 ProviderTurnStartResult,
20 TurnId,
2221} from "@t3tools/contracts";
2322import type { Effect } from "effect";
2423import type { Stream } from "effect";
......@@ -33,12 +32,12 @@ export interface ProviderAdapterCapabilities {
3332}
3433
3534export interface ProviderThreadTurnSnapshot {
36 readonly id: ProviderTurnId;
35 readonly id: TurnId;
3736 readonly items: ReadonlyArray<unknown>;
3837}
3938
4039export interface ProviderThreadSnapshot {
41 readonly threadId: ProviderThreadId;
40 readonly threadId: ThreadId;
4241 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
4342}
4443
......@@ -67,15 +66,15 @@ export interface ProviderAdapterShape<TError> {
6766 * Interrupt an active turn.
6867 */
6968 readonly interruptTurn: (
70 sessionId: ProviderSessionId,
71 turnId?: ProviderTurnId,
69 threadId: ThreadId,
70 turnId?: TurnId,
7271 ) => Effect.Effect<void, TError>;
7372
7473 /**
7574 * Respond to an interactive approval request.
7675 */
7776 readonly respondToRequest: (
78 sessionId: ProviderSessionId,
77 threadId: ThreadId,
7978 requestId: ApprovalRequestId,
8079 decision: ProviderApprovalDecision,
8180 ) => Effect.Effect<void, TError>;
......@@ -83,7 +82,7 @@ export interface ProviderAdapterShape<TError> {
8382 /**
8483 * Stop one provider session.
8584 */
86 readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>;
85 readonly stopSession: (threadId: ThreadId) => Effect.Effect<void, TError>;
8786
8887 /**
8988 * List currently active provider sessions for this adapter.
......@@ -93,20 +92,20 @@ export interface ProviderAdapterShape<TError> {
9392 /**
9493 * Check whether this adapter owns an active session id.
9594 */
96 readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>;
95 readonly hasSession: (threadId: ThreadId) => Effect.Effect<boolean>;
9796
9897 /**
9998 * Read a provider thread snapshot.
10099 */
101100 readonly readThread: (
102 sessionId: ProviderSessionId,
101 threadId: ThreadId,
103102 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
104103
105104 /**
106105 * Roll back a provider thread by N turns.
107106 */
108107 readonly rollbackThread: (
109 sessionId: ProviderSessionId,
108 threadId: ThreadId,
110109 numTurns: number,
111110 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
112111
apps/server/src/provider/Services/ProviderService.ts+1-2
......@@ -18,7 +18,6 @@ import type {
1818 ProviderRuntimeEvent,
1919 ProviderSendTurnInput,
2020 ProviderSession,
21 ProviderSessionId,
2221 ProviderSessionStartInput,
2322 ProviderStopSessionInput,
2423 ThreadId,
......@@ -88,7 +87,7 @@ export interface ProviderServiceShape {
8887 * Roll back provider conversation state by a number of turns.
8988 */
9089 readonly rollbackConversation: (input: {
91 readonly sessionId: ProviderSessionId;
90 readonly threadId: ThreadId;
9291 readonly numTurns: number;
9392 }) => Effect.Effect<void, ProviderServiceError>;
9493
apps/server/src/provider/Services/ProviderSessionDirectory.ts+10-57
......@@ -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 */
101import type {
112 ProviderKind,
12 ProviderSessionId,
133 ProviderSessionRuntimeStatus,
14 ProviderThreadId,
154 RuntimeMode,
165 ThreadId,
176} from "@t3tools/contracts";
......@@ -20,84 +9,48 @@ import type { Effect } from "effect";
209
2110import type {
2211 ProviderSessionDirectoryPersistenceError,
23 ProviderSessionNotFoundError,
2412 ProviderValidationError,
2513} from "../Errors.ts";
2614
27export interface ProviderSessionBinding {
28 readonly sessionId: ProviderSessionId;
15export interface ProviderRuntimeBinding {
16 readonly threadId: ThreadId;
2917 readonly provider: ProviderKind;
30 readonly threadId?: ThreadId;
3118 readonly adapterKey?: string;
32 readonly providerThreadId?: ProviderThreadId | null;
3319 readonly status?: ProviderSessionRuntimeStatus;
3420 readonly resumeCursor?: unknown | null;
3521 readonly runtimePayload?: unknown | null;
3622 readonly runtimeMode?: RuntimeMode;
3723}
3824
39export type ProviderSessionDirectoryReadError =
40 | ProviderSessionNotFoundError
41 | ProviderSessionDirectoryPersistenceError;
25export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError;
4226
4327export type ProviderSessionDirectoryWriteError =
4428 | ProviderValidationError
4529 | ProviderSessionDirectoryPersistenceError;
4630
47/**
48 * ProviderSessionDirectoryShape - Service API for provider session ownership metadata.
49 */
5031export 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 */
5732 readonly upsert: (
58 binding: ProviderSessionBinding,
33 binding: ProviderRuntimeBinding,
5934 ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;
6035
61 /**
62 * Resolve the provider owner for a session id.
63 */
6436 readonly getProvider: (
65 sessionId: ProviderSessionId,
37 threadId: ThreadId,
6638 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
6739
68 /**
69 * Resolve the full tracked binding for a session id.
70 */
7140 readonly getBinding: (
72 sessionId: ProviderSessionId,
73 ) => Effect.Effect<Option.Option<ProviderSessionBinding>, 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>;
41 threadId: ThreadId,
42 ) => Effect.Effect<Option.Option<ProviderRuntimeBinding>, ProviderSessionDirectoryReadError>;
8143
82 /**
83 * Remove a session binding.
84 */
8544 readonly remove: (
86 sessionId: ProviderSessionId,
45 threadId: ThreadId,
8746 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;
8847
89 /**
90 * List tracked session ids.
91 */
92 readonly listSessionIds: () => Effect.Effect<
93 ReadonlyArray<ProviderSessionId>,
48 readonly listThreadIds: () => Effect.Effect<
49 ReadonlyArray<ThreadId>,
9450 ProviderSessionDirectoryPersistenceError
9551 >;
9652}
9753
98/**
99 * ProviderSessionDirectory - Service tag for session ownership lookup.
100 */
10154export class ProviderSessionDirectory extends ServiceMap.Service<
10255 ProviderSessionDirectory,
10356 ProviderSessionDirectoryShape
apps/server/src/serverLayers.ts+9-7
......@@ -58,14 +58,16 @@ export function makeServerProviderLayer(): Layer.Layer<
5858 const codexAdapterLayer = makeCodexAdapterLive(
5959 nativeEventLogger ? { nativeEventLogger } : undefined,
6060 );
61 const claudeAdapterLayer = makeClaudeCodeAdapterLive({
62 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
63 });
64 const cursorAdapterLayer = makeCursorAdapterLive({
65 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
66 });
61 const claudeAdapterLayer = makeClaudeCodeAdapterLive(
62 nativeEventLogger ? { nativeEventLogger } : undefined,
63 );
64 const cursorAdapterLayer = makeCursorAdapterLive(
65 nativeEventLogger ? { nativeEventLogger } : undefined,
66 );
6767 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
68 Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer, cursorAdapterLayer)),
68 Layer.provide(codexAdapterLayer),
69 Layer.provide(claudeAdapterLayer),
70 Layer.provide(cursorAdapterLayer),
6971 Layer.provideMerge(providerSessionDirectoryLayer),
7072 );
7173 return makeProviderServiceLive(
apps/server/src/wsServer.test.ts+11-16
......@@ -18,9 +18,8 @@ import {
1818 ORCHESTRATION_WS_CHANNELS,
1919 ORCHESTRATION_WS_METHODS,
2020 ProviderItemId,
21 ProviderSessionId,
22 ProviderThreadId,
23 ProviderTurnId,
21 ThreadId,
22 TurnId,
2423 WS_CHANNELS,
2524 WS_METHODS,
2625 type WebSocketResponse,
......@@ -60,11 +59,9 @@ interface PendingMessages {
6059const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
6160
6261const 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);
6762const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
63const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
64const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
6865
6966const defaultOpenService: OpenShape = {
7067 openBrowser: () => Effect.void,
......@@ -1146,26 +1143,24 @@ describe("WebSocket Server", () => {
11461143
11471144 it("keeps orchestration domain push behavior for provider runtime events", async () => {
11481145 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
1149 const sessionId = asProviderSessionId("sess-test");
11501146 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
11511147 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
11521148 };
11531149 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
11541150 const providerService: ProviderServiceShape = {
1155 startSession: () =>
1151 startSession: (threadId) =>
11561152 Effect.succeed({
1157 sessionId,
11581153 provider: "codex",
11591154 status: "ready",
11601155 runtimeMode: "full-access",
1161 threadId: asProviderThreadId("provider-thread-1"),
1156 threadId,
11621157 createdAt: new Date().toISOString(),
11631158 updatedAt: new Date().toISOString(),
11641159 }),
1165 sendTurn: () =>
1160 sendTurn: ({ threadId }) =>
11661161 Effect.succeed({
1167 threadId: asProviderThreadId("provider-thread-1"),
1168 turnId: asProviderTurnId("provider-turn-1"),
1162 threadId,
1163 turnId: asTurnId("provider-turn-1"),
11691164 }),
11701165 interruptTurn: () => unsupported(),
11711166 respondToRequest: () => unsupported(),
......@@ -1239,9 +1234,9 @@ describe("WebSocket Server", () => {
12391234 type: "content.delta",
12401235 eventId: asEventId("evt-ws-runtime-message-delta"),
12411236 provider: "codex",
1242 sessionId,
1237 threadId: asThreadId("thread-1"),
12431238 createdAt: new Date().toISOString(),
1244 turnId: asProviderTurnId("turn-1"),
1239 turnId: asTurnId("turn-1"),
12451240 itemId: asProviderItemId("item-1"),
12461241 payload: {
12471242 streamKind: "assistant_text",
apps/web/src/components/BranchToolbar.tsx+2-3
......@@ -52,8 +52,7 @@ export default function BranchToolbar({
5252 const api = readNativeApi();
5353 // If the effective cwd is about to change, stop the running session so the
5454 // next message creates a new one with the correct cwd.
55 const sessionId = serverThread?.session?.sessionId;
56 if (sessionId && worktreePath !== activeWorktreePath && api) {
55 if (serverThread?.session && worktreePath !== activeWorktreePath && api) {
5756 void api.orchestration
5857 .dispatchCommand({
5958 type: "thread.session.stop",
......@@ -89,7 +88,7 @@ export default function BranchToolbar({
8988 },
9089 [
9190 activeThreadId,
92 serverThread?.session?.sessionId,
91 serverThread?.session,
9392 activeWorktreePath,
9493 hasServerThread,
9594 setThreadBranchAction,
apps/web/src/components/ChatView.tsx+2
......@@ -3324,6 +3324,8 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
33243324 <AlertTitle className="text-xs">
33253325 {approval.requestKind === "command"
33263326 ? "Command approval requested"
3327 : approval.requestKind === "file-read"
3328 ? "File-read approval requested"
33273329 : "File-change approval requested"}
33283330 </AlertTitle>
33293331 <AlertDescription
apps/web/src/session-logic.ts+8-3
......@@ -28,7 +28,7 @@ export interface WorkLogEntry {
2828
2929export interface PendingApproval {
3030 requestId: ApprovalRequestId;
31 requestKind: "command" | "file-change";
31 requestKind: "command" | "file-read" | "file-change";
3232 createdAt: string;
3333 detail?: string;
3434}
......@@ -95,6 +95,8 @@ function requestKindFromRequestType(
9595 case "command_execution_approval":
9696 case "exec_command_approval":
9797 return "command";
98 case "file_read_approval":
99 return "file-read";
98100 case "file_change_approval":
99101 case "apply_patch_approval":
100102 return "file-change";
......@@ -119,11 +121,14 @@ export function derivePendingApprovals(
119121 ? ApprovalRequestId.makeUnsafe(payload.requestId)
120122 : null;
121123 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")
123128 ? payload.requestKind
124129 : payload
125130 ? requestKindFromRequestType(payload.requestType)
126 : null;
131 : null;
127132 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
128133
129134 if (activity.kind === "approval.requested" && requestId && requestKind) {
apps/web/src/store.test.ts-4
......@@ -147,8 +147,6 @@ describe("store read model sync", () => {
147147 threadId: ThreadId.makeUnsafe("thread-1"),
148148 status: "ready",
149149 providerName: "claudeCode",
150 providerSessionId: null,
151 providerThreadId: null,
152150 runtimeMode: "approval-required",
153151 activeTurnId: null,
154152 lastError: null,
......@@ -171,8 +169,6 @@ describe("store read model sync", () => {
171169 threadId: ThreadId.makeUnsafe("thread-1"),
172170 status: "ready",
173171 providerName: "cursor",
174 providerSessionId: null,
175 providerThreadId: null,
176172 runtimeMode: "approval-required",
177173 activeTurnId: null,
178174 lastError: null,
apps/web/src/store.ts+1-6
......@@ -4,7 +4,6 @@ import {
44 getModelOptions,
55 normalizeModelSlug,
66 type ProviderKind,
7 ProviderSessionId,
87 ThreadId,
98 type OrchestrationReadModel,
109 type OrchestrationSessionStatus,
......@@ -247,7 +246,7 @@ export function syncServerReadModel(
247246 const existing = existingThreadById.get(thread.id);
248247 return {
249248 id: thread.id,
250 codexThreadId: thread.session?.providerThreadId ?? null,
249 codexThreadId: null,
251250 projectId: thread.projectId,
252251 title: thread.title,
253252 model: resolveModelSlugForProvider(
......@@ -260,13 +259,9 @@ export function syncServerReadModel(
260259 runtimeMode: thread.runtimeMode,
261260 session: thread.session
262261 ? {
263 sessionId:
264 thread.session.providerSessionId ??
265 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
266262 provider: toLegacyProvider(thread.session.providerName),
267263 status: toLegacySessionStatus(thread.session.status),
268264 orchestrationStatus: thread.session.status,
269 threadId: thread.session.providerThreadId,
270265 activeTurnId: thread.session.activeTurnId ?? undefined,
271266 createdAt: thread.session.updatedAt,
272267 updatedAt: thread.session.updatedAt,
apps/web/src/types.ts+1-5
......@@ -8,8 +8,6 @@ import type {
88 TurnId,
99 MessageId,
1010 CheckpointRef,
11 ProviderThreadId,
12 ProviderSessionId,
1311 ProviderKind,
1412} from "@t3tools/contracts";
1513
......@@ -75,7 +73,7 @@ export interface Project {
7573
7674export interface Thread {
7775 id: ThreadId;
78 codexThreadId: ProviderThreadId | null;
76 codexThreadId: string | null;
7977 projectId: ProjectId;
8078 title: string;
8179 model: string;
......@@ -93,10 +91,8 @@ export interface Thread {
9391}
9492
9593export interface ThreadSession {
96 sessionId: ProviderSessionId;
9794 provider: ProviderKind;
9895 status: SessionPhase | "error" | "closed";
99 threadId: ProviderThreadId | null;
10096 activeTurnId?: TurnId | undefined;
10197 createdAt: string;
10298 updatedAt: string;
packages/contracts/src/baseSchemas.ts-6
......@@ -28,12 +28,6 @@ export type MessageId = typeof MessageId.Type;
2828export const TurnId = makeEntityId("TurnId");
2929export 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;
3731export const ProviderItemId = makeEntityId("ProviderItemId");
3832export type ProviderItemId = typeof ProviderItemId.Type;
3933export const RuntimeSessionId = makeEntityId("RuntimeSessionId");
packages/contracts/src/orchestration.ts+2-9
......@@ -9,9 +9,6 @@ import {
99 NonNegativeInt,
1010 ProjectId,
1111 ProviderItemId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
1512 ThreadId,
1613 TrimmedNonEmptyString,
1714 TurnId,
......@@ -35,7 +32,7 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3532export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
3633export type RuntimeMode = typeof RuntimeMode.Type;
3734export 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"]);
3936export type ProviderRequestKind = typeof ProviderRequestKind.Type;
4037export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
4138export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
......@@ -149,8 +146,6 @@ export const OrchestrationSession = Schema.Struct({
149146 threadId: ThreadId,
150147 status: OrchestrationSessionStatus,
151148 providerName: Schema.NullOr(TrimmedNonEmptyString),
152 providerSessionId: Schema.NullOr(ProviderSessionId),
153 providerThreadId: Schema.NullOr(ProviderThreadId),
154149 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
155150 activeTurnId: Schema.NullOr(TurnId),
156151 lastError: Schema.NullOr(TrimmedNonEmptyString),
......@@ -637,9 +632,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({
637632});
638633
639634export const OrchestrationEventMetadata = Schema.Struct({
640 providerSessionId: Schema.optional(ProviderSessionId),
641 providerThreadId: Schema.optional(ProviderThreadId),
642 providerTurnId: Schema.optional(ProviderTurnId),
635 providerTurnId: Schema.optional(TrimmedNonEmptyString),
643636 providerItemId: Schema.optional(ProviderItemId),
644637 adapterKey: Schema.optional(TrimmedNonEmptyString),
645638 requestId: Schema.optional(ApprovalRequestId),
packages/contracts/src/provider.ts+16-19
......@@ -6,10 +6,8 @@ import {
66 IsoDateTime,
77 NonNegativeInt,
88 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
129 ThreadId,
10 TurnId,
1311} from "./baseSchemas";
1412import {
1513 ChatAttachment,
......@@ -34,15 +32,14 @@ export const ProviderSessionStatus = Schema.Literals([
3432export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;
3533
3634export const ProviderSession = Schema.Struct({
37 sessionId: ProviderSessionId,
3835 provider: ProviderKind,
3936 status: ProviderSessionStatus,
4037 runtimeMode: RuntimeMode,
4138 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
4239 model: Schema.optional(TrimmedNonEmptyStringSchema),
43 threadId: Schema.optional(ProviderThreadId),
40 threadId: ThreadId,
4441 resumeCursor: Schema.optional(Schema.Unknown),
45 activeTurnId: Schema.optional(ProviderTurnId),
42 activeTurnId: Schema.optional(TurnId),
4643 createdAt: IsoDateTime,
4744 updatedAt: IsoDateTime,
4845 lastError: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -75,6 +72,7 @@ export const ProviderStartOptions = Schema.Struct({
7572export type ProviderStartOptions = typeof ProviderStartOptions.Type;
7673
7774export const ProviderSessionStartInput = Schema.Struct({
75 threadId: ThreadId,
7876 provider: Schema.optional(ProviderKind),
7977 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
8078 model: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -85,7 +83,7 @@ export const ProviderSessionStartInput = Schema.Struct({
8583export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
8684
8785export const ProviderSendTurnInput = Schema.Struct({
88 sessionId: ProviderSessionId,
86 threadId: ThreadId,
8987 input: Schema.optional(
9088 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
9189 ),
......@@ -98,25 +96,25 @@ export const ProviderSendTurnInput = Schema.Struct({
9896export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
9997
10098export const ProviderTurnStartResult = Schema.Struct({
101 threadId: Schema.optional(ProviderThreadId),
102 turnId: ProviderTurnId,
99 threadId: ThreadId,
100 turnId: TurnId,
103101 resumeCursor: Schema.optional(Schema.Unknown),
104102});
105103export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
106104
107105export const ProviderInterruptTurnInput = Schema.Struct({
108 sessionId: ProviderSessionId,
109 turnId: Schema.optional(ProviderTurnId),
106 threadId: ThreadId,
107 turnId: Schema.optional(TurnId),
110108});
111109export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;
112110
113111export const ProviderStopSessionInput = Schema.Struct({
114 sessionId: ProviderSessionId,
112 threadId: ThreadId,
115113});
116114export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;
117115
118116export const ProviderListCheckpointsInput = Schema.Struct({
119 sessionId: ProviderSessionId,
117 threadId: ThreadId,
120118});
121119export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;
122120
......@@ -137,7 +135,7 @@ export const ProviderListCheckpointsResult = Schema.Struct({
137135export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;
138136
139137export const ProviderRevertToCheckpointInput = Schema.Struct({
140 sessionId: ProviderSessionId,
138 threadId: ThreadId,
141139 turnCount: NonNegativeInt,
142140});
143141export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;
......@@ -152,7 +150,7 @@ export const ProviderRevertToCheckpointResult = Schema.Struct({
152150export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;
153151
154152export const ProviderGetCheckpointDiffInput = Schema.Struct({
155 sessionId: ProviderSessionId,
153 threadId: ThreadId,
156154 ...TurnCountRange.fields,
157155});
158156export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;
......@@ -165,7 +163,7 @@ export const ProviderGetCheckpointDiffResult = Schema.Struct({
165163export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;
166164
167165export const ProviderRespondToRequestInput = Schema.Struct({
168 sessionId: ProviderSessionId,
166 threadId: ThreadId,
169167 requestId: ApprovalRequestId,
170168 decision: ProviderApprovalDecision,
171169});
......@@ -178,12 +176,11 @@ export const ProviderEvent = Schema.Struct({
178176 id: EventId,
179177 kind: ProviderEventKind,
180178 provider: ProviderKind,
181 sessionId: ProviderSessionId,
179 threadId: ThreadId,
182180 createdAt: IsoDateTime,
183181 method: TrimmedNonEmptyStringSchema,
184182 message: Schema.optional(TrimmedNonEmptyStringSchema),
185 threadId: Schema.optional(ProviderThreadId),
186 turnId: Schema.optional(ProviderTurnId),
183 turnId: Schema.optional(TurnId),
187184 itemId: Schema.optional(ProviderItemId),
188185 requestId: Schema.optional(ApprovalRequestId),
189186 requestKind: Schema.optional(ProviderRequestKind),
packages/contracts/src/providerRuntime.ts+10-11
......@@ -3,12 +3,8 @@ import {
33 EventId,
44 IsoDateTime,
55 ProviderItemId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ProviderTurnId,
96 RuntimeItemId,
107 RuntimeRequestId,
11 RuntimeSessionId,
128 RuntimeTaskId,
139 ThreadId,
1410 TrimmedNonEmptyString,
......@@ -44,9 +40,7 @@ export const ProviderRequestId = TrimmedNonEmptyStringSchema;
4440export type ProviderRequestId = typeof ProviderRequestId.Type;
4541
4642export const ProviderRefs = Schema.Struct({
47 providerSessionId: Schema.optional(ProviderSessionId),
48 providerThreadId: Schema.optional(ProviderThreadId),
49 providerTurnId: Schema.optional(ProviderTurnId),
43 providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema),
5044 providerItemId: Schema.optional(ProviderItemId),
5145 providerRequestId: Schema.optional(ProviderRequestId),
5246});
......@@ -131,6 +125,7 @@ export type CanonicalItemType = typeof CanonicalItemType.Type;
131125
132126export const CanonicalRequestType = Schema.Literals([
133127 "command_execution_approval",
128 "file_read_approval",
134129 "file_change_approval",
135130 "apply_patch_approval",
136131 "exec_command_approval",
......@@ -239,9 +234,8 @@ export const RuntimeErrorType = Schema.Literal("runtime.error");
239234export const ProviderRuntimeEventBase = Schema.Struct({
240235 eventId: EventId,
241236 provider: ProviderKind,
242 sessionId: RuntimeSessionId,
237 threadId: ThreadId,
243238 createdAt: IsoDateTime,
244 threadId: Schema.optional(ThreadId),
245239 turnId: Schema.optional(TurnId),
246240 itemId: Schema.optional(RuntimeItemId),
247241 requestId: Schema.optional(RuntimeRequestId),
......@@ -276,7 +270,7 @@ export const SessionExitedPayload = Schema.Struct({
276270export type SessionExitedPayload = typeof SessionExitedPayload.Type;
277271
278272export const ThreadStartedPayload = Schema.Struct({
279 providerThreadId: Schema.optional(ProviderThreadId),
273 providerThreadId: Schema.optional(TrimmedNonEmptyStringSchema),
280274});
281275export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;
282276
......@@ -948,7 +942,12 @@ export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolv
948942export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;
949943
950944// 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]);
952951export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
953952
954953export 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.