| author | |
| committer | |
| log | 26f631b638205506d292c829feaf5d539e05804d |
| tree | 000a395457cb18a291b90b407b4f5b95ef5c318b |
| parent | 981cf405612932afa8a0b98b8c98474ef7e1015d |
- 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 model54 files changed, 1865 insertions(+), 2665 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+233-235| ... | ... | @@ -196,257 +196,255 @@ export const makeOrchestrationIntegrationHarness = ( |
| 196 | 196 | options?: MakeOrchestrationIntegrationHarnessOptions, |
| 197 | 197 | ) => |
| 198 | 198 | 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); | |
| 223 | 223 | |
| 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 | ); | |
| 260 | 260 | |
| 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 | ); | |
| 297 | 297 | |
| 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); | |
| 322 | 322 | |
| 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); | |
| 328 | 328 | |
| 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, | |
| 344 | 332 | timeoutMs, |
| 345 | ) as Effect.Effect<OrchestrationThread, never>; | |
| 333 | ) => | |
| 334 | waitFor( | |
| 335 | snapshotQuery | |
| 336 | .getSnapshot() | |
| 337 | .pipe( | |
| 338 | Effect.map( | |
| 339 | (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, | |
| 340 | ), | |
| 341 | ), | |
| 342 | (thread): thread is OrchestrationThread => thread !== null && predicate(thread), | |
| 343 | `projected thread '${threadId}'`, | |
| 344 | timeoutMs, | |
| 345 | ) as Effect.Effect<OrchestrationThread, never>; | |
| 346 | 346 | |
| 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, | |
| 357 | 349 | timeoutMs, |
| 358 | ); | |
| 350 | ) => | |
| 351 | waitFor( | |
| 352 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 353 | Effect.map((chunk): ReadonlyArray<OrchestrationEvent> => Array.from(chunk)), | |
| 354 | ), | |
| 355 | (events) => events.some(predicate), | |
| 356 | "domain event", | |
| 357 | timeoutMs, | |
| 358 | ); | |
| 359 | 359 | |
| 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 | }), | |
| 376 | 377 | }), |
| 377 | }), | |
| 378 | ), | |
| 378 | 379 | ), |
| 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 | >; | |
| 397 | 397 | |
| 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; | |
| 404 | 404 | |
| 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())); | |
| 413 | 411 | |
| 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; | |
| 421 | 419 | |
| 422 | if (failureCause) { | |
| 423 | return yield* Effect.failCause(failureCause); | |
| 424 | } | |
| 425 | }); | |
| 420 | if (failureCause) { | |
| 421 | return yield* Effect.failCause(failureCause); | |
| 422 | } | |
| 423 | }); | |
| 426 | 424 | |
| 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 | }); | |
| 435 | 433 | |
| 436 | return { | |
| 437 | rootDir, | |
| 438 | workspaceDir, | |
| 434 | return { | |
| 435 | rootDir, | |
| 436 | workspaceDir, | |
| 439 | 437 | dbPath, |
| 440 | 438 | 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 { |
| 7 | 7 | ProviderRuntimeEvent, |
| 8 | 8 | RuntimeSessionId, |
| 9 | 9 | ProviderSession, |
| 10 | ProviderSessionId, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | 10 | ProviderTurnStartResult, |
| 14 | 11 | ThreadId, |
| 15 | 12 | TurnId, |
| ... | ... | @@ -39,9 +36,8 @@ export type FixtureProviderRuntimeEvent = { |
| 39 | 36 | readonly type: string; |
| 40 | 37 | readonly eventId: EventId; |
| 41 | 38 | readonly provider: "codex" | "claudeCode" | "cursor"; |
| 42 | readonly sessionId: string; | |
| 43 | 39 | readonly createdAt: string; |
| 44 | readonly threadId?: string | undefined; | |
| 40 | readonly threadId: string; | |
| 45 | 41 | readonly turnId?: string | undefined; |
| 46 | 42 | readonly itemId?: string | undefined; |
| 47 | 43 | readonly requestId?: string | undefined; |
| ... | ... | @@ -184,18 +180,18 @@ export interface TestProviderAdapterHarness { |
| 184 | 180 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; |
| 185 | 181 | readonly provider: "codex" | "claudeCode"; |
| 186 | 182 | readonly queueTurnResponse: ( |
| 187 | sessionId: string, | |
| 183 | threadId: ThreadId, | |
| 188 | 184 | response: TestTurnResponse, |
| 189 | 185 | ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>; |
| 190 | 186 | readonly queueTurnResponseForNextSession: ( |
| 191 | 187 | response: TestTurnResponse, |
| 192 | 188 | ) => Effect.Effect<void, never>; |
| 193 | 189 | 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; | |
| 199 | 195 | readonly requestId: ApprovalRequestId; |
| 200 | 196 | readonly decision: ProviderApprovalDecision; |
| 201 | 197 | }>; |
| ... | ... | @@ -211,19 +207,19 @@ function nowIso(): string { |
| 211 | 207 | |
| 212 | 208 | function sessionNotFound( |
| 213 | 209 | provider: "codex" | "claudeCode", |
| 214 | sessionId: string, | |
| 210 | threadId: ThreadId, | |
| 215 | 211 | ): ProviderAdapterSessionNotFoundError { |
| 216 | 212 | return new ProviderAdapterSessionNotFoundError({ |
| 217 | 213 | provider, |
| 218 | sessionId, | |
| 214 | threadId: String(threadId), | |
| 219 | 215 | }); |
| 220 | 216 | } |
| 221 | 217 | |
| 222 | 218 | function missingSessionEffect( |
| 223 | 219 | provider: "codex" | "claudeCode", |
| 224 | sessionId: string, | |
| 220 | threadId: ThreadId, | |
| 225 | 221 | ): Effect.Effect<never, ProviderAdapterError> { |
| 226 | return Effect.fail(sessionNotFound(provider, sessionId)); | |
| 222 | return Effect.fail(sessionNotFound(provider, threadId)); | |
| 227 | 223 | } |
| 228 | 224 | |
| 229 | 225 | export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => |
| ... | ... | @@ -231,13 +227,13 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 231 | 227 | const provider = options?.provider ?? "codex"; |
| 232 | 228 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 233 | 229 | let sessionCount = 0; |
| 234 | const sessions = new Map<string, SessionState>(); | |
| 230 | const sessions = new Map<ThreadId, SessionState>(); | |
| 235 | 231 | const queuedResponsesForNextSession: TestTurnResponse[] = []; |
| 236 | const interruptCallsBySession = new Map<string, Array<ProviderTurnId | undefined>>(); | |
| 232 | const interruptCallsBySession = new Map<ThreadId, Array<TurnId | undefined>>(); | |
| 237 | 233 | const approvalResponsesBySession = new Map< |
| 238 | string, | |
| 234 | ThreadId, | |
| 239 | 235 | Array<{ |
| 240 | readonly sessionId: ProviderSessionId; | |
| 236 | readonly threadId: ThreadId; | |
| 241 | 237 | readonly requestId: ApprovalRequestId; |
| 242 | 238 | readonly decision: ProviderApprovalDecision; |
| 243 | 239 | }> |
| ... | ... | @@ -256,23 +252,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 256 | 252 | } |
| 257 | 253 | |
| 258 | 254 | sessionCount += 1; |
| 259 | const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); | |
| 260 | const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); | |
| 255 | const threadId = input.threadId; | |
| 261 | 256 | const createdAt = nowIso(); |
| 262 | 257 | |
| 263 | 258 | const session: ProviderSession = { |
| 264 | sessionId, | |
| 265 | 259 | provider, |
| 266 | 260 | status: "ready", |
| 267 | 261 | runtimeMode: input.runtimeMode, |
| 268 | 262 | threadId, |
| 269 | 263 | cwd: input.cwd, |
| 270 | resumeCursor: input.resumeCursor ?? { sessionId }, | |
| 264 | resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount }, | |
| 271 | 265 | createdAt, |
| 272 | 266 | updatedAt: createdAt, |
| 273 | 267 | }; |
| 274 | 268 | |
| 275 | sessions.set(sessionId, { | |
| 269 | sessions.set(threadId, { | |
| 276 | 270 | session, |
| 277 | 271 | snapshot: { |
| 278 | 272 | threadId, |
| ... | ... | @@ -288,21 +282,21 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 288 | 282 | |
| 289 | 283 | const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) => |
| 290 | 284 | Effect.gen(function* () { |
| 291 | const state = sessions.get(input.sessionId); | |
| 285 | const state = sessions.get(input.threadId); | |
| 292 | 286 | if (!state) { |
| 293 | return yield* missingSessionEffect(provider, input.sessionId); | |
| 287 | return yield* missingSessionEffect(provider, input.threadId); | |
| 294 | 288 | } |
| 295 | 289 | |
| 296 | 290 | state.turnCount += 1; |
| 297 | 291 | const turnCount = state.turnCount; |
| 298 | const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); | |
| 292 | const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); | |
| 299 | 293 | |
| 300 | 294 | const response = state.queuedResponses.shift(); |
| 301 | 295 | if (!response) { |
| 302 | 296 | return yield* new ProviderAdapterValidationError({ |
| 303 | 297 | provider, |
| 304 | 298 | operation: "sendTurn", |
| 305 | issue: `No queued turn response for session ${input.sessionId}.`, | |
| 299 | issue: `No queued turn response for thread ${input.threadId}.`, | |
| 306 | 300 | }); |
| 307 | 301 | } |
| 308 | 302 | |
| ... | ... | @@ -313,14 +307,12 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 313 | 307 | ...(fixtureEvent as Record<string, unknown>), |
| 314 | 308 | eventId: randomUUID(), |
| 315 | 309 | provider, |
| 316 | sessionId: RuntimeSessionId.makeUnsafe(input.sessionId), | |
| 310 | sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), | |
| 317 | 311 | createdAt: nowIso(), |
| 318 | 312 | }; |
| 319 | if (Object.hasOwn(rawEvent, "threadId")) { | |
| 320 | rawEvent.threadId = ThreadId.makeUnsafe(state.snapshot.threadId); | |
| 321 | } | |
| 313 | rawEvent.threadId = state.snapshot.threadId; | |
| 322 | 314 | if (Object.hasOwn(rawEvent, "turnId")) { |
| 323 | rawEvent.turnId = TurnId.makeUnsafe(turnId); | |
| 315 | rawEvent.turnId = turnId; | |
| 324 | 316 | } |
| 325 | 317 | |
| 326 | 318 | const runtimeEvent = normalizeFixtureEvent(rawEvent); |
| ... | ... | @@ -373,10 +365,9 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 373 | 365 | type: "turn.completed", |
| 374 | 366 | eventId: EventId.makeUnsafe(randomUUID()), |
| 375 | 367 | provider, |
| 376 | sessionId: RuntimeSessionId.makeUnsafe(input.sessionId), | |
| 377 | 368 | createdAt: nowIso(), |
| 378 | threadId: ThreadId.makeUnsafe(state.snapshot.threadId), | |
| 379 | turnId: TurnId.makeUnsafe(turnId), | |
| 369 | threadId: state.snapshot.threadId, | |
| 370 | turnId, | |
| 380 | 371 | payload: { |
| 381 | 372 | state: "completed", |
| 382 | 373 | }, |
| ... | ... | @@ -394,60 +385,60 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 394 | 385 | }); |
| 395 | 386 | |
| 396 | 387 | const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = ( |
| 397 | sessionId, | |
| 388 | threadId, | |
| 398 | 389 | turnId, |
| 399 | 390 | ) => |
| 400 | sessions.has(sessionId) | |
| 391 | sessions.has(threadId) | |
| 401 | 392 | ? Effect.sync(() => { |
| 402 | const existing = interruptCallsBySession.get(sessionId) ?? []; | |
| 393 | const existing = interruptCallsBySession.get(threadId) ?? []; | |
| 403 | 394 | existing.push(turnId); |
| 404 | interruptCallsBySession.set(sessionId, existing); | |
| 395 | interruptCallsBySession.set(threadId, existing); | |
| 405 | 396 | }) |
| 406 | : missingSessionEffect(provider, sessionId); | |
| 397 | : missingSessionEffect(provider, threadId); | |
| 407 | 398 | |
| 408 | 399 | const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = ( |
| 409 | sessionId, | |
| 400 | threadId, | |
| 410 | 401 | requestId, |
| 411 | 402 | decision, |
| 412 | 403 | ) => |
| 413 | sessions.has(sessionId) | |
| 404 | sessions.has(threadId) | |
| 414 | 405 | ? Effect.sync(() => { |
| 415 | const existing = approvalResponsesBySession.get(sessionId) ?? []; | |
| 406 | const existing = approvalResponsesBySession.get(threadId) ?? []; | |
| 416 | 407 | existing.push({ |
| 417 | sessionId, | |
| 408 | threadId, | |
| 418 | 409 | requestId, |
| 419 | 410 | decision, |
| 420 | 411 | }); |
| 421 | approvalResponsesBySession.set(sessionId, existing); | |
| 412 | approvalResponsesBySession.set(threadId, existing); | |
| 422 | 413 | }) |
| 423 | : missingSessionEffect(provider, sessionId); | |
| 414 | : missingSessionEffect(provider, threadId); | |
| 424 | 415 | |
| 425 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) => | |
| 416 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) => | |
| 426 | 417 | Effect.sync(() => { |
| 427 | sessions.delete(sessionId); | |
| 418 | sessions.delete(threadId); | |
| 428 | 419 | }); |
| 429 | 420 | |
| 430 | 421 | const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () => |
| 431 | 422 | Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); |
| 432 | 423 | |
| 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)); | |
| 435 | 426 | |
| 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); | |
| 438 | 429 | if (!state) { |
| 439 | return missingSessionEffect(provider, sessionId); | |
| 430 | return missingSessionEffect(provider, threadId); | |
| 440 | 431 | } |
| 441 | 432 | return Effect.succeed(state.snapshot); |
| 442 | 433 | }; |
| 443 | 434 | |
| 444 | 435 | const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = ( |
| 445 | sessionId, | |
| 436 | threadId, | |
| 446 | 437 | numTurns, |
| 447 | 438 | ) => { |
| 448 | const state = sessions.get(sessionId); | |
| 439 | const state = sessions.get(threadId); | |
| 449 | 440 | if (!state) { |
| 450 | return missingSessionEffect(provider, sessionId); | |
| 441 | return missingSessionEffect(provider, threadId); | |
| 451 | 442 | } |
| 452 | 443 | if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { |
| 453 | 444 | return Effect.fail( |
| ... | ... | @@ -494,16 +485,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 494 | 485 | }; |
| 495 | 486 | |
| 496 | 487 | const queueTurnResponse = ( |
| 497 | sessionId: string, | |
| 488 | threadId: ThreadId, | |
| 498 | 489 | response: TestTurnResponse, |
| 499 | 490 | ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> => |
| 500 | Effect.sync(() => sessions.get(sessionId)).pipe( | |
| 491 | Effect.sync(() => sessions.get(threadId)).pipe( | |
| 501 | 492 | Effect.flatMap((state) => |
| 502 | 493 | state |
| 503 | 494 | ? Effect.sync(() => { |
| 504 | 495 | state.queuedResponses.push(response); |
| 505 | 496 | }) |
| 506 | : Effect.fail(sessionNotFound(provider, sessionId)), | |
| 497 | : Effect.fail(sessionNotFound(provider, threadId)), | |
| 507 | 498 | ), |
| 508 | 499 | ); |
| 509 | 500 | |
| ... | ... | @@ -514,8 +505,8 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 514 | 505 | queuedResponsesForNextSession.push(response); |
| 515 | 506 | }); |
| 516 | 507 | |
| 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); | |
| 519 | 510 | if (!state) { |
| 520 | 511 | return []; |
| 521 | 512 | } |
| ... | ... | @@ -524,25 +515,25 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter |
| 524 | 515 | |
| 525 | 516 | const getStartCount = (): number => sessionCount; |
| 526 | 517 | |
| 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); | |
| 529 | 520 | if (!calls) { |
| 530 | 521 | return []; |
| 531 | 522 | } |
| 532 | 523 | return [...calls]; |
| 533 | 524 | }; |
| 534 | 525 | |
| 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); | |
| 537 | 528 | |
| 538 | 529 | const getApprovalResponses = ( |
| 539 | sessionId: string, | |
| 530 | threadId: ThreadId, | |
| 540 | 531 | ): ReadonlyArray<{ |
| 541 | readonly sessionId: ProviderSessionId; | |
| 532 | readonly threadId: ThreadId; | |
| 542 | 533 | readonly requestId: ApprovalRequestId; |
| 543 | 534 | readonly decision: ProviderApprovalDecision; |
| 544 | 535 | }> => { |
| 545 | const responses = approvalResponsesBySession.get(sessionId); | |
| 536 | const responses = approvalResponsesBySession.get(threadId); | |
| 546 | 537 | if (!responses) { |
| 547 | 538 | return []; |
| 548 | 539 | } |
apps/server/integration/orchestrationEngine.integration.test.ts+72-117| ... | ... | @@ -7,9 +7,6 @@ import { |
| 7 | 7 | EventId, |
| 8 | 8 | MessageId, |
| 9 | 9 | ProjectId, |
| 10 | ProviderSessionId, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | 10 | ThreadId, |
| 14 | 11 | } from "@t3tools/contracts"; |
| 15 | 12 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -27,18 +24,12 @@ import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; |
| 27 | 24 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 28 | 25 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 29 | 26 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 30 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 31 | ProviderSessionId.makeUnsafe(value); | |
| 32 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 34 | 27 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 35 | 28 | ApprovalRequestId.makeUnsafe(value); |
| 36 | 29 | |
| 37 | 30 | const PROJECT_ID = asProjectId("project-1"); |
| 38 | 31 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); |
| 39 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); | |
| 40 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); | |
| 41 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); | |
| 32 | const FIXTURE_TURN_ID = "fixture-turn"; | |
| 42 | 33 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); |
| 43 | 34 | type IntegrationProvider = "codex" | "claudeCode"; |
| 44 | 35 | |
| ... | ... | @@ -81,7 +72,6 @@ function runtimeBase(eventId: string, createdAt: string, provider: IntegrationPr |
| 81 | 72 | return { |
| 82 | 73 | eventId: asEventId(eventId), |
| 83 | 74 | provider, |
| 84 | sessionId: FIXTURE_SESSION_ID, | |
| 85 | 75 | createdAt, |
| 86 | 76 | }; |
| 87 | 77 | } |
| ... | ... | @@ -167,20 +157,20 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + |
| 167 | 157 | { |
| 168 | 158 | type: "turn.started", |
| 169 | 159 | ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), |
| 170 | threadId: FIXTURE_THREAD_ID, | |
| 160 | threadId: THREAD_ID, | |
| 171 | 161 | turnId: FIXTURE_TURN_ID, |
| 172 | 162 | }, |
| 173 | 163 | { |
| 174 | 164 | type: "message.delta", |
| 175 | 165 | ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), |
| 176 | threadId: FIXTURE_THREAD_ID, | |
| 166 | threadId: THREAD_ID, | |
| 177 | 167 | turnId: FIXTURE_TURN_ID, |
| 178 | 168 | delta: "Single turn response.\n", |
| 179 | 169 | }, |
| 180 | 170 | { |
| 181 | 171 | type: "turn.completed", |
| 182 | 172 | ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), |
| 183 | threadId: FIXTURE_THREAD_ID, | |
| 173 | threadId: THREAD_ID, | |
| 184 | 174 | turnId: FIXTURE_TURN_ID, |
| 185 | 175 | status: "completed", |
| 186 | 176 | }, |
| ... | ... | @@ -276,15 +266,12 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 276 | 266 | (entry) => |
| 277 | 267 | entry.session?.status === "ready" && |
| 278 | 268 | entry.session.providerName === "codex" && |
| 279 | entry.session.providerThreadId !== null && | |
| 280 | 269 | entry.messages.some( |
| 281 | 270 | (message) => message.role === "assistant" && message.streaming === false, |
| 282 | 271 | ), |
| 283 | 272 | 180_000, |
| 284 | 273 | ); |
| 285 | ||
| 286 | const originalProviderThreadId = firstThread.session?.providerThreadId; | |
| 287 | assert.isNotNull(originalProviderThreadId); | |
| 274 | assert.equal(firstThread.session?.threadId, "thread-1"); | |
| 288 | 275 | |
| 289 | 276 | yield* harness.engine.dispatch({ |
| 290 | 277 | type: "thread.turn.start", |
| ... | ... | @@ -305,15 +292,13 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 305 | 292 | (entry) => |
| 306 | 293 | entry.session?.status === "ready" && |
| 307 | 294 | entry.session.providerName === "codex" && |
| 308 | entry.session.providerThreadId !== null && | |
| 309 | 295 | entry.session.runtimeMode === "approval-required" && |
| 310 | 296 | entry.messages.some( |
| 311 | 297 | (message) => message.role === "assistant" && message.text.includes("BETA"), |
| 312 | 298 | ), |
| 313 | 299 | 180_000, |
| 314 | 300 | ); |
| 315 | ||
| 316 | assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId); | |
| 301 | assert.equal(secondThread.session?.threadId, "thread-1"); | |
| 317 | 302 | }), |
| 318 | 303 | ), |
| 319 | 304 | ); |
| ... | ... | @@ -328,13 +313,13 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 328 | 313 | { |
| 329 | 314 | type: "turn.started", |
| 330 | 315 | ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), |
| 331 | threadId: FIXTURE_THREAD_ID, | |
| 316 | threadId: THREAD_ID, | |
| 332 | 317 | turnId: FIXTURE_TURN_ID, |
| 333 | 318 | }, |
| 334 | 319 | { |
| 335 | 320 | type: "tool.started", |
| 336 | 321 | ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), |
| 337 | threadId: FIXTURE_THREAD_ID, | |
| 322 | threadId: THREAD_ID, | |
| 338 | 323 | turnId: FIXTURE_TURN_ID, |
| 339 | 324 | toolKind: "command", |
| 340 | 325 | title: "Edit file", |
| ... | ... | @@ -343,7 +328,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 343 | 328 | { |
| 344 | 329 | type: "tool.completed", |
| 345 | 330 | ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), |
| 346 | threadId: FIXTURE_THREAD_ID, | |
| 331 | threadId: THREAD_ID, | |
| 347 | 332 | turnId: FIXTURE_TURN_ID, |
| 348 | 333 | toolKind: "command", |
| 349 | 334 | title: "Edit file", |
| ... | ... | @@ -352,14 +337,14 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 352 | 337 | { |
| 353 | 338 | type: "message.delta", |
| 354 | 339 | ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), |
| 355 | threadId: FIXTURE_THREAD_ID, | |
| 340 | threadId: THREAD_ID, | |
| 356 | 341 | turnId: FIXTURE_TURN_ID, |
| 357 | 342 | delta: "Updated README to v2.\n", |
| 358 | 343 | }, |
| 359 | 344 | { |
| 360 | 345 | type: "turn.completed", |
| 361 | 346 | ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), |
| 362 | threadId: FIXTURE_THREAD_ID, | |
| 347 | threadId: THREAD_ID, | |
| 363 | 348 | turnId: FIXTURE_TURN_ID, |
| 364 | 349 | status: "completed", |
| 365 | 350 | }, |
| ... | ... | @@ -377,35 +362,30 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 377 | 362 | text: "Make first edit", |
| 378 | 363 | }); |
| 379 | 364 | |
| 380 | const firstTurnThread = yield* harness.waitForThread( | |
| 365 | yield* harness.waitForThread( | |
| 381 | 366 | THREAD_ID, |
| 382 | (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null, | |
| 367 | (entry) => entry.checkpoints.length === 1 && entry.session?.threadId === "thread-1", | |
| 383 | 368 | ); |
| 384 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 385 | assert.equal(sessionId !== null, true); | |
| 386 | if (!sessionId) { | |
| 387 | return; | |
| 388 | } | |
| 389 | 369 | |
| 390 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 370 | yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { | |
| 391 | 371 | events: [ |
| 392 | 372 | { |
| 393 | 373 | type: "turn.started", |
| 394 | 374 | ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), |
| 395 | threadId: FIXTURE_THREAD_ID, | |
| 375 | threadId: THREAD_ID, | |
| 396 | 376 | turnId: FIXTURE_TURN_ID, |
| 397 | 377 | }, |
| 398 | 378 | { |
| 399 | 379 | type: "message.delta", |
| 400 | 380 | ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), |
| 401 | threadId: FIXTURE_THREAD_ID, | |
| 381 | threadId: THREAD_ID, | |
| 402 | 382 | turnId: FIXTURE_TURN_ID, |
| 403 | 383 | delta: "Updated README to v3.\n", |
| 404 | 384 | }, |
| 405 | 385 | { |
| 406 | 386 | type: "turn.completed", |
| 407 | 387 | ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), |
| 408 | threadId: FIXTURE_THREAD_ID, | |
| 388 | threadId: THREAD_ID, | |
| 409 | 389 | turnId: FIXTURE_TURN_ID, |
| 410 | 390 | status: "completed", |
| 411 | 391 | }, |
| ... | ... | @@ -492,13 +472,13 @@ it.live("tracks approval requests and resolves pending approvals on user respons |
| 492 | 472 | { |
| 493 | 473 | type: "turn.started", |
| 494 | 474 | ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), |
| 495 | threadId: FIXTURE_THREAD_ID, | |
| 475 | threadId: THREAD_ID, | |
| 496 | 476 | turnId: FIXTURE_TURN_ID, |
| 497 | 477 | }, |
| 498 | 478 | { |
| 499 | 479 | type: "approval.requested", |
| 500 | 480 | ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), |
| 501 | threadId: FIXTURE_THREAD_ID, | |
| 481 | threadId: THREAD_ID, | |
| 502 | 482 | turnId: FIXTURE_TURN_ID, |
| 503 | 483 | requestId: APPROVAL_REQUEST_ID, |
| 504 | 484 | requestKind: "command", |
| ... | ... | @@ -507,7 +487,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons |
| 507 | 487 | { |
| 508 | 488 | type: "turn.completed", |
| 509 | 489 | ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), |
| 510 | threadId: FIXTURE_THREAD_ID, | |
| 490 | threadId: THREAD_ID, | |
| 511 | 491 | turnId: FIXTURE_TURN_ID, |
| 512 | 492 | status: "completed", |
| 513 | 493 | }, |
| ... | ... | @@ -551,9 +531,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons |
| 551 | 531 | assert.equal(resolvedRow.status, "resolved"); |
| 552 | 532 | assert.equal(resolvedRow.decision, "accept"); |
| 553 | 533 | |
| 554 | const providerSessionId = thread.session?.providerSessionId ?? "test-session-1"; | |
| 555 | 534 | const approvalResponses = yield* waitForSync( |
| 556 | () => harness.adapterHarness.getApprovalResponses(providerSessionId), | |
| 535 | () => harness.adapterHarness.getApprovalResponses(THREAD_ID), | |
| 557 | 536 | (responses) => responses.length === 1, |
| 558 | 537 | "provider approval response", |
| 559 | 538 | ); |
| ... | ... | @@ -574,13 +553,13 @@ it.live("records failed turn runtime state and checkpoint status as error", () = |
| 574 | 553 | { |
| 575 | 554 | type: "turn.started", |
| 576 | 555 | ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), |
| 577 | threadId: FIXTURE_THREAD_ID, | |
| 556 | threadId: THREAD_ID, | |
| 578 | 557 | turnId: FIXTURE_TURN_ID, |
| 579 | 558 | }, |
| 580 | 559 | { |
| 581 | 560 | type: "content.delta", |
| 582 | 561 | ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), |
| 583 | threadId: FIXTURE_THREAD_ID, | |
| 562 | threadId: THREAD_ID, | |
| 584 | 563 | turnId: FIXTURE_TURN_ID, |
| 585 | 564 | payload: { |
| 586 | 565 | streamKind: "assistant_text", |
| ... | ... | @@ -590,7 +569,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () = |
| 590 | 569 | { |
| 591 | 570 | type: "runtime.error", |
| 592 | 571 | ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), |
| 593 | threadId: FIXTURE_THREAD_ID, | |
| 572 | threadId: THREAD_ID, | |
| 594 | 573 | turnId: FIXTURE_TURN_ID, |
| 595 | 574 | payload: { |
| 596 | 575 | message: "Sandbox command failed.", |
| ... | ... | @@ -599,7 +578,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () = |
| 599 | 578 | { |
| 600 | 579 | type: "turn.completed", |
| 601 | 580 | ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), |
| 602 | threadId: FIXTURE_THREAD_ID, | |
| 581 | threadId: THREAD_ID, | |
| 603 | 582 | turnId: FIXTURE_TURN_ID, |
| 604 | 583 | payload: { |
| 605 | 584 | state: "failed", |
| ... | ... | @@ -653,13 +632,13 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 653 | 632 | { |
| 654 | 633 | type: "turn.started", |
| 655 | 634 | ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), |
| 656 | threadId: FIXTURE_THREAD_ID, | |
| 635 | threadId: THREAD_ID, | |
| 657 | 636 | turnId: FIXTURE_TURN_ID, |
| 658 | 637 | }, |
| 659 | 638 | { |
| 660 | 639 | type: "tool.started", |
| 661 | 640 | ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), |
| 662 | threadId: FIXTURE_THREAD_ID, | |
| 641 | threadId: THREAD_ID, | |
| 663 | 642 | turnId: FIXTURE_TURN_ID, |
| 664 | 643 | toolKind: "command", |
| 665 | 644 | title: "Edit file", |
| ... | ... | @@ -668,7 +647,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 668 | 647 | { |
| 669 | 648 | type: "tool.completed", |
| 670 | 649 | ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), |
| 671 | threadId: FIXTURE_THREAD_ID, | |
| 650 | threadId: THREAD_ID, | |
| 672 | 651 | turnId: FIXTURE_TURN_ID, |
| 673 | 652 | toolKind: "command", |
| 674 | 653 | title: "Edit file", |
| ... | ... | @@ -677,14 +656,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 677 | 656 | { |
| 678 | 657 | type: "message.delta", |
| 679 | 658 | ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), |
| 680 | threadId: FIXTURE_THREAD_ID, | |
| 659 | threadId: THREAD_ID, | |
| 681 | 660 | turnId: FIXTURE_TURN_ID, |
| 682 | 661 | delta: "Updated README to v2.\n", |
| 683 | 662 | }, |
| 684 | 663 | { |
| 685 | 664 | type: "turn.completed", |
| 686 | 665 | ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), |
| 687 | threadId: FIXTURE_THREAD_ID, | |
| 666 | threadId: THREAD_ID, | |
| 688 | 667 | turnId: FIXTURE_TURN_ID, |
| 689 | 668 | status: "completed", |
| 690 | 669 | }, |
| ... | ... | @@ -701,28 +680,23 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 701 | 680 | text: "First edit", |
| 702 | 681 | }); |
| 703 | 682 | |
| 704 | const firstTurnThread = yield* harness.waitForThread( | |
| 683 | yield* harness.waitForThread( | |
| 705 | 684 | THREAD_ID, |
| 706 | (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1, | |
| 685 | (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, | |
| 707 | 686 | ); |
| 708 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 709 | assert.equal(sessionId !== null, true); | |
| 710 | if (!sessionId) { | |
| 711 | return; | |
| 712 | } | |
| 713 | 687 | |
| 714 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 688 | yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { | |
| 715 | 689 | events: [ |
| 716 | 690 | { |
| 717 | 691 | type: "turn.started", |
| 718 | 692 | ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), |
| 719 | threadId: FIXTURE_THREAD_ID, | |
| 693 | threadId: THREAD_ID, | |
| 720 | 694 | turnId: FIXTURE_TURN_ID, |
| 721 | 695 | }, |
| 722 | 696 | { |
| 723 | 697 | type: "tool.started", |
| 724 | 698 | ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), |
| 725 | threadId: FIXTURE_THREAD_ID, | |
| 699 | threadId: THREAD_ID, | |
| 726 | 700 | turnId: FIXTURE_TURN_ID, |
| 727 | 701 | toolKind: "command", |
| 728 | 702 | title: "Edit file", |
| ... | ... | @@ -731,7 +705,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 731 | 705 | { |
| 732 | 706 | type: "tool.completed", |
| 733 | 707 | ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), |
| 734 | threadId: FIXTURE_THREAD_ID, | |
| 708 | threadId: THREAD_ID, | |
| 735 | 709 | turnId: FIXTURE_TURN_ID, |
| 736 | 710 | toolKind: "command", |
| 737 | 711 | title: "Edit file", |
| ... | ... | @@ -740,14 +714,14 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 740 | 714 | { |
| 741 | 715 | type: "message.delta", |
| 742 | 716 | ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), |
| 743 | threadId: FIXTURE_THREAD_ID, | |
| 717 | threadId: THREAD_ID, | |
| 744 | 718 | turnId: FIXTURE_TURN_ID, |
| 745 | 719 | delta: "Updated README to v3.\n", |
| 746 | 720 | }, |
| 747 | 721 | { |
| 748 | 722 | type: "turn.completed", |
| 749 | 723 | ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), |
| 750 | threadId: FIXTURE_THREAD_ID, | |
| 724 | threadId: THREAD_ID, | |
| 751 | 725 | turnId: FIXTURE_TURN_ID, |
| 752 | 726 | status: "completed", |
| 753 | 727 | }, |
| ... | ... | @@ -816,7 +790,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 816 | 790 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), |
| 817 | 791 | false, |
| 818 | 792 | ); |
| 819 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); | |
| 793 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); | |
| 820 | 794 | |
| 821 | 795 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ |
| 822 | 796 | threadId: THREAD_ID, |
| ... | ... | @@ -874,20 +848,20 @@ it.live("starts a claudeCode session on first turn when provider is requested", |
| 874 | 848 | { |
| 875 | 849 | type: "turn.started", |
| 876 | 850 | ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), |
| 877 | threadId: FIXTURE_THREAD_ID, | |
| 851 | threadId: THREAD_ID, | |
| 878 | 852 | turnId: FIXTURE_TURN_ID, |
| 879 | 853 | }, |
| 880 | 854 | { |
| 881 | 855 | type: "message.delta", |
| 882 | 856 | ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), |
| 883 | threadId: FIXTURE_THREAD_ID, | |
| 857 | threadId: THREAD_ID, | |
| 884 | 858 | turnId: FIXTURE_TURN_ID, |
| 885 | 859 | delta: "Claude first turn.\n", |
| 886 | 860 | }, |
| 887 | 861 | { |
| 888 | 862 | type: "turn.completed", |
| 889 | 863 | ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), |
| 890 | threadId: FIXTURE_THREAD_ID, | |
| 864 | threadId: THREAD_ID, | |
| 891 | 865 | turnId: FIXTURE_TURN_ID, |
| 892 | 866 | status: "completed", |
| 893 | 867 | }, |
| ... | ... | @@ -928,20 +902,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res |
| 928 | 902 | { |
| 929 | 903 | type: "turn.started", |
| 930 | 904 | ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), |
| 931 | threadId: FIXTURE_THREAD_ID, | |
| 905 | threadId: THREAD_ID, | |
| 932 | 906 | turnId: FIXTURE_TURN_ID, |
| 933 | 907 | }, |
| 934 | 908 | { |
| 935 | 909 | type: "message.delta", |
| 936 | 910 | ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), |
| 937 | threadId: FIXTURE_THREAD_ID, | |
| 911 | threadId: THREAD_ID, | |
| 938 | 912 | turnId: FIXTURE_TURN_ID, |
| 939 | 913 | delta: "Turn before restart.\n", |
| 940 | 914 | }, |
| 941 | 915 | { |
| 942 | 916 | type: "turn.completed", |
| 943 | 917 | ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), |
| 944 | threadId: FIXTURE_THREAD_ID, | |
| 918 | threadId: THREAD_ID, | |
| 945 | 919 | turnId: FIXTURE_TURN_ID, |
| 946 | 920 | status: "completed", |
| 947 | 921 | }, |
| ... | ... | @@ -956,16 +930,10 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res |
| 956 | 930 | provider: "claudeCode", |
| 957 | 931 | }); |
| 958 | 932 | |
| 959 | const firstThread = yield* harness.waitForThread( | |
| 933 | yield* harness.waitForThread( | |
| 960 | 934 | 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", | |
| 963 | 936 | ); |
| 964 | const staleSessionId = firstThread.session?.providerSessionId; | |
| 965 | assert.equal(staleSessionId !== null, true); | |
| 966 | if (!staleSessionId) { | |
| 967 | return; | |
| 968 | } | |
| 969 | 937 | |
| 970 | 938 | yield* harness.providerService.stopAll(); |
| 971 | 939 | yield* waitForSync( |
| ... | ... | @@ -979,20 +947,20 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res |
| 979 | 947 | { |
| 980 | 948 | type: "turn.started", |
| 981 | 949 | ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), |
| 982 | threadId: FIXTURE_THREAD_ID, | |
| 950 | threadId: THREAD_ID, | |
| 983 | 951 | turnId: FIXTURE_TURN_ID, |
| 984 | 952 | }, |
| 985 | 953 | { |
| 986 | 954 | type: "message.delta", |
| 987 | 955 | ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), |
| 988 | threadId: FIXTURE_THREAD_ID, | |
| 956 | threadId: THREAD_ID, | |
| 989 | 957 | turnId: FIXTURE_TURN_ID, |
| 990 | 958 | delta: "Turn after restart.\n", |
| 991 | 959 | }, |
| 992 | 960 | { |
| 993 | 961 | type: "turn.completed", |
| 994 | 962 | ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), |
| 995 | threadId: FIXTURE_THREAD_ID, | |
| 963 | threadId: THREAD_ID, | |
| 996 | 964 | turnId: FIXTURE_TURN_ID, |
| 997 | 965 | status: "completed", |
| 998 | 966 | }, |
| ... | ... | @@ -1021,7 +989,7 @@ it.live("recovers claudeCode sessions after provider stopAll using persisted res |
| 1021 | 989 | !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), |
| 1022 | 990 | ); |
| 1023 | 991 | assert.equal(recoveredThread.session?.providerName, "claudeCode"); |
| 1024 | assert.equal(recoveredThread.session?.providerSessionId, staleSessionId); | |
| 992 | assert.equal(recoveredThread.session?.threadId, "thread-1"); | |
| 1025 | 993 | }), |
| 1026 | 994 | "claudeCode", |
| 1027 | 995 | ), |
| ... | ... | @@ -1038,13 +1006,13 @@ it.live("forwards claudeCode approval responses to the provider session", () => |
| 1038 | 1006 | { |
| 1039 | 1007 | type: "turn.started", |
| 1040 | 1008 | ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), |
| 1041 | threadId: FIXTURE_THREAD_ID, | |
| 1009 | threadId: THREAD_ID, | |
| 1042 | 1010 | turnId: FIXTURE_TURN_ID, |
| 1043 | 1011 | }, |
| 1044 | 1012 | { |
| 1045 | 1013 | type: "approval.requested", |
| 1046 | 1014 | ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), |
| 1047 | threadId: FIXTURE_THREAD_ID, | |
| 1015 | threadId: THREAD_ID, | |
| 1048 | 1016 | turnId: FIXTURE_TURN_ID, |
| 1049 | 1017 | requestId: APPROVAL_REQUEST_ID, |
| 1050 | 1018 | requestKind: "command", |
| ... | ... | @@ -1053,7 +1021,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => |
| 1053 | 1021 | { |
| 1054 | 1022 | type: "turn.completed", |
| 1055 | 1023 | ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), |
| 1056 | threadId: FIXTURE_THREAD_ID, | |
| 1024 | threadId: THREAD_ID, | |
| 1057 | 1025 | turnId: FIXTURE_TURN_ID, |
| 1058 | 1026 | status: "completed", |
| 1059 | 1027 | }, |
| ... | ... | @@ -1071,11 +1039,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => |
| 1071 | 1039 | const thread = yield* harness.waitForThread(THREAD_ID, (entry) => |
| 1072 | 1040 | entry.activities.some((activity) => activity.kind === "approval.requested"), |
| 1073 | 1041 | ); |
| 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"); | |
| 1079 | 1043 | |
| 1080 | 1044 | yield* harness.engine.dispatch({ |
| 1081 | 1045 | type: "thread.approval.respond", |
| ... | ... | @@ -1092,7 +1056,7 @@ it.live("forwards claudeCode approval responses to the provider session", () => |
| 1092 | 1056 | ); |
| 1093 | 1057 | |
| 1094 | 1058 | const approvalResponses = yield* waitForSync( |
| 1095 | () => harness.adapterHarness.getApprovalResponses(providerSessionId), | |
| 1059 | () => harness.adapterHarness.getApprovalResponses(THREAD_ID), | |
| 1096 | 1060 | (responses) => responses.length === 1, |
| 1097 | 1061 | "claude provider approval response", |
| 1098 | 1062 | ); |
| ... | ... | @@ -1113,20 +1077,20 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => |
| 1113 | 1077 | { |
| 1114 | 1078 | type: "turn.started", |
| 1115 | 1079 | ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), |
| 1116 | threadId: FIXTURE_THREAD_ID, | |
| 1080 | threadId: THREAD_ID, | |
| 1117 | 1081 | turnId: FIXTURE_TURN_ID, |
| 1118 | 1082 | }, |
| 1119 | 1083 | { |
| 1120 | 1084 | type: "message.delta", |
| 1121 | 1085 | ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), |
| 1122 | threadId: FIXTURE_THREAD_ID, | |
| 1086 | threadId: THREAD_ID, | |
| 1123 | 1087 | turnId: FIXTURE_TURN_ID, |
| 1124 | 1088 | delta: "Long running output.\n", |
| 1125 | 1089 | }, |
| 1126 | 1090 | { |
| 1127 | 1091 | type: "turn.completed", |
| 1128 | 1092 | ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), |
| 1129 | threadId: FIXTURE_THREAD_ID, | |
| 1093 | threadId: THREAD_ID, | |
| 1130 | 1094 | turnId: FIXTURE_TURN_ID, |
| 1131 | 1095 | status: "completed", |
| 1132 | 1096 | }, |
| ... | ... | @@ -1143,13 +1107,9 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => |
| 1143 | 1107 | |
| 1144 | 1108 | const thread = yield* harness.waitForThread( |
| 1145 | 1109 | THREAD_ID, |
| 1146 | (entry) => entry.session?.providerSessionId !== null, | |
| 1110 | (entry) => entry.session?.threadId === "thread-1", | |
| 1147 | 1111 | ); |
| 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"); | |
| 1153 | 1113 | |
| 1154 | 1114 | yield* harness.engine.dispatch({ |
| 1155 | 1115 | type: "thread.turn.interrupt", |
| ... | ... | @@ -1160,7 +1120,7 @@ it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => |
| 1160 | 1120 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested"); |
| 1161 | 1121 | |
| 1162 | 1122 | const interruptCalls = yield* waitForSync( |
| 1163 | () => harness.adapterHarness.getInterruptCalls(providerSessionId), | |
| 1123 | () => harness.adapterHarness.getInterruptCalls(THREAD_ID), | |
| 1164 | 1124 | (calls) => calls.length === 1, |
| 1165 | 1125 | "claude provider interrupt call", |
| 1166 | 1126 | ); |
| ... | ... | @@ -1181,20 +1141,20 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( |
| 1181 | 1141 | { |
| 1182 | 1142 | type: "turn.started", |
| 1183 | 1143 | ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), |
| 1184 | threadId: FIXTURE_THREAD_ID, | |
| 1144 | threadId: THREAD_ID, | |
| 1185 | 1145 | turnId: FIXTURE_TURN_ID, |
| 1186 | 1146 | }, |
| 1187 | 1147 | { |
| 1188 | 1148 | type: "message.delta", |
| 1189 | 1149 | ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), |
| 1190 | threadId: FIXTURE_THREAD_ID, | |
| 1150 | threadId: THREAD_ID, | |
| 1191 | 1151 | turnId: FIXTURE_TURN_ID, |
| 1192 | 1152 | delta: "README -> v2\n", |
| 1193 | 1153 | }, |
| 1194 | 1154 | { |
| 1195 | 1155 | type: "turn.completed", |
| 1196 | 1156 | ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), |
| 1197 | threadId: FIXTURE_THREAD_ID, | |
| 1157 | threadId: THREAD_ID, | |
| 1198 | 1158 | turnId: FIXTURE_TURN_ID, |
| 1199 | 1159 | status: "completed", |
| 1200 | 1160 | }, |
| ... | ... | @@ -1213,35 +1173,30 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( |
| 1213 | 1173 | provider: "claudeCode", |
| 1214 | 1174 | }); |
| 1215 | 1175 | |
| 1216 | const firstThread = yield* harness.waitForThread( | |
| 1176 | yield* harness.waitForThread( | |
| 1217 | 1177 | THREAD_ID, |
| 1218 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.providerSessionId !== null, | |
| 1178 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", | |
| 1219 | 1179 | ); |
| 1220 | const sessionId = firstThread.session?.providerSessionId; | |
| 1221 | assert.equal(sessionId !== null, true); | |
| 1222 | if (!sessionId) { | |
| 1223 | return; | |
| 1224 | } | |
| 1225 | 1180 | |
| 1226 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 1181 | yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { | |
| 1227 | 1182 | events: [ |
| 1228 | 1183 | { |
| 1229 | 1184 | type: "turn.started", |
| 1230 | 1185 | ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), |
| 1231 | threadId: FIXTURE_THREAD_ID, | |
| 1186 | threadId: THREAD_ID, | |
| 1232 | 1187 | turnId: FIXTURE_TURN_ID, |
| 1233 | 1188 | }, |
| 1234 | 1189 | { |
| 1235 | 1190 | type: "message.delta", |
| 1236 | 1191 | ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), |
| 1237 | threadId: FIXTURE_THREAD_ID, | |
| 1192 | threadId: THREAD_ID, | |
| 1238 | 1193 | turnId: FIXTURE_TURN_ID, |
| 1239 | 1194 | delta: "README -> v3\n", |
| 1240 | 1195 | }, |
| 1241 | 1196 | { |
| 1242 | 1197 | type: "turn.completed", |
| 1243 | 1198 | ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), |
| 1244 | threadId: FIXTURE_THREAD_ID, | |
| 1199 | threadId: THREAD_ID, | |
| 1245 | 1200 | turnId: FIXTURE_TURN_ID, |
| 1246 | 1201 | status: "completed", |
| 1247 | 1202 | }, |
| ... | ... | @@ -1289,7 +1244,7 @@ it.live("reverts claudeCode turns and rolls back provider conversation state", ( |
| 1289 | 1244 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), |
| 1290 | 1245 | false, |
| 1291 | 1246 | ); |
| 1292 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); | |
| 1247 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); | |
| 1293 | 1248 | }), |
| 1294 | 1249 | "claudeCode", |
| 1295 | 1250 | ), |
apps/server/integration/providerService.integration.test.ts+16-12| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; | |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 2 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | import { it, assert } from "@effect/vitest"; |
| ... | ... | @@ -93,18 +93,18 @@ const collectEventsDuring = <A, E, R>( |
| 93 | 93 | const runTurn = (input: { |
| 94 | 94 | readonly provider: ProviderServiceShape; |
| 95 | 95 | readonly harness: TestProviderAdapterHarness; |
| 96 | readonly sessionId: ProviderSessionId; | |
| 96 | readonly threadId: ThreadId; | |
| 97 | 97 | readonly userText: string; |
| 98 | 98 | readonly response: TestTurnResponse; |
| 99 | 99 | }) => |
| 100 | 100 | Effect.gen(function* () { |
| 101 | yield* input.harness.queueTurnResponse(input.sessionId, input.response); | |
| 101 | yield* input.harness.queueTurnResponse(input.threadId, input.response); | |
| 102 | 102 | |
| 103 | 103 | return yield* collectEventsDuring( |
| 104 | 104 | input.provider.streamEvents, |
| 105 | 105 | input.response.events.length, |
| 106 | 106 | input.provider.sendTurn({ |
| 107 | sessionId: input.sessionId, | |
| 107 | threadId: input.threadId, | |
| 108 | 108 | input: input.userText, |
| 109 | 109 | attachments: [], |
| 110 | 110 | }), |
| ... | ... | @@ -120,6 +120,7 @@ it.effect("replays typed runtime fixture events", () => |
| 120 | 120 | const session = yield* provider.startSession( |
| 121 | 121 | ThreadId.makeUnsafe("thread-integration-typed"), |
| 122 | 122 | { |
| 123 | threadId: ThreadId.makeUnsafe("thread-integration-typed"), | |
| 123 | 124 | provider: "codex", |
| 124 | 125 | cwd: fixture.cwd, |
| 125 | 126 | runtimeMode: "full-access", |
| ... | ... | @@ -130,7 +131,7 @@ it.effect("replays typed runtime fixture events", () => |
| 130 | 131 | const observedEvents = yield* runTurn({ |
| 131 | 132 | provider, |
| 132 | 133 | harness: fixture.harness, |
| 133 | sessionId: session.sessionId, | |
| 134 | threadId: session.threadId, | |
| 134 | 135 | userText: "hello", |
| 135 | 136 | response: { events: codexTurnTextFixture }, |
| 136 | 137 | }); |
| ... | ... | @@ -154,6 +155,7 @@ it.effect("replays file-changing fixture turn events", () => |
| 154 | 155 | const session = yield* provider.startSession( |
| 155 | 156 | ThreadId.makeUnsafe("thread-integration-tools"), |
| 156 | 157 | { |
| 158 | threadId: ThreadId.makeUnsafe("thread-integration-tools"), | |
| 157 | 159 | provider: "codex", |
| 158 | 160 | cwd: fixture.cwd, |
| 159 | 161 | runtimeMode: "full-access", |
| ... | ... | @@ -164,7 +166,7 @@ it.effect("replays file-changing fixture turn events", () => |
| 164 | 166 | const observedEvents = yield* runTurn({ |
| 165 | 167 | provider, |
| 166 | 168 | harness: fixture.harness, |
| 167 | sessionId: session.sessionId, | |
| 169 | threadId: session.threadId, | |
| 168 | 170 | userText: "make a small change", |
| 169 | 171 | response: { |
| 170 | 172 | events: codexTurnToolFixture, |
| ... | ... | @@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () => |
| 192 | 194 | const session = yield* provider.startSession( |
| 193 | 195 | ThreadId.makeUnsafe("thread-integration-multi"), |
| 194 | 196 | { |
| 197 | threadId: ThreadId.makeUnsafe("thread-integration-multi"), | |
| 195 | 198 | provider: "codex", |
| 196 | 199 | cwd: fixture.cwd, |
| 197 | 200 | runtimeMode: "full-access", |
| ... | ... | @@ -202,7 +205,7 @@ it.effect("runs multi-turn tool/approval flow", () => |
| 202 | 205 | const firstTurnEvents = yield* runTurn({ |
| 203 | 206 | provider, |
| 204 | 207 | harness: fixture.harness, |
| 205 | sessionId: session.sessionId, | |
| 208 | threadId: session.threadId, | |
| 206 | 209 | userText: "turn 1", |
| 207 | 210 | response: { |
| 208 | 211 | events: codexTurnToolFixture, |
| ... | ... | @@ -218,7 +221,7 @@ it.effect("runs multi-turn tool/approval flow", () => |
| 218 | 221 | const secondTurnEvents = yield* runTurn({ |
| 219 | 222 | provider, |
| 220 | 223 | harness: fixture.harness, |
| 221 | sessionId: session.sessionId, | |
| 224 | threadId: session.threadId, | |
| 222 | 225 | userText: "turn 2 approval", |
| 223 | 226 | response: { |
| 224 | 227 | events: codexTurnApprovalFixture, |
| ... | ... | @@ -245,6 +248,7 @@ it.effect("rolls back provider conversation state only", () => |
| 245 | 248 | const session = yield* provider.startSession( |
| 246 | 249 | ThreadId.makeUnsafe("thread-integration-rollback"), |
| 247 | 250 | { |
| 251 | threadId: ThreadId.makeUnsafe("thread-integration-rollback"), | |
| 248 | 252 | provider: "codex", |
| 249 | 253 | cwd: fixture.cwd, |
| 250 | 254 | runtimeMode: "full-access", |
| ... | ... | @@ -255,7 +259,7 @@ it.effect("rolls back provider conversation state only", () => |
| 255 | 259 | yield* runTurn({ |
| 256 | 260 | provider, |
| 257 | 261 | harness: fixture.harness, |
| 258 | sessionId: session.sessionId, | |
| 262 | threadId: session.threadId, | |
| 259 | 263 | userText: "turn 1", |
| 260 | 264 | response: { |
| 261 | 265 | events: codexTurnToolFixture, |
| ... | ... | @@ -267,7 +271,7 @@ it.effect("rolls back provider conversation state only", () => |
| 267 | 271 | yield* runTurn({ |
| 268 | 272 | provider, |
| 269 | 273 | harness: fixture.harness, |
| 270 | sessionId: session.sessionId, | |
| 274 | threadId: session.threadId, | |
| 271 | 275 | userText: "turn 2 approval", |
| 272 | 276 | response: { |
| 273 | 277 | events: codexTurnApprovalFixture, |
| ... | ... | @@ -277,11 +281,11 @@ it.effect("rolls back provider conversation state only", () => |
| 277 | 281 | }); |
| 278 | 282 | |
| 279 | 283 | yield* provider.rollbackConversation({ |
| 280 | sessionId: session.sessionId, | |
| 284 | threadId: session.threadId, | |
| 281 | 285 | numTurns: 1, |
| 282 | 286 | }); |
| 283 | 287 | |
| 284 | const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); | |
| 288 | const rollbackCalls = fixture.harness.getRollbackCalls(session.threadId); | |
| 285 | 289 | assert.deepEqual(rollbackCalls, [1]); |
| 286 | 290 | |
| 287 | 291 | const readme = yield* readFileString(join(fixture.cwd, "README.md")); |
apps/server/src/codexAppServerManager.test.ts+20-17| ... | ... | @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import os from "node:os"; |
| 5 | 5 | import path from "node:path"; |
| 6 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 6 | import { ThreadId } from "@t3tools/contracts"; | |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | CodexAppServerManager, |
| ... | ... | @@ -12,7 +12,7 @@ import { |
| 12 | 12 | normalizeCodexModelSlug, |
| 13 | 13 | } from "./codexAppServerManager"; |
| 14 | 14 | |
| 15 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 15 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 16 | 16 | |
| 17 | 17 | function createSendTurnHarness() { |
| 18 | 18 | const manager = new CodexAppServerManager(); |
| ... | ... | @@ -168,6 +168,7 @@ describe("startSession", () => { |
| 168 | 168 | try { |
| 169 | 169 | await expect( |
| 170 | 170 | manager.startSession({ |
| 171 | threadId: asThreadId("thread-1"), | |
| 171 | 172 | provider: "codex", |
| 172 | 173 | runtimeMode: "full-access", |
| 173 | 174 | }), |
| ... | ... | @@ -191,7 +192,7 @@ describe("sendTurn", () => { |
| 191 | 192 | createSendTurnHarness(); |
| 192 | 193 | |
| 193 | 194 | const result = await manager.sendTurn({ |
| 194 | sessionId: asSessionId("sess_1"), | |
| 195 | threadId: asThreadId("thread_1"), | |
| 195 | 196 | input: "Inspect this image", |
| 196 | 197 | attachments: [ |
| 197 | 198 | { |
| ... | ... | @@ -207,7 +208,7 @@ describe("sendTurn", () => { |
| 207 | 208 | threadId: "thread_1", |
| 208 | 209 | turnId: "turn_1", |
| 209 | 210 | }); |
| 210 | expect(requireSession).toHaveBeenCalledWith("sess_1"); | |
| 211 | expect(requireSession).toHaveBeenCalledWith("thread_1"); | |
| 211 | 212 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| 212 | 213 | threadId: "thread_1", |
| 213 | 214 | input: [ |
| ... | ... | @@ -234,7 +235,7 @@ describe("sendTurn", () => { |
| 234 | 235 | const { manager, context, sendRequest } = createSendTurnHarness(); |
| 235 | 236 | |
| 236 | 237 | await manager.sendTurn({ |
| 237 | sessionId: asSessionId("sess_1"), | |
| 238 | threadId: asThreadId("thread_1"), | |
| 238 | 239 | attachments: [ |
| 239 | 240 | { |
| 240 | 241 | type: "image", |
| ... | ... | @@ -259,7 +260,7 @@ describe("sendTurn", () => { |
| 259 | 260 | |
| 260 | 261 | await expect( |
| 261 | 262 | manager.sendTurn({ |
| 262 | sessionId: asSessionId("sess_1"), | |
| 263 | threadId: asThreadId("thread_1"), | |
| 263 | 264 | }), |
| 264 | 265 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 265 | 266 | }); |
| ... | ... | @@ -280,9 +281,9 @@ describe("thread checkpoint control", () => { |
| 280 | 281 | }, |
| 281 | 282 | }); |
| 282 | 283 | |
| 283 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 284 | const result = await manager.readThread(asThreadId("thread_1")); | |
| 284 | 285 | |
| 285 | expect(requireSession).toHaveBeenCalledWith("sess_1"); | |
| 286 | expect(requireSession).toHaveBeenCalledWith("thread_1"); | |
| 286 | 287 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| 287 | 288 | threadId: "thread_1", |
| 288 | 289 | includeTurns: true, |
| ... | ... | @@ -310,7 +311,7 @@ describe("thread checkpoint control", () => { |
| 310 | 311 | ], |
| 311 | 312 | }); |
| 312 | 313 | |
| 313 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 314 | const result = await manager.readThread(asThreadId("thread_1")); | |
| 314 | 315 | |
| 315 | 316 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| 316 | 317 | threadId: "thread_1", |
| ... | ... | @@ -336,7 +337,7 @@ describe("thread checkpoint control", () => { |
| 336 | 337 | }, |
| 337 | 338 | }); |
| 338 | 339 | |
| 339 | const result = await manager.rollbackThread(asSessionId("sess_1"), 2); | |
| 340 | const result = await manager.rollbackThread(asThreadId("thread_1"), 2); | |
| 340 | 341 | |
| 341 | 342 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { |
| 342 | 343 | threadId: "thread_1", |
| ... | ... | @@ -364,6 +365,7 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 364 | 365 | |
| 365 | 366 | try { |
| 366 | 367 | const firstSession = await manager.startSession({ |
| 368 | threadId: asThreadId("thread-live"), | |
| 367 | 369 | provider: "codex", |
| 368 | 370 | cwd: workspaceDir, |
| 369 | 371 | runtimeMode: "full-access", |
| ... | ... | @@ -378,24 +380,25 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 378 | 380 | }); |
| 379 | 381 | |
| 380 | 382 | const firstTurn = await manager.sendTurn({ |
| 381 | sessionId: firstSession.sessionId, | |
| 383 | threadId: firstSession.threadId, | |
| 382 | 384 | input: `Reply with exactly the word ALPHA ${randomUUID()}`, |
| 383 | 385 | }); |
| 384 | 386 | |
| 385 | 387 | expect(firstTurn.threadId).toBe(firstSession.threadId); |
| 386 | 388 | |
| 387 | 389 | await vi.waitFor(async () => { |
| 388 | const snapshot = await manager.readThread(firstSession.sessionId); | |
| 390 | const snapshot = await manager.readThread(firstSession.threadId); | |
| 389 | 391 | expect(snapshot.turns.length).toBeGreaterThan(0); |
| 390 | 392 | }, { timeout: 120_000, interval: 1_000 }); |
| 391 | 393 | |
| 392 | const firstSnapshot = await manager.readThread(firstSession.sessionId); | |
| 394 | const firstSnapshot = await manager.readThread(firstSession.threadId); | |
| 393 | 395 | const originalThreadId = firstSnapshot.threadId; |
| 394 | 396 | const originalTurnCount = firstSnapshot.turns.length; |
| 395 | 397 | |
| 396 | manager.stopSession(firstSession.sessionId); | |
| 398 | manager.stopSession(firstSession.threadId); | |
| 397 | 399 | |
| 398 | 400 | const resumedSession = await manager.startSession({ |
| 401 | threadId: firstSession.threadId, | |
| 399 | 402 | provider: "codex", |
| 400 | 403 | cwd: workspaceDir, |
| 401 | 404 | runtimeMode: "approval-required", |
| ... | ... | @@ -412,17 +415,17 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" |
| 412 | 415 | |
| 413 | 416 | expect(resumedSession.threadId).toBe(originalThreadId); |
| 414 | 417 | |
| 415 | const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId); | |
| 418 | const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId); | |
| 416 | 419 | expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); |
| 417 | 420 | expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); |
| 418 | 421 | |
| 419 | 422 | await manager.sendTurn({ |
| 420 | sessionId: resumedSession.sessionId, | |
| 423 | threadId: resumedSession.threadId, | |
| 421 | 424 | input: `Reply with exactly the word BETA ${randomUUID()}`, |
| 422 | 425 | }); |
| 423 | 426 | |
| 424 | 427 | await vi.waitFor(async () => { |
| 425 | const snapshot = await manager.readThread(resumedSession.sessionId); | |
| 428 | const snapshot = await manager.readThread(resumedSession.threadId); | |
| 426 | 429 | expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); |
| 427 | 430 | }, { timeout: 120_000, interval: 1_000 }); |
| 428 | 431 | } finally { |
apps/server/src/codexAppServerManager.ts+118-108| ... | ... | @@ -8,9 +8,8 @@ import { |
| 8 | 8 | EventId, |
| 9 | 9 | ProviderItemId, |
| 10 | 10 | ProviderRequestKind, |
| 11 | ProviderSessionId, | |
| 12 | ProviderThreadId, | |
| 13 | ProviderTurnId, | |
| 11 | ThreadId, | |
| 12 | TurnId, | |
| 14 | 13 | normalizeModelSlug, |
| 15 | 14 | type ProviderApprovalDecision, |
| 16 | 15 | type ProviderEvent, |
| ... | ... | @@ -18,7 +17,7 @@ import { |
| 18 | 17 | type ProviderSessionStartInput, |
| 19 | 18 | type ProviderTurnStartResult, |
| 20 | 19 | } from "@t3tools/contracts"; |
| 21 | import { Effect } from "effect"; | |
| 20 | import { Effect, ServiceMap } from "effect"; | |
| 22 | 21 | |
| 23 | 22 | type PendingRequestKey = string; |
| 24 | 23 | |
| ... | ... | @@ -34,8 +33,8 @@ interface PendingApprovalRequest { |
| 34 | 33 | jsonRpcId: string | number; |
| 35 | 34 | method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; |
| 36 | 35 | requestKind: ProviderRequestKind; |
| 37 | threadId?: ProviderThreadId; | |
| 38 | turnId?: ProviderTurnId; | |
| 36 | threadId: ThreadId; | |
| 37 | turnId?: TurnId; | |
| 39 | 38 | itemId?: ProviderItemId; |
| 40 | 39 | } |
| 41 | 40 | |
| ... | ... | @@ -72,7 +71,7 @@ interface JsonRpcNotification { |
| 72 | 71 | } |
| 73 | 72 | |
| 74 | 73 | export interface CodexAppServerSendTurnInput { |
| 75 | readonly sessionId: ProviderSessionId; | |
| 74 | readonly threadId: ThreadId; | |
| 76 | 75 | readonly input?: string; |
| 77 | 76 | readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; |
| 78 | 77 | readonly model?: string; |
| ... | ... | @@ -80,12 +79,12 @@ export interface CodexAppServerSendTurnInput { |
| 80 | 79 | } |
| 81 | 80 | |
| 82 | 81 | export interface CodexThreadTurnSnapshot { |
| 83 | id: ProviderTurnId; | |
| 82 | id: TurnId; | |
| 84 | 83 | items: unknown[]; |
| 85 | 84 | } |
| 86 | 85 | |
| 87 | 86 | export interface CodexThreadSnapshot { |
| 88 | threadId: ProviderThreadId; | |
| 87 | threadId: string; | |
| 89 | 88 | turns: CodexThreadTurnSnapshot[]; |
| 90 | 89 | } |
| 91 | 90 | |
| ... | ... | @@ -191,10 +190,16 @@ export interface CodexAppServerManagerEvents { |
| 191 | 190 | } |
| 192 | 191 | |
| 193 | 192 | export 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 | } | |
| 195 | 200 | |
| 196 | 201 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 197 | const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); | |
| 202 | const threadId = input.threadId; | |
| 198 | 203 | const now = new Date().toISOString(); |
| 199 | 204 | let context: CodexSessionContext | undefined; |
| 200 | 205 | |
| ... | ... | @@ -202,12 +207,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 202 | 207 | const resolvedCwd = input.cwd ?? process.cwd(); |
| 203 | 208 | |
| 204 | 209 | const session: ProviderSession = { |
| 205 | sessionId, | |
| 206 | 210 | provider: "codex", |
| 207 | 211 | status: "connecting", |
| 208 | 212 | runtimeMode: input.runtimeMode, |
| 209 | 213 | model: normalizeCodexModelSlug(input.model), |
| 210 | 214 | cwd: resolvedCwd, |
| 215 | threadId, | |
| 211 | 216 | createdAt: now, |
| 212 | 217 | updatedAt: now, |
| 213 | 218 | }; |
| ... | ... | @@ -236,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 236 | 241 | stopping: false, |
| 237 | 242 | }; |
| 238 | 243 | |
| 239 | this.sessions.set(sessionId, context); | |
| 244 | this.sessions.set(threadId, context); | |
| 240 | 245 | this.attachProcessListeners(context); |
| 241 | 246 | |
| 242 | 247 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); |
| ... | ... | @@ -274,12 +279,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 274 | 279 | : "Starting a new Codex thread.", |
| 275 | 280 | ); |
| 276 | 281 | await Effect.logInfo("codex app-server opening thread", { |
| 277 | sessionId, | |
| 282 | threadId, | |
| 278 | 283 | requestedRuntimeMode: input.runtimeMode, |
| 279 | 284 | requestedModel: normalizedModel ?? null, |
| 280 | 285 | requestedCwd: resolvedCwd, |
| 281 | 286 | resumeThreadId: resumeThreadId ?? null, |
| 282 | }).pipe(Effect.runPromise); | |
| 287 | }).pipe(this.runPromise); | |
| 283 | 288 | |
| 284 | 289 | let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; |
| 285 | 290 | let threadOpenResponse: unknown; |
| ... | ... | @@ -298,12 +303,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 298 | 303 | error instanceof Error ? error.message : "Codex thread resume failed.", |
| 299 | 304 | ); |
| 300 | 305 | await Effect.logWarning("codex app-server thread resume failed", { |
| 301 | sessionId, | |
| 306 | threadId, | |
| 302 | 307 | requestedRuntimeMode: input.runtimeMode, |
| 303 | 308 | resumeThreadId, |
| 304 | 309 | recoverable: false, |
| 305 | 310 | cause: error instanceof Error ? error.message : String(error), |
| 306 | }).pipe(Effect.runPromise); | |
| 311 | }).pipe(this.runPromise); | |
| 307 | 312 | throw error; |
| 308 | 313 | } |
| 309 | 314 | |
| ... | ... | @@ -314,12 +319,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 314 | 319 | `Could not resume thread ${resumeThreadId}; started a new thread instead.`, |
| 315 | 320 | ); |
| 316 | 321 | await Effect.logWarning("codex app-server thread resume fell back to fresh start", { |
| 317 | sessionId, | |
| 322 | threadId, | |
| 318 | 323 | requestedRuntimeMode: input.runtimeMode, |
| 319 | 324 | resumeThreadId, |
| 320 | 325 | recoverable: true, |
| 321 | 326 | cause: error instanceof Error ? error.message : String(error), |
| 322 | }).pipe(Effect.runPromise); | |
| 327 | }).pipe(this.runPromise); | |
| 323 | 328 | threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); |
| 324 | 329 | } |
| 325 | 330 | } else { |
| ... | ... | @@ -334,22 +339,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 334 | 339 | if (!threadIdRaw) { |
| 335 | 340 | throw new Error(`${threadOpenMethod} response did not include a thread id.`); |
| 336 | 341 | } |
| 337 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 342 | const providerThreadId = threadIdRaw; | |
| 338 | 343 | |
| 339 | 344 | this.updateSession(context, { |
| 340 | 345 | status: "ready", |
| 341 | threadId, | |
| 342 | resumeCursor: { threadId }, | |
| 346 | resumeCursor: { threadId: providerThreadId }, | |
| 343 | 347 | }); |
| 344 | this.emitLifecycleEvent(context, "session/threadOpenResolved", `Codex ${threadOpenMethod} resolved.`); | |
| 348 | this.emitLifecycleEvent( | |
| 349 | context, | |
| 350 | "session/threadOpenResolved", | |
| 351 | `Codex ${threadOpenMethod} resolved.`, | |
| 352 | ); | |
| 345 | 353 | await Effect.logInfo("codex app-server thread open resolved", { |
| 346 | sessionId, | |
| 354 | threadId, | |
| 347 | 355 | threadOpenMethod, |
| 348 | 356 | requestedResumeThreadId: resumeThreadId ?? null, |
| 349 | resolvedThreadId: threadId, | |
| 357 | resolvedThreadId: providerThreadId, | |
| 350 | 358 | 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}`); | |
| 353 | 361 | return { ...context.session }; |
| 354 | 362 | } catch (error) { |
| 355 | 363 | const message = error instanceof Error ? error.message : "Failed to start Codex session."; |
| ... | ... | @@ -359,13 +367,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 359 | 367 | lastError: message, |
| 360 | 368 | }); |
| 361 | 369 | this.emitErrorEvent(context, "session/startFailed", message); |
| 362 | this.stopSession(sessionId); | |
| 370 | this.stopSession(threadId); | |
| 363 | 371 | } else { |
| 364 | 372 | this.emitEvent({ |
| 365 | 373 | id: EventId.makeUnsafe(randomUUID()), |
| 366 | 374 | kind: "error", |
| 367 | 375 | provider: "codex", |
| 368 | sessionId, | |
| 376 | threadId, | |
| 369 | 377 | createdAt: new Date().toISOString(), |
| 370 | 378 | method: "session/startFailed", |
| 371 | 379 | message, |
| ... | ... | @@ -376,10 +384,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 376 | 384 | } |
| 377 | 385 | |
| 378 | 386 | 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); | |
| 383 | 388 | |
| 384 | 389 | const turnInput: Array< |
| 385 | 390 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| ... | ... | @@ -403,15 +408,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 403 | 408 | throw new Error("Turn input must include text or attachments."); |
| 404 | 409 | } |
| 405 | 410 | |
| 411 | const providerThreadId = readResumeThreadId({ | |
| 412 | threadId: context.session.threadId, | |
| 413 | runtimeMode: context.session.runtimeMode, | |
| 414 | resumeCursor: context.session.resumeCursor, | |
| 415 | }); | |
| 416 | if (!providerThreadId) { | |
| 417 | throw new Error("Session is missing provider resume thread id."); | |
| 418 | } | |
| 406 | 419 | const turnStartParams: { |
| 407 | threadId: ProviderThreadId; | |
| 420 | threadId: string; | |
| 408 | 421 | input: Array< |
| 409 | 422 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| 410 | 423 | >; |
| 411 | 424 | model?: string; |
| 412 | 425 | effort?: string; |
| 413 | 426 | } = { |
| 414 | threadId: context.session.threadId, | |
| 427 | threadId: providerThreadId, | |
| 415 | 428 | input: turnInput, |
| 416 | 429 | }; |
| 417 | 430 | const normalizedModel = normalizeCodexModelSlug(input.model); |
| ... | ... | @@ -429,7 +442,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 429 | 442 | if (!turnIdRaw) { |
| 430 | 443 | throw new Error("turn/start response did not include a turn id."); |
| 431 | 444 | } |
| 432 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | |
| 445 | const turnId = TurnId.makeUnsafe(turnIdRaw); | |
| 433 | 446 | |
| 434 | 447 | this.updateSession(context, { |
| 435 | 448 | status: "running", |
| ... | ... | @@ -448,49 +461,59 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 448 | 461 | }; |
| 449 | 462 | } |
| 450 | 463 | |
| 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); | |
| 453 | 466 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| 454 | 467 | |
| 455 | if (!effectiveTurnId || !context.session.threadId) { | |
| 468 | const providerThreadId = readResumeThreadId({ | |
| 469 | threadId: context.session.threadId, | |
| 470 | runtimeMode: context.session.runtimeMode, | |
| 471 | resumeCursor: context.session.resumeCursor, | |
| 472 | }); | |
| 473 | if (!effectiveTurnId || !providerThreadId) { | |
| 456 | 474 | return; |
| 457 | 475 | } |
| 458 | 476 | |
| 459 | 477 | await this.sendRequest(context, "turn/interrupt", { |
| 460 | threadId: context.session.threadId, | |
| 478 | threadId: providerThreadId, | |
| 461 | 479 | turnId: effectiveTurnId, |
| 462 | 480 | }); |
| 463 | 481 | } |
| 464 | 482 | |
| 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."); | |
| 470 | 492 | } |
| 471 | 493 | |
| 472 | 494 | const response = await this.sendRequest(context, "thread/read", { |
| 473 | threadId, | |
| 495 | threadId: providerThreadId, | |
| 474 | 496 | includeTurns: true, |
| 475 | 497 | }); |
| 476 | 498 | return this.parseThreadSnapshot("thread/read", response); |
| 477 | 499 | } |
| 478 | 500 | |
| 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."); | |
| 487 | 510 | } |
| 488 | 511 | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| 489 | 512 | throw new Error("numTurns must be an integer >= 1."); |
| 490 | 513 | } |
| 491 | 514 | |
| 492 | 515 | const response = await this.sendRequest(context, "thread/rollback", { |
| 493 | threadId, | |
| 516 | threadId: providerThreadId, | |
| 494 | 517 | numTurns, |
| 495 | 518 | }); |
| 496 | 519 | this.updateSession(context, { |
| ... | ... | @@ -501,11 +524,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 501 | 524 | } |
| 502 | 525 | |
| 503 | 526 | async respondToRequest( |
| 504 | sessionId: ProviderSessionId, | |
| 527 | threadId: ThreadId, | |
| 505 | 528 | requestId: ApprovalRequestId, |
| 506 | 529 | decision: ProviderApprovalDecision, |
| 507 | 530 | ): Promise<void> { |
| 508 | const context = this.requireSession(sessionId); | |
| 531 | const context = this.requireSession(threadId); | |
| 509 | 532 | const pendingRequest = context.pendingApprovals.get(requestId); |
| 510 | 533 | if (!pendingRequest) { |
| 511 | 534 | throw new Error(`Unknown pending approval request: ${requestId}`); |
| ... | ... | @@ -523,10 +546,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 523 | 546 | id: EventId.makeUnsafe(randomUUID()), |
| 524 | 547 | kind: "notification", |
| 525 | 548 | provider: "codex", |
| 526 | sessionId: context.session.sessionId, | |
| 549 | threadId: context.session.threadId, | |
| 527 | 550 | createdAt: new Date().toISOString(), |
| 528 | 551 | method: "item/requestApproval/decision", |
| 529 | threadId: pendingRequest.threadId, | |
| 530 | 552 | turnId: pendingRequest.turnId, |
| 531 | 553 | itemId: pendingRequest.itemId, |
| 532 | 554 | requestId: pendingRequest.requestId, |
| ... | ... | @@ -539,8 +561,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 539 | 561 | }); |
| 540 | 562 | } |
| 541 | 563 | |
| 542 | stopSession(sessionId: ProviderSessionId): void { | |
| 543 | const context = this.sessions.get(sessionId); | |
| 564 | stopSession(threadId: ThreadId): void { | |
| 565 | const context = this.sessions.get(threadId); | |
| 544 | 566 | if (!context) { |
| 545 | 567 | return; |
| 546 | 568 | } |
| ... | ... | @@ -565,7 +587,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 565 | 587 | activeTurnId: undefined, |
| 566 | 588 | }); |
| 567 | 589 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); |
| 568 | this.sessions.delete(sessionId); | |
| 590 | this.sessions.delete(threadId); | |
| 569 | 591 | } |
| 570 | 592 | |
| 571 | 593 | listSessions(): ProviderSession[] { |
| ... | ... | @@ -574,24 +596,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 574 | 596 | })); |
| 575 | 597 | } |
| 576 | 598 | |
| 577 | hasSession(sessionId: ProviderSessionId): boolean { | |
| 578 | return this.sessions.has(sessionId); | |
| 599 | hasSession(threadId: ThreadId): boolean { | |
| 600 | return this.sessions.has(threadId); | |
| 579 | 601 | } |
| 580 | 602 | |
| 581 | 603 | 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); | |
| 584 | 606 | } |
| 585 | 607 | } |
| 586 | 608 | |
| 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); | |
| 589 | 611 | if (!context) { |
| 590 | throw new Error(`Unknown session: ${sessionId}`); | |
| 612 | throw new Error(`Unknown session for thread: ${threadId}`); | |
| 591 | 613 | } |
| 592 | 614 | |
| 593 | 615 | if (context.session.status === "closed") { |
| 594 | throw new Error(`Session is closed: ${sessionId}`); | |
| 616 | throw new Error(`Session is closed for thread: ${threadId}`); | |
| 595 | 617 | } |
| 596 | 618 | |
| 597 | 619 | return context; |
| ... | ... | @@ -636,7 +658,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 636 | 658 | lastError: code === 0 ? context.session.lastError : message, |
| 637 | 659 | }); |
| 638 | 660 | this.emitLifecycleEvent(context, "session/exited", message); |
| 639 | this.sessions.delete(context.session.sessionId); | |
| 661 | this.sessions.delete(context.session.threadId); | |
| 640 | 662 | }); |
| 641 | 663 | } |
| 642 | 664 | |
| ... | ... | @@ -698,10 +720,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 698 | 720 | id: EventId.makeUnsafe(randomUUID()), |
| 699 | 721 | kind: "notification", |
| 700 | 722 | provider: "codex", |
| 701 | sessionId: context.session.sessionId, | |
| 723 | threadId: context.session.threadId, | |
| 702 | 724 | createdAt: new Date().toISOString(), |
| 703 | 725 | method: notification.method, |
| 704 | threadId: route.threadId, | |
| 705 | 726 | turnId: route.turnId, |
| 706 | 727 | itemId: route.itemId, |
| 707 | 728 | textDelta, |
| ... | ... | @@ -709,19 +730,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 709 | 730 | }); |
| 710 | 731 | |
| 711 | 732 | if (notification.method === "thread/started") { |
| 712 | const threadId = toProviderThreadId( | |
| 733 | const providerThreadId = normalizeProviderThreadId( | |
| 713 | 734 | this.readString(this.readObject(notification.params)?.thread, "id"), |
| 714 | 735 | ); |
| 715 | if (threadId) { | |
| 716 | this.updateSession(context, { threadId }); | |
| 736 | if (providerThreadId) { | |
| 737 | this.updateSession(context, { resumeCursor: { threadId: providerThreadId } }); | |
| 717 | 738 | } |
| 718 | 739 | return; |
| 719 | 740 | } |
| 720 | 741 | |
| 721 | 742 | 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")); | |
| 725 | 744 | this.updateSession(context, { |
| 726 | 745 | status: "running", |
| 727 | 746 | activeTurnId: turnId, |
| ... | ... | @@ -766,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 766 | 785 | ? "item/commandExecution/requestApproval" |
| 767 | 786 | : "item/fileChange/requestApproval", |
| 768 | 787 | requestKind, |
| 769 | ...(route.threadId ? { threadId: route.threadId } : {}), | |
| 788 | threadId: context.session.threadId, | |
| 770 | 789 | ...(route.turnId ? { turnId: route.turnId } : {}), |
| 771 | 790 | ...(route.itemId ? { itemId: route.itemId } : {}), |
| 772 | 791 | }; |
| ... | ... | @@ -777,10 +796,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 777 | 796 | id: EventId.makeUnsafe(randomUUID()), |
| 778 | 797 | kind: "request", |
| 779 | 798 | provider: "codex", |
| 780 | sessionId: context.session.sessionId, | |
| 799 | threadId: context.session.threadId, | |
| 781 | 800 | createdAt: new Date().toISOString(), |
| 782 | 801 | method: request.method, |
| 783 | threadId: route.threadId, | |
| 784 | 802 | turnId: route.turnId, |
| 785 | 803 | itemId: route.itemId, |
| 786 | 804 | requestId, |
| ... | ... | @@ -872,7 +890,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 872 | 890 | id: EventId.makeUnsafe(randomUUID()), |
| 873 | 891 | kind: "session", |
| 874 | 892 | provider: "codex", |
| 875 | sessionId: context.session.sessionId, | |
| 893 | threadId: context.session.threadId, | |
| 876 | 894 | createdAt: new Date().toISOString(), |
| 877 | 895 | method, |
| 878 | 896 | message, |
| ... | ... | @@ -884,7 +902,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 884 | 902 | id: EventId.makeUnsafe(randomUUID()), |
| 885 | 903 | kind: "error", |
| 886 | 904 | provider: "codex", |
| 887 | sessionId: context.session.sessionId, | |
| 905 | threadId: context.session.threadId, | |
| 888 | 906 | createdAt: new Date().toISOString(), |
| 889 | 907 | method, |
| 890 | 908 | message, |
| ... | ... | @@ -908,6 +926,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 908 | 926 | return "command"; |
| 909 | 927 | } |
| 910 | 928 | |
| 929 | if (method === "item/fileRead/requestApproval") { | |
| 930 | return "file-read"; | |
| 931 | } | |
| 932 | ||
| 911 | 933 | if (method === "item/fileChange/requestApproval") { |
| 912 | 934 | return "file-change"; |
| 913 | 935 | } |
| ... | ... | @@ -923,14 +945,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 923 | 945 | if (!threadIdRaw) { |
| 924 | 946 | throw new Error(`${method} response did not include a thread id.`); |
| 925 | 947 | } |
| 926 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 927 | ||
| 928 | 948 | const turnsRaw = |
| 929 | 949 | this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; |
| 930 | 950 | const turns = turnsRaw.map((turnValue, index) => { |
| 931 | 951 | const turn = this.readObject(turnValue); |
| 932 | 952 | const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; |
| 933 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | |
| 953 | const turnId = TurnId.makeUnsafe(turnIdRaw); | |
| 934 | 954 | const items = this.readArray(turn, "items") ?? []; |
| 935 | 955 | return { |
| 936 | 956 | id: turnId, |
| ... | ... | @@ -939,7 +959,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 939 | 959 | }); |
| 940 | 960 | |
| 941 | 961 | return { |
| 942 | threadId, | |
| 962 | threadId: threadIdRaw, | |
| 943 | 963 | turns, |
| 944 | 964 | }; |
| 945 | 965 | } |
| ... | ... | @@ -977,31 +997,21 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 977 | 997 | } |
| 978 | 998 | |
| 979 | 999 | private readRouteFields(params: unknown): { |
| 980 | threadId?: ProviderThreadId; | |
| 981 | turnId?: ProviderTurnId; | |
| 1000 | turnId?: TurnId; | |
| 982 | 1001 | itemId?: ProviderItemId; |
| 983 | 1002 | } { |
| 984 | 1003 | const route: { |
| 985 | threadId?: ProviderThreadId; | |
| 986 | turnId?: ProviderTurnId; | |
| 1004 | turnId?: TurnId; | |
| 987 | 1005 | itemId?: ProviderItemId; |
| 988 | 1006 | } = {}; |
| 989 | 1007 | |
| 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( | |
| 995 | 1009 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), |
| 996 | 1010 | ); |
| 997 | 1011 | const itemId = toProviderItemId( |
| 998 | 1012 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"), |
| 999 | 1013 | ); |
| 1000 | 1014 | |
| 1001 | if (threadId) { | |
| 1002 | route.threadId = threadId; | |
| 1003 | } | |
| 1004 | ||
| 1005 | 1015 | if (turnId) { |
| 1006 | 1016 | route.turnId = turnId; |
| 1007 | 1017 | } |
| ... | ... | @@ -1065,8 +1075,8 @@ function brandIfNonEmpty<T extends string>( |
| 1065 | 1075 | return normalized?.length ? maker(normalized) : undefined; |
| 1066 | 1076 | } |
| 1067 | 1077 | |
| 1068 | function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { | |
| 1069 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); | |
| 1078 | function normalizeProviderThreadId(value: string | undefined): string | undefined { | |
| 1079 | return brandIfNonEmpty(value, (normalized) => normalized); | |
| 1070 | 1080 | } |
| 1071 | 1081 | |
| 1072 | 1082 | function readCodexProviderOptions(input: ProviderSessionStartInput): { |
| ... | ... | @@ -1083,20 +1093,20 @@ function readCodexProviderOptions(input: ProviderSessionStartInput): { |
| 1083 | 1093 | }; |
| 1084 | 1094 | } |
| 1085 | 1095 | |
| 1086 | function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined { | |
| 1096 | function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { | |
| 1087 | 1097 | if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { |
| 1088 | 1098 | return undefined; |
| 1089 | 1099 | } |
| 1090 | 1100 | const rawThreadId = (resumeCursor as Record<string, unknown>).threadId; |
| 1091 | return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined; | |
| 1101 | return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; | |
| 1092 | 1102 | } |
| 1093 | 1103 | |
| 1094 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { | |
| 1104 | function readResumeThreadId(input: ProviderSessionStartInput): string | undefined { | |
| 1095 | 1105 | return readResumeCursorThreadId(input.resumeCursor); |
| 1096 | 1106 | } |
| 1097 | 1107 | |
| 1098 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { | |
| 1099 | return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); | |
| 1108 | function toTurnId(value: string | undefined): TurnId | undefined { | |
| 1109 | return brandIfNonEmpty(value, TurnId.makeUnsafe); | |
| 1100 | 1110 | } |
| 1101 | 1111 | |
| 1102 | 1112 | function toProviderItemId(value: string | undefined): ProviderItemId | undefined { |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+43-71| ... | ... | @@ -9,9 +9,6 @@ import { |
| 9 | 9 | EventId, |
| 10 | 10 | MessageId, |
| 11 | 11 | ProjectId, |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | 12 | ThreadId, |
| 16 | 13 | TurnId, |
| 17 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -40,18 +37,14 @@ import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 40 | 37 | import { ServerConfig } from "../../config.ts"; |
| 41 | 38 | |
| 42 | 39 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 43 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 44 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 45 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 46 | 40 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 47 | 41 | |
| 48 | 42 | type LegacyProviderRuntimeEvent = { |
| 49 | 43 | readonly type: string; |
| 50 | 44 | readonly eventId: EventId; |
| 51 | 45 | readonly provider: "codex" | "claudeCode" | "cursor"; |
| 52 | readonly sessionId: string; | |
| 53 | 46 | readonly createdAt: string; |
| 54 | readonly threadId?: string | undefined; | |
| 47 | readonly threadId: ThreadId; | |
| 55 | 48 | readonly turnId?: string | undefined; |
| 56 | 49 | readonly itemId?: string | undefined; |
| 57 | 50 | readonly requestId?: string | undefined; |
| ... | ... | @@ -68,7 +61,7 @@ function createProviderServiceHarness( |
| 68 | 61 | const now = new Date().toISOString(); |
| 69 | 62 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 70 | 63 | const rollbackConversation = vi.fn( |
| 71 | (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void, | |
| 64 | (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void, | |
| 72 | 65 | ); |
| 73 | 66 | |
| 74 | 67 | const unsupported = <A>() => |
| ... | ... | @@ -77,11 +70,10 @@ function createProviderServiceHarness( |
| 77 | 70 | hasSession |
| 78 | 71 | ? Effect.succeed([ |
| 79 | 72 | { |
| 80 | sessionId: asSessionId("sess-1"), | |
| 81 | 73 | provider: providerName, |
| 82 | 74 | status: "ready", |
| 83 | 75 | runtimeMode: "full-access", |
| 84 | threadId: asProviderThreadId("provider-thread-1"), | |
| 76 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 85 | 77 | cwd: sessionCwd, |
| 86 | 78 | createdAt: now, |
| 87 | 79 | updatedAt: now, |
| ... | ... | @@ -344,8 +336,6 @@ describe("CheckpointReactor", () => { |
| 344 | 336 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 345 | 337 | status: "ready", |
| 346 | 338 | providerName: "codex", |
| 347 | providerSessionId: asSessionId("sess-1"), | |
| 348 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 349 | 339 | runtimeMode: "approval-required", |
| 350 | 340 | activeTurnId: null, |
| 351 | 341 | lastError: null, |
| ... | ... | @@ -359,10 +349,10 @@ describe("CheckpointReactor", () => { |
| 359 | 349 | type: "turn.started", |
| 360 | 350 | eventId: EventId.makeUnsafe("evt-turn-started-1"), |
| 361 | 351 | provider: "codex", |
| 362 | sessionId: asSessionId("sess-1"), | |
| 352 | ||
| 363 | 353 | 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"), | |
| 366 | 356 | }); |
| 367 | 357 | await waitForGitRefExists( |
| 368 | 358 | harness.cwd, |
| ... | ... | @@ -374,10 +364,10 @@ describe("CheckpointReactor", () => { |
| 374 | 364 | type: "turn.completed", |
| 375 | 365 | eventId: EventId.makeUnsafe("evt-turn-completed-1"), |
| 376 | 366 | provider: "codex", |
| 377 | sessionId: asSessionId("sess-1"), | |
| 367 | ||
| 378 | 368 | 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"), | |
| 381 | 371 | payload: { state: "completed" }, |
| 382 | 372 | }); |
| 383 | 373 | |
| ... | ... | @@ -422,8 +412,6 @@ describe("CheckpointReactor", () => { |
| 422 | 412 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 423 | 413 | status: "running", |
| 424 | 414 | providerName: "codex", |
| 425 | providerSessionId: asSessionId("sess-1"), | |
| 426 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 427 | 415 | runtimeMode: "approval-required", |
| 428 | 416 | activeTurnId: asTurnId("turn-main"), |
| 429 | 417 | lastError: null, |
| ... | ... | @@ -437,10 +425,10 @@ describe("CheckpointReactor", () => { |
| 437 | 425 | type: "turn.started", |
| 438 | 426 | eventId: EventId.makeUnsafe("evt-turn-started-main"), |
| 439 | 427 | provider: "codex", |
| 440 | sessionId: asSessionId("sess-1"), | |
| 428 | ||
| 441 | 429 | 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"), | |
| 444 | 432 | }); |
| 445 | 433 | await waitForGitRefExists( |
| 446 | 434 | harness.cwd, |
| ... | ... | @@ -453,10 +441,10 @@ describe("CheckpointReactor", () => { |
| 453 | 441 | type: "turn.completed", |
| 454 | 442 | eventId: EventId.makeUnsafe("evt-turn-completed-aux"), |
| 455 | 443 | provider: "codex", |
| 456 | sessionId: asSessionId("sess-1"), | |
| 444 | ||
| 457 | 445 | 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"), | |
| 460 | 448 | payload: { state: "completed" }, |
| 461 | 449 | }); |
| 462 | 450 | |
| ... | ... | @@ -471,10 +459,10 @@ describe("CheckpointReactor", () => { |
| 471 | 459 | type: "turn.completed", |
| 472 | 460 | eventId: EventId.makeUnsafe("evt-turn-completed-main"), |
| 473 | 461 | provider: "codex", |
| 474 | sessionId: asSessionId("sess-1"), | |
| 462 | ||
| 475 | 463 | 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"), | |
| 478 | 466 | payload: { state: "completed" }, |
| 479 | 467 | }); |
| 480 | 468 | |
| ... | ... | @@ -501,8 +489,6 @@ describe("CheckpointReactor", () => { |
| 501 | 489 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 502 | 490 | status: "ready", |
| 503 | 491 | providerName: "claudeCode", |
| 504 | providerSessionId: asSessionId("sess-1"), | |
| 505 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 506 | 492 | runtimeMode: "approval-required", |
| 507 | 493 | activeTurnId: null, |
| 508 | 494 | lastError: null, |
| ... | ... | @@ -516,10 +502,10 @@ describe("CheckpointReactor", () => { |
| 516 | 502 | type: "turn.started", |
| 517 | 503 | eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), |
| 518 | 504 | provider: "claudeCode", |
| 519 | sessionId: asSessionId("sess-1"), | |
| 505 | ||
| 520 | 506 | 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"), | |
| 523 | 509 | }); |
| 524 | 510 | await waitForGitRefExists( |
| 525 | 511 | harness.cwd, |
| ... | ... | @@ -531,10 +517,10 @@ describe("CheckpointReactor", () => { |
| 531 | 517 | type: "turn.completed", |
| 532 | 518 | eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), |
| 533 | 519 | provider: "claudeCode", |
| 534 | sessionId: asSessionId("sess-1"), | |
| 520 | ||
| 535 | 521 | 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"), | |
| 538 | 524 | payload: { state: "completed" }, |
| 539 | 525 | }); |
| 540 | 526 | |
| ... | ... | @@ -563,8 +549,6 @@ describe("CheckpointReactor", () => { |
| 563 | 549 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 564 | 550 | status: "ready", |
| 565 | 551 | providerName: "codex", |
| 566 | providerSessionId: asSessionId("sess-1"), | |
| 567 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 568 | 552 | runtimeMode: "approval-required", |
| 569 | 553 | activeTurnId: null, |
| 570 | 554 | lastError: null, |
| ... | ... | @@ -578,10 +562,10 @@ describe("CheckpointReactor", () => { |
| 578 | 562 | type: "turn.completed", |
| 579 | 563 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), |
| 580 | 564 | provider: "codex", |
| 581 | sessionId: asSessionId("sess-1"), | |
| 565 | ||
| 582 | 566 | 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"), | |
| 585 | 569 | payload: { state: "completed" }, |
| 586 | 570 | }); |
| 587 | 571 | |
| ... | ... | @@ -652,8 +636,6 @@ describe("CheckpointReactor", () => { |
| 652 | 636 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 653 | 637 | status: "running", |
| 654 | 638 | providerName: "codex", |
| 655 | providerSessionId: asSessionId("sess-missing"), | |
| 656 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), | |
| 657 | 639 | runtimeMode: "approval-required", |
| 658 | 640 | activeTurnId: asTurnId("turn-missing-cwd"), |
| 659 | 641 | lastError: null, |
| ... | ... | @@ -668,10 +650,10 @@ describe("CheckpointReactor", () => { |
| 668 | 650 | type: "turn.completed", |
| 669 | 651 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), |
| 670 | 652 | provider: "codex", |
| 671 | sessionId: asSessionId("sess-missing"), | |
| 653 | ||
| 672 | 654 | 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"), | |
| 675 | 657 | payload: { state: "completed" }, |
| 676 | 658 | }); |
| 677 | 659 | |
| ... | ... | @@ -701,8 +683,6 @@ describe("CheckpointReactor", () => { |
| 701 | 683 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 702 | 684 | status: "ready", |
| 703 | 685 | providerName: "codex", |
| 704 | providerSessionId: asSessionId("sess-1"), | |
| 705 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 706 | 686 | runtimeMode: "approval-required", |
| 707 | 687 | activeTurnId: null, |
| 708 | 688 | lastError: null, |
| ... | ... | @@ -716,10 +696,10 @@ describe("CheckpointReactor", () => { |
| 716 | 696 | type: "checkpoint.captured", |
| 717 | 697 | eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), |
| 718 | 698 | provider: "codex", |
| 719 | sessionId: asSessionId("sess-1"), | |
| 699 | ||
| 720 | 700 | 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"), | |
| 723 | 703 | turnCount: 3, |
| 724 | 704 | status: "completed", |
| 725 | 705 | }); |
| ... | ... | @@ -753,8 +733,6 @@ describe("CheckpointReactor", () => { |
| 753 | 733 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 754 | 734 | status: "ready", |
| 755 | 735 | providerName: "codex", |
| 756 | providerSessionId: asSessionId("sess-1"), | |
| 757 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 758 | 736 | runtimeMode: "approval-required", |
| 759 | 737 | activeTurnId: null, |
| 760 | 738 | lastError: null, |
| ... | ... | @@ -768,10 +746,10 @@ describe("CheckpointReactor", () => { |
| 768 | 746 | type: "turn.completed", |
| 769 | 747 | eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), |
| 770 | 748 | provider: "codex", |
| 771 | sessionId: asSessionId("sess-1"), | |
| 749 | ||
| 772 | 750 | 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"), | |
| 775 | 753 | payload: { state: "completed" }, |
| 776 | 754 | }); |
| 777 | 755 | |
| ... | ... | @@ -779,10 +757,10 @@ describe("CheckpointReactor", () => { |
| 779 | 757 | type: "turn.started", |
| 780 | 758 | eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), |
| 781 | 759 | provider: "codex", |
| 782 | sessionId: asSessionId("sess-1"), | |
| 760 | ||
| 783 | 761 | 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"), | |
| 786 | 764 | }); |
| 787 | 765 | |
| 788 | 766 | await waitForGitRefExists( |
| ... | ... | @@ -807,8 +785,6 @@ describe("CheckpointReactor", () => { |
| 807 | 785 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 808 | 786 | status: "ready", |
| 809 | 787 | providerName: "codex", |
| 810 | providerSessionId: asSessionId("sess-1"), | |
| 811 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 812 | 788 | runtimeMode: "approval-required", |
| 813 | 789 | activeTurnId: null, |
| 814 | 790 | lastError: null, |
| ... | ... | @@ -865,7 +841,7 @@ describe("CheckpointReactor", () => { |
| 865 | 841 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 866 | 842 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); |
| 867 | 843 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ |
| 868 | sessionId: asSessionId("sess-1"), | |
| 844 | ||
| 869 | 845 | numTurns: 1, |
| 870 | 846 | }); |
| 871 | 847 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); |
| ... | ... | @@ -887,8 +863,6 @@ describe("CheckpointReactor", () => { |
| 887 | 863 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 888 | 864 | status: "ready", |
| 889 | 865 | providerName: "claudeCode", |
| 890 | providerSessionId: asSessionId("sess-1"), | |
| 891 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 892 | 866 | runtimeMode: "approval-required", |
| 893 | 867 | activeTurnId: null, |
| 894 | 868 | lastError: null, |
| ... | ... | @@ -940,7 +914,7 @@ describe("CheckpointReactor", () => { |
| 940 | 914 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); |
| 941 | 915 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); |
| 942 | 916 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ |
| 943 | sessionId: asSessionId("sess-1"), | |
| 917 | ||
| 944 | 918 | numTurns: 1, |
| 945 | 919 | }); |
| 946 | 920 | }); |
| ... | ... | @@ -958,8 +932,6 @@ describe("CheckpointReactor", () => { |
| 958 | 932 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 959 | 933 | status: "ready", |
| 960 | 934 | providerName: "codex", |
| 961 | providerSessionId: asSessionId("sess-1"), | |
| 962 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 963 | 935 | runtimeMode: "approval-required", |
| 964 | 936 | activeTurnId: null, |
| 965 | 937 | lastError: null, |
| ... | ... | @@ -1032,11 +1004,11 @@ describe("CheckpointReactor", () => { |
| 1032 | 1004 | |
| 1033 | 1005 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); |
| 1034 | 1006 | expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ |
| 1035 | sessionId: asSessionId("sess-1"), | |
| 1007 | ||
| 1036 | 1008 | numTurns: 1, |
| 1037 | 1009 | }); |
| 1038 | 1010 | expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ |
| 1039 | sessionId: asSessionId("sess-1"), | |
| 1011 | ||
| 1040 | 1012 | numTurns: 1, |
| 1041 | 1013 | }); |
| 1042 | 1014 | }); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+27-71| ... | ... | @@ -2,13 +2,10 @@ import { |
| 2 | 2 | CommandId, |
| 3 | 3 | EventId, |
| 4 | 4 | MessageId, |
| 5 | ProviderSessionId, | |
| 6 | ProviderThreadId, | |
| 7 | 5 | ThreadId, |
| 8 | 6 | TurnId, |
| 9 | 7 | type OrchestrationEvent, |
| 10 | 8 | type ProviderRuntimeEvent, |
| 11 | RuntimeSessionId, | |
| 12 | 9 | } from "@t3tools/contracts"; |
| 13 | 10 | import { Cause, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 14 | 11 | |
| ... | ... | @@ -38,14 +35,6 @@ function toTurnId(value: string | undefined): TurnId | null { |
| 38 | 35 | return value === undefined ? null : TurnId.makeUnsafe(String(value)); |
| 39 | 36 | } |
| 40 | 37 | |
| 41 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 42 | return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value)); | |
| 43 | } | |
| 44 | ||
| 45 | function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId { | |
| 46 | return ProviderSessionId.makeUnsafe(String(value)); | |
| 47 | } | |
| 48 | ||
| 49 | 38 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { |
| 50 | 39 | if (left === null || left === undefined || right === null || right === undefined) { |
| 51 | 40 | return false; |
| ... | ... | @@ -100,107 +89,75 @@ const make = Effect.gen(function* () { |
| 100 | 89 | }); |
| 101 | 90 | |
| 102 | 91 | const appendCaptureFailureActivity = (input: { |
| 103 | readonly sessionId: ProviderSessionId; | |
| 92 | readonly threadId: ThreadId; | |
| 104 | 93 | readonly turnId: TurnId | null; |
| 105 | 94 | readonly detail: string; |
| 106 | 95 | readonly createdAt: string; |
| 107 | 96 | }) => |
| 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, | |
| 130 | 108 | }, |
| 109 | turnId: input.turnId, | |
| 131 | 110 | createdAt: input.createdAt, |
| 132 | }); | |
| 111 | }, | |
| 112 | createdAt: input.createdAt, | |
| 133 | 113 | }); |
| 134 | 114 | |
| 135 | 115 | const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( |
| 136 | 116 | threadId: ThreadId, |
| 137 | 117 | ): Effect.fn.Return< |
| 138 | Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> | |
| 118 | Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> | |
| 139 | 119 | > { |
| 140 | 120 | const readModel = yield* orchestrationEngine.getReadModel(); |
| 141 | 121 | const thread = readModel.threads.find((entry) => entry.id === threadId); |
| 142 | const projectedSessionId = thread?.session?.providerSessionId ?? null; | |
| 143 | const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined; | |
| 144 | 122 | |
| 145 | 123 | const sessions = yield* providerService.listSessions(); |
| 146 | 124 | |
| 147 | 125 | const findSessionWithCwd = ( |
| 148 | 126 | session: (typeof sessions)[number] | undefined, |
| 149 | ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { | |
| 127 | ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => { | |
| 150 | 128 | if (!session?.cwd) { |
| 151 | 129 | return Option.none(); |
| 152 | 130 | } |
| 153 | return Option.some({ sessionId: session.sessionId, cwd: session.cwd }); | |
| 131 | return Option.some({ threadId: session.threadId, cwd: session.cwd }); | |
| 154 | 132 | }; |
| 155 | 133 | |
| 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 | ); | |
| 158 | 138 | const fromProjected = findSessionWithCwd(projectedSession); |
| 159 | 139 | if (Option.isSome(fromProjected)) { |
| 160 | 140 | return fromProjected; |
| 161 | 141 | } |
| 162 | 142 | } |
| 163 | 143 | |
| 164 | if (projectedProviderThreadId) { | |
| 165 | const matchedSession = sessions.find( | |
| 166 | (session) => session.threadId === projectedProviderThreadId, | |
| 167 | ); | |
| 168 | const fromProviderThread = findSessionWithCwd(matchedSession); | |
| 169 | if (Option.isSome(fromProviderThread)) { | |
| 170 | return fromProviderThread; | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | 144 | return Option.none(); |
| 175 | 145 | }); |
| 176 | 146 | |
| 177 | 147 | const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( |
| 178 | 148 | event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>, |
| 179 | 149 | ) { |
| 180 | const providerSessionId = toProviderSessionId(event.sessionId); | |
| 181 | 150 | const turnId = toTurnId(event.turnId); |
| 182 | 151 | if (!turnId) { |
| 183 | 152 | return; |
| 184 | 153 | } |
| 185 | 154 | |
| 186 | 155 | 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); | |
| 190 | 157 | if (!thread) { |
| 191 | 158 | return; |
| 192 | 159 | } |
| 193 | 160 | |
| 194 | const projectedProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 195 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 196 | if ( | |
| 197 | projectedProviderThreadId !== null && | |
| 198 | eventProviderThreadId !== null && | |
| 199 | !sameId(projectedProviderThreadId, eventProviderThreadId) | |
| 200 | ) { | |
| 201 | return; | |
| 202 | } | |
| 203 | ||
| 204 | 161 | // When a primary turn is active, only that turn may produce completion checkpoints. |
| 205 | 162 | if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { |
| 206 | 163 | return; |
| ... | ... | @@ -272,7 +229,7 @@ const make = Effect.gen(function* () { |
| 272 | 229 | ), |
| 273 | 230 | Effect.tapError((error) => |
| 274 | 231 | appendCaptureFailureActivity({ |
| 275 | sessionId: providerSessionId, | |
| 232 | threadId: thread.id, | |
| 276 | 233 | turnId, |
| 277 | 234 | detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, |
| 278 | 235 | createdAt: event.createdAt, |
| ... | ... | @@ -332,7 +289,6 @@ const make = Effect.gen(function* () { |
| 332 | 289 | const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* ( |
| 333 | 290 | event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>, |
| 334 | 291 | ) { |
| 335 | const providerSessionId = toProviderSessionId(event.sessionId); | |
| 336 | 292 | const turnId = toTurnId(event.turnId); |
| 337 | 293 | if (!turnId) { |
| 338 | 294 | return; |
| ... | ... | @@ -340,7 +296,7 @@ const make = Effect.gen(function* () { |
| 340 | 296 | |
| 341 | 297 | const readModel = yield* orchestrationEngine.getReadModel(); |
| 342 | 298 | const thread = readModel.threads.find( |
| 343 | (entry) => entry.session?.providerSessionId === providerSessionId, | |
| 299 | (entry) => entry.id === event.threadId, | |
| 344 | 300 | ); |
| 345 | 301 | if (!thread) { |
| 346 | 302 | return; |
| ... | ... | @@ -520,7 +476,7 @@ const make = Effect.gen(function* () { |
| 520 | 476 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 521 | 477 | if (rolledBackTurns > 0) { |
| 522 | 478 | yield* providerService.rollbackConversation({ |
| 523 | sessionId: sessionRuntime.value.sessionId, | |
| 479 | threadId: sessionRuntime.value.threadId, | |
| 524 | 480 | numTurns: rolledBackTurns, |
| 525 | 481 | }); |
| 526 | 482 | } |
| ... | ... | @@ -588,7 +544,7 @@ const make = Effect.gen(function* () { |
| 588 | 544 | yield* captureCheckpointFromTurnCompletion(event).pipe( |
| 589 | 545 | Effect.catch((error) => |
| 590 | 546 | appendCaptureFailureActivity({ |
| 591 | sessionId: toProviderSessionId(event.sessionId), | |
| 547 | threadId: event.threadId, | |
| 592 | 548 | turnId, |
| 593 | 549 | detail: error.message, |
| 594 | 550 | createdAt: new Date().toISOString(), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+1-8| ... | ... | @@ -4,8 +4,6 @@ import { |
| 4 | 4 | CorrelationId, |
| 5 | 5 | EventId, |
| 6 | 6 | MessageId, |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 9 | 7 | ProjectId, |
| 10 | 8 | ThreadId, |
| 11 | 9 | TurnId, |
| ... | ... | @@ -1771,18 +1769,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta |
| 1771 | 1769 | commandId: CommandId.makeUnsafe("cmd-restart-2"), |
| 1772 | 1770 | causationEventId: null, |
| 1773 | 1771 | 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: {}, | |
| 1778 | 1773 | payload: { |
| 1779 | 1774 | threadId, |
| 1780 | 1775 | session: { |
| 1781 | 1776 | threadId, |
| 1782 | 1777 | status: "running", |
| 1783 | 1778 | providerName: "codex", |
| 1784 | providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), | |
| 1785 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), | |
| 1786 | 1779 | runtimeMode: "approval-required", |
| 1787 | 1780 | activeTurnId: turnId, |
| 1788 | 1781 | lastError: null, |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts-2| ... | ... | @@ -665,8 +665,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 665 | 665 | threadId: event.payload.threadId, |
| 666 | 666 | status: event.payload.session.status, |
| 667 | 667 | providerName: event.payload.session.providerName, |
| 668 | providerSessionId: event.payload.session.providerSessionId, | |
| 669 | providerThreadId: event.payload.session.providerThreadId, | |
| 670 | 668 | runtimeMode: event.payload.session.runtimeMode, |
| 671 | 669 | activeTurnId: event.payload.session.activeTurnId, |
| 672 | 670 | lastError: event.payload.session.lastError, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts-7| ... | ... | @@ -3,8 +3,6 @@ import { |
| 3 | 3 | EventId, |
| 4 | 4 | MessageId, |
| 5 | 5 | ProjectId, |
| 6 | ProviderSessionId, | |
| 7 | ProviderThreadId, | |
| 8 | 6 | ThreadId, |
| 9 | 7 | TurnId, |
| 10 | 8 | } from "@t3tools/contracts"; |
| ... | ... | @@ -22,9 +20,6 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 22 | 20 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 23 | 21 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 24 | 22 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| 25 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 26 | ProviderSessionId.makeUnsafe(value); | |
| 27 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 28 | 23 | |
| 29 | 24 | const projectionSnapshotLayer = it.layer( |
| 30 | 25 | OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| ... | ... | @@ -290,8 +285,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 290 | 285 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 291 | 286 | status: "running", |
| 292 | 287 | providerName: "codex", |
| 293 | providerSessionId: asProviderSessionId("provider-session-1"), | |
| 294 | providerThreadId: asProviderThreadId("provider-thread-1"), | |
| 295 | 288 | runtimeMode: "approval-required", |
| 296 | 289 | activeTurnId: asTurnId("turn-1"), |
| 297 | 290 | lastError: null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts-2| ... | ... | @@ -461,8 +461,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 461 | 461 | threadId: row.threadId, |
| 462 | 462 | status: row.status, |
| 463 | 463 | providerName: row.providerName, |
| 464 | providerSessionId: row.providerSessionId, | |
| 465 | providerThreadId: row.providerThreadId, | |
| 466 | 464 | runtimeMode: row.runtimeMode, |
| 467 | 465 | activeTurnId: row.activeTurnId, |
| 468 | 466 | lastError: row.lastError, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+31-39| ... | ... | @@ -9,9 +9,6 @@ import { |
| 9 | 9 | EventId, |
| 10 | 10 | MessageId, |
| 11 | 11 | ProjectId, |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | 12 | ThreadId, |
| 16 | 13 | TurnId, |
| 17 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -38,8 +35,6 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 38 | 35 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 39 | 36 | |
| 40 | 37 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 41 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 42 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 43 | 38 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 44 | 39 | ApprovalRequestId.makeUnsafe(value); |
| 45 | 40 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| ... | ... | @@ -110,8 +105,14 @@ describe("ProviderCommandReactor", () => { |
| 110 | 105 | typeof input === "object" && input !== null && "model" in input && typeof input.model === "string" |
| 111 | 106 | ? input.model |
| 112 | 107 | : undefined; |
| 108 | const threadId = | |
| 109 | typeof input === "object" && | |
| 110 | input !== null && | |
| 111 | "threadId" in input && | |
| 112 | typeof input.threadId === "string" | |
| 113 | ? ThreadId.makeUnsafe(input.threadId) | |
| 114 | : ThreadId.makeUnsafe(`thread-${sessionIndex}`); | |
| 113 | 115 | const session: ProviderSession = { |
| 114 | sessionId: asSessionId(`sess-${sessionIndex}`), | |
| 115 | 116 | provider, |
| 116 | 117 | status: "ready" as const, |
| 117 | 118 | runtimeMode: |
| ... | ... | @@ -122,7 +123,7 @@ describe("ProviderCommandReactor", () => { |
| 122 | 123 | ? input.runtimeMode |
| 123 | 124 | : "full-access", |
| 124 | 125 | ...(model !== undefined ? { model } : {}), |
| 125 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), | |
| 126 | threadId, | |
| 126 | 127 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, |
| 127 | 128 | createdAt: now, |
| 128 | 129 | updatedAt: now, |
| ... | ... | @@ -132,22 +133,22 @@ describe("ProviderCommandReactor", () => { |
| 132 | 133 | }); |
| 133 | 134 | const sendTurn = vi.fn((_: unknown) => |
| 134 | 135 | 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"), | |
| 137 | 138 | }), |
| 138 | 139 | ); |
| 139 | 140 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 140 | 141 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); |
| 141 | 142 | const stopSession = vi.fn((input: unknown) => |
| 142 | 143 | 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 | |
| 146 | 147 | : undefined; |
| 147 | if (!sessionId) { | |
| 148 | if (!threadId) { | |
| 148 | 149 | return; |
| 149 | 150 | } |
| 150 | const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId); | |
| 151 | const index = runtimeSessions.findIndex((session) => session.threadId === threadId); | |
| 151 | 152 | if (index >= 0) { |
| 152 | 153 | runtimeSessions.splice(index, 1); |
| 153 | 154 | } |
| ... | ... | @@ -284,7 +285,7 @@ describe("ProviderCommandReactor", () => { |
| 284 | 285 | |
| 285 | 286 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 286 | 287 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 287 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 288 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 288 | 289 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 289 | 290 | }); |
| 290 | 291 | |
| ... | ... | @@ -321,7 +322,7 @@ describe("ProviderCommandReactor", () => { |
| 321 | 322 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 322 | 323 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 323 | 324 | expect(thread?.session?.providerName).toBe("claudeCode"); |
| 324 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 325 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 325 | 326 | }); |
| 326 | 327 | |
| 327 | 328 | it("starts first turn with cursor provider when provider is specified", async () => { |
| ... | ... | @@ -357,7 +358,7 @@ describe("ProviderCommandReactor", () => { |
| 357 | 358 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 358 | 359 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 359 | 360 | expect(thread?.session?.providerName).toBe("cursor"); |
| 360 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 361 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 361 | 362 | }); |
| 362 | 363 | |
| 363 | 364 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| ... | ... | @@ -500,7 +501,7 @@ describe("ProviderCommandReactor", () => { |
| 500 | 501 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 501 | 502 | expect(harness.startSession.mock.calls.length).toBe(1); |
| 502 | 503 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 503 | sessionId: asSessionId("sess-1"), | |
| 504 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 504 | 505 | model: "gpt-5.3-codex", |
| 505 | 506 | }); |
| 506 | 507 | }); |
| ... | ... | @@ -554,8 +555,6 @@ describe("ProviderCommandReactor", () => { |
| 554 | 555 | return thread?.runtimeMode === "approval-required"; |
| 555 | 556 | }); |
| 556 | 557 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 557 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | |
| 558 | ||
| 559 | 558 | await Effect.runPromise( |
| 560 | 559 | harness.engine.dispatch({ |
| 561 | 560 | type: "thread.turn.start", |
| ... | ... | @@ -574,18 +573,19 @@ describe("ProviderCommandReactor", () => { |
| 574 | 573 | |
| 575 | 574 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 576 | 575 | |
| 577 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | |
| 576 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 578 | 577 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 578 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 579 | 579 | resumeCursor: { opaque: "cursor-1" }, |
| 580 | 580 | runtimeMode: "approval-required", |
| 581 | 581 | }); |
| 582 | 582 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 583 | sessionId: asSessionId("sess-2"), | |
| 583 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 584 | 584 | }); |
| 585 | 585 | |
| 586 | 586 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 587 | 587 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 588 | expect(thread?.session?.providerSessionId).toBe("sess-2"); | |
| 588 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 589 | 589 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 590 | 590 | }); |
| 591 | 591 | |
| ... | ... | @@ -629,12 +629,12 @@ describe("ProviderCommandReactor", () => { |
| 629 | 629 | }), |
| 630 | 630 | ); |
| 631 | 631 | |
| 632 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | |
| 633 | 632 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 634 | 633 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 635 | 634 | |
| 636 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | |
| 635 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 637 | 636 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 637 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 638 | 638 | provider: "claudeCode", |
| 639 | 639 | runtimeMode: "approval-required", |
| 640 | 640 | }); |
| ... | ... | @@ -642,7 +642,7 @@ describe("ProviderCommandReactor", () => { |
| 642 | 642 | |
| 643 | 643 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 644 | 644 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 645 | expect(thread?.session?.providerSessionId).toBe("sess-2"); | |
| 645 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 646 | 646 | expect(thread?.session?.providerName).toBe("claudeCode"); |
| 647 | 647 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 648 | 648 | }); |
| ... | ... | @@ -707,7 +707,7 @@ describe("ProviderCommandReactor", () => { |
| 707 | 707 | |
| 708 | 708 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 709 | 709 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 710 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 710 | expect(thread?.session?.threadId).toBe("thread-1"); | |
| 711 | 711 | expect(thread?.session?.runtimeMode).toBe("full-access"); |
| 712 | 712 | }); |
| 713 | 713 | |
| ... | ... | @@ -724,8 +724,6 @@ describe("ProviderCommandReactor", () => { |
| 724 | 724 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 725 | 725 | status: "running", |
| 726 | 726 | providerName: "codex", |
| 727 | providerSessionId: asSessionId("sess-1"), | |
| 728 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 729 | 727 | runtimeMode: "approval-required", |
| 730 | 728 | activeTurnId: asTurnId("turn-1"), |
| 731 | 729 | lastError: null, |
| ... | ... | @@ -747,7 +745,8 @@ describe("ProviderCommandReactor", () => { |
| 747 | 745 | |
| 748 | 746 | await waitFor(() => harness.interruptTurn.mock.calls.length === 1); |
| 749 | 747 | expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ |
| 750 | sessionId: "sess-1", | |
| 748 | threadId: "thread-1", | |
| 749 | turnId: "turn-1", | |
| 751 | 750 | }); |
| 752 | 751 | }); |
| 753 | 752 | |
| ... | ... | @@ -764,8 +763,6 @@ describe("ProviderCommandReactor", () => { |
| 764 | 763 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 765 | 764 | status: "running", |
| 766 | 765 | providerName: "codex", |
| 767 | providerSessionId: asSessionId("sess-1"), | |
| 768 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 769 | 766 | runtimeMode: "approval-required", |
| 770 | 767 | activeTurnId: null, |
| 771 | 768 | lastError: null, |
| ... | ... | @@ -788,7 +785,7 @@ describe("ProviderCommandReactor", () => { |
| 788 | 785 | |
| 789 | 786 | await waitFor(() => harness.respondToRequest.mock.calls.length === 1); |
| 790 | 787 | expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ |
| 791 | sessionId: "sess-1", | |
| 788 | threadId: "thread-1", | |
| 792 | 789 | requestId: "approval-request-1", |
| 793 | 790 | decision: "accept", |
| 794 | 791 | }); |
| ... | ... | @@ -816,8 +813,6 @@ describe("ProviderCommandReactor", () => { |
| 816 | 813 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 817 | 814 | status: "running", |
| 818 | 815 | providerName: "cursor", |
| 819 | providerSessionId: asSessionId("sess-1"), | |
| 820 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 821 | 816 | runtimeMode: "approval-required", |
| 822 | 817 | activeTurnId: null, |
| 823 | 818 | lastError: null, |
| ... | ... | @@ -901,8 +896,6 @@ describe("ProviderCommandReactor", () => { |
| 901 | 896 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 902 | 897 | status: "ready", |
| 903 | 898 | providerName: "codex", |
| 904 | providerSessionId: asSessionId("sess-1"), | |
| 905 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 906 | 899 | runtimeMode: "approval-required", |
| 907 | 900 | activeTurnId: null, |
| 908 | 901 | lastError: null, |
| ... | ... | @@ -926,8 +919,7 @@ describe("ProviderCommandReactor", () => { |
| 926 | 919 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 927 | 920 | expect(thread?.session).not.toBeNull(); |
| 928 | 921 | 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"); | |
| 931 | 923 | expect(thread?.session?.activeTurnId).toBeNull(); |
| 932 | 924 | }); |
| 933 | 925 | }); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+26-45| ... | ... | @@ -4,9 +4,8 @@ import { |
| 4 | 4 | EventId, |
| 5 | 5 | type OrchestrationEvent, |
| 6 | 6 | type ProviderKind, |
| 7 | type ProviderSessionId, | |
| 8 | 7 | type OrchestrationSession, |
| 9 | type ThreadId, | |
| 8 | ThreadId, | |
| 10 | 9 | type ProviderSession, |
| 11 | 10 | type RuntimeMode, |
| 12 | 11 | type TurnId, |
| ... | ... | @@ -220,9 +219,9 @@ const make = Effect.gen(function* () { |
| 220 | 219 | projects: readModel.projects, |
| 221 | 220 | }); |
| 222 | 221 | |
| 223 | const resolveActiveSession = (sessionId: ProviderSessionId) => | |
| 222 | const resolveActiveSession = (threadId: ThreadId) => | |
| 224 | 223 | providerService.listSessions().pipe( |
| 225 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), | |
| 224 | Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)), | |
| 226 | 225 | ); |
| 227 | 226 | |
| 228 | 227 | const startProviderSession = (input?: { |
| ... | ... | @@ -230,6 +229,7 @@ const make = Effect.gen(function* () { |
| 230 | 229 | readonly provider?: ProviderKind; |
| 231 | 230 | }) => |
| 232 | 231 | providerService.startSession(threadId, { |
| 232 | threadId, | |
| 233 | 233 | ...(input?.provider ?? preferredProvider |
| 234 | 234 | ? { provider: input?.provider ?? preferredProvider } |
| 235 | 235 | : {}), |
| ... | ... | @@ -246,8 +246,6 @@ const make = Effect.gen(function* () { |
| 246 | 246 | threadId, |
| 247 | 247 | status: mapProviderSessionStatusToOrchestrationStatus(session.status), |
| 248 | 248 | providerName: session.provider, |
| 249 | providerSessionId: session.sessionId, | |
| 250 | providerThreadId: session.threadId ?? null, | |
| 251 | 249 | runtimeMode: desiredRuntimeMode, |
| 252 | 250 | // Provider turn ids are not orchestration turn ids. |
| 253 | 251 | activeTurnId: null, |
| ... | ... | @@ -257,11 +255,12 @@ const make = Effect.gen(function* () { |
| 257 | 255 | createdAt, |
| 258 | 256 | }); |
| 259 | 257 | |
| 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) { | |
| 262 | 261 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; |
| 263 | 262 | const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; |
| 264 | const activeSession = yield* resolveActiveSession(existingSessionId); | |
| 263 | const activeSession = yield* resolveActiveSession(existingSessionThreadId); | |
| 265 | 264 | const sessionModelSwitch = |
| 266 | 265 | currentProvider === undefined |
| 267 | 266 | ? "in-session" |
| ... | ... | @@ -271,12 +270,8 @@ const make = Effect.gen(function* () { |
| 271 | 270 | const shouldRestartForModelChange = |
| 272 | 271 | modelChanged && sessionModelSwitch === "restart-session"; |
| 273 | 272 | |
| 274 | if ( | |
| 275 | !runtimeModeChanged && | |
| 276 | !providerChanged && | |
| 277 | !shouldRestartForModelChange | |
| 278 | ) { | |
| 279 | return existingSessionId; | |
| 273 | if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) { | |
| 274 | return existingSessionThreadId; | |
| 280 | 275 | } |
| 281 | 276 | |
| 282 | 277 | const resumeCursor = |
| ... | ... | @@ -285,7 +280,7 @@ const make = Effect.gen(function* () { |
| 285 | 280 | : (activeSession?.resumeCursor ?? undefined); |
| 286 | 281 | yield* Effect.logInfo("provider command reactor restarting provider session", { |
| 287 | 282 | threadId, |
| 288 | existingSessionId, | |
| 283 | existingSessionThreadId, | |
| 289 | 284 | currentProvider, |
| 290 | 285 | desiredProvider: options?.provider ?? currentProvider, |
| 291 | 286 | currentRuntimeMode: thread.session?.runtimeMode, |
| ... | ... | @@ -302,30 +297,20 @@ const make = Effect.gen(function* () { |
| 302 | 297 | }); |
| 303 | 298 | yield* Effect.logInfo("provider command reactor restarted provider session", { |
| 304 | 299 | threadId, |
| 305 | previousSessionId: existingSessionId, | |
| 306 | restartedSessionId: restartedSession.sessionId, | |
| 300 | previousSessionId: existingSessionThreadId, | |
| 301 | restartedSessionThreadId: restartedSession.threadId, | |
| 307 | 302 | provider: restartedSession.provider, |
| 308 | providerThreadId: restartedSession.threadId, | |
| 309 | 303 | runtimeMode: restartedSession.runtimeMode, |
| 310 | 304 | }); |
| 311 | 305 | 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; | |
| 322 | 307 | } |
| 323 | 308 | |
| 324 | 309 | const startedSession = yield* startProviderSession( |
| 325 | 310 | options?.provider !== undefined ? { provider: options.provider } : undefined, |
| 326 | 311 | ); |
| 327 | 312 | yield* bindSessionToThread(startedSession); |
| 328 | return startedSession.sessionId; | |
| 313 | return startedSession.threadId; | |
| 329 | 314 | }); |
| 330 | 315 | |
| 331 | 316 | const sendTurnForThread = Effect.fnUntraced(function* (input: { |
| ... | ... | @@ -341,14 +326,14 @@ const make = Effect.gen(function* () { |
| 341 | 326 | if (!thread) { |
| 342 | 327 | return; |
| 343 | 328 | } |
| 344 | const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { | |
| 329 | yield* ensureSessionForThread(input.threadId, input.createdAt, { | |
| 345 | 330 | ...(input.provider !== undefined ? { provider: input.provider } : {}), |
| 346 | 331 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 347 | 332 | }); |
| 348 | 333 | const normalizedInput = toNonEmptyProviderInput(input.messageText); |
| 349 | 334 | const normalizedAttachments = input.attachments ?? []; |
| 350 | 335 | const activeSession = yield* providerService.listSessions().pipe( |
| 351 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), | |
| 336 | Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)), | |
| 352 | 337 | ); |
| 353 | 338 | const sessionModelSwitch = |
| 354 | 339 | activeSession === undefined |
| ... | ... | @@ -358,7 +343,7 @@ const make = Effect.gen(function* () { |
| 358 | 343 | sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; |
| 359 | 344 | |
| 360 | 345 | yield* providerService.sendTurn({ |
| 361 | sessionId, | |
| 346 | threadId: input.threadId, | |
| 362 | 347 | ...(normalizedInput ? { input: normalizedInput } : {}), |
| 363 | 348 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), |
| 364 | 349 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), |
| ... | ... | @@ -487,8 +472,8 @@ const make = Effect.gen(function* () { |
| 487 | 472 | if (!thread) { |
| 488 | 473 | return; |
| 489 | 474 | } |
| 490 | const sessionId = thread.session?.providerSessionId; | |
| 491 | if (!sessionId) { | |
| 475 | const hasSession = thread.session && thread.session.status !== "stopped"; | |
| 476 | if (!hasSession) { | |
| 492 | 477 | return yield* appendProviderFailureActivity({ |
| 493 | 478 | threadId: event.payload.threadId, |
| 494 | 479 | kind: "provider.turn.interrupt.failed", |
| ... | ... | @@ -500,7 +485,7 @@ const make = Effect.gen(function* () { |
| 500 | 485 | } |
| 501 | 486 | |
| 502 | 487 | // Orchestration turn ids are not provider turn ids, so interrupt by session. |
| 503 | yield* providerService.interruptTurn({ sessionId }); | |
| 488 | yield* providerService.interruptTurn({ threadId: event.payload.threadId }); | |
| 504 | 489 | }); |
| 505 | 490 | |
| 506 | 491 | const processApprovalResponseRequested = Effect.fnUntraced(function* ( |
| ... | ... | @@ -510,8 +495,8 @@ const make = Effect.gen(function* () { |
| 510 | 495 | if (!thread) { |
| 511 | 496 | return; |
| 512 | 497 | } |
| 513 | const sessionId = thread.session?.providerSessionId; | |
| 514 | if (!sessionId) { | |
| 498 | const hasSession = thread.session && thread.session.status !== "stopped"; | |
| 499 | if (!hasSession) { | |
| 515 | 500 | return yield* appendProviderFailureActivity({ |
| 516 | 501 | threadId: event.payload.threadId, |
| 517 | 502 | kind: "provider.approval.respond.failed", |
| ... | ... | @@ -525,7 +510,7 @@ const make = Effect.gen(function* () { |
| 525 | 510 | |
| 526 | 511 | yield* providerService |
| 527 | 512 | .respondToRequest({ |
| 528 | sessionId, | |
| 513 | threadId: event.payload.threadId, | |
| 529 | 514 | requestId: event.payload.requestId, |
| 530 | 515 | decision: event.payload.decision, |
| 531 | 516 | }) |
| ... | ... | @@ -559,10 +544,8 @@ const make = Effect.gen(function* () { |
| 559 | 544 | } |
| 560 | 545 | |
| 561 | 546 | 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 }); | |
| 566 | 549 | } |
| 567 | 550 | |
| 568 | 551 | yield* setThreadSession({ |
| ... | ... | @@ -571,8 +554,6 @@ const make = Effect.gen(function* () { |
| 571 | 554 | threadId: thread.id, |
| 572 | 555 | status: "stopped", |
| 573 | 556 | providerName: thread.session?.providerName ?? null, |
| 574 | providerSessionId: null, | |
| 575 | providerThreadId: null, | |
| 576 | 557 | runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 577 | 558 | activeTurnId: null, |
| 578 | 559 | lastError: thread.session?.lastError ?? null, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+89-113| ... | ... | @@ -6,10 +6,8 @@ import { |
| 6 | 6 | MessageId, |
| 7 | 7 | ProjectId, |
| 8 | 8 | ProviderItemId, |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | 9 | ThreadId, |
| 10 | TurnId, | |
| 13 | 11 | } from "@t3tools/contracts"; |
| 14 | 12 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; |
| 15 | 13 | import { afterEach, describe, expect, it } from "vitest"; |
| ... | ... | @@ -33,20 +31,18 @@ import { ServerConfig } from "../../config.ts"; |
| 33 | 31 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 34 | 32 | |
| 35 | 33 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 36 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 37 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 38 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 39 | 34 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 40 | 35 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 41 | 36 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 37 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 38 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 42 | 39 | |
| 43 | 40 | type LegacyProviderRuntimeEvent = { |
| 44 | 41 | readonly type: string; |
| 45 | 42 | readonly eventId: EventId; |
| 46 | 43 | readonly provider: "codex" | "claudeCode" | "cursor"; |
| 47 | readonly sessionId: string; | |
| 48 | 44 | readonly createdAt: string; |
| 49 | readonly threadId?: string | undefined; | |
| 45 | readonly threadId: ThreadId; | |
| 50 | 46 | readonly turnId?: string | undefined; |
| 51 | 47 | readonly itemId?: string | undefined; |
| 52 | 48 | readonly requestId?: string | undefined; |
| ... | ... | @@ -182,8 +178,6 @@ describe("ProviderRuntimeIngestion", () => { |
| 182 | 178 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 183 | 179 | status: "ready", |
| 184 | 180 | providerName: "codex", |
| 185 | providerSessionId: asSessionId("sess-1"), | |
| 186 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 187 | 181 | runtimeMode: "approval-required", |
| 188 | 182 | activeTurnId: null, |
| 189 | 183 | updatedAt: createdAt, |
| ... | ... | @@ -207,9 +201,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 207 | 201 | type: "turn.started", |
| 208 | 202 | eventId: asEventId("evt-turn-started"), |
| 209 | 203 | provider: "codex", |
| 210 | sessionId: asSessionId("sess-1"), | |
| 204 | threadId: asThreadId("thread-1"), | |
| 211 | 205 | createdAt: now, |
| 212 | turnId: asProviderTurnId("turn-1"), | |
| 206 | turnId: asTurnId("turn-1"), | |
| 213 | 207 | }); |
| 214 | 208 | |
| 215 | 209 | await waitForThread( |
| ... | ... | @@ -221,9 +215,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 221 | 215 | type: "turn.completed", |
| 222 | 216 | eventId: asEventId("evt-turn-completed"), |
| 223 | 217 | provider: "codex", |
| 224 | sessionId: asSessionId("sess-1"), | |
| 218 | threadId: asThreadId("thread-1"), | |
| 225 | 219 | createdAt: new Date().toISOString(), |
| 226 | turnId: asProviderTurnId("turn-1"), | |
| 220 | turnId: asTurnId("turn-1"), | |
| 227 | 221 | payload: { |
| 228 | 222 | state: "failed", |
| 229 | 223 | errorMessage: "turn failed", |
| ... | ... | @@ -249,9 +243,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 249 | 243 | type: "session.state.changed", |
| 250 | 244 | eventId: asEventId("evt-session-state-waiting"), |
| 251 | 245 | provider: "codex", |
| 252 | sessionId: asSessionId("sess-1"), | |
| 246 | threadId: asThreadId("thread-1"), | |
| 253 | 247 | createdAt: waitingAt, |
| 254 | threadId: asProviderThreadId("provider-thread-1"), | |
| 255 | 248 | payload: { |
| 256 | 249 | state: "waiting", |
| 257 | 250 | reason: "awaiting approval", |
| ... | ... | @@ -269,9 +262,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 269 | 262 | type: "session.state.changed", |
| 270 | 263 | eventId: asEventId("evt-session-state-error"), |
| 271 | 264 | provider: "codex", |
| 272 | sessionId: asSessionId("sess-1"), | |
| 265 | threadId: asThreadId("thread-1"), | |
| 273 | 266 | createdAt: new Date().toISOString(), |
| 274 | threadId: asProviderThreadId("provider-thread-1"), | |
| 275 | 267 | payload: { |
| 276 | 268 | state: "error", |
| 277 | 269 | reason: "provider crashed", |
| ... | ... | @@ -292,9 +284,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 292 | 284 | type: "session.state.changed", |
| 293 | 285 | eventId: asEventId("evt-session-state-stopped"), |
| 294 | 286 | provider: "codex", |
| 295 | sessionId: asSessionId("sess-1"), | |
| 287 | threadId: asThreadId("thread-1"), | |
| 296 | 288 | createdAt: new Date().toISOString(), |
| 297 | threadId: asProviderThreadId("provider-thread-1"), | |
| 298 | 289 | payload: { |
| 299 | 290 | state: "stopped", |
| 300 | 291 | }, |
| ... | ... | @@ -314,9 +305,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 314 | 305 | type: "session.state.changed", |
| 315 | 306 | eventId: asEventId("evt-session-state-ready"), |
| 316 | 307 | provider: "codex", |
| 317 | sessionId: asSessionId("sess-1"), | |
| 308 | threadId: asThreadId("thread-1"), | |
| 318 | 309 | createdAt: new Date().toISOString(), |
| 319 | threadId: asProviderThreadId("provider-thread-1"), | |
| 320 | 310 | payload: { |
| 321 | 311 | state: "ready", |
| 322 | 312 | }, |
| ... | ... | @@ -341,10 +331,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 341 | 331 | type: "turn.started", |
| 342 | 332 | eventId: asEventId("evt-turn-started-midturn-lifecycle"), |
| 343 | 333 | provider: "codex", |
| 344 | sessionId: asSessionId("sess-1"), | |
| 345 | 334 | 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"), | |
| 348 | 337 | }); |
| 349 | 338 | |
| 350 | 339 | await waitForThread( |
| ... | ... | @@ -358,17 +347,15 @@ describe("ProviderRuntimeIngestion", () => { |
| 358 | 347 | type: "thread.started", |
| 359 | 348 | eventId: asEventId("evt-thread-started-midturn-lifecycle"), |
| 360 | 349 | provider: "codex", |
| 361 | sessionId: asSessionId("sess-1"), | |
| 362 | 350 | createdAt: new Date().toISOString(), |
| 363 | threadId: asProviderThreadId("provider-thread-1"), | |
| 351 | threadId: asThreadId("thread-1"), | |
| 364 | 352 | }); |
| 365 | 353 | harness.emit({ |
| 366 | 354 | type: "session.started", |
| 367 | 355 | eventId: asEventId("evt-session-started-midturn-lifecycle"), |
| 368 | 356 | provider: "codex", |
| 369 | sessionId: asSessionId("sess-1"), | |
| 370 | 357 | createdAt: new Date().toISOString(), |
| 371 | threadId: asProviderThreadId("provider-thread-1"), | |
| 358 | threadId: asThreadId("thread-1"), | |
| 372 | 359 | }); |
| 373 | 360 | |
| 374 | 361 | await Effect.runPromise(Effect.sleep("40 millis")); |
| ... | ... | @@ -381,10 +368,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 381 | 368 | type: "turn.completed", |
| 382 | 369 | eventId: asEventId("evt-turn-completed-midturn-lifecycle"), |
| 383 | 370 | provider: "codex", |
| 384 | sessionId: asSessionId("sess-1"), | |
| 385 | 371 | 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"), | |
| 388 | 374 | status: "completed", |
| 389 | 375 | }); |
| 390 | 376 | |
| ... | ... | @@ -402,10 +388,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 402 | 388 | type: "turn.started", |
| 403 | 389 | eventId: asEventId("evt-turn-started-primary"), |
| 404 | 390 | provider: "codex", |
| 405 | sessionId: asSessionId("sess-1"), | |
| 406 | 391 | createdAt: now, |
| 407 | threadId: asProviderThreadId("provider-thread-1"), | |
| 408 | turnId: asProviderTurnId("turn-primary"), | |
| 392 | threadId: asThreadId("thread-1"), | |
| 393 | turnId: asTurnId("turn-primary"), | |
| 409 | 394 | }); |
| 410 | 395 | |
| 411 | 396 | await waitForThread( |
| ... | ... | @@ -418,10 +403,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 418 | 403 | type: "turn.completed", |
| 419 | 404 | eventId: asEventId("evt-turn-completed-aux"), |
| 420 | 405 | provider: "codex", |
| 421 | sessionId: asSessionId("sess-1"), | |
| 422 | 406 | 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"), | |
| 425 | 409 | status: "completed", |
| 426 | 410 | }); |
| 427 | 411 | |
| ... | ... | @@ -437,10 +421,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 437 | 421 | type: "turn.completed", |
| 438 | 422 | eventId: asEventId("evt-turn-completed-primary"), |
| 439 | 423 | provider: "codex", |
| 440 | sessionId: asSessionId("sess-1"), | |
| 441 | 424 | 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"), | |
| 444 | 427 | status: "completed", |
| 445 | 428 | }); |
| 446 | 429 | |
| ... | ... | @@ -463,8 +446,6 @@ describe("ProviderRuntimeIngestion", () => { |
| 463 | 446 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 464 | 447 | status: "ready", |
| 465 | 448 | providerName: "claudeCode", |
| 466 | providerSessionId: asSessionId("sess-1"), | |
| 467 | providerThreadId: asProviderThreadId("claude-thread-placeholder"), | |
| 468 | 449 | runtimeMode: "approval-required", |
| 469 | 450 | activeTurnId: null, |
| 470 | 451 | updatedAt: seededAt, |
| ... | ... | @@ -478,10 +459,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 478 | 459 | type: "turn.started", |
| 479 | 460 | eventId: asEventId("evt-turn-started-claude-placeholder"), |
| 480 | 461 | provider: "claudeCode", |
| 481 | sessionId: asSessionId("sess-1"), | |
| 482 | 462 | 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"), | |
| 485 | 465 | }); |
| 486 | 466 | |
| 487 | 467 | await waitForThread( |
| ... | ... | @@ -495,10 +475,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 495 | 475 | type: "turn.completed", |
| 496 | 476 | eventId: asEventId("evt-turn-completed-claude-placeholder"), |
| 497 | 477 | provider: "claudeCode", |
| 498 | sessionId: asSessionId("sess-1"), | |
| 499 | 478 | 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"), | |
| 502 | 481 | status: "completed", |
| 503 | 482 | }); |
| 504 | 483 | |
| ... | ... | @@ -516,9 +495,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 516 | 495 | type: "turn.started", |
| 517 | 496 | eventId: asEventId("evt-turn-started-guarded"), |
| 518 | 497 | provider: "codex", |
| 519 | sessionId: asSessionId("sess-1"), | |
| 520 | 498 | createdAt: now, |
| 521 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 499 | threadId: asThreadId("thread-1"), | |
| 500 | turnId: asTurnId("turn-guarded-main"), | |
| 522 | 501 | }); |
| 523 | 502 | |
| 524 | 503 | await waitForThread( |
| ... | ... | @@ -532,9 +511,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 532 | 511 | type: "turn.completed", |
| 533 | 512 | eventId: asEventId("evt-turn-completed-guarded-other"), |
| 534 | 513 | provider: "codex", |
| 535 | sessionId: asSessionId("sess-1"), | |
| 536 | 514 | createdAt: new Date().toISOString(), |
| 537 | turnId: asProviderTurnId("turn-guarded-other"), | |
| 515 | threadId: asThreadId("thread-1"), | |
| 516 | turnId: asTurnId("turn-guarded-other"), | |
| 538 | 517 | status: "completed", |
| 539 | 518 | }); |
| 540 | 519 | |
| ... | ... | @@ -550,9 +529,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 550 | 529 | type: "turn.completed", |
| 551 | 530 | eventId: asEventId("evt-turn-completed-guarded-main"), |
| 552 | 531 | provider: "codex", |
| 553 | sessionId: asSessionId("sess-1"), | |
| 554 | 532 | createdAt: new Date().toISOString(), |
| 555 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 533 | threadId: asThreadId("thread-1"), | |
| 534 | turnId: asTurnId("turn-guarded-main"), | |
| 556 | 535 | status: "completed", |
| 557 | 536 | }); |
| 558 | 537 | |
| ... | ... | @@ -570,9 +549,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 570 | 549 | type: "content.delta", |
| 571 | 550 | eventId: asEventId("evt-message-delta-1"), |
| 572 | 551 | provider: "codex", |
| 573 | sessionId: asSessionId("sess-1"), | |
| 574 | 552 | createdAt: now, |
| 575 | turnId: asProviderTurnId("turn-2"), | |
| 553 | threadId: asThreadId("thread-1"), | |
| 554 | turnId: asTurnId("turn-2"), | |
| 576 | 555 | itemId: asItemId("item-1"), |
| 577 | 556 | payload: { |
| 578 | 557 | streamKind: "assistant_text", |
| ... | ... | @@ -583,9 +562,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 583 | 562 | type: "content.delta", |
| 584 | 563 | eventId: asEventId("evt-message-delta-2"), |
| 585 | 564 | provider: "codex", |
| 586 | sessionId: asSessionId("sess-1"), | |
| 587 | 565 | createdAt: now, |
| 588 | turnId: asProviderTurnId("turn-2"), | |
| 566 | threadId: asThreadId("thread-1"), | |
| 567 | turnId: asTurnId("turn-2"), | |
| 589 | 568 | itemId: asItemId("item-1"), |
| 590 | 569 | payload: { |
| 591 | 570 | streamKind: "assistant_text", |
| ... | ... | @@ -596,9 +575,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 596 | 575 | type: "item.completed", |
| 597 | 576 | eventId: asEventId("evt-message-completed"), |
| 598 | 577 | provider: "codex", |
| 599 | sessionId: asSessionId("sess-1"), | |
| 600 | 578 | createdAt: now, |
| 601 | turnId: asProviderTurnId("turn-2"), | |
| 579 | threadId: asThreadId("thread-1"), | |
| 580 | turnId: asTurnId("turn-2"), | |
| 602 | 581 | itemId: asItemId("item-1"), |
| 603 | 582 | payload: { |
| 604 | 583 | itemType: "assistant_message", |
| ... | ... | @@ -627,9 +606,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 627 | 606 | type: "item.completed", |
| 628 | 607 | eventId: asEventId("evt-assistant-item-completed-no-delta"), |
| 629 | 608 | provider: "codex", |
| 630 | sessionId: asSessionId("sess-1"), | |
| 631 | 609 | createdAt: now, |
| 632 | turnId: asProviderTurnId("turn-no-delta"), | |
| 610 | threadId: asThreadId("thread-1"), | |
| 611 | turnId: asTurnId("turn-no-delta"), | |
| 633 | 612 | itemId: asItemId("item-no-delta"), |
| 634 | 613 | payload: { |
| 635 | 614 | itemType: "assistant_message", |
| ... | ... | @@ -659,9 +638,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 659 | 638 | type: "turn.started", |
| 660 | 639 | eventId: asEventId("evt-turn-started-buffered"), |
| 661 | 640 | provider: "codex", |
| 662 | sessionId: asSessionId("sess-1"), | |
| 663 | 641 | createdAt: now, |
| 664 | turnId: asProviderTurnId("turn-buffered"), | |
| 642 | threadId: asThreadId("thread-1"), | |
| 643 | turnId: asTurnId("turn-buffered"), | |
| 665 | 644 | }); |
| 666 | 645 | await waitForThread( |
| 667 | 646 | harness.engine, |
| ... | ... | @@ -673,9 +652,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 673 | 652 | type: "content.delta", |
| 674 | 653 | eventId: asEventId("evt-message-delta-buffered"), |
| 675 | 654 | provider: "codex", |
| 676 | sessionId: asSessionId("sess-1"), | |
| 677 | 655 | createdAt: now, |
| 678 | turnId: asProviderTurnId("turn-buffered"), | |
| 656 | threadId: asThreadId("thread-1"), | |
| 657 | turnId: asTurnId("turn-buffered"), | |
| 679 | 658 | itemId: asItemId("item-buffered"), |
| 680 | 659 | payload: { |
| 681 | 660 | streamKind: "assistant_text", |
| ... | ... | @@ -698,9 +677,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 698 | 677 | type: "item.completed", |
| 699 | 678 | eventId: asEventId("evt-message-completed-buffered"), |
| 700 | 679 | provider: "codex", |
| 701 | sessionId: asSessionId("sess-1"), | |
| 702 | 680 | createdAt: now, |
| 703 | turnId: asProviderTurnId("turn-buffered"), | |
| 681 | threadId: asThreadId("thread-1"), | |
| 682 | turnId: asTurnId("turn-buffered"), | |
| 704 | 683 | itemId: asItemId("item-buffered"), |
| 705 | 684 | payload: { |
| 706 | 685 | itemType: "assistant_message", |
| ... | ... | @@ -747,9 +726,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 747 | 726 | type: "turn.started", |
| 748 | 727 | eventId: asEventId("evt-turn-started-streaming-mode"), |
| 749 | 728 | provider: "codex", |
| 750 | sessionId: asSessionId("sess-1"), | |
| 751 | 729 | createdAt: now, |
| 752 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 730 | threadId: asThreadId("thread-1"), | |
| 731 | turnId: asTurnId("turn-streaming-mode"), | |
| 753 | 732 | }); |
| 754 | 733 | await waitForThread( |
| 755 | 734 | harness.engine, |
| ... | ... | @@ -762,9 +741,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 762 | 741 | type: "content.delta", |
| 763 | 742 | eventId: asEventId("evt-message-delta-streaming-mode"), |
| 764 | 743 | provider: "codex", |
| 765 | sessionId: asSessionId("sess-1"), | |
| 766 | 744 | createdAt: now, |
| 767 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 745 | threadId: asThreadId("thread-1"), | |
| 746 | turnId: asTurnId("turn-streaming-mode"), | |
| 768 | 747 | itemId: asItemId("item-streaming-mode"), |
| 769 | 748 | payload: { |
| 770 | 749 | streamKind: "assistant_text", |
| ... | ... | @@ -789,9 +768,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 789 | 768 | type: "item.completed", |
| 790 | 769 | eventId: asEventId("evt-message-completed-streaming-mode"), |
| 791 | 770 | provider: "codex", |
| 792 | sessionId: asSessionId("sess-1"), | |
| 793 | 771 | createdAt: now, |
| 794 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 772 | threadId: asThreadId("thread-1"), | |
| 773 | turnId: asTurnId("turn-streaming-mode"), | |
| 795 | 774 | itemId: asItemId("item-streaming-mode"), |
| 796 | 775 | payload: { |
| 797 | 776 | itemType: "assistant_message", |
| ... | ... | @@ -821,9 +800,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 821 | 800 | type: "turn.started", |
| 822 | 801 | eventId: asEventId("evt-turn-started-buffer-spill"), |
| 823 | 802 | provider: "codex", |
| 824 | sessionId: asSessionId("sess-1"), | |
| 825 | 803 | createdAt: now, |
| 826 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 804 | threadId: asThreadId("thread-1"), | |
| 805 | turnId: asTurnId("turn-buffer-spill"), | |
| 827 | 806 | }); |
| 828 | 807 | await waitForThread( |
| 829 | 808 | harness.engine, |
| ... | ... | @@ -836,9 +815,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 836 | 815 | type: "content.delta", |
| 837 | 816 | eventId: asEventId("evt-message-delta-buffer-spill"), |
| 838 | 817 | provider: "codex", |
| 839 | sessionId: asSessionId("sess-1"), | |
| 840 | 818 | createdAt: now, |
| 841 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 819 | threadId: asThreadId("thread-1"), | |
| 820 | turnId: asTurnId("turn-buffer-spill"), | |
| 842 | 821 | itemId: asItemId("item-buffer-spill"), |
| 843 | 822 | payload: { |
| 844 | 823 | streamKind: "assistant_text", |
| ... | ... | @@ -849,9 +828,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 849 | 828 | type: "item.completed", |
| 850 | 829 | eventId: asEventId("evt-message-completed-buffer-spill"), |
| 851 | 830 | provider: "codex", |
| 852 | sessionId: asSessionId("sess-1"), | |
| 853 | 831 | createdAt: now, |
| 854 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 832 | threadId: asThreadId("thread-1"), | |
| 833 | turnId: asTurnId("turn-buffer-spill"), | |
| 855 | 834 | itemId: asItemId("item-buffer-spill"), |
| 856 | 835 | payload: { |
| 857 | 836 | itemType: "assistant_message", |
| ... | ... | @@ -881,9 +860,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 881 | 860 | type: "turn.started", |
| 882 | 861 | eventId: asEventId("evt-turn-started-for-complete-dedup"), |
| 883 | 862 | provider: "codex", |
| 884 | sessionId: asSessionId("sess-1"), | |
| 885 | 863 | createdAt: now, |
| 886 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 864 | threadId: asThreadId("thread-1"), | |
| 865 | turnId: asTurnId("turn-complete-dedup"), | |
| 887 | 866 | }); |
| 888 | 867 | |
| 889 | 868 | await waitForThread( |
| ... | ... | @@ -897,9 +876,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 897 | 876 | type: "content.delta", |
| 898 | 877 | eventId: asEventId("evt-message-delta-for-complete-dedup"), |
| 899 | 878 | provider: "codex", |
| 900 | sessionId: asSessionId("sess-1"), | |
| 901 | 879 | createdAt: now, |
| 902 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 880 | threadId: asThreadId("thread-1"), | |
| 881 | turnId: asTurnId("turn-complete-dedup"), | |
| 903 | 882 | itemId: asItemId("item-complete-dedup"), |
| 904 | 883 | payload: { |
| 905 | 884 | streamKind: "assistant_text", |
| ... | ... | @@ -910,9 +889,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 910 | 889 | type: "item.completed", |
| 911 | 890 | eventId: asEventId("evt-message-completed-for-complete-dedup"), |
| 912 | 891 | provider: "codex", |
| 913 | sessionId: asSessionId("sess-1"), | |
| 914 | 892 | createdAt: now, |
| 915 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 893 | threadId: asThreadId("thread-1"), | |
| 894 | turnId: asTurnId("turn-complete-dedup"), | |
| 916 | 895 | itemId: asItemId("item-complete-dedup"), |
| 917 | 896 | payload: { |
| 918 | 897 | itemType: "assistant_message", |
| ... | ... | @@ -923,9 +902,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 923 | 902 | type: "turn.completed", |
| 924 | 903 | eventId: asEventId("evt-turn-completed-for-complete-dedup"), |
| 925 | 904 | provider: "codex", |
| 926 | sessionId: asSessionId("sess-1"), | |
| 927 | 905 | createdAt: now, |
| 928 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 906 | threadId: asThreadId("thread-1"), | |
| 907 | turnId: asTurnId("turn-complete-dedup"), | |
| 929 | 908 | payload: { |
| 930 | 909 | state: "completed", |
| 931 | 910 | }, |
| ... | ... | @@ -967,8 +946,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 967 | 946 | type: "request.opened", |
| 968 | 947 | eventId: asEventId("evt-request-opened"), |
| 969 | 948 | provider: "codex", |
| 970 | sessionId: asSessionId("sess-1"), | |
| 971 | 949 | createdAt: now, |
| 950 | threadId: asThreadId("thread-1"), | |
| 972 | 951 | requestId: ApprovalRequestId.makeUnsafe("req-open"), |
| 973 | 952 | payload: { |
| 974 | 953 | requestType: "command_execution_approval", |
| ... | ... | @@ -980,8 +959,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 980 | 959 | type: "request.resolved", |
| 981 | 960 | eventId: asEventId("evt-request-resolved"), |
| 982 | 961 | provider: "codex", |
| 983 | sessionId: asSessionId("sess-1"), | |
| 984 | 962 | createdAt: now, |
| 963 | threadId: asThreadId("thread-1"), | |
| 985 | 964 | requestId: ApprovalRequestId.makeUnsafe("req-open"), |
| 986 | 965 | payload: { |
| 987 | 966 | requestType: "command_execution_approval", |
| ... | ... | @@ -1033,9 +1012,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1033 | 1012 | type: "runtime.error", |
| 1034 | 1013 | eventId: asEventId("evt-runtime-error"), |
| 1035 | 1014 | provider: "codex", |
| 1036 | sessionId: asSessionId("sess-1"), | |
| 1037 | 1015 | createdAt: now, |
| 1038 | turnId: asProviderTurnId("turn-3"), | |
| 1016 | threadId: asThreadId("thread-1"), | |
| 1017 | turnId: asTurnId("turn-3"), | |
| 1039 | 1018 | payload: { |
| 1040 | 1019 | message: "runtime exploded", |
| 1041 | 1020 | }, |
| ... | ... | @@ -1060,26 +1039,24 @@ describe("ProviderRuntimeIngestion", () => { |
| 1060 | 1039 | type: "session.started", |
| 1061 | 1040 | eventId: asEventId("evt-session-started"), |
| 1062 | 1041 | provider: "codex", |
| 1063 | sessionId: asSessionId("sess-1"), | |
| 1064 | 1042 | createdAt: now, |
| 1065 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 1043 | threadId: asThreadId("thread-1"), | |
| 1066 | 1044 | message: "session started", |
| 1067 | 1045 | }); |
| 1068 | 1046 | harness.emit({ |
| 1069 | 1047 | type: "thread.started", |
| 1070 | 1048 | eventId: asEventId("evt-thread-started"), |
| 1071 | 1049 | provider: "codex", |
| 1072 | sessionId: asSessionId("sess-1"), | |
| 1073 | 1050 | createdAt: now, |
| 1074 | threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), | |
| 1051 | threadId: asThreadId("thread-1"), | |
| 1075 | 1052 | }); |
| 1076 | 1053 | harness.emit({ |
| 1077 | 1054 | type: "item.started", |
| 1078 | 1055 | eventId: asEventId("evt-tool-started"), |
| 1079 | 1056 | provider: "codex", |
| 1080 | sessionId: asSessionId("sess-1"), | |
| 1081 | 1057 | createdAt: now, |
| 1082 | turnId: asProviderTurnId("turn-9"), | |
| 1058 | threadId: asThreadId("thread-1"), | |
| 1059 | turnId: asTurnId("turn-9"), | |
| 1083 | 1060 | payload: { |
| 1084 | 1061 | itemType: "command_execution", |
| 1085 | 1062 | status: "in_progress", |
| ... | ... | @@ -1114,9 +1091,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 1114 | 1091 | type: "thread.metadata.updated", |
| 1115 | 1092 | eventId: asEventId("evt-thread-metadata-updated"), |
| 1116 | 1093 | provider: "codex", |
| 1117 | sessionId: asSessionId("sess-1"), | |
| 1118 | 1094 | createdAt: now, |
| 1119 | threadId: asProviderThreadId("provider-thread-1"), | |
| 1095 | threadId: asThreadId("thread-1"), | |
| 1120 | 1096 | payload: { |
| 1121 | 1097 | name: "Renamed by provider", |
| 1122 | 1098 | metadata: { source: "provider" }, |
| ... | ... | @@ -1127,9 +1103,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1127 | 1103 | type: "turn.plan.updated", |
| 1128 | 1104 | eventId: asEventId("evt-turn-plan-updated"), |
| 1129 | 1105 | provider: "codex", |
| 1130 | sessionId: asSessionId("sess-1"), | |
| 1131 | 1106 | createdAt: now, |
| 1132 | turnId: asProviderTurnId("turn-p1"), | |
| 1107 | threadId: asThreadId("thread-1"), | |
| 1108 | turnId: asTurnId("turn-p1"), | |
| 1133 | 1109 | payload: { |
| 1134 | 1110 | explanation: "Working through the plan", |
| 1135 | 1111 | plan: [ |
| ... | ... | @@ -1143,9 +1119,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1143 | 1119 | type: "item.updated", |
| 1144 | 1120 | eventId: asEventId("evt-item-updated"), |
| 1145 | 1121 | provider: "codex", |
| 1146 | sessionId: asSessionId("sess-1"), | |
| 1147 | 1122 | createdAt: now, |
| 1148 | turnId: asProviderTurnId("turn-p1"), | |
| 1123 | threadId: asThreadId("thread-1"), | |
| 1124 | turnId: asTurnId("turn-p1"), | |
| 1149 | 1125 | itemId: asItemId("item-p1-tool"), |
| 1150 | 1126 | payload: { |
| 1151 | 1127 | itemType: "command_execution", |
| ... | ... | @@ -1160,9 +1136,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1160 | 1136 | type: "runtime.warning", |
| 1161 | 1137 | eventId: asEventId("evt-runtime-warning"), |
| 1162 | 1138 | provider: "codex", |
| 1163 | sessionId: asSessionId("sess-1"), | |
| 1164 | 1139 | createdAt: now, |
| 1165 | turnId: asProviderTurnId("turn-p1"), | |
| 1140 | threadId: asThreadId("thread-1"), | |
| 1141 | turnId: asTurnId("turn-p1"), | |
| 1166 | 1142 | payload: { |
| 1167 | 1143 | message: "Provider got slow", |
| 1168 | 1144 | detail: { latencyMs: 1500 }, |
| ... | ... | @@ -1173,9 +1149,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1173 | 1149 | type: "turn.diff.updated", |
| 1174 | 1150 | eventId: asEventId("evt-turn-diff-updated"), |
| 1175 | 1151 | provider: "codex", |
| 1176 | sessionId: asSessionId("sess-1"), | |
| 1177 | 1152 | createdAt: now, |
| 1178 | turnId: asProviderTurnId("turn-p1"), | |
| 1153 | threadId: asThreadId("thread-1"), | |
| 1154 | turnId: asTurnId("turn-p1"), | |
| 1179 | 1155 | itemId: asItemId("item-p1-assistant"), |
| 1180 | 1156 | payload: { |
| 1181 | 1157 | unifiedDiff: "diff --git a/file.txt b/file.txt\n+hello\n", |
| ... | ... | @@ -1249,9 +1225,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1249 | 1225 | type: "content.delta", |
| 1250 | 1226 | eventId: asEventId("evt-invalid-delta"), |
| 1251 | 1227 | provider: "codex", |
| 1252 | sessionId: asSessionId("sess-1"), | |
| 1253 | 1228 | createdAt: now, |
| 1254 | turnId: asProviderTurnId("turn-invalid"), | |
| 1229 | threadId: asThreadId("thread-1"), | |
| 1230 | turnId: asTurnId("turn-invalid"), | |
| 1255 | 1231 | itemId: asItemId("item-invalid"), |
| 1256 | 1232 | payload: { |
| 1257 | 1233 | streamKind: "assistant_text", |
| ... | ... | @@ -1263,9 +1239,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 1263 | 1239 | type: "runtime.error", |
| 1264 | 1240 | eventId: asEventId("evt-runtime-error-after-failure"), |
| 1265 | 1241 | provider: "codex", |
| 1266 | sessionId: asSessionId("sess-1"), | |
| 1267 | 1242 | createdAt: new Date().toISOString(), |
| 1268 | turnId: asProviderTurnId("turn-after-failure"), | |
| 1243 | threadId: asThreadId("thread-1"), | |
| 1244 | turnId: asTurnId("turn-after-failure"), | |
| 1269 | 1245 | payload: { |
| 1270 | 1246 | message: "runtime still processed", |
| 1271 | 1247 | }, |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+33-95| ... | ... | @@ -5,13 +5,10 @@ import { |
| 5 | 5 | MessageId, |
| 6 | 6 | type OrchestrationEvent, |
| 7 | 7 | CheckpointRef, |
| 8 | ProviderThreadId, | |
| 9 | type ThreadId, | |
| 8 | ThreadId, | |
| 10 | 9 | TurnId, |
| 11 | 10 | type OrchestrationThreadActivity, |
| 12 | 11 | type ProviderRuntimeEvent, |
| 13 | ProviderSessionId, | |
| 14 | RuntimeSessionId, | |
| 15 | 12 | } from "@t3tools/contracts"; |
| 16 | 13 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; |
| 17 | 14 | |
| ... | ... | @@ -22,7 +19,7 @@ import { |
| 22 | 19 | type ProviderRuntimeIngestionShape, |
| 23 | 20 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 24 | 21 | |
| 25 | const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; | |
| 22 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | |
| 26 | 23 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => |
| 27 | 24 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); |
| 28 | 25 | |
| ... | ... | @@ -53,14 +50,6 @@ function toTurnId(value: TurnId | string | undefined): TurnId | undefined { |
| 53 | 50 | return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); |
| 54 | 51 | } |
| 55 | 52 | |
| 56 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 57 | return value === undefined ? null : ProviderThreadId.makeUnsafe(String(value)); | |
| 58 | } | |
| 59 | ||
| 60 | function toProviderSessionId(value: RuntimeSessionId): ProviderSessionId { | |
| 61 | return ProviderSessionId.makeUnsafe(String(value)); | |
| 62 | } | |
| 63 | ||
| 64 | 53 | function toApprovalRequestId(value: string | undefined): ApprovalRequestId | undefined { |
| 65 | 54 | return value === undefined ? undefined : ApprovalRequestId.makeUnsafe(value); |
| 66 | 55 | } |
| ... | ... | @@ -72,13 +61,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine |
| 72 | 61 | return left === right; |
| 73 | 62 | } |
| 74 | 63 | |
| 75 | function isSyntheticClaudeThreadId( | |
| 76 | provider: ProviderRuntimeEvent["provider"], | |
| 77 | threadId: ProviderThreadId | null, | |
| 78 | ): boolean { | |
| 79 | return provider === "claudeCode" && threadId !== null && threadId.startsWith("claude-thread-"); | |
| 80 | } | |
| 81 | ||
| 82 | 64 | function truncateDetail(value: string, limit = 180): string { |
| 83 | 65 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; |
| 84 | 66 | } |
| ... | ... | @@ -148,11 +130,13 @@ function orchestrationSessionStatusFromRuntimeState( |
| 148 | 130 | |
| 149 | 131 | function requestKindFromCanonicalRequestType( |
| 150 | 132 | requestType: string | undefined, |
| 151 | ): "command" | "file-change" | undefined { | |
| 133 | ): "command" | "file-read" | "file-change" | undefined { | |
| 152 | 134 | switch (requestType) { |
| 153 | 135 | case "command_execution_approval": |
| 154 | 136 | case "exec_command_approval": |
| 155 | 137 | return "command"; |
| 138 | case "file_read_approval": | |
| 139 | return "file-read"; | |
| 156 | 140 | case "file_change_approval": |
| 157 | 141 | case "apply_patch_approval": |
| 158 | 142 | return "file-change"; |
| ... | ... | @@ -194,6 +178,8 @@ function runtimeEventToActivities( |
| 194 | 178 | summary: |
| 195 | 179 | requestKind === "command" |
| 196 | 180 | ? "Command approval requested" |
| 181 | : requestKind === "file-read" | |
| 182 | ? "File-read approval requested" | |
| 197 | 183 | : requestKind === "file-change" |
| 198 | 184 | ? "File-change approval requested" |
| 199 | 185 | : "Approval requested", |
| ... | ... | @@ -380,15 +366,15 @@ const make = Effect.gen(function* () { |
| 380 | 366 | }); |
| 381 | 367 | |
| 382 | 368 | const rememberAssistantMessageId = ( |
| 383 | sessionId: ProviderSessionId, | |
| 369 | threadId: ThreadId, | |
| 384 | 370 | turnId: TurnId, |
| 385 | 371 | messageId: MessageId, |
| 386 | 372 | ) => |
| 387 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( | |
| 373 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( | |
| 388 | 374 | Effect.flatMap((existingIds) => |
| 389 | 375 | Cache.set( |
| 390 | 376 | turnMessageIdsByTurnKey, |
| 391 | providerTurnKey(sessionId, turnId), | |
| 377 | providerTurnKey(threadId, turnId), | |
| 392 | 378 | Option.match(existingIds, { |
| 393 | 379 | onNone: () => new Set([messageId]), |
| 394 | 380 | onSome: (ids) => { |
| ... | ... | @@ -402,11 +388,11 @@ const make = Effect.gen(function* () { |
| 402 | 388 | ); |
| 403 | 389 | |
| 404 | 390 | const forgetAssistantMessageId = ( |
| 405 | sessionId: ProviderSessionId, | |
| 391 | threadId: ThreadId, | |
| 406 | 392 | turnId: TurnId, |
| 407 | 393 | messageId: MessageId, |
| 408 | 394 | ) => |
| 409 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( | |
| 395 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe( | |
| 410 | 396 | Effect.flatMap((existingIds) => |
| 411 | 397 | Option.match(existingIds, { |
| 412 | 398 | onNone: () => Effect.void, |
| ... | ... | @@ -414,23 +400,23 @@ const make = Effect.gen(function* () { |
| 414 | 400 | const nextIds = new Set(ids); |
| 415 | 401 | nextIds.delete(messageId); |
| 416 | 402 | if (nextIds.size === 0) { |
| 417 | return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); | |
| 403 | return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); | |
| 418 | 404 | } |
| 419 | return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds); | |
| 405 | return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId), nextIds); | |
| 420 | 406 | }, |
| 421 | 407 | }), |
| 422 | 408 | ), |
| 423 | 409 | ); |
| 424 | 410 | |
| 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( | |
| 427 | 413 | Effect.map((existingIds) => |
| 428 | 414 | Option.getOrElse(existingIds, (): Set<MessageId> => new Set<MessageId>()), |
| 429 | 415 | ), |
| 430 | 416 | ); |
| 431 | 417 | |
| 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)); | |
| 434 | 420 | |
| 435 | 421 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => |
| 436 | 422 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| ... | ... | @@ -505,9 +491,9 @@ const make = Effect.gen(function* () { |
| 505 | 491 | yield* clearAssistantMessageState(input.messageId); |
| 506 | 492 | }); |
| 507 | 493 | |
| 508 | const clearTurnStateForSession = (sessionId: ProviderSessionId) => | |
| 494 | const clearTurnStateForSession = (threadId: ThreadId) => | |
| 509 | 495 | Effect.gen(function* () { |
| 510 | const prefix = `${sessionId}:`; | |
| 496 | const prefix = `${threadId}:`; | |
| 511 | 497 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); |
| 512 | 498 | yield* Effect.forEach( |
| 513 | 499 | turnKeys, |
| ... | ... | @@ -532,29 +518,14 @@ const make = Effect.gen(function* () { |
| 532 | 518 | |
| 533 | 519 | const processRuntimeEvent = (event: ProviderRuntimeEvent) => |
| 534 | 520 | Effect.gen(function* () { |
| 535 | const providerSessionId = toProviderSessionId(event.sessionId); | |
| 536 | 521 | 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); | |
| 540 | 523 | if (!thread) return; |
| 541 | 524 | |
| 542 | 525 | const now = event.createdAt; |
| 543 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 544 | const scopedSessionProviderThreadId = isSyntheticClaudeThreadId( | |
| 545 | event.provider, | |
| 546 | sessionProviderThreadId, | |
| 547 | ) | |
| 548 | ? null | |
| 549 | : sessionProviderThreadId; | |
| 550 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 551 | 526 | const eventTurnId = toTurnId(event.turnId); |
| 552 | 527 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| 553 | 528 | |
| 554 | const matchesThreadScope = | |
| 555 | eventProviderThreadId === null || | |
| 556 | scopedSessionProviderThreadId === null || | |
| 557 | sameId(eventProviderThreadId, scopedSessionProviderThreadId); | |
| 558 | 529 | const conflictsWithActiveTurn = |
| 559 | 530 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); |
| 560 | 531 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; |
| ... | ... | @@ -568,27 +539,10 @@ const make = Effect.gen(function* () { |
| 568 | 539 | return true; |
| 569 | 540 | case "session.started": |
| 570 | 541 | case "thread.started": |
| 571 | if (!matchesThreadScope) { | |
| 572 | return false; | |
| 573 | } | |
| 574 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. | |
| 575 | if ( | |
| 576 | eventProviderThreadId !== null && | |
| 577 | scopedSessionProviderThreadId !== null && | |
| 578 | !sameId(eventProviderThreadId, scopedSessionProviderThreadId) | |
| 579 | ) { | |
| 580 | return false; | |
| 581 | } | |
| 582 | 542 | return true; |
| 583 | 543 | case "turn.started": |
| 584 | if (!matchesThreadScope) { | |
| 585 | return false; | |
| 586 | } | |
| 587 | 544 | return !conflictsWithActiveTurn; |
| 588 | 545 | case "turn.completed": |
| 589 | if (!matchesThreadScope) { | |
| 590 | return false; | |
| 591 | } | |
| 592 | 546 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { |
| 593 | 547 | return false; |
| 594 | 548 | } |
| ... | ... | @@ -617,12 +571,6 @@ const make = Effect.gen(function* () { |
| 617 | 571 | : event.type === "turn.completed" || event.type === "session.exited" |
| 618 | 572 | ? null |
| 619 | 573 | : activeTurnId; |
| 620 | const providerThreadIdFromEvent = | |
| 621 | event.type === "thread.started" | |
| 622 | ? toProviderThreadId(event.threadId) | |
| 623 | : toProviderThreadId(event.threadId); | |
| 624 | const providerThreadId = | |
| 625 | providerThreadIdFromEvent ?? scopedSessionProviderThreadId ?? null; | |
| 626 | 574 | const status = (() => { |
| 627 | 575 | switch (event.type) { |
| 628 | 576 | case "session.state.changed": |
| ... | ... | @@ -658,8 +606,6 @@ const make = Effect.gen(function* () { |
| 658 | 606 | threadId: thread.id, |
| 659 | 607 | status, |
| 660 | 608 | providerName: event.provider, |
| 661 | providerSessionId, | |
| 662 | providerThreadId, | |
| 663 | 609 | runtimeMode: thread.session?.runtimeMode ?? "full-access", |
| 664 | 610 | activeTurnId: nextActiveTurnId, |
| 665 | 611 | lastError, |
| ... | ... | @@ -677,11 +623,11 @@ const make = Effect.gen(function* () { |
| 677 | 623 | |
| 678 | 624 | if (assistantDelta && assistantDelta.length > 0) { |
| 679 | 625 | const assistantMessageId = MessageId.makeUnsafe( |
| 680 | `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, | |
| 626 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, | |
| 681 | 627 | ); |
| 682 | 628 | const turnId = toTurnId(event.turnId); |
| 683 | 629 | if (turnId) { |
| 684 | yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId); | |
| 630 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); | |
| 685 | 631 | } |
| 686 | 632 | |
| 687 | 633 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); |
| ... | ... | @@ -714,7 +660,7 @@ const make = Effect.gen(function* () { |
| 714 | 660 | const assistantCompletion = |
| 715 | 661 | event.type === "item.completed" && event.payload.itemType === "assistant_message" |
| 716 | 662 | ? { |
| 717 | messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`), | |
| 663 | messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), | |
| 718 | 664 | fallbackText: event.payload.detail, |
| 719 | 665 | } |
| 720 | 666 | : undefined; |
| ... | ... | @@ -723,7 +669,7 @@ const make = Effect.gen(function* () { |
| 723 | 669 | const assistantMessageId = assistantCompletion.messageId; |
| 724 | 670 | const turnId = toTurnId(event.turnId); |
| 725 | 671 | if (turnId) { |
| 726 | yield* rememberAssistantMessageId(providerSessionId, turnId, assistantMessageId); | |
| 672 | yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); | |
| 727 | 673 | } |
| 728 | 674 | |
| 729 | 675 | yield* finalizeAssistantMessage({ |
| ... | ... | @@ -740,14 +686,14 @@ const make = Effect.gen(function* () { |
| 740 | 686 | }); |
| 741 | 687 | |
| 742 | 688 | if (turnId) { |
| 743 | yield* forgetAssistantMessageId(providerSessionId, turnId, assistantMessageId); | |
| 689 | yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); | |
| 744 | 690 | } |
| 745 | 691 | } |
| 746 | 692 | |
| 747 | 693 | if (event.type === "turn.completed") { |
| 748 | 694 | const turnId = toTurnId(event.turnId); |
| 749 | 695 | if (turnId) { |
| 750 | const assistantMessageIds = yield* getAssistantMessageIdsForTurn(providerSessionId, turnId); | |
| 696 | const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); | |
| 751 | 697 | yield* Effect.forEach( |
| 752 | 698 | assistantMessageIds, |
| 753 | 699 | (assistantMessageId) => |
| ... | ... | @@ -762,12 +708,12 @@ const make = Effect.gen(function* () { |
| 762 | 708 | }), |
| 763 | 709 | { concurrency: 1 }, |
| 764 | 710 | ).pipe(Effect.asVoid); |
| 765 | yield* clearAssistantMessageIdsForTurn(providerSessionId, turnId); | |
| 711 | yield* clearAssistantMessageIdsForTurn(thread.id, turnId); | |
| 766 | 712 | } |
| 767 | 713 | } |
| 768 | 714 | |
| 769 | 715 | if (event.type === "session.exited") { |
| 770 | yield* clearTurnStateForSession(providerSessionId); | |
| 716 | yield* clearTurnStateForSession(thread.id); | |
| 771 | 717 | } |
| 772 | 718 | |
| 773 | 719 | if (event.type === "runtime.error") { |
| ... | ... | @@ -775,15 +721,9 @@ const make = Effect.gen(function* () { |
| 775 | 721 | |
| 776 | 722 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD |
| 777 | 723 | ? 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); | |
| 787 | 727 | |
| 788 | 728 | if (shouldApplyRuntimeError) { |
| 789 | 729 | yield* orchestrationEngine.dispatch({ |
| ... | ... | @@ -794,8 +734,6 @@ const make = Effect.gen(function* () { |
| 794 | 734 | threadId: thread.id, |
| 795 | 735 | status: "error", |
| 796 | 736 | providerName: event.provider, |
| 797 | providerSessionId, | |
| 798 | providerThreadId, | |
| 799 | 737 | runtimeMode: thread.session?.runtimeMode ?? "full-access", |
| 800 | 738 | activeTurnId: eventTurnId ?? null, |
| 801 | 739 | lastError: runtimeErrorMessage, |
| ... | ... | @@ -819,7 +757,7 @@ const make = Effect.gen(function* () { |
| 819 | 757 | const turnId = toTurnId(event.turnId); |
| 820 | 758 | if (turnId) { |
| 821 | 759 | const assistantMessageId = MessageId.makeUnsafe( |
| 822 | `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, | |
| 760 | `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, | |
| 823 | 761 | ); |
| 824 | 762 | yield* orchestrationEngine.dispatch({ |
| 825 | 763 | type: "thread.turn.diff.complete", |
apps/server/src/orchestration/decider.ts+1-8| ... | ... | @@ -391,14 +391,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 391 | 391 | aggregateId: command.threadId, |
| 392 | 392 | occurredAt: command.createdAt, |
| 393 | 393 | 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: {}, | |
| 402 | 395 | }), |
| 403 | 396 | type: "thread.session-set", |
| 404 | 397 | payload: { |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts-2| ... | ... | @@ -74,8 +74,6 @@ function inferActorKind( |
| 74 | 74 | return "server"; |
| 75 | 75 | } |
| 76 | 76 | if ( |
| 77 | event.metadata.providerSessionId !== undefined || | |
| 78 | event.metadata.providerThreadId !== undefined || | |
| 79 | 77 | event.metadata.providerTurnId !== undefined || |
| 80 | 78 | event.metadata.providerItemId !== undefined || |
| 81 | 79 | event.metadata.adapterKey !== undefined |
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts-8| ... | ... | @@ -23,8 +23,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 23 | 23 | thread_id, |
| 24 | 24 | status, |
| 25 | 25 | provider_name, |
| 26 | provider_session_id, | |
| 27 | provider_thread_id, | |
| 28 | 26 | runtime_mode, |
| 29 | 27 | active_turn_id, |
| 30 | 28 | last_error, |
| ... | ... | @@ -34,8 +32,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 34 | 32 | ${row.threadId}, |
| 35 | 33 | ${row.status}, |
| 36 | 34 | ${row.providerName}, |
| 37 | ${row.providerSessionId}, | |
| 38 | ${row.providerThreadId}, | |
| 39 | 35 | ${row.runtimeMode}, |
| 40 | 36 | ${row.activeTurnId}, |
| 41 | 37 | ${row.lastError}, |
| ... | ... | @@ -45,8 +41,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 45 | 41 | DO UPDATE SET |
| 46 | 42 | status = excluded.status, |
| 47 | 43 | provider_name = excluded.provider_name, |
| 48 | provider_session_id = excluded.provider_session_id, | |
| 49 | provider_thread_id = excluded.provider_thread_id, | |
| 50 | 44 | runtime_mode = excluded.runtime_mode, |
| 51 | 45 | active_turn_id = excluded.active_turn_id, |
| 52 | 46 | last_error = excluded.last_error, |
| ... | ... | @@ -63,8 +57,6 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { |
| 63 | 57 | thread_id AS "threadId", |
| 64 | 58 | status, |
| 65 | 59 | provider_name AS "providerName", |
| 66 | provider_session_id AS "providerSessionId", | |
| 67 | provider_thread_id AS "providerThreadId", | |
| 68 | 60 | runtime_mode AS "runtimeMode", |
| 69 | 61 | active_turn_id AS "activeTurnId", |
| 70 | 62 | last_error AS "lastError", |
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+20-30| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 3 | 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 4 | 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; |
| ... | ... | @@ -24,7 +24,7 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( |
| 24 | 24 | const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); |
| 25 | 25 | |
| 26 | 26 | const GetRuntimeRequestSchema = Schema.Struct({ |
| 27 | providerSessionId: ProviderSessionId, | |
| 27 | threadId: ThreadId, | |
| 28 | 28 | }); |
| 29 | 29 | |
| 30 | 30 | const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; |
| ... | ... | @@ -44,11 +44,9 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 44 | 44 | execute: (runtime) => |
| 45 | 45 | sql` |
| 46 | 46 | INSERT INTO provider_session_runtime ( |
| 47 | provider_session_id, | |
| 48 | 47 | thread_id, |
| 49 | 48 | provider_name, |
| 50 | 49 | adapter_key, |
| 51 | provider_thread_id, | |
| 52 | 50 | runtime_mode, |
| 53 | 51 | status, |
| 54 | 52 | last_seen_at, |
| ... | ... | @@ -56,23 +54,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 56 | 54 | runtime_payload_json |
| 57 | 55 | ) |
| 58 | 56 | VALUES ( |
| 59 | ${runtime.providerSessionId}, | |
| 60 | 57 | ${runtime.threadId}, |
| 61 | 58 | ${runtime.providerName}, |
| 62 | 59 | ${runtime.adapterKey}, |
| 63 | ${runtime.providerThreadId}, | |
| 64 | 60 | ${runtime.runtimeMode}, |
| 65 | 61 | ${runtime.status}, |
| 66 | 62 | ${runtime.lastSeenAt}, |
| 67 | 63 | ${runtime.resumeCursor}, |
| 68 | 64 | ${runtime.runtimePayload} |
| 69 | 65 | ) |
| 70 | ON CONFLICT (provider_session_id) | |
| 66 | ON CONFLICT (thread_id) | |
| 71 | 67 | DO UPDATE SET |
| 72 | thread_id = excluded.thread_id, | |
| 73 | 68 | provider_name = excluded.provider_name, |
| 74 | 69 | adapter_key = excluded.adapter_key, |
| 75 | provider_thread_id = excluded.provider_thread_id, | |
| 76 | 70 | runtime_mode = excluded.runtime_mode, |
| 77 | 71 | status = excluded.status, |
| 78 | 72 | last_seen_at = excluded.last_seen_at, |
| ... | ... | @@ -81,24 +75,22 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 81 | 75 | `, |
| 82 | 76 | }); |
| 83 | 77 | |
| 84 | const getRuntimeRowBySessionId = SqlSchema.findOneOption({ | |
| 78 | const getRuntimeRowByThreadId = SqlSchema.findOneOption({ | |
| 85 | 79 | Request: GetRuntimeRequestSchema, |
| 86 | 80 | Result: ProviderSessionRuntimeDbRowSchema, |
| 87 | execute: ({ providerSessionId }) => | |
| 81 | execute: ({ threadId }) => | |
| 88 | 82 | sql` |
| 89 | 83 | SELECT |
| 90 | provider_session_id AS "providerSessionId", | |
| 91 | 84 | thread_id AS "threadId", |
| 92 | 85 | provider_name AS "providerName", |
| 93 | 86 | adapter_key AS "adapterKey", |
| 94 | provider_thread_id AS "providerThreadId", | |
| 95 | 87 | runtime_mode AS "runtimeMode", |
| 96 | 88 | status, |
| 97 | 89 | last_seen_at AS "lastSeenAt", |
| 98 | 90 | resume_cursor_json AS "resumeCursor", |
| 99 | 91 | runtime_payload_json AS "runtimePayload" |
| 100 | 92 | FROM provider_session_runtime |
| 101 | WHERE provider_session_id = ${providerSessionId} | |
| 93 | WHERE thread_id = ${threadId} | |
| 102 | 94 | `, |
| 103 | 95 | }); |
| 104 | 96 | |
| ... | ... | @@ -108,27 +100,25 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 108 | 100 | execute: () => |
| 109 | 101 | sql` |
| 110 | 102 | SELECT |
| 111 | provider_session_id AS "providerSessionId", | |
| 112 | 103 | thread_id AS "threadId", |
| 113 | 104 | provider_name AS "providerName", |
| 114 | 105 | adapter_key AS "adapterKey", |
| 115 | provider_thread_id AS "providerThreadId", | |
| 116 | 106 | runtime_mode AS "runtimeMode", |
| 117 | 107 | status, |
| 118 | 108 | last_seen_at AS "lastSeenAt", |
| 119 | 109 | resume_cursor_json AS "resumeCursor", |
| 120 | 110 | runtime_payload_json AS "runtimePayload" |
| 121 | 111 | 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 | |
| 123 | 113 | `, |
| 124 | 114 | }); |
| 125 | 115 | |
| 126 | const deleteRuntimeBySessionId = SqlSchema.void({ | |
| 116 | const deleteRuntimeByThreadId = SqlSchema.void({ | |
| 127 | 117 | Request: DeleteRuntimeRequestSchema, |
| 128 | execute: ({ providerSessionId }) => | |
| 118 | execute: ({ threadId }) => | |
| 129 | 119 | sql` |
| 130 | 120 | DELETE FROM provider_session_runtime |
| 131 | WHERE provider_session_id = ${providerSessionId} | |
| 121 | WHERE thread_id = ${threadId} | |
| 132 | 122 | `, |
| 133 | 123 | }); |
| 134 | 124 | |
| ... | ... | @@ -142,12 +132,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 142 | 132 | ), |
| 143 | 133 | ); |
| 144 | 134 | |
| 145 | const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) => | |
| 146 | getRuntimeRowBySessionId(input).pipe( | |
| 135 | const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => | |
| 136 | getRuntimeRowByThreadId(input).pipe( | |
| 147 | 137 | Effect.mapError( |
| 148 | 138 | toPersistenceSqlOrDecodeError( |
| 149 | "ProviderSessionRuntimeRepository.getBySessionId:query", | |
| 150 | "ProviderSessionRuntimeRepository.getBySessionId:decodeRow", | |
| 139 | "ProviderSessionRuntimeRepository.getByThreadId:query", | |
| 140 | "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", | |
| 151 | 141 | ), |
| 152 | 142 | ), |
| 153 | 143 | Effect.flatMap((runtimeRowOption) => |
| ... | ... | @@ -157,7 +147,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 157 | 147 | decodeRuntime(row).pipe( |
| 158 | 148 | Effect.mapError( |
| 159 | 149 | toPersistenceDecodeError( |
| 160 | "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime", | |
| 150 | "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", | |
| 161 | 151 | ), |
| 162 | 152 | ), |
| 163 | 153 | Effect.map((runtime) => Option.some(runtime)), |
| ... | ... | @@ -188,18 +178,18 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { |
| 188 | 178 | ), |
| 189 | 179 | ); |
| 190 | 180 | |
| 191 | const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) => | |
| 192 | deleteRuntimeBySessionId(input).pipe( | |
| 181 | const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => | |
| 182 | deleteRuntimeByThreadId(input).pipe( | |
| 193 | 183 | Effect.mapError( |
| 194 | toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"), | |
| 184 | toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), | |
| 195 | 185 | ), |
| 196 | 186 | ); |
| 197 | 187 | |
| 198 | 188 | return { |
| 199 | 189 | upsert, |
| 200 | getBySessionId, | |
| 190 | getByThreadId, | |
| 201 | 191 | list, |
| 202 | deleteBySessionId, | |
| 192 | deleteByThreadId, | |
| 203 | 193 | } satisfies ProviderSessionRuntimeRepositoryShape; |
| 204 | 194 | }); |
| 205 | 195 |
apps/server/src/persistence/Layers/ProviderSessions.ts deleted-225| ... | ... | @@ -1,225 +0,0 @@ |
| 1 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | ProviderSessionRepositoryPersistenceError, | |
| 8 | ProviderSessionRepositoryValidationError, | |
| 9 | } from "../Errors.ts"; | |
| 10 | import { | |
| 11 | ProviderSessionRepository, | |
| 12 | type ProviderSessionEntry, | |
| 13 | type ProviderSessionRepositoryShape, | |
| 14 | } from "../Services/ProviderSessions.ts"; | |
| 15 | ||
| 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); | |
| 17 | ||
| 18 | const ProviderSessionRowSchema = Schema.Struct({ | |
| 19 | sessionId: ProviderSessionId, | |
| 20 | provider: ProviderKind, | |
| 21 | threadId: Schema.NullOr(ThreadId), | |
| 22 | createdAt: Schema.String, | |
| 23 | updatedAt: Schema.String, | |
| 24 | }); | |
| 25 | ||
| 26 | const SessionIdRequestSchema = Schema.Struct({ | |
| 27 | sessionId: ProviderSessionId, | |
| 28 | }); | |
| 29 | ||
| 30 | const UpsertSessionRequestSchema = Schema.Struct({ | |
| 31 | sessionId: ProviderSessionId, | |
| 32 | provider: ProviderKind, | |
| 33 | threadId: Schema.NullOr(ThreadId), | |
| 34 | }); | |
| 35 | ||
| 36 | function 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 | ||
| 43 | function 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 | ||
| 54 | function 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 | ||
| 60 | function 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 | ||
| 71 | function 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 | ||
| 81 | const 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 | ||
| 222 | export const ProviderSessionRepositoryLive = Layer.effect( | |
| 223 | ProviderSessionRepository, | |
| 224 | makeProviderSessionRepository, | |
| 225 | ); |
apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts+2-8| ... | ... | @@ -6,11 +6,10 @@ export default Effect.gen(function* () { |
| 6 | 6 | |
| 7 | 7 | yield* sql` |
| 8 | 8 | 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, | |
| 11 | 10 | provider_name TEXT NOT NULL, |
| 12 | 11 | adapter_key TEXT NOT NULL, |
| 13 | provider_thread_id TEXT, | |
| 12 | runtime_mode TEXT NOT NULL DEFAULT 'full-access', | |
| 14 | 13 | status TEXT NOT NULL, |
| 15 | 14 | last_seen_at TEXT NOT NULL, |
| 16 | 15 | resume_cursor_json TEXT, |
| ... | ... | @@ -18,11 +17,6 @@ export default Effect.gen(function* () { |
| 18 | 17 | ) |
| 19 | 18 | `; |
| 20 | 19 | |
| 21 | yield* sql` | |
| 22 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread | |
| 23 | ON provider_session_runtime(thread_id) | |
| 24 | `; | |
| 25 | ||
| 26 | 20 | yield* sql` |
| 27 | 21 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status |
| 28 | 22 | ON provider_session_runtime(status) |
apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts+1-12| ... | ... | @@ -2,16 +2,5 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | 4 | export 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; | |
| 17 | 6 | }); |
apps/server/src/persistence/Services/ProjectionThreadSessions.ts-4| ... | ... | @@ -10,8 +10,6 @@ import { |
| 10 | 10 | RuntimeMode, |
| 11 | 11 | IsoDateTime, |
| 12 | 12 | OrchestrationSessionStatus, |
| 13 | ProviderSessionId, | |
| 14 | ProviderThreadId, | |
| 15 | 13 | ThreadId, |
| 16 | 14 | TurnId, |
| 17 | 15 | } from "@t3tools/contracts"; |
| ... | ... | @@ -24,8 +22,6 @@ export const ProjectionThreadSession = Schema.Struct({ |
| 24 | 22 | threadId: ThreadId, |
| 25 | 23 | status: OrchestrationSessionStatus, |
| 26 | 24 | providerName: Schema.NullOr(Schema.String), |
| 27 | providerSessionId: Schema.NullOr(ProviderSessionId), | |
| 28 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 29 | 25 | runtimeMode: RuntimeMode, |
| 30 | 26 | activeTurnId: Schema.NullOr(TurnId), |
| 31 | 27 | lastError: Schema.NullOr(Schema.String), |
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+7-15| ... | ... | @@ -7,9 +7,7 @@ |
| 7 | 7 | */ |
| 8 | 8 | import { |
| 9 | 9 | IsoDateTime, |
| 10 | ProviderSessionId, | |
| 11 | 10 | ProviderSessionRuntimeStatus, |
| 12 | ProviderThreadId, | |
| 13 | 11 | RuntimeMode, |
| 14 | 12 | ThreadId, |
| 15 | 13 | } from "@t3tools/contracts"; |
| ... | ... | @@ -19,11 +17,9 @@ import type { Effect } from "effect"; |
| 19 | 17 | import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; |
| 20 | 18 | |
| 21 | 19 | export const ProviderSessionRuntime = Schema.Struct({ |
| 22 | providerSessionId: ProviderSessionId, | |
| 23 | 20 | threadId: ThreadId, |
| 24 | 21 | providerName: Schema.String, |
| 25 | 22 | adapterKey: Schema.String, |
| 26 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 27 | 23 | runtimeMode: RuntimeMode, |
| 28 | 24 | status: ProviderSessionRuntimeStatus, |
| 29 | 25 | lastSeenAt: IsoDateTime, |
| ... | ... | @@ -32,14 +28,10 @@ export const ProviderSessionRuntime = Schema.Struct({ |
| 32 | 28 | }); |
| 33 | 29 | export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; |
| 34 | 30 | |
| 35 | export const GetProviderSessionRuntimeInput = Schema.Struct({ | |
| 36 | providerSessionId: ProviderSessionId, | |
| 37 | }); | |
| 31 | export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); | |
| 38 | 32 | export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; |
| 39 | 33 | |
| 40 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ | |
| 41 | providerSessionId: ProviderSessionId, | |
| 42 | }); | |
| 34 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); | |
| 43 | 35 | export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; |
| 44 | 36 | |
| 45 | 37 | /** |
| ... | ... | @@ -49,16 +41,16 @@ export interface ProviderSessionRuntimeRepositoryShape { |
| 49 | 41 | /** |
| 50 | 42 | * Insert or replace a provider runtime row. |
| 51 | 43 | * |
| 52 | * Upserts by `providerSessionId`, including JSON payload/cursor fields. | |
| 44 | * Upserts by canonical `threadId`, including JSON payload/cursor fields. | |
| 53 | 45 | */ |
| 54 | 46 | readonly upsert: ( |
| 55 | 47 | runtime: ProviderSessionRuntime, |
| 56 | 48 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; |
| 57 | 49 | |
| 58 | 50 | /** |
| 59 | * Read provider runtime state by provider session id. | |
| 51 | * Read provider runtime state by canonical thread id. | |
| 60 | 52 | */ |
| 61 | readonly getBySessionId: ( | |
| 53 | readonly getByThreadId: ( | |
| 62 | 54 | input: GetProviderSessionRuntimeInput, |
| 63 | 55 | ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>; |
| 64 | 56 | |
| ... | ... | @@ -73,9 +65,9 @@ export interface ProviderSessionRuntimeRepositoryShape { |
| 73 | 65 | >; |
| 74 | 66 | |
| 75 | 67 | /** |
| 76 | * Delete provider runtime state by provider session id. | |
| 68 | * Delete provider runtime state by canonical thread id. | |
| 77 | 69 | */ |
| 78 | readonly deleteBySessionId: ( | |
| 70 | readonly deleteByThreadId: ( | |
| 79 | 71 | input: DeleteProviderSessionRuntimeInput, |
| 80 | 72 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; |
| 81 | 73 | } |
apps/server/src/persistence/Services/ProviderSessions.ts deleted-81| ... | ... | @@ -1,81 +0,0 @@ |
| 1 | /** | |
| 2 | * ProviderSessionRepository - Repository interface for provider session lookup. | |
| 3 | * | |
| 4 | * Owns persistence operations that map internal sessions to provider kinds and | |
| 5 | * optional thread ownership. | |
| 6 | * | |
| 7 | * @module ProviderSessionRepository | |
| 8 | */ | |
| 9 | import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 10 | import { Option, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export interface ProviderSessionEntry { | |
| 16 | readonly sessionId: ProviderSessionId; | |
| 17 | readonly provider: ProviderKind; | |
| 18 | readonly threadId?: ThreadId; | |
| 19 | readonly createdAt: string; | |
| 20 | readonly updatedAt: string; | |
| 21 | } | |
| 22 | ||
| 23 | export interface UpsertProviderSessionInput { | |
| 24 | readonly sessionId: ProviderSessionId; | |
| 25 | readonly provider: ProviderKind; | |
| 26 | readonly threadId?: ThreadId; | |
| 27 | } | |
| 28 | ||
| 29 | export interface DeleteProviderSessionInput { | |
| 30 | readonly sessionId: ProviderSessionId; | |
| 31 | } | |
| 32 | ||
| 33 | export interface GetProviderSessionInput { | |
| 34 | readonly sessionId: ProviderSessionId; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * ProviderSessionRepositoryShape - Service API for provider-session records. | |
| 39 | */ | |
| 40 | export 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 | */ | |
| 78 | export class ProviderSessionRepository extends ServiceMap.Service< | |
| 79 | ProviderSessionRepository, | |
| 80 | ProviderSessionRepositoryShape | |
| 81 | >()("t3/persistence/Services/ProviderSessions/ProviderSessionRepository") {} |
apps/server/src/provider/Errors.ts+8-8| ... | ... | @@ -26,12 +26,12 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass |
| 26 | 26 | "ProviderAdapterSessionNotFoundError", |
| 27 | 27 | { |
| 28 | 28 | provider: Schema.String, |
| 29 | sessionId: Schema.String, | |
| 29 | threadId: Schema.String, | |
| 30 | 30 | cause: Schema.optional(Schema.Defect), |
| 31 | 31 | }, |
| 32 | 32 | ) { |
| 33 | 33 | override get message(): string { |
| 34 | return `Unknown ${this.provider} adapter session: ${this.sessionId}`; | |
| 34 | return `Unknown ${this.provider} adapter thread: ${this.threadId}`; | |
| 35 | 35 | } |
| 36 | 36 | } |
| 37 | 37 | |
| ... | ... | @@ -42,12 +42,12 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P |
| 42 | 42 | "ProviderAdapterSessionClosedError", |
| 43 | 43 | { |
| 44 | 44 | provider: Schema.String, |
| 45 | sessionId: Schema.String, | |
| 45 | threadId: Schema.String, | |
| 46 | 46 | cause: Schema.optional(Schema.Defect), |
| 47 | 47 | }, |
| 48 | 48 | ) { |
| 49 | 49 | override get message(): string { |
| 50 | return `${this.provider} adapter session is closed: ${this.sessionId}`; | |
| 50 | return `${this.provider} adapter thread is closed: ${this.threadId}`; | |
| 51 | 51 | } |
| 52 | 52 | } |
| 53 | 53 | |
| ... | ... | @@ -75,13 +75,13 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide |
| 75 | 75 | "ProviderAdapterProcessError", |
| 76 | 76 | { |
| 77 | 77 | provider: Schema.String, |
| 78 | sessionId: Schema.String, | |
| 78 | threadId: Schema.String, | |
| 79 | 79 | detail: Schema.String, |
| 80 | 80 | cause: Schema.optional(Schema.Defect), |
| 81 | 81 | }, |
| 82 | 82 | ) { |
| 83 | 83 | override get message(): string { |
| 84 | return `Provider adapter process error (${this.provider}) for session ${this.sessionId}: ${this.detail}`; | |
| 84 | return `Provider adapter process error (${this.provider}) for thread ${this.threadId}: ${this.detail}`; | |
| 85 | 85 | } |
| 86 | 86 | } |
| 87 | 87 | |
| ... | ... | @@ -122,12 +122,12 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn |
| 122 | 122 | export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<ProviderSessionNotFoundError>()( |
| 123 | 123 | "ProviderSessionNotFoundError", |
| 124 | 124 | { |
| 125 | sessionId: Schema.String, | |
| 125 | threadId: Schema.String, | |
| 126 | 126 | cause: Schema.optional(Schema.Defect), |
| 127 | 127 | }, |
| 128 | 128 | ) { |
| 129 | 129 | override get message(): string { |
| 130 | return `Unknown provider session: ${this.sessionId}`; | |
| 130 | return `Unknown provider thread: ${this.threadId}`; | |
| 131 | 131 | } |
| 132 | 132 | } |
| 133 | 133 |
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+34-18| ... | ... | @@ -5,7 +5,7 @@ import type { |
| 5 | 5 | SDKMessage, |
| 6 | 6 | SDKUserMessage, |
| 7 | 7 | } from "@anthropic-ai/claude-agent-sdk"; |
| 8 | import { ApprovalRequestId, type ProviderTurnId } from "@t3tools/contracts"; | |
| 8 | import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; | |
| 9 | 9 | import { assert, describe, it } from "@effect/vitest"; |
| 10 | 10 | import { Effect, Fiber, Random, Stream } from "effect"; |
| 11 | 11 | |
| ... | ... | @@ -161,13 +161,17 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { |
| 161 | 161 | }; |
| 162 | 162 | } |
| 163 | 163 | |
| 164 | ||
| 165 | const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); | |
| 166 | const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); | |
| 167 | ||
| 164 | 168 | describe("ClaudeCodeAdapterLive", () => { |
| 165 | 169 | it.effect("returns validation error for non-claudeCode provider on startSession", () => { |
| 166 | 170 | const harness = makeHarness(); |
| 167 | 171 | return Effect.gen(function* () { |
| 168 | 172 | const adapter = yield* ClaudeCodeAdapter; |
| 169 | 173 | const result = yield* adapter |
| 170 | .startSession({ provider: "codex", runtimeMode: "full-access" }) | |
| 174 | .startSession({ threadId: THREAD_ID, provider: "codex", runtimeMode: "full-access" }) | |
| 171 | 175 | .pipe(Effect.result); |
| 172 | 176 | |
| 173 | 177 | assert.equal(result._tag, "Failure"); |
| ... | ... | @@ -193,6 +197,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 193 | 197 | return Effect.gen(function* () { |
| 194 | 198 | const adapter = yield* ClaudeCodeAdapter; |
| 195 | 199 | yield* adapter.startSession({ |
| 200 | threadId: THREAD_ID, | |
| 196 | 201 | provider: "claudeCode", |
| 197 | 202 | runtimeMode: "full-access", |
| 198 | 203 | }); |
| ... | ... | @@ -211,6 +216,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 211 | 216 | return Effect.gen(function* () { |
| 212 | 217 | const adapter = yield* ClaudeCodeAdapter; |
| 213 | 218 | yield* adapter.startSession({ |
| 219 | threadId: THREAD_ID, | |
| 214 | 220 | provider: "claudeCode", |
| 215 | 221 | runtimeMode: "full-access", |
| 216 | 222 | providerOptions: { |
| ... | ... | @@ -240,13 +246,14 @@ describe("ClaudeCodeAdapterLive", () => { |
| 240 | 246 | ); |
| 241 | 247 | |
| 242 | 248 | const session = yield* adapter.startSession({ |
| 249 | threadId: THREAD_ID, | |
| 243 | 250 | provider: "claudeCode", |
| 244 | 251 | model: "claude-sonnet-4-5", |
| 245 | 252 | runtimeMode: "full-access", |
| 246 | 253 | }); |
| 247 | 254 | |
| 248 | 255 | const turn = yield* adapter.sendTurn({ |
| 249 | sessionId: session.sessionId, | |
| 256 | threadId: session.threadId, | |
| 250 | 257 | input: "hello", |
| 251 | 258 | attachments: [], |
| 252 | 259 | }); |
| ... | ... | @@ -376,12 +383,13 @@ describe("ClaudeCodeAdapterLive", () => { |
| 376 | 383 | ); |
| 377 | 384 | |
| 378 | 385 | const session = yield* adapter.startSession({ |
| 386 | threadId: THREAD_ID, | |
| 379 | 387 | provider: "claudeCode", |
| 380 | 388 | runtimeMode: "full-access", |
| 381 | 389 | }); |
| 382 | 390 | |
| 383 | 391 | const turn = yield* adapter.sendTurn({ |
| 384 | sessionId: session.sessionId, | |
| 392 | threadId: session.threadId, | |
| 385 | 393 | input: "hello", |
| 386 | 394 | attachments: [], |
| 387 | 395 | }); |
| ... | ... | @@ -464,12 +472,13 @@ describe("ClaudeCodeAdapterLive", () => { |
| 464 | 472 | ); |
| 465 | 473 | |
| 466 | 474 | const session = yield* adapter.startSession({ |
| 475 | threadId: THREAD_ID, | |
| 467 | 476 | provider: "claudeCode", |
| 468 | 477 | runtimeMode: "full-access", |
| 469 | 478 | }); |
| 470 | 479 | |
| 471 | 480 | const turn = yield* adapter.sendTurn({ |
| 472 | sessionId: session.sessionId, | |
| 481 | threadId: session.threadId, | |
| 473 | 482 | input: "hello", |
| 474 | 483 | attachments: [], |
| 475 | 484 | }); |
| ... | ... | @@ -533,13 +542,14 @@ describe("ClaudeCodeAdapterLive", () => { |
| 533 | 542 | ); |
| 534 | 543 | |
| 535 | 544 | const session = yield* adapter.startSession({ |
| 545 | threadId: THREAD_ID, | |
| 536 | 546 | provider: "claudeCode", |
| 537 | 547 | runtimeMode: "full-access", |
| 538 | 548 | }); |
| 539 | 549 | assert.equal(session.threadId, undefined); |
| 540 | 550 | |
| 541 | 551 | const turn = yield* adapter.sendTurn({ |
| 542 | sessionId: session.sessionId, | |
| 552 | threadId: session.threadId, | |
| 543 | 553 | input: "hello", |
| 544 | 554 | attachments: [], |
| 545 | 555 | }); |
| ... | ... | @@ -602,6 +612,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 602 | 612 | const adapter = yield* ClaudeCodeAdapter; |
| 603 | 613 | |
| 604 | 614 | const session = yield* adapter.startSession({ |
| 615 | threadId: THREAD_ID, | |
| 605 | 616 | provider: "claudeCode", |
| 606 | 617 | runtimeMode: "approval-required", |
| 607 | 618 | }); |
| ... | ... | @@ -647,7 +658,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 647 | 658 | } |
| 648 | 659 | |
| 649 | 660 | yield* adapter.respondToRequest( |
| 650 | session.sessionId, | |
| 661 | session.threadId, | |
| 651 | 662 | ApprovalRequestId.makeUnsafe(runtimeRequestId), |
| 652 | 663 | "accept", |
| 653 | 664 | ); |
| ... | ... | @@ -678,6 +689,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 678 | 689 | const adapter = yield* ClaudeCodeAdapter; |
| 679 | 690 | |
| 680 | 691 | const session = yield* adapter.startSession({ |
| 692 | threadId: RESUME_THREAD_ID, | |
| 681 | 693 | provider: "claudeCode", |
| 682 | 694 | resumeCursor: { |
| 683 | 695 | threadId: "resume-thread-1", |
| ... | ... | @@ -711,6 +723,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 711 | 723 | const adapter = yield* ClaudeCodeAdapter; |
| 712 | 724 | |
| 713 | 725 | const session = yield* adapter.startSession({ |
| 726 | threadId: THREAD_ID, | |
| 714 | 727 | provider: "claudeCode", |
| 715 | 728 | runtimeMode: "full-access", |
| 716 | 729 | }); |
| ... | ... | @@ -734,12 +747,13 @@ describe("ClaudeCodeAdapterLive", () => { |
| 734 | 747 | const adapter = yield* ClaudeCodeAdapter; |
| 735 | 748 | |
| 736 | 749 | const session = yield* adapter.startSession({ |
| 750 | threadId: THREAD_ID, | |
| 737 | 751 | provider: "claudeCode", |
| 738 | 752 | runtimeMode: "full-access", |
| 739 | 753 | }); |
| 740 | 754 | |
| 741 | 755 | const firstTurn = yield* adapter.sendTurn({ |
| 742 | sessionId: session.sessionId, | |
| 756 | threadId: session.threadId, | |
| 743 | 757 | input: "first", |
| 744 | 758 | attachments: [], |
| 745 | 759 | }); |
| ... | ... | @@ -765,7 +779,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 765 | 779 | } |
| 766 | 780 | |
| 767 | 781 | const secondTurn = yield* adapter.sendTurn({ |
| 768 | sessionId: session.sessionId, | |
| 782 | threadId: session.threadId, | |
| 769 | 783 | input: "second", |
| 770 | 784 | attachments: [], |
| 771 | 785 | }); |
| ... | ... | @@ -790,16 +804,16 @@ describe("ClaudeCodeAdapterLive", () => { |
| 790 | 804 | assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); |
| 791 | 805 | } |
| 792 | 806 | |
| 793 | const threadBeforeRollback = yield* adapter.readThread(session.sessionId); | |
| 807 | const threadBeforeRollback = yield* adapter.readThread(session.threadId); | |
| 794 | 808 | assert.equal(threadBeforeRollback.turns.length, 2); |
| 795 | 809 | |
| 796 | const rolledBack = yield* adapter.rollbackThread(session.sessionId, 1); | |
| 810 | const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); | |
| 797 | 811 | assert.equal(rolledBack.turns.length, 1); |
| 798 | assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId as ProviderTurnId); | |
| 812 | assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); | |
| 799 | 813 | |
| 800 | const threadAfterRollback = yield* adapter.readThread(session.sessionId); | |
| 814 | const threadAfterRollback = yield* adapter.readThread(session.threadId); | |
| 801 | 815 | assert.equal(threadAfterRollback.turns.length, 1); |
| 802 | assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId as ProviderTurnId); | |
| 816 | assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); | |
| 803 | 817 | }).pipe( |
| 804 | 818 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 805 | 819 | Effect.provide(harness.layer), |
| ... | ... | @@ -812,11 +826,12 @@ describe("ClaudeCodeAdapterLive", () => { |
| 812 | 826 | const adapter = yield* ClaudeCodeAdapter; |
| 813 | 827 | |
| 814 | 828 | const session = yield* adapter.startSession({ |
| 829 | threadId: THREAD_ID, | |
| 815 | 830 | provider: "claudeCode", |
| 816 | 831 | runtimeMode: "full-access", |
| 817 | 832 | }); |
| 818 | 833 | yield* adapter.sendTurn({ |
| 819 | sessionId: session.sessionId, | |
| 834 | threadId: session.threadId, | |
| 820 | 835 | input: "hello", |
| 821 | 836 | model: "claude-opus-4-6", |
| 822 | 837 | attachments: [], |
| ... | ... | @@ -834,7 +849,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 834 | 849 | event?: { |
| 835 | 850 | provider?: string; |
| 836 | 851 | method?: string; |
| 837 | sessionId?: string; | |
| 852 | threadId?: string; | |
| 838 | 853 | turnId?: string; |
| 839 | 854 | }; |
| 840 | 855 | }> = []; |
| ... | ... | @@ -852,11 +867,12 @@ describe("ClaudeCodeAdapterLive", () => { |
| 852 | 867 | const adapter = yield* ClaudeCodeAdapter; |
| 853 | 868 | |
| 854 | 869 | const session = yield* adapter.startSession({ |
| 870 | threadId: THREAD_ID, | |
| 855 | 871 | provider: "claudeCode", |
| 856 | 872 | runtimeMode: "full-access", |
| 857 | 873 | }); |
| 858 | 874 | const turn = yield* adapter.sendTurn({ |
| 859 | sessionId: session.sessionId, | |
| 875 | threadId: session.threadId, | |
| 860 | 876 | input: "hello", |
| 861 | 877 | attachments: [], |
| 862 | 878 | }); |
| ... | ... | @@ -895,7 +911,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 895 | 911 | |
| 896 | 912 | assert.equal(nativeEvents.length > 0, true); |
| 897 | 913 | assert.equal(nativeEvents.some((record) => record.event?.provider === "claudeCode"), true); |
| 898 | assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 914 | assert.equal(nativeEvents.some((record) => String(record.event?.threadId) === String(session.threadId)), true); | |
| 899 | 915 | assert.equal( |
| 900 | 916 | nativeEvents.some((record) => String(record.event?.turnId) === String(turn.turnId)), |
| 901 | 917 | true, |
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+135-202| ... | ... | @@ -27,13 +27,9 @@ import { |
| 27 | 27 | type ProviderRuntimeEvent, |
| 28 | 28 | type ProviderRuntimeTurnStatus, |
| 29 | 29 | type ProviderSendTurnInput, |
| 30 | ProviderSessionId, | |
| 31 | 30 | type ProviderSession, |
| 32 | ProviderThreadId, | |
| 33 | ProviderTurnId, | |
| 34 | 31 | RuntimeItemId, |
| 35 | 32 | RuntimeRequestId, |
| 36 | RuntimeSessionId, | |
| 37 | 33 | RuntimeTaskId, |
| 38 | 34 | ThreadId, |
| 39 | 35 | TurnId, |
| ... | ... | @@ -63,14 +59,14 @@ type PromptQueueItem = |
| 63 | 59 | }; |
| 64 | 60 | |
| 65 | 61 | interface ClaudeResumeState { |
| 66 | readonly threadId?: ProviderThreadId; | |
| 62 | readonly threadId?: ThreadId; | |
| 67 | 63 | readonly resume?: string; |
| 68 | 64 | readonly resumeSessionAt?: string; |
| 69 | 65 | readonly turnCount?: number; |
| 70 | 66 | } |
| 71 | 67 | |
| 72 | 68 | interface ClaudeTurnState { |
| 73 | readonly turnId: ProviderTurnId; | |
| 69 | readonly turnId: TurnId; | |
| 74 | 70 | readonly assistantItemId: string; |
| 75 | 71 | readonly startedAt: string; |
| 76 | 72 | readonly items: Array<unknown>; |
| ... | ... | @@ -102,13 +98,13 @@ interface ClaudeSessionContext { |
| 102 | 98 | resumeSessionId: string | undefined; |
| 103 | 99 | readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; |
| 104 | 100 | readonly turns: Array<{ |
| 105 | id: ReturnType<typeof ProviderTurnId.makeUnsafe>; | |
| 101 | id: TurnId; | |
| 106 | 102 | items: Array<unknown>; |
| 107 | 103 | }>; |
| 108 | 104 | readonly inFlightTools: Map<number, ToolInFlight>; |
| 109 | 105 | turnState: ClaudeTurnState | undefined; |
| 110 | 106 | lastAssistantUuid: string | undefined; |
| 111 | lastThreadStartedId: ProviderThreadId | undefined; | |
| 107 | lastThreadStartedId: string | undefined; | |
| 112 | 108 | stopped: boolean; |
| 113 | 109 | } |
| 114 | 110 | |
| ... | ... | @@ -144,22 +140,14 @@ function toMessage(cause: unknown, fallback: string): string { |
| 144 | 140 | return fallback; |
| 145 | 141 | } |
| 146 | 142 | |
| 147 | function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId { | |
| 148 | return RuntimeSessionId.makeUnsafe(value); | |
| 149 | } | |
| 150 | ||
| 151 | function asRuntimeThreadId(value: ProviderThreadId): ThreadId { | |
| 152 | return ThreadId.makeUnsafe(value); | |
| 153 | } | |
| 154 | ||
| 155 | function asRuntimeTurnId(value: ProviderTurnId): TurnId { | |
| 156 | return TurnId.makeUnsafe(value); | |
| 157 | } | |
| 158 | ||
| 159 | 143 | function asRuntimeItemId(value: string): RuntimeItemId { |
| 160 | 144 | return RuntimeItemId.makeUnsafe(value); |
| 161 | 145 | } |
| 162 | 146 | |
| 147 | function asCanonicalTurnId(value: TurnId): TurnId { | |
| 148 | return value; | |
| 149 | } | |
| 150 | ||
| 163 | 151 | function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { |
| 164 | 152 | return RuntimeRequestId.makeUnsafe(value); |
| 165 | 153 | } |
| ... | ... | @@ -192,7 +180,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef |
| 192 | 180 | const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; |
| 193 | 181 | const threadId = |
| 194 | 182 | threadIdCandidate && !isSyntheticClaudeThreadId(threadIdCandidate) |
| 195 | ? threadIdCandidate | |
| 183 | ? ThreadId.makeUnsafe(threadIdCandidate) | |
| 196 | 184 | : undefined; |
| 197 | 185 | const resumeCandidate = |
| 198 | 186 | typeof cursor.resume === "string" |
| ... | ... | @@ -206,7 +194,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef |
| 206 | 194 | const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; |
| 207 | 195 | |
| 208 | 196 | return { |
| 209 | ...(threadId ? { threadId: ProviderThreadId.makeUnsafe(threadId) } : {}), | |
| 197 | ...(threadId ? { threadId } : {}), | |
| 210 | 198 | ...(resume ? { resume } : {}), |
| 211 | 199 | ...(resumeSessionAt ? { resumeSessionAt } : {}), |
| 212 | 200 | ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 |
| ... | ... | @@ -243,6 +231,10 @@ function classifyToolItemType(toolName: string): CanonicalItemType { |
| 243 | 231 | } |
| 244 | 232 | |
| 245 | 233 | function classifyRequestType(toolName: string): CanonicalRequestType { |
| 234 | const normalized = toolName.toLowerCase(); | |
| 235 | if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { | |
| 236 | return "file_read_approval"; | |
| 237 | } | |
| 246 | 238 | return classifyToolItemType(toolName) === "command_execution" |
| 247 | 239 | ? "command_execution_approval" |
| 248 | 240 | : "file_change_approval"; |
| ... | ... | @@ -324,6 +316,12 @@ function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoni |
| 324 | 316 | return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; |
| 325 | 317 | } |
| 326 | 318 | |
| 319 | function providerThreadRef( | |
| 320 | context: ClaudeSessionContext, | |
| 321 | ): { readonly providerThreadId: string } | {} { | |
| 322 | return context.resumeSessionId ? { providerThreadId: context.resumeSessionId } : {}; | |
| 323 | } | |
| 324 | ||
| 327 | 325 | function extractAssistantText(message: SDKMessage): string { |
| 328 | 326 | if (message.type !== "assistant") { |
| 329 | 327 | return ""; |
| ... | ... | @@ -353,21 +351,21 @@ function extractAssistantText(message: SDKMessage): string { |
| 353 | 351 | } |
| 354 | 352 | |
| 355 | 353 | function toSessionError( |
| 356 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 354 | threadId: ThreadId, | |
| 357 | 355 | cause: unknown, |
| 358 | 356 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { |
| 359 | 357 | const normalized = toMessage(cause, "").toLowerCase(); |
| 360 | 358 | if (normalized.includes("unknown session") || normalized.includes("not found")) { |
| 361 | 359 | return new ProviderAdapterSessionNotFoundError({ |
| 362 | 360 | provider: PROVIDER, |
| 363 | sessionId, | |
| 361 | threadId, | |
| 364 | 362 | cause, |
| 365 | 363 | }); |
| 366 | 364 | } |
| 367 | 365 | if (normalized.includes("closed")) { |
| 368 | 366 | return new ProviderAdapterSessionClosedError({ |
| 369 | 367 | provider: PROVIDER, |
| 370 | sessionId, | |
| 368 | threadId, | |
| 371 | 369 | cause, |
| 372 | 370 | }); |
| 373 | 371 | } |
| ... | ... | @@ -375,11 +373,11 @@ function toSessionError( |
| 375 | 373 | } |
| 376 | 374 | |
| 377 | 375 | function toRequestError( |
| 378 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 376 | threadId: ThreadId, | |
| 379 | 377 | method: string, |
| 380 | 378 | cause: unknown, |
| 381 | 379 | ): ProviderAdapterError { |
| 382 | const sessionError = toSessionError(sessionId, cause); | |
| 380 | const sessionError = toSessionError(threadId, cause); | |
| 383 | 381 | if (sessionError) { |
| 384 | 382 | return sessionError; |
| 385 | 383 | } |
| ... | ... | @@ -469,7 +467,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 469 | 467 | readonly options: ClaudeQueryOptions; |
| 470 | 468 | }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); |
| 471 | 469 | |
| 472 | const sessions = new Map<ProviderSessionId, ClaudeSessionContext>(); | |
| 470 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); | |
| 473 | 471 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 474 | 472 | |
| 475 | 473 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| ... | ... | @@ -502,11 +500,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 502 | 500 | : crypto.randomUUID(), |
| 503 | 501 | kind: "notification", |
| 504 | 502 | provider: PROVIDER, |
| 505 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 506 | 503 | createdAt: observedAt, |
| 507 | 504 | 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) } : {}), | |
| 510 | 509 | ...(itemId ? { itemId: ProviderItemId.makeUnsafe(itemId) } : {}), |
| 511 | 510 | payload: message, |
| 512 | 511 | }, |
| ... | ... | @@ -518,9 +517,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 518 | 517 | const snapshotThread = ( |
| 519 | 518 | context: ClaudeSessionContext, |
| 520 | 519 | ): Effect.Effect<{ |
| 521 | threadId: ReturnType<typeof ProviderThreadId.makeUnsafe>; | |
| 520 | threadId: ThreadId; | |
| 522 | 521 | turns: ReadonlyArray<{ |
| 523 | id: ReturnType<typeof ProviderTurnId.makeUnsafe>; | |
| 522 | id: TurnId; | |
| 524 | 523 | items: ReadonlyArray<unknown>; |
| 525 | 524 | }>; |
| 526 | 525 | }, ProviderAdapterValidationError> => |
| ... | ... | @@ -569,19 +568,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 569 | 568 | if (typeof message.session_id !== "string" || message.session_id.length === 0) { |
| 570 | 569 | return; |
| 571 | 570 | } |
| 572 | const nextThreadId = ProviderThreadId.makeUnsafe(message.session_id); | |
| 571 | const nextThreadId = message.session_id; | |
| 573 | 572 | 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); | |
| 585 | 574 | |
| 586 | 575 | if (context.lastThreadStartedId !== nextThreadId) { |
| 587 | 576 | context.lastThreadStartedId = nextThreadId; |
| ... | ... | @@ -590,16 +579,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 590 | 579 | type: "thread.started", |
| 591 | 580 | eventId: stamp.eventId, |
| 592 | 581 | provider: PROVIDER, |
| 593 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 594 | 582 | createdAt: stamp.createdAt, |
| 595 | threadId: asRuntimeThreadId(nextThreadId), | |
| 583 | threadId: context.session.threadId, | |
| 596 | 584 | payload: { |
| 597 | 585 | providerThreadId: nextThreadId, |
| 598 | 586 | }, |
| 599 | providerRefs: { | |
| 600 | providerSessionId: context.session.sessionId, | |
| 601 | providerThreadId: nextThreadId, | |
| 602 | }, | |
| 587 | providerRefs: {}, | |
| 603 | 588 | raw: { |
| 604 | 589 | source: "claude.sdk.message", |
| 605 | 590 | method: "claude/thread/started", |
| ... | ... | @@ -626,19 +611,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 626 | 611 | type: "runtime.error", |
| 627 | 612 | eventId: stamp.eventId, |
| 628 | 613 | provider: PROVIDER, |
| 629 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 630 | 614 | 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) } : {}), | |
| 633 | 617 | payload: { |
| 634 | 618 | message, |
| 635 | 619 | class: "provider_error", |
| 636 | 620 | ...(cause !== undefined ? { detail: cause } : {}), |
| 637 | 621 | }, |
| 638 | 622 | 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) } : {}), | |
| 642 | 625 | }, |
| 643 | 626 | }); |
| 644 | 627 | }); |
| ... | ... | @@ -655,18 +638,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 655 | 638 | type: "runtime.warning", |
| 656 | 639 | eventId: stamp.eventId, |
| 657 | 640 | provider: PROVIDER, |
| 658 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 659 | 641 | 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) } : {}), | |
| 662 | 644 | payload: { |
| 663 | 645 | message, |
| 664 | 646 | ...(detail !== undefined ? { detail } : {}), |
| 665 | 647 | }, |
| 666 | 648 | 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) } : {}), | |
| 670 | 651 | }, |
| 671 | 652 | }); |
| 672 | 653 | }); |
| ... | ... | @@ -685,9 +666,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 685 | 666 | type: "turn.completed", |
| 686 | 667 | eventId: stamp.eventId, |
| 687 | 668 | provider: PROVIDER, |
| 688 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 689 | 669 | createdAt: stamp.createdAt, |
| 690 | ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), | |
| 670 | threadId: context.session.threadId, | |
| 691 | 671 | payload: { |
| 692 | 672 | state: status, |
| 693 | 673 | ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), |
| ... | ... | @@ -698,10 +678,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 698 | 678 | : {}), |
| 699 | 679 | ...(errorMessage ? { errorMessage } : {}), |
| 700 | 680 | }, |
| 701 | providerRefs: { | |
| 702 | providerSessionId: context.session.sessionId, | |
| 703 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 704 | }, | |
| 681 | providerRefs: {}, | |
| 705 | 682 | }); |
| 706 | 683 | return; |
| 707 | 684 | } |
| ... | ... | @@ -713,19 +690,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 713 | 690 | type: "content.delta", |
| 714 | 691 | eventId: deltaStamp.eventId, |
| 715 | 692 | provider: PROVIDER, |
| 716 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 717 | 693 | 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, | |
| 720 | 696 | itemId: asRuntimeItemId(turnState.assistantItemId), |
| 721 | 697 | payload: { |
| 722 | 698 | streamKind: "assistant_text", |
| 723 | 699 | delta: turnState.fallbackAssistantText, |
| 724 | 700 | }, |
| 725 | 701 | 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), | |
| 729 | 704 | providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), |
| 730 | 705 | }, |
| 731 | 706 | }); |
| ... | ... | @@ -736,19 +711,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 736 | 711 | type: "item.completed", |
| 737 | 712 | eventId: stamp.eventId, |
| 738 | 713 | provider: PROVIDER, |
| 739 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 740 | 714 | createdAt: stamp.createdAt, |
| 741 | 715 | 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, | |
| 744 | 718 | payload: { |
| 745 | 719 | itemType: "assistant_message", |
| 746 | 720 | status: "completed", |
| 747 | 721 | title: "Assistant message", |
| 748 | 722 | }, |
| 749 | 723 | providerRefs: { |
| 750 | providerSessionId: context.session.sessionId, | |
| 751 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 724 | ...providerThreadRef(context), | |
| 752 | 725 | providerTurnId: turnState.turnId, |
| 753 | 726 | providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), |
| 754 | 727 | }, |
| ... | ... | @@ -765,10 +738,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 765 | 738 | type: "turn.completed", |
| 766 | 739 | eventId: stamp.eventId, |
| 767 | 740 | provider: PROVIDER, |
| 768 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 769 | 741 | 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, | |
| 772 | 744 | payload: { |
| 773 | 745 | state: status, |
| 774 | 746 | ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), |
| ... | ... | @@ -780,8 +752,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 780 | 752 | ...(errorMessage ? { errorMessage } : {}), |
| 781 | 753 | }, |
| 782 | 754 | providerRefs: { |
| 783 | providerSessionId: context.session.sessionId, | |
| 784 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 755 | ...providerThreadRef(context), | |
| 785 | 756 | providerTurnId: turnState.turnId, |
| 786 | 757 | }, |
| 787 | 758 | }); |
| ... | ... | @@ -826,18 +797,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 826 | 797 | type: "content.delta", |
| 827 | 798 | eventId: stamp.eventId, |
| 828 | 799 | provider: PROVIDER, |
| 829 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 830 | 800 | 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, | |
| 833 | 803 | itemId: asRuntimeItemId(context.turnState.assistantItemId), |
| 834 | 804 | payload: { |
| 835 | 805 | streamKind: streamKindFromDeltaType(event.delta.type), |
| 836 | 806 | delta: event.delta.text, |
| 837 | 807 | }, |
| 838 | 808 | providerRefs: { |
| 839 | providerSessionId: context.session.sessionId, | |
| 840 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 809 | ...providerThreadRef(context), | |
| 841 | 810 | providerTurnId: context.turnState.turnId, |
| 842 | 811 | providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), |
| 843 | 812 | }, |
| ... | ... | @@ -884,10 +853,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 884 | 853 | type: "item.started", |
| 885 | 854 | eventId: stamp.eventId, |
| 886 | 855 | 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) } : {}), | |
| 891 | 859 | itemId: asRuntimeItemId(tool.itemId), |
| 892 | 860 | payload: { |
| 893 | 861 | itemType: tool.itemType, |
| ... | ... | @@ -900,9 +868,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 900 | 868 | }, |
| 901 | 869 | }, |
| 902 | 870 | 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) } : {}), | |
| 906 | 873 | providerItemId: ProviderItemId.makeUnsafe(tool.itemId), |
| 907 | 874 | }, |
| 908 | 875 | raw: { |
| ... | ... | @@ -927,10 +894,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 927 | 894 | type: "item.completed", |
| 928 | 895 | eventId: stamp.eventId, |
| 929 | 896 | 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) } : {}), | |
| 934 | 900 | itemId: asRuntimeItemId(tool.itemId), |
| 935 | 901 | payload: { |
| 936 | 902 | itemType: tool.itemType, |
| ... | ... | @@ -939,9 +905,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 939 | 905 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 940 | 906 | }, |
| 941 | 907 | 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) } : {}), | |
| 945 | 910 | providerItemId: ProviderItemId.makeUnsafe(tool.itemId), |
| 946 | 911 | }, |
| 947 | 912 | raw: { |
| ... | ... | @@ -980,10 +945,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 980 | 945 | type: "item.updated", |
| 981 | 946 | eventId: stamp.eventId, |
| 982 | 947 | provider: PROVIDER, |
| 983 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 984 | 948 | 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, | |
| 987 | 951 | itemId: asRuntimeItemId(context.turnState.assistantItemId), |
| 988 | 952 | payload: { |
| 989 | 953 | itemType: "assistant_message", |
| ... | ... | @@ -992,8 +956,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 992 | 956 | data: message.message, |
| 993 | 957 | }, |
| 994 | 958 | providerRefs: { |
| 995 | providerSessionId: context.session.sessionId, | |
| 996 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 959 | ...providerThreadRef(context), | |
| 997 | 960 | providerTurnId: context.turnState.turnId, |
| 998 | 961 | providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), |
| 999 | 962 | }, |
| ... | ... | @@ -1041,13 +1004,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1041 | 1004 | const base = { |
| 1042 | 1005 | eventId: stamp.eventId, |
| 1043 | 1006 | provider: PROVIDER, |
| 1044 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1045 | 1007 | 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) } : {}), | |
| 1048 | 1010 | providerRefs: { |
| 1049 | providerSessionId: context.session.sessionId, | |
| 1050 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 1011 | ...providerThreadRef(context), | |
| 1051 | 1012 | ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), |
| 1052 | 1013 | }, |
| 1053 | 1014 | raw: { |
| ... | ... | @@ -1202,13 +1163,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1202 | 1163 | const base = { |
| 1203 | 1164 | eventId: stamp.eventId, |
| 1204 | 1165 | provider: PROVIDER, |
| 1205 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1206 | 1166 | 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) } : {}), | |
| 1209 | 1169 | providerRefs: { |
| 1210 | providerSessionId: context.session.sessionId, | |
| 1211 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 1170 | ...providerThreadRef(context), | |
| 1212 | 1171 | ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), |
| 1213 | 1172 | }, |
| 1214 | 1173 | raw: { |
| ... | ... | @@ -1284,6 +1243,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1284 | 1243 | case "stream_event": |
| 1285 | 1244 | yield* handleStreamEvent(context, message); |
| 1286 | 1245 | return; |
| 1246 | case "user": | |
| 1247 | return; | |
| 1287 | 1248 | case "assistant": |
| 1288 | 1249 | yield* handleAssistantMessage(context, message); |
| 1289 | 1250 | return; |
| ... | ... | @@ -1341,19 +1302,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1341 | 1302 | type: "request.resolved", |
| 1342 | 1303 | eventId: stamp.eventId, |
| 1343 | 1304 | 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) } : {}), | |
| 1348 | 1308 | requestId: asRuntimeRequestId(requestId), |
| 1349 | 1309 | payload: { |
| 1350 | 1310 | requestType: pending.requestType, |
| 1351 | 1311 | decision: "cancel", |
| 1352 | 1312 | }, |
| 1353 | 1313 | 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) } : {}), | |
| 1357 | 1316 | providerRequestId: requestId, |
| 1358 | 1317 | }, |
| 1359 | 1318 | }); |
| ... | ... | @@ -1382,32 +1341,28 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1382 | 1341 | type: "session.exited", |
| 1383 | 1342 | eventId: stamp.eventId, |
| 1384 | 1343 | 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, | |
| 1388 | 1346 | payload: { |
| 1389 | 1347 | reason: "Session stopped", |
| 1390 | 1348 | exitKind: "graceful", |
| 1391 | 1349 | }, |
| 1392 | providerRefs: { | |
| 1393 | providerSessionId: context.session.sessionId, | |
| 1394 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 1395 | }, | |
| 1350 | providerRefs: {}, | |
| 1396 | 1351 | }); |
| 1397 | 1352 | } |
| 1398 | 1353 | |
| 1399 | sessions.delete(context.session.sessionId); | |
| 1354 | sessions.delete(context.session.threadId); | |
| 1400 | 1355 | }); |
| 1401 | 1356 | |
| 1402 | 1357 | const requireSession = ( |
| 1403 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 1358 | threadId: ThreadId, | |
| 1404 | 1359 | ): Effect.Effect<ClaudeSessionContext, ProviderAdapterError> => { |
| 1405 | const context = sessions.get(sessionId); | |
| 1360 | const context = sessions.get(threadId); | |
| 1406 | 1361 | if (!context) { |
| 1407 | 1362 | return Effect.fail( |
| 1408 | 1363 | new ProviderAdapterSessionNotFoundError({ |
| 1409 | 1364 | provider: PROVIDER, |
| 1410 | sessionId, | |
| 1365 | threadId, | |
| 1411 | 1366 | }), |
| 1412 | 1367 | ); |
| 1413 | 1368 | } |
| ... | ... | @@ -1415,7 +1370,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1415 | 1370 | return Effect.fail( |
| 1416 | 1371 | new ProviderAdapterSessionClosedError({ |
| 1417 | 1372 | provider: PROVIDER, |
| 1418 | sessionId, | |
| 1373 | threadId, | |
| 1419 | 1374 | }), |
| 1420 | 1375 | ); |
| 1421 | 1376 | } |
| ... | ... | @@ -1433,11 +1388,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1433 | 1388 | } |
| 1434 | 1389 | |
| 1435 | 1390 | const startedAt = yield* nowIso; |
| 1436 | const sessionId = ProviderSessionId.makeUnsafe( | |
| 1437 | `claude-session-${yield* Random.nextUUIDv4}`, | |
| 1438 | ); | |
| 1439 | 1391 | const resumeState = readClaudeResumeState(input.resumeCursor); |
| 1440 | const threadId = resumeState?.threadId; | |
| 1392 | const threadId = input.threadId; | |
| 1441 | 1393 | |
| 1442 | 1394 | const promptQueue = yield* Queue.unbounded<PromptQueueItem>(); |
| 1443 | 1395 | const prompt = Stream.fromQueue(promptQueue).pipe( |
| ... | ... | @@ -1488,10 +1440,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1488 | 1440 | type: "request.opened", |
| 1489 | 1441 | eventId: requestedStamp.eventId, |
| 1490 | 1442 | 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) } : {}), | |
| 1495 | 1446 | requestId: asRuntimeRequestId(requestId), |
| 1496 | 1447 | payload: { |
| 1497 | 1448 | requestType, |
| ... | ... | @@ -1503,11 +1454,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1503 | 1454 | }, |
| 1504 | 1455 | }, |
| 1505 | 1456 | providerRefs: { |
| 1506 | providerSessionId: context.session.sessionId, | |
| 1507 | ...(context.session.threadId | |
| 1457 | ...(context.session.threadId | |
| 1508 | 1458 | ? { providerThreadId: context.session.threadId } |
| 1509 | 1459 | : {}), |
| 1510 | ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), | |
| 1460 | ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), | |
| 1511 | 1461 | providerRequestId: requestId, |
| 1512 | 1462 | }, |
| 1513 | 1463 | raw: { |
| ... | ... | @@ -1542,21 +1492,19 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1542 | 1492 | type: "request.resolved", |
| 1543 | 1493 | eventId: resolvedStamp.eventId, |
| 1544 | 1494 | 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) } : {}), | |
| 1549 | 1498 | requestId: asRuntimeRequestId(requestId), |
| 1550 | 1499 | payload: { |
| 1551 | 1500 | requestType, |
| 1552 | 1501 | decision, |
| 1553 | 1502 | }, |
| 1554 | 1503 | providerRefs: { |
| 1555 | providerSessionId: context.session.sessionId, | |
| 1556 | ...(context.session.threadId | |
| 1504 | ...(context.session.threadId | |
| 1557 | 1505 | ? { providerThreadId: context.session.threadId } |
| 1558 | 1506 | : {}), |
| 1559 | ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), | |
| 1507 | ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), | |
| 1560 | 1508 | providerRequestId: requestId, |
| 1561 | 1509 | }, |
| 1562 | 1510 | raw: { |
| ... | ... | @@ -1623,14 +1571,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1623 | 1571 | catch: (cause) => |
| 1624 | 1572 | new ProviderAdapterProcessError({ |
| 1625 | 1573 | provider: PROVIDER, |
| 1626 | sessionId, | |
| 1574 | threadId, | |
| 1627 | 1575 | detail: toMessage(cause, "Failed to start Claude runtime session."), |
| 1628 | 1576 | cause, |
| 1629 | 1577 | }), |
| 1630 | 1578 | }); |
| 1631 | 1579 | |
| 1632 | 1580 | const session: ProviderSession = { |
| 1633 | sessionId, | |
| 1581 | threadId, | |
| 1634 | 1582 | provider: PROVIDER, |
| 1635 | 1583 | status: "ready", |
| 1636 | 1584 | runtimeMode: input.runtimeMode, |
| ... | ... | @@ -1664,21 +1612,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1664 | 1612 | stopped: false, |
| 1665 | 1613 | }; |
| 1666 | 1614 | yield* Ref.set(contextRef, context); |
| 1667 | sessions.set(sessionId, context); | |
| 1615 | sessions.set(threadId, context); | |
| 1668 | 1616 | |
| 1669 | 1617 | const sessionStartedStamp = yield* makeEventStamp(); |
| 1670 | 1618 | yield* offerRuntimeEvent({ |
| 1671 | 1619 | type: "session.started", |
| 1672 | 1620 | eventId: sessionStartedStamp.eventId, |
| 1673 | 1621 | provider: PROVIDER, |
| 1674 | sessionId: asRuntimeSessionId(sessionId), | |
| 1675 | 1622 | createdAt: sessionStartedStamp.createdAt, |
| 1676 | ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), | |
| 1623 | threadId, | |
| 1677 | 1624 | payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, |
| 1678 | providerRefs: { | |
| 1679 | providerSessionId: sessionId, | |
| 1680 | ...(threadId ? { providerThreadId: threadId } : {}), | |
| 1681 | }, | |
| 1625 | providerRefs: {}, | |
| 1682 | 1626 | }); |
| 1683 | 1627 | |
| 1684 | 1628 | const configuredStamp = yield* makeEventStamp(); |
| ... | ... | @@ -1686,9 +1630,8 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1686 | 1630 | type: "session.configured", |
| 1687 | 1631 | eventId: configuredStamp.eventId, |
| 1688 | 1632 | provider: PROVIDER, |
| 1689 | sessionId: asRuntimeSessionId(sessionId), | |
| 1690 | 1633 | createdAt: configuredStamp.createdAt, |
| 1691 | ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), | |
| 1634 | threadId, | |
| 1692 | 1635 | payload: { |
| 1693 | 1636 | config: { |
| 1694 | 1637 | ...(input.model ? { model: input.model } : {}), |
| ... | ... | @@ -1699,10 +1642,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1699 | 1642 | : {}), |
| 1700 | 1643 | }, |
| 1701 | 1644 | }, |
| 1702 | providerRefs: { | |
| 1703 | providerSessionId: sessionId, | |
| 1704 | ...(threadId ? { providerThreadId: threadId } : {}), | |
| 1705 | }, | |
| 1645 | providerRefs: {}, | |
| 1706 | 1646 | }); |
| 1707 | 1647 | |
| 1708 | 1648 | const readyStamp = yield* makeEventStamp(); |
| ... | ... | @@ -1710,16 +1650,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1710 | 1650 | type: "session.state.changed", |
| 1711 | 1651 | eventId: readyStamp.eventId, |
| 1712 | 1652 | provider: PROVIDER, |
| 1713 | sessionId: asRuntimeSessionId(sessionId), | |
| 1714 | 1653 | createdAt: readyStamp.createdAt, |
| 1715 | ...(threadId ? { threadId: asRuntimeThreadId(threadId) } : {}), | |
| 1654 | threadId, | |
| 1716 | 1655 | payload: { |
| 1717 | 1656 | state: "ready", |
| 1718 | 1657 | }, |
| 1719 | providerRefs: { | |
| 1720 | providerSessionId: sessionId, | |
| 1721 | ...(threadId ? { providerThreadId: threadId } : {}), | |
| 1722 | }, | |
| 1658 | providerRefs: {}, | |
| 1723 | 1659 | }); |
| 1724 | 1660 | |
| 1725 | 1661 | Effect.runFork(runSdkStream(context)); |
| ... | ... | @@ -1731,24 +1667,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1731 | 1667 | |
| 1732 | 1668 | const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) => |
| 1733 | 1669 | Effect.gen(function* () { |
| 1734 | const context = yield* requireSession(input.sessionId); | |
| 1670 | const context = yield* requireSession(input.threadId); | |
| 1735 | 1671 | |
| 1736 | 1672 | if (context.turnState) { |
| 1737 | 1673 | return yield* new ProviderAdapterValidationError({ |
| 1738 | 1674 | provider: PROVIDER, |
| 1739 | 1675 | operation: "sendTurn", |
| 1740 | issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, | |
| 1676 | issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, | |
| 1741 | 1677 | }); |
| 1742 | 1678 | } |
| 1743 | 1679 | |
| 1744 | 1680 | if (input.model) { |
| 1745 | 1681 | yield* Effect.tryPromise({ |
| 1746 | 1682 | try: () => context.query.setModel(input.model), |
| 1747 | catch: (cause) => toRequestError(input.sessionId, "turn/setModel", cause), | |
| 1683 | catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), | |
| 1748 | 1684 | }); |
| 1749 | 1685 | } |
| 1750 | 1686 | |
| 1751 | const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4); | |
| 1687 | const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); | |
| 1752 | 1688 | const turnState: ClaudeTurnState = { |
| 1753 | 1689 | turnId, |
| 1754 | 1690 | assistantItemId: yield* Random.nextUUIDv4, |
| ... | ... | @@ -1773,18 +1709,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1773 | 1709 | type: "turn.started", |
| 1774 | 1710 | eventId: turnStartedStamp.eventId, |
| 1775 | 1711 | provider: PROVIDER, |
| 1776 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1777 | 1712 | createdAt: turnStartedStamp.createdAt, |
| 1778 | ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), | |
| 1779 | turnId: asRuntimeTurnId(turnId), | |
| 1713 | threadId: context.session.threadId, | |
| 1714 | turnId, | |
| 1780 | 1715 | payload: { |
| 1781 | 1716 | ...(input.model ? { model: input.model } : {}), |
| 1782 | 1717 | ...(input.effort ? { effort: input.effort } : {}), |
| 1783 | 1718 | }, |
| 1784 | 1719 | providerRefs: { |
| 1785 | providerSessionId: context.session.sessionId, | |
| 1786 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 1787 | providerTurnId: turnId, | |
| 1720 | providerTurnId: String(turnId), | |
| 1788 | 1721 | }, |
| 1789 | 1722 | }); |
| 1790 | 1723 | |
| ... | ... | @@ -1793,10 +1726,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1793 | 1726 | yield* Queue.offer(context.promptQueue, { |
| 1794 | 1727 | type: "message", |
| 1795 | 1728 | message, |
| 1796 | }).pipe(Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause))); | |
| 1729 | }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); | |
| 1797 | 1730 | |
| 1798 | 1731 | return { |
| 1799 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 1732 | threadId: context.session.threadId, | |
| 1800 | 1733 | turnId, |
| 1801 | 1734 | ...(context.session.resumeCursor !== undefined |
| 1802 | 1735 | ? { resumeCursor: context.session.resumeCursor } |
| ... | ... | @@ -1804,24 +1737,24 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1804 | 1737 | }; |
| 1805 | 1738 | }); |
| 1806 | 1739 | |
| 1807 | const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (sessionId, _turnId) => | |
| 1740 | const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId, _turnId) => | |
| 1808 | 1741 | Effect.gen(function* () { |
| 1809 | const context = yield* requireSession(sessionId); | |
| 1742 | const context = yield* requireSession(threadId); | |
| 1810 | 1743 | yield* Effect.tryPromise({ |
| 1811 | 1744 | try: () => context.query.interrupt(), |
| 1812 | catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), | |
| 1745 | catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), | |
| 1813 | 1746 | }); |
| 1814 | 1747 | }); |
| 1815 | 1748 | |
| 1816 | const readThread: ClaudeCodeAdapterShape["readThread"] = (sessionId) => | |
| 1749 | const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) => | |
| 1817 | 1750 | Effect.gen(function* () { |
| 1818 | const context = yield* requireSession(sessionId); | |
| 1751 | const context = yield* requireSession(threadId); | |
| 1819 | 1752 | return yield* snapshotThread(context); |
| 1820 | 1753 | }); |
| 1821 | 1754 | |
| 1822 | const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (sessionId, numTurns) => | |
| 1755 | const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => | |
| 1823 | 1756 | Effect.gen(function* () { |
| 1824 | const context = yield* requireSession(sessionId); | |
| 1757 | const context = yield* requireSession(threadId); | |
| 1825 | 1758 | const nextLength = Math.max(0, context.turns.length - numTurns); |
| 1826 | 1759 | context.turns.splice(nextLength); |
| 1827 | 1760 | yield* updateResumeCursor(context); |
| ... | ... | @@ -1829,12 +1762,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1829 | 1762 | }); |
| 1830 | 1763 | |
| 1831 | 1764 | const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( |
| 1832 | sessionId, | |
| 1765 | threadId, | |
| 1833 | 1766 | requestId, |
| 1834 | 1767 | decision, |
| 1835 | 1768 | ) => |
| 1836 | 1769 | Effect.gen(function* () { |
| 1837 | const context = yield* requireSession(sessionId); | |
| 1770 | const context = yield* requireSession(threadId); | |
| 1838 | 1771 | const pending = context.pendingApprovals.get(requestId); |
| 1839 | 1772 | if (!pending) { |
| 1840 | 1773 | return yield* new ProviderAdapterRequestError({ |
| ... | ... | @@ -1848,9 +1781,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1848 | 1781 | yield* Deferred.succeed(pending.decision, decision); |
| 1849 | 1782 | }); |
| 1850 | 1783 | |
| 1851 | const stopSession: ClaudeCodeAdapterShape["stopSession"] = (sessionId) => | |
| 1784 | const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) => | |
| 1852 | 1785 | Effect.gen(function* () { |
| 1853 | const context = yield* requireSession(sessionId); | |
| 1786 | const context = yield* requireSession(threadId); | |
| 1854 | 1787 | yield* stopSessionInternal(context, { |
| 1855 | 1788 | emitExitEvent: true, |
| 1856 | 1789 | }); |
| ... | ... | @@ -1859,9 +1792,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { |
| 1859 | 1792 | const listSessions: ClaudeCodeAdapterShape["listSessions"] = () => |
| 1860 | 1793 | Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); |
| 1861 | 1794 | |
| 1862 | const hasSession: ClaudeCodeAdapterShape["hasSession"] = (sessionId) => | |
| 1795 | const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) => | |
| 1863 | 1796 | Effect.sync(() => { |
| 1864 | const context = sessions.get(sessionId); | |
| 1797 | const context = sessions.get(threadId); | |
| 1865 | 1798 | return context !== undefined && !context.stopped; |
| 1866 | 1799 | }); |
| 1867 | 1800 |
apps/server/src/provider/Layers/CodexAdapter.test.ts+31-34| ... | ... | @@ -4,12 +4,11 @@ import { |
| 4 | 4 | ProviderItemId, |
| 5 | 5 | type ProviderApprovalDecision, |
| 6 | 6 | type ProviderEvent, |
| 7 | ProviderSessionId, | |
| 8 | 7 | type ProviderSession, |
| 9 | 8 | type ProviderSessionStartInput, |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | 9 | type ProviderTurnStartResult, |
| 10 | ThreadId, | |
| 11 | TurnId, | |
| 13 | 12 | } from "@t3tools/contracts"; |
| 14 | 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 15 | 14 | import { afterAll, assert, it, vi } from "@effect/vitest"; |
| ... | ... | @@ -27,8 +26,8 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 27 | 26 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 28 | 27 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 29 | 28 | |
| 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 31 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 29 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 30 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 32 | 31 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 33 | 32 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 34 | 33 | |
| ... | ... | @@ -37,11 +36,10 @@ class FakeCodexManager extends CodexAppServerManager { |
| 37 | 36 | async (input: ProviderSessionStartInput): Promise<ProviderSession> => { |
| 38 | 37 | const now = new Date().toISOString(); |
| 39 | 38 | return { |
| 40 | sessionId: asSessionId("sess-1"), | |
| 41 | 39 | provider: "codex", |
| 42 | 40 | status: "ready", |
| 43 | 41 | runtimeMode: input.runtimeMode, |
| 44 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 42 | threadId: input.threadId, | |
| 45 | 43 | cwd: input.cwd, |
| 46 | 44 | createdAt: now, |
| 47 | 45 | updatedAt: now, |
| ... | ... | @@ -51,28 +49,28 @@ class FakeCodexManager extends CodexAppServerManager { |
| 51 | 49 | |
| 52 | 50 | public sendTurnImpl = vi.fn( |
| 53 | 51 | async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({ |
| 54 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 52 | threadId: asThreadId("thread-1"), | |
| 55 | 53 | turnId: asTurnId("turn-1"), |
| 56 | 54 | }), |
| 57 | 55 | ); |
| 58 | 56 | |
| 59 | 57 | public interruptTurnImpl = vi.fn( |
| 60 | async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined, | |
| 58 | async (_threadId: ThreadId, _turnId?: TurnId): Promise<void> => undefined, | |
| 61 | 59 | ); |
| 62 | 60 | |
| 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"), | |
| 65 | 63 | turns: [], |
| 66 | 64 | })); |
| 67 | 65 | |
| 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"), | |
| 70 | 68 | turns: [], |
| 71 | 69 | })); |
| 72 | 70 | |
| 73 | 71 | public respondToRequestImpl = vi.fn( |
| 74 | 72 | async ( |
| 75 | _sessionId: ProviderSessionId, | |
| 73 | _threadId: ThreadId, | |
| 76 | 74 | _requestId: ApprovalRequestId, |
| 77 | 75 | _decision: ProviderApprovalDecision, |
| 78 | 76 | ): Promise<void> => undefined, |
| ... | ... | @@ -88,33 +86,33 @@ class FakeCodexManager extends CodexAppServerManager { |
| 88 | 86 | return this.sendTurnImpl(input); |
| 89 | 87 | } |
| 90 | 88 | |
| 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); | |
| 93 | 91 | } |
| 94 | 92 | |
| 95 | override readThread(sessionId: ProviderSessionId) { | |
| 96 | return this.readThreadImpl(sessionId); | |
| 93 | override readThread(threadId: ThreadId) { | |
| 94 | return this.readThreadImpl(threadId); | |
| 97 | 95 | } |
| 98 | 96 | |
| 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); | |
| 101 | 99 | } |
| 102 | 100 | |
| 103 | 101 | override respondToRequest( |
| 104 | sessionId: ProviderSessionId, | |
| 102 | threadId: ThreadId, | |
| 105 | 103 | requestId: ApprovalRequestId, |
| 106 | 104 | decision: ProviderApprovalDecision, |
| 107 | 105 | ): Promise<void> { |
| 108 | return this.respondToRequestImpl(sessionId, requestId, decision); | |
| 106 | return this.respondToRequestImpl(threadId, requestId, decision); | |
| 109 | 107 | } |
| 110 | 108 | |
| 111 | override stopSession(_sessionId: ProviderSessionId): void {} | |
| 109 | override stopSession(_threadId: ThreadId): void {} | |
| 112 | 110 | |
| 113 | 111 | override listSessions(): ProviderSession[] { |
| 114 | 112 | return []; |
| 115 | 113 | } |
| 116 | 114 | |
| 117 | override hasSession(_sessionId: ProviderSessionId): boolean { | |
| 115 | override hasSession(_threadId: ThreadId): boolean { | |
| 118 | 116 | return false; |
| 119 | 117 | } |
| 120 | 118 | |
| ... | ... | @@ -128,9 +126,8 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory |
| 128 | 126 | getProvider: () => |
| 129 | 127 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), |
| 130 | 128 | getBinding: () => Effect.succeed(Option.none()), |
| 131 | getThreadId: () => Effect.succeed(Option.none()), | |
| 132 | 129 | remove: () => Effect.void, |
| 133 | listSessionIds: () => Effect.succeed([]), | |
| 130 | listThreadIds: () => Effect.succeed([]), | |
| 134 | 131 | }); |
| 135 | 132 | |
| 136 | 133 | const validationManager = new FakeCodexManager(); |
| ... | ... | @@ -149,6 +146,7 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 149 | 146 | const result = yield* adapter |
| 150 | 147 | .startSession({ |
| 151 | 148 | provider: "claudeCode", |
| 149 | threadId: asThreadId("thread-1"), | |
| 152 | 150 | runtimeMode: "full-access", |
| 153 | 151 | }) |
| 154 | 152 | .pipe(Effect.result); |
| ... | ... | @@ -184,7 +182,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 184 | 182 | const adapter = yield* CodexAdapter; |
| 185 | 183 | const result = yield* adapter |
| 186 | 184 | .sendTurn({ |
| 187 | sessionId: asSessionId("sess-missing"), | |
| 185 | threadId: asThreadId("sess-missing"), | |
| 188 | 186 | input: "hello", |
| 189 | 187 | attachments: [], |
| 190 | 188 | }) |
| ... | ... | @@ -200,7 +198,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 200 | 198 | return; |
| 201 | 199 | } |
| 202 | 200 | assert.equal(result.failure.provider, "codex"); |
| 203 | assert.equal(result.failure.sessionId, "sess-missing"); | |
| 201 | assert.equal(result.failure.threadId, "sess-missing"); | |
| 204 | 202 | assert.instanceOf(result.failure.cause, Error); |
| 205 | 203 | }), |
| 206 | 204 | ); |
| ... | ... | @@ -225,10 +223,9 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 225 | 223 | id: asEventId("evt-msg-complete"), |
| 226 | 224 | kind: "notification", |
| 227 | 225 | provider: "codex", |
| 228 | sessionId: asSessionId("sess-1"), | |
| 229 | 226 | createdAt: new Date().toISOString(), |
| 230 | 227 | method: "item/completed", |
| 231 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 228 | threadId: asThreadId("thread-1"), | |
| 232 | 229 | turnId: asTurnId("turn-1"), |
| 233 | 230 | itemId: asItemId("msg_1"), |
| 234 | 231 | payload: { |
| ... | ... | @@ -265,7 +262,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 265 | 262 | id: asEventId("evt-session-closed"), |
| 266 | 263 | kind: "session", |
| 267 | 264 | provider: "codex", |
| 268 | sessionId: asSessionId("sess-1"), | |
| 265 | threadId: asThreadId("thread-1"), | |
| 269 | 266 | createdAt: new Date().toISOString(), |
| 270 | 267 | method: "session/closed", |
| 271 | 268 | message: "Session stopped", |
| ... | ... | @@ -282,7 +279,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 282 | 279 | if (firstEvent.value.type !== "session.exited") { |
| 283 | 280 | return; |
| 284 | 281 | } |
| 285 | assert.equal(firstEvent.value.sessionId, "sess-1"); | |
| 282 | assert.equal(firstEvent.value.threadId, "thread-1"); | |
| 286 | 283 | assert.equal(firstEvent.value.payload.reason, "Session stopped"); |
| 287 | 284 | }), |
| 288 | 285 | ); |
| ... | ... | @@ -296,7 +293,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 296 | 293 | id: asEventId("evt-request-resolved"), |
| 297 | 294 | kind: "notification", |
| 298 | 295 | provider: "codex", |
| 299 | sessionId: asSessionId("sess-1"), | |
| 296 | threadId: asThreadId("thread-1"), | |
| 300 | 297 | createdAt: new Date().toISOString(), |
| 301 | 298 | method: "serverRequest/resolved", |
| 302 | 299 | requestId: ApprovalRequestId.makeUnsafe("req-1"), |
| ... | ... | @@ -334,7 +331,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 334 | 331 | id: asEventId("evt-windows-sandbox-failed"), |
| 335 | 332 | kind: "notification", |
| 336 | 333 | provider: "codex", |
| 337 | sessionId: asSessionId("sess-1"), | |
| 334 | threadId: asThreadId("thread-1"), | |
| 338 | 335 | createdAt: new Date().toISOString(), |
| 339 | 336 | method: "windowsSandbox/setupCompleted", |
| 340 | 337 | message: "Sandbox setup failed", |
apps/server/src/provider/Layers/CodexAdapter.ts+150-153| ... | ... | @@ -13,16 +13,11 @@ import { |
| 13 | 13 | type ProviderRuntimeEvent, |
| 14 | 14 | RuntimeItemId, |
| 15 | 15 | RuntimeRequestId, |
| 16 | RuntimeSessionId, | |
| 17 | 16 | ProviderApprovalDecision, |
| 18 | 17 | ProviderItemId, |
| 19 | ProviderSessionId, | |
| 20 | ProviderThreadId, | |
| 21 | ProviderTurnId, | |
| 22 | 18 | ThreadId, |
| 23 | TurnId, | |
| 24 | 19 | } from "@t3tools/contracts"; |
| 25 | import { Effect, FileSystem, Layer, Option, Queue, Schema, Stream } from "effect"; | |
| 20 | import { Effect, FileSystem, Layer, Queue, Schema, ServiceMap, Stream } from "effect"; | |
| 26 | 21 | |
| 27 | 22 | import { |
| 28 | 23 | ProviderAdapterProcessError, |
| ... | ... | @@ -33,7 +28,6 @@ import { |
| 33 | 28 | type ProviderAdapterError, |
| 34 | 29 | } from "../Errors.ts"; |
| 35 | 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 36 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 37 | 31 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; |
| 38 | 32 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 39 | 33 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -43,7 +37,7 @@ const PROVIDER = "codex" as const; |
| 43 | 37 | |
| 44 | 38 | export interface CodexAdapterLiveOptions { |
| 45 | 39 | readonly manager?: CodexAppServerManager; |
| 46 | readonly makeManager?: () => CodexAppServerManager; | |
| 40 | readonly makeManager?: (services?: ServiceMap.ServiceMap<never>) => CodexAppServerManager; | |
| 47 | 41 | readonly nativeEventLogPath?: string; |
| 48 | 42 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 49 | 43 | } |
| ... | ... | @@ -56,33 +50,29 @@ function toMessage(cause: unknown, fallback: string): string { |
| 56 | 50 | } |
| 57 | 51 | |
| 58 | 52 | function toSessionError( |
| 59 | sessionId: ProviderSessionId, | |
| 53 | threadId: ThreadId, | |
| 60 | 54 | cause: unknown, |
| 61 | 55 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { |
| 62 | 56 | const normalized = toMessage(cause, "").toLowerCase(); |
| 63 | 57 | if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { |
| 64 | 58 | return new ProviderAdapterSessionNotFoundError({ |
| 65 | 59 | provider: PROVIDER, |
| 66 | sessionId, | |
| 60 | threadId, | |
| 67 | 61 | cause, |
| 68 | 62 | }); |
| 69 | 63 | } |
| 70 | 64 | if (normalized.includes("session is closed")) { |
| 71 | 65 | return new ProviderAdapterSessionClosedError({ |
| 72 | 66 | provider: PROVIDER, |
| 73 | sessionId, | |
| 67 | threadId, | |
| 74 | 68 | cause, |
| 75 | 69 | }); |
| 76 | 70 | } |
| 77 | 71 | return undefined; |
| 78 | 72 | } |
| 79 | 73 | |
| 80 | function toRequestError( | |
| 81 | sessionId: ProviderSessionId, | |
| 82 | method: string, | |
| 83 | cause: unknown, | |
| 84 | ): ProviderAdapterError { | |
| 85 | const sessionError = toSessionError(sessionId, cause); | |
| 74 | function toRequestError(threadId: ThreadId, method: string, cause: unknown): ProviderAdapterError { | |
| 75 | const sessionError = toSessionError(threadId, cause); | |
| 86 | 76 | if (sessionError) { |
| 87 | 77 | return sessionError; |
| 88 | 78 | } |
| ... | ... | @@ -139,7 +129,8 @@ function toCanonicalItemType(raw: unknown): CanonicalItemType { |
| 139 | 129 | if (type.includes("reasoning") || type.includes("thought")) return "reasoning"; |
| 140 | 130 | if (type.includes("plan") || type.includes("todo")) return "plan"; |
| 141 | 131 | if (type.includes("command")) return "command_execution"; |
| 142 | if (type.includes("file change") || type.includes("patch") || type.includes("edit")) return "file_change"; | |
| 132 | if (type.includes("file change") || type.includes("patch") || type.includes("edit")) | |
| 133 | return "file_change"; | |
| 143 | 134 | if (type.includes("mcp")) return "mcp_tool_call"; |
| 144 | 135 | if (type.includes("dynamic tool")) return "dynamic_tool_call"; |
| 145 | 136 | if (type.includes("collab")) return "collab_agent_tool_call"; |
| ... | ... | @@ -211,6 +202,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { |
| 211 | 202 | switch (method) { |
| 212 | 203 | case "item/commandExecution/requestApproval": |
| 213 | 204 | return "command_execution_approval"; |
| 205 | case "item/fileRead/requestApproval": | |
| 206 | return "file_read_approval"; | |
| 214 | 207 | case "item/fileChange/requestApproval": |
| 215 | 208 | return "file_change_approval"; |
| 216 | 209 | case "applyPatchApproval": |
| ... | ... | @@ -232,6 +225,8 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { |
| 232 | 225 | switch (kind) { |
| 233 | 226 | case "command": |
| 234 | 227 | return "command_execution_approval"; |
| 228 | case "file-read": | |
| 229 | return "file_read_approval"; | |
| 235 | 230 | case "file-change": |
| 236 | 231 | return "file_change_approval"; |
| 237 | 232 | default: |
| ... | ... | @@ -239,7 +234,9 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { |
| 239 | 234 | } |
| 240 | 235 | } |
| 241 | 236 | |
| 242 | function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | undefined): CanonicalRequestType { | |
| 237 | function toRequestTypeFromResolvedPayload( | |
| 238 | payload: Record<string, unknown> | undefined, | |
| 239 | ): CanonicalRequestType { | |
| 243 | 240 | const request = asObject(payload?.request); |
| 244 | 241 | const method = asString(request?.method) ?? asString(payload?.method); |
| 245 | 242 | if (method) { |
| ... | ... | @@ -252,13 +249,9 @@ function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | und |
| 252 | 249 | return "unknown"; |
| 253 | 250 | } |
| 254 | 251 | |
| 255 | function toThreadState(value: unknown): | |
| 256 | | "active" | |
| 257 | | "idle" | |
| 258 | | "archived" | |
| 259 | | "closed" | |
| 260 | | "compacted" | |
| 261 | | "error" { | |
| 252 | function toThreadState( | |
| 253 | value: unknown, | |
| 254 | ): "active" | "idle" | "archived" | "closed" | "compacted" | "error" { | |
| 262 | 255 | switch (value) { |
| 263 | 256 | case "idle": |
| 264 | 257 | return "idle"; |
| ... | ... | @@ -278,7 +271,13 @@ function toThreadState(value: unknown): |
| 278 | 271 | |
| 279 | 272 | function contentStreamKindFromMethod( |
| 280 | 273 | method: string, |
| 281 | ): "assistant_text" | "reasoning_text" | "reasoning_summary_text" | "plan_text" | "command_output" | "file_change_output" { | |
| 274 | ): | |
| 275 | | "assistant_text" | |
| 276 | | "reasoning_text" | |
| 277 | | "reasoning_summary_text" | |
| 278 | | "plan_text" | |
| 279 | | "command_output" | |
| 280 | | "file_change_output" { | |
| 282 | 281 | switch (method) { |
| 283 | 282 | case "item/agentMessage/delta": |
| 284 | 283 | return "assistant_text"; |
| ... | ... | @@ -297,18 +296,6 @@ function contentStreamKindFromMethod( |
| 297 | 296 | } |
| 298 | 297 | } |
| 299 | 298 | |
| 300 | function asRuntimeSessionId(sessionId: ProviderSessionId): RuntimeSessionId { | |
| 301 | return RuntimeSessionId.makeUnsafe(sessionId); | |
| 302 | } | |
| 303 | ||
| 304 | function asRuntimeThreadId(threadId: ProviderThreadId): ThreadId { | |
| 305 | return ThreadId.makeUnsafe(threadId); | |
| 306 | } | |
| 307 | ||
| 308 | function asRuntimeTurnId(turnId: ProviderTurnId): TurnId { | |
| 309 | return TurnId.makeUnsafe(turnId); | |
| 310 | } | |
| 311 | ||
| 312 | 299 | function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { |
| 313 | 300 | return RuntimeItemId.makeUnsafe(itemId); |
| 314 | 301 | } |
| ... | ... | @@ -321,29 +308,28 @@ function eventRawSource(event: ProviderEvent): NonNullable<ProviderRuntimeEvent[ |
| 321 | 308 | return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; |
| 322 | 309 | } |
| 323 | 310 | |
| 324 | function 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; | |
| 311 | function providerRefsFromEvent( | |
| 312 | event: ProviderEvent, | |
| 313 | ): ProviderRuntimeEvent["providerRefs"] | undefined { | |
| 314 | const refs: Record<string, string> = {}; | |
| 329 | 315 | if (event.turnId) refs.providerTurnId = event.turnId; |
| 330 | 316 | if (event.itemId) refs.providerItemId = event.itemId; |
| 331 | 317 | if (event.requestId) refs.providerRequestId = event.requestId; |
| 332 | 318 | |
| 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; | |
| 336 | 320 | } |
| 337 | 321 | |
| 338 | function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "type" | "payload"> { | |
| 322 | function runtimeEventBase( | |
| 323 | event: ProviderEvent, | |
| 324 | canonicalThreadId: ThreadId, | |
| 325 | ): Omit<ProviderRuntimeEvent, "type" | "payload"> { | |
| 339 | 326 | const refs = providerRefsFromEvent(event); |
| 340 | 327 | return { |
| 341 | 328 | eventId: event.id, |
| 342 | 329 | provider: event.provider, |
| 343 | sessionId: asRuntimeSessionId(event.sessionId), | |
| 330 | threadId: canonicalThreadId, | |
| 344 | 331 | createdAt: event.createdAt, |
| 345 | ...(event.threadId ? { threadId: asRuntimeThreadId(event.threadId) } : {}), | |
| 346 | ...(event.turnId ? { turnId: asRuntimeTurnId(event.turnId) } : {}), | |
| 332 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 347 | 333 | ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}), |
| 348 | 334 | ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}), |
| 349 | 335 | ...(refs ? { providerRefs: refs } : {}), |
| ... | ... | @@ -357,6 +343,7 @@ function runtimeEventBase(event: ProviderEvent): Omit<ProviderRuntimeEvent, "typ |
| 357 | 343 | |
| 358 | 344 | function mapItemLifecycle( |
| 359 | 345 | event: ProviderEvent, |
| 346 | canonicalThreadId: ThreadId, | |
| 360 | 347 | lifecycle: "item.started" | "item.updated" | "item.completed", |
| 361 | 348 | ): ProviderRuntimeEvent | undefined { |
| 362 | 349 | const payload = asObject(event.payload); |
| ... | ... | @@ -380,7 +367,7 @@ function mapItemLifecycle( |
| 380 | 367 | : undefined; |
| 381 | 368 | |
| 382 | 369 | return { |
| 383 | ...runtimeEventBase(event), | |
| 370 | ...runtimeEventBase(event, canonicalThreadId), | |
| 384 | 371 | type: lifecycle, |
| 385 | 372 | payload: { |
| 386 | 373 | itemType, |
| ... | ... | @@ -392,7 +379,10 @@ function mapItemLifecycle( |
| 392 | 379 | }; |
| 393 | 380 | } |
| 394 | 381 | |
| 395 | function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntimeEvent> { | |
| 382 | function mapToRuntimeEvents( | |
| 383 | event: ProviderEvent, | |
| 384 | canonicalThreadId: ThreadId, | |
| 385 | ): ReadonlyArray<ProviderRuntimeEvent> { | |
| 396 | 386 | const payload = asObject(event.payload); |
| 397 | 387 | const turn = asObject(payload?.turn); |
| 398 | 388 | |
| ... | ... | @@ -402,7 +392,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 402 | 392 | } |
| 403 | 393 | return [ |
| 404 | 394 | { |
| 405 | ...runtimeEventBase(event), | |
| 395 | ...runtimeEventBase(event, canonicalThreadId), | |
| 406 | 396 | type: "runtime.error", |
| 407 | 397 | payload: { |
| 408 | 398 | message: event.message, |
| ... | ... | @@ -418,7 +408,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 418 | 408 | asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); |
| 419 | 409 | return [ |
| 420 | 410 | { |
| 421 | ...runtimeEventBase(event), | |
| 411 | ...runtimeEventBase(event, canonicalThreadId), | |
| 422 | 412 | type: "request.opened", |
| 423 | 413 | payload: { |
| 424 | 414 | requestType: toRequestTypeFromMethod(event.method), |
| ... | ... | @@ -437,7 +427,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 437 | 427 | : toRequestTypeFromMethod(event.method); |
| 438 | 428 | return [ |
| 439 | 429 | { |
| 440 | ...runtimeEventBase(event), | |
| 430 | ...runtimeEventBase(event, canonicalThreadId), | |
| 441 | 431 | type: "request.resolved", |
| 442 | 432 | payload: { |
| 443 | 433 | requestType, |
| ... | ... | @@ -451,7 +441,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 451 | 441 | if (event.method === "session/connecting") { |
| 452 | 442 | return [ |
| 453 | 443 | { |
| 454 | ...runtimeEventBase(event), | |
| 444 | ...runtimeEventBase(event, canonicalThreadId), | |
| 455 | 445 | type: "session.state.changed", |
| 456 | 446 | payload: { |
| 457 | 447 | state: "starting", |
| ... | ... | @@ -464,7 +454,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 464 | 454 | if (event.method === "session/ready") { |
| 465 | 455 | return [ |
| 466 | 456 | { |
| 467 | ...runtimeEventBase(event), | |
| 457 | ...runtimeEventBase(event, canonicalThreadId), | |
| 468 | 458 | type: "session.state.changed", |
| 469 | 459 | payload: { |
| 470 | 460 | state: "ready", |
| ... | ... | @@ -477,7 +467,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 477 | 467 | if (event.method === "session/started") { |
| 478 | 468 | return [ |
| 479 | 469 | { |
| 480 | ...runtimeEventBase(event), | |
| 470 | ...runtimeEventBase(event, canonicalThreadId), | |
| 481 | 471 | type: "session.started", |
| 482 | 472 | payload: { |
| 483 | 473 | ...(event.message ? { message: event.message } : {}), |
| ... | ... | @@ -490,7 +480,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 490 | 480 | if (event.method === "session/exited" || event.method === "session/closed") { |
| 491 | 481 | return [ |
| 492 | 482 | { |
| 493 | ...runtimeEventBase(event), | |
| 483 | ...runtimeEventBase(event, canonicalThreadId), | |
| 494 | 484 | type: "session.exited", |
| 495 | 485 | payload: { |
| 496 | 486 | ...(event.message ? { reason: event.message } : {}), |
| ... | ... | @@ -502,15 +492,13 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 502 | 492 | |
| 503 | 493 | if (event.method === "thread/started") { |
| 504 | 494 | 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); | |
| 508 | 496 | if (!providerThreadId) { |
| 509 | 497 | return []; |
| 510 | 498 | } |
| 511 | 499 | return [ |
| 512 | 500 | { |
| 513 | ...runtimeEventBase(event), | |
| 501 | ...runtimeEventBase(event, canonicalThreadId), | |
| 514 | 502 | type: "thread.started", |
| 515 | 503 | payload: { |
| 516 | 504 | providerThreadId, |
| ... | ... | @@ -528,8 +516,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 528 | 516 | ) { |
| 529 | 517 | return [ |
| 530 | 518 | { |
| 531 | ...runtimeEventBase(event), | |
| 532 | 519 | type: "thread.state.changed", |
| 520 | ...runtimeEventBase(event, canonicalThreadId), | |
| 533 | 521 | payload: { |
| 534 | 522 | state: |
| 535 | 523 | event.method === "thread/archived" |
| ... | ... | @@ -548,8 +536,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 548 | 536 | if (event.method === "thread/name/updated") { |
| 549 | 537 | return [ |
| 550 | 538 | { |
| 551 | ...runtimeEventBase(event), | |
| 552 | 539 | type: "thread.metadata.updated", |
| 540 | ...runtimeEventBase(event, canonicalThreadId), | |
| 553 | 541 | payload: { |
| 554 | 542 | ...(asString(payload?.threadName) ? { name: asString(payload?.threadName) } : {}), |
| 555 | 543 | ...(event.payload !== undefined ? { metadata: asObject(event.payload) } : {}), |
| ... | ... | @@ -561,8 +549,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 561 | 549 | if (event.method === "thread/tokenUsage/updated") { |
| 562 | 550 | return [ |
| 563 | 551 | { |
| 564 | ...runtimeEventBase(event), | |
| 565 | 552 | type: "thread.token-usage.updated", |
| 553 | ...runtimeEventBase(event, canonicalThreadId), | |
| 566 | 554 | payload: { |
| 567 | 555 | usage: event.payload ?? {}, |
| 568 | 556 | }, |
| ... | ... | @@ -571,16 +559,14 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 571 | 559 | } |
| 572 | 560 | |
| 573 | 561 | 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; | |
| 577 | 563 | if (!turnId) { |
| 578 | 564 | return []; |
| 579 | 565 | } |
| 580 | 566 | return [ |
| 581 | 567 | { |
| 582 | ...runtimeEventBase(event), | |
| 583 | turnId: asRuntimeTurnId(turnId), | |
| 568 | ...runtimeEventBase(event, canonicalThreadId), | |
| 569 | turnId, | |
| 584 | 570 | type: "turn.started", |
| 585 | 571 | payload: { |
| 586 | 572 | ...(asString(turn?.model) ? { model: asString(turn?.model) } : {}), |
| ... | ... | @@ -594,7 +580,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 594 | 580 | const errorMessage = asString(asObject(turn?.error)?.message); |
| 595 | 581 | return [ |
| 596 | 582 | { |
| 597 | ...runtimeEventBase(event), | |
| 583 | ...runtimeEventBase(event, canonicalThreadId), | |
| 598 | 584 | type: "turn.completed", |
| 599 | 585 | payload: { |
| 600 | 586 | state: toTurnStatus(turn?.status), |
| ... | ... | @@ -613,7 +599,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 613 | 599 | if (event.method === "turn/aborted") { |
| 614 | 600 | return [ |
| 615 | 601 | { |
| 616 | ...runtimeEventBase(event), | |
| 602 | ...runtimeEventBase(event, canonicalThreadId), | |
| 617 | 603 | type: "turn.aborted", |
| 618 | 604 | payload: { |
| 619 | 605 | reason: event.message ?? "Turn aborted", |
| ... | ... | @@ -626,10 +612,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 626 | 612 | const steps = Array.isArray(payload?.plan) ? payload.plan : []; |
| 627 | 613 | return [ |
| 628 | 614 | { |
| 629 | ...runtimeEventBase(event), | |
| 615 | ...runtimeEventBase(event, canonicalThreadId), | |
| 630 | 616 | type: "turn.plan.updated", |
| 631 | 617 | payload: { |
| 632 | ...(asString(payload?.explanation) ? { explanation: asString(payload?.explanation) } : {}), | |
| 618 | ...(asString(payload?.explanation) | |
| 619 | ? { explanation: asString(payload?.explanation) } | |
| 620 | : {}), | |
| 633 | 621 | plan: steps |
| 634 | 622 | .map((entry) => asObject(entry)) |
| 635 | 623 | .filter((entry): entry is Record<string, unknown> => entry !== undefined) |
| ... | ... | @@ -648,7 +636,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 648 | 636 | if (event.method === "turn/diff/updated") { |
| 649 | 637 | return [ |
| 650 | 638 | { |
| 651 | ...runtimeEventBase(event), | |
| 639 | ...runtimeEventBase(event, canonicalThreadId), | |
| 652 | 640 | type: "turn.diff.updated", |
| 653 | 641 | payload: { |
| 654 | 642 | unifiedDiff: |
| ... | ... | @@ -662,12 +650,12 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 662 | 650 | } |
| 663 | 651 | |
| 664 | 652 | if (event.method === "item/started") { |
| 665 | const started = mapItemLifecycle(event, "item.started"); | |
| 653 | const started = mapItemLifecycle(event, canonicalThreadId, "item.started"); | |
| 666 | 654 | return started ? [started] : []; |
| 667 | 655 | } |
| 668 | 656 | |
| 669 | 657 | if (event.method === "item/completed") { |
| 670 | const completed = mapItemLifecycle(event, "item.completed"); | |
| 658 | const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); | |
| 671 | 659 | return completed ? [completed] : []; |
| 672 | 660 | } |
| 673 | 661 | |
| ... | ... | @@ -675,7 +663,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 675 | 663 | event.method === "item/reasoning/summaryPartAdded" || |
| 676 | 664 | event.method === "item/commandExecution/terminalInteraction" |
| 677 | 665 | ) { |
| 678 | const updated = mapItemLifecycle(event, "item.updated"); | |
| 666 | const updated = mapItemLifecycle(event, canonicalThreadId, "item.updated"); | |
| 679 | 667 | return updated ? [updated] : []; |
| 680 | 668 | } |
| 681 | 669 | |
| ... | ... | @@ -697,13 +685,17 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 697 | 685 | } |
| 698 | 686 | return [ |
| 699 | 687 | { |
| 700 | ...runtimeEventBase(event), | |
| 688 | ...runtimeEventBase(event, canonicalThreadId), | |
| 701 | 689 | type: "content.delta", |
| 702 | 690 | payload: { |
| 703 | 691 | streamKind: contentStreamKindFromMethod(event.method), |
| 704 | 692 | 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 | : {}), | |
| 707 | 699 | }, |
| 708 | 700 | }, |
| 709 | 701 | ]; |
| ... | ... | @@ -712,7 +704,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 712 | 704 | if (event.method === "item/mcpToolCall/progress") { |
| 713 | 705 | return [ |
| 714 | 706 | { |
| 715 | ...runtimeEventBase(event), | |
| 707 | ...runtimeEventBase(event, canonicalThreadId), | |
| 716 | 708 | type: "tool.progress", |
| 717 | 709 | payload: { |
| 718 | 710 | ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}), |
| ... | ... | @@ -735,7 +727,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 735 | 727 | : "unknown"; |
| 736 | 728 | return [ |
| 737 | 729 | { |
| 738 | ...runtimeEventBase(event), | |
| 730 | ...runtimeEventBase(event, canonicalThreadId), | |
| 739 | 731 | type: "request.resolved", |
| 740 | 732 | payload: { |
| 741 | 733 | requestType, |
| ... | ... | @@ -748,8 +740,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 748 | 740 | if (event.method === "model/rerouted") { |
| 749 | 741 | return [ |
| 750 | 742 | { |
| 751 | ...runtimeEventBase(event), | |
| 752 | 743 | type: "model.rerouted", |
| 744 | ...runtimeEventBase(event, canonicalThreadId), | |
| 753 | 745 | payload: { |
| 754 | 746 | fromModel: asString(payload?.fromModel) ?? "unknown", |
| 755 | 747 | toModel: asString(payload?.toModel) ?? "unknown", |
| ... | ... | @@ -762,8 +754,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 762 | 754 | if (event.method === "deprecationNotice") { |
| 763 | 755 | return [ |
| 764 | 756 | { |
| 765 | ...runtimeEventBase(event), | |
| 766 | 757 | type: "deprecation.notice", |
| 758 | ...runtimeEventBase(event, canonicalThreadId), | |
| 767 | 759 | payload: { |
| 768 | 760 | summary: asString(payload?.summary) ?? "Deprecation notice", |
| 769 | 761 | ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), |
| ... | ... | @@ -775,8 +767,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 775 | 767 | if (event.method === "configWarning") { |
| 776 | 768 | return [ |
| 777 | 769 | { |
| 778 | ...runtimeEventBase(event), | |
| 779 | 770 | type: "config.warning", |
| 771 | ...runtimeEventBase(event, canonicalThreadId), | |
| 780 | 772 | payload: { |
| 781 | 773 | summary: asString(payload?.summary) ?? "Configuration warning", |
| 782 | 774 | ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), |
| ... | ... | @@ -790,8 +782,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 790 | 782 | if (event.method === "account/updated") { |
| 791 | 783 | return [ |
| 792 | 784 | { |
| 793 | ...runtimeEventBase(event), | |
| 794 | 785 | type: "account.updated", |
| 786 | ...runtimeEventBase(event, canonicalThreadId), | |
| 795 | 787 | payload: { |
| 796 | 788 | account: event.payload ?? {}, |
| 797 | 789 | }, |
| ... | ... | @@ -802,8 +794,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 802 | 794 | if (event.method === "account/rateLimits/updated") { |
| 803 | 795 | return [ |
| 804 | 796 | { |
| 805 | ...runtimeEventBase(event), | |
| 806 | 797 | type: "account.rate-limits.updated", |
| 798 | ...runtimeEventBase(event, canonicalThreadId), | |
| 807 | 799 | payload: { |
| 808 | 800 | rateLimits: event.payload ?? {}, |
| 809 | 801 | }, |
| ... | ... | @@ -814,8 +806,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 814 | 806 | if (event.method === "mcpServer/oauthLogin/completed") { |
| 815 | 807 | return [ |
| 816 | 808 | { |
| 817 | ...runtimeEventBase(event), | |
| 818 | 809 | type: "mcp.oauth.completed", |
| 810 | ...runtimeEventBase(event, canonicalThreadId), | |
| 819 | 811 | payload: { |
| 820 | 812 | success: payload?.success === true, |
| 821 | 813 | ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}), |
| ... | ... | @@ -829,8 +821,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 829 | 821 | const realtimeSessionId = asString(payload?.realtimeSessionId); |
| 830 | 822 | return [ |
| 831 | 823 | { |
| 832 | ...runtimeEventBase(event), | |
| 833 | 824 | type: "thread.realtime.started", |
| 825 | ...runtimeEventBase(event, canonicalThreadId), | |
| 834 | 826 | payload: { |
| 835 | 827 | realtimeSessionId, |
| 836 | 828 | }, |
| ... | ... | @@ -841,8 +833,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 841 | 833 | if (event.method === "thread/realtime/itemAdded") { |
| 842 | 834 | return [ |
| 843 | 835 | { |
| 844 | ...runtimeEventBase(event), | |
| 845 | 836 | type: "thread.realtime.item-added", |
| 837 | ...runtimeEventBase(event, canonicalThreadId), | |
| 846 | 838 | payload: { |
| 847 | 839 | item: event.payload ?? {}, |
| 848 | 840 | }, |
| ... | ... | @@ -853,8 +845,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 853 | 845 | if (event.method === "thread/realtime/outputAudio/delta") { |
| 854 | 846 | return [ |
| 855 | 847 | { |
| 856 | ...runtimeEventBase(event), | |
| 857 | 848 | type: "thread.realtime.audio.delta", |
| 849 | ...runtimeEventBase(event, canonicalThreadId), | |
| 858 | 850 | payload: { |
| 859 | 851 | audio: event.payload ?? {}, |
| 860 | 852 | }, |
| ... | ... | @@ -866,8 +858,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 866 | 858 | const message = asString(payload?.message) ?? event.message ?? "Realtime error"; |
| 867 | 859 | return [ |
| 868 | 860 | { |
| 869 | ...runtimeEventBase(event), | |
| 870 | 861 | type: "thread.realtime.error", |
| 862 | ...runtimeEventBase(event, canonicalThreadId), | |
| 871 | 863 | payload: { |
| 872 | 864 | message, |
| 873 | 865 | }, |
| ... | ... | @@ -878,8 +870,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 878 | 870 | if (event.method === "thread/realtime/closed") { |
| 879 | 871 | return [ |
| 880 | 872 | { |
| 881 | ...runtimeEventBase(event), | |
| 882 | 873 | type: "thread.realtime.closed", |
| 874 | ...runtimeEventBase(event, canonicalThreadId), | |
| 883 | 875 | payload: { |
| 884 | 876 | reason: event.message, |
| 885 | 877 | }, |
| ... | ... | @@ -892,8 +884,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 892 | 884 | asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; |
| 893 | 885 | return [ |
| 894 | 886 | { |
| 895 | ...runtimeEventBase(event), | |
| 896 | 887 | type: "runtime.error", |
| 888 | ...runtimeEventBase(event, canonicalThreadId), | |
| 897 | 889 | payload: { |
| 898 | 890 | message, |
| 899 | 891 | class: "provider_error", |
| ... | ... | @@ -906,8 +898,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 906 | 898 | if (event.method === "windows/worldWritableWarning") { |
| 907 | 899 | return [ |
| 908 | 900 | { |
| 909 | ...runtimeEventBase(event), | |
| 910 | 901 | type: "runtime.warning", |
| 902 | ...runtimeEventBase(event, canonicalThreadId), | |
| 911 | 903 | payload: { |
| 912 | 904 | message: event.message ?? "Windows world-writable warning", |
| 913 | 905 | ...(event.payload !== undefined ? { detail: event.payload } : {}), |
| ... | ... | @@ -924,8 +916,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 924 | 916 | |
| 925 | 917 | return [ |
| 926 | 918 | { |
| 927 | ...runtimeEventBase(event), | |
| 928 | 919 | type: "session.state.changed", |
| 920 | ...runtimeEventBase(event, canonicalThreadId), | |
| 929 | 921 | payload: { |
| 930 | 922 | state: success === false ? "error" : "ready", |
| 931 | 923 | reason: success === false ? failureMessage : successMessage, |
| ... | ... | @@ -935,8 +927,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 935 | 927 | ...(success === false |
| 936 | 928 | ? [ |
| 937 | 929 | { |
| 938 | ...runtimeEventBase(event), | |
| 939 | 930 | type: "runtime.warning" as const, |
| 931 | ...runtimeEventBase(event, canonicalThreadId), | |
| 940 | 932 | payload: { |
| 941 | 933 | message: failureMessage, |
| 942 | 934 | ...(event.payload !== undefined ? { detail: event.payload } : {}), |
| ... | ... | @@ -954,7 +946,6 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 954 | 946 | Effect.gen(function* () { |
| 955 | 947 | const fileSystem = yield* FileSystem.FileSystem; |
| 956 | 948 | const serverConfig = yield* Effect.service(ServerConfig); |
| 957 | const directory = yield* ProviderSessionDirectory; | |
| 958 | 949 | const nativeEventLogger = |
| 959 | 950 | options?.nativeEventLogger ?? |
| 960 | 951 | (options?.nativeEventLogPath !== undefined |
| ... | ... | @@ -964,14 +955,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 964 | 955 | : undefined); |
| 965 | 956 | |
| 966 | 957 | const manager = yield* Effect.acquireRelease( |
| 967 | Effect.sync(() => { | |
| 958 | Effect.gen(function* () { | |
| 968 | 959 | if (options?.manager) { |
| 969 | 960 | return options.manager; |
| 970 | 961 | } |
| 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); | |
| 975 | 964 | }), |
| 976 | 965 | (manager) => |
| 977 | 966 | Effect.sync(() => { |
| ... | ... | @@ -999,11 +988,13 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 999 | 988 | catch: (cause) => |
| 1000 | 989 | new ProviderAdapterProcessError({ |
| 1001 | 990 | provider: PROVIDER, |
| 1002 | sessionId: "pending", | |
| 991 | threadId: input.threadId, | |
| 1003 | 992 | detail: toMessage(cause, "Failed to start Codex adapter session."), |
| 1004 | 993 | cause, |
| 1005 | 994 | }), |
| 1006 | }); | |
| 995 | }).pipe( | |
| 996 | Effect.map((session) => session), | |
| 997 | ); | |
| 1007 | 998 | }; |
| 1008 | 999 | |
| 1009 | 1000 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
| ... | ... | @@ -1018,7 +1009,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1018 | 1009 | }); |
| 1019 | 1010 | if (!attachmentPath) { |
| 1020 | 1011 | return yield* toRequestError( |
| 1021 | input.sessionId, | |
| 1012 | input.threadId, | |
| 1022 | 1013 | "turn/start", |
| 1023 | 1014 | new Error(`Invalid attachment id '${attachment.id}'.`), |
| 1024 | 1015 | ); |
| ... | ... | @@ -1026,7 +1017,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1026 | 1017 | const bytes = yield* fileSystem |
| 1027 | 1018 | .readFile(attachmentPath) |
| 1028 | 1019 | .pipe( |
| 1029 | Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), | |
| 1020 | Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)), | |
| 1030 | 1021 | ); |
| 1031 | 1022 | return { |
| 1032 | 1023 | type: "image" as const, |
| ... | ... | @@ -1039,7 +1030,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1039 | 1030 | return yield* Effect.tryPromise({ |
| 1040 | 1031 | try: () => { |
| 1041 | 1032 | const managerInput = { |
| 1042 | sessionId: input.sessionId, | |
| 1033 | threadId: input.threadId, | |
| 1043 | 1034 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1044 | 1035 | ...(input.model !== undefined ? { model: input.model } : {}), |
| 1045 | 1036 | ...(input.effort !== undefined ? { effort: input.effort } : {}), |
| ... | ... | @@ -1047,23 +1038,33 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1047 | 1038 | }; |
| 1048 | 1039 | return manager.sendTurn(managerInput); |
| 1049 | 1040 | }, |
| 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 | ); | |
| 1052 | 1048 | }); |
| 1053 | 1049 | |
| 1054 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => | |
| 1050 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => | |
| 1055 | 1051 | 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), | |
| 1058 | 1054 | }); |
| 1059 | 1055 | |
| 1060 | const readThread: CodexAdapterShape["readThread"] = (sessionId) => | |
| 1056 | const readThread: CodexAdapterShape["readThread"] = (threadId) => | |
| 1061 | 1057 | 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) => { | |
| 1067 | 1068 | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| 1068 | 1069 | return Effect.fail( |
| 1069 | 1070 | new ProviderAdapterValidationError({ |
| ... | ... | @@ -1075,31 +1076,36 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1075 | 1076 | } |
| 1076 | 1077 | |
| 1077 | 1078 | 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 | ); | |
| 1081 | 1087 | }; |
| 1082 | 1088 | |
| 1083 | 1089 | const respondToRequest: CodexAdapterShape["respondToRequest"] = ( |
| 1084 | sessionId, | |
| 1090 | threadId, | |
| 1085 | 1091 | requestId, |
| 1086 | 1092 | decision, |
| 1087 | 1093 | ) => |
| 1088 | 1094 | 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), | |
| 1091 | 1097 | }); |
| 1092 | 1098 | |
| 1093 | const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => | |
| 1099 | const stopSession: CodexAdapterShape["stopSession"] = (threadId) => | |
| 1094 | 1100 | Effect.sync(() => { |
| 1095 | manager.stopSession(sessionId); | |
| 1101 | manager.stopSession(threadId); | |
| 1096 | 1102 | }); |
| 1097 | 1103 | |
| 1098 | 1104 | const listSessions: CodexAdapterShape["listSessions"] = () => |
| 1099 | 1105 | Effect.sync(() => manager.listSessions()); |
| 1100 | 1106 | |
| 1101 | const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => | |
| 1102 | Effect.sync(() => manager.hasSession(sessionId)); | |
| 1107 | const hasSession: CodexAdapterShape["hasSession"] = (threadId) => | |
| 1108 | Effect.sync(() => manager.hasSession(threadId)); | |
| 1103 | 1109 | |
| 1104 | 1110 | const stopAll: CodexAdapterShape["stopAll"] = () => |
| 1105 | 1111 | Effect.sync(() => { |
| ... | ... | @@ -1115,33 +1121,24 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1115 | 1121 | if (!nativeEventLogger) { |
| 1116 | 1122 | return; |
| 1117 | 1123 | } |
| 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); | |
| 1127 | 1125 | }); |
| 1128 | 1126 | |
| 1129 | 1127 | const services = yield* Effect.services<never>(); |
| 1130 | 1128 | const listener = (event: ProviderEvent) => |
| 1131 | 1129 | Effect.gen(function* () { |
| 1132 | 1130 | 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); | |
| 1145 | 1142 | }).pipe(Effect.runPromiseWith(services)); |
| 1146 | 1143 | manager.on("event", listener); |
| 1147 | 1144 | return listener; |
apps/server/src/provider/Layers/CursorAdapter.test.ts+33-16| ... | ... | @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; |
| 2 | 2 | import { PassThrough } from "node:stream"; |
| 3 | 3 | import readline from "node:readline"; |
| 4 | 4 | |
| 5 | import { ApprovalRequestId } from "@t3tools/contracts"; | |
| 5 | import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; | |
| 6 | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 7 | 7 | import { Effect, Fiber, Stream } from "effect"; |
| 8 | 8 | |
| ... | ... | @@ -10,6 +10,10 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 10 | 10 | import { CursorAdapter } from "../Services/CursorAdapter.ts"; |
| 11 | 11 | import { makeCursorAdapterLive } from "./CursorAdapter.ts"; |
| 12 | 12 | |
| 13 | const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); | |
| 14 | const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); | |
| 15 | const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy"); | |
| 16 | ||
| 13 | 17 | class FakeCursorAcpProcess extends EventEmitter { |
| 14 | 18 | readonly stdin = new PassThrough(); |
| 15 | 19 | readonly stdout = new PassThrough(); |
| ... | ... | @@ -256,7 +260,11 @@ describe("CursorAdapterLive", () => { |
| 256 | 260 | return Effect.gen(function* () { |
| 257 | 261 | const adapter = yield* CursorAdapter; |
| 258 | 262 | const result = yield* adapter |
| 259 | .startSession({ provider: "codex", runtimeMode: "full-access" }) | |
| 263 | .startSession({ | |
| 264 | provider: "codex", | |
| 265 | threadId: THREAD_ID, | |
| 266 | runtimeMode: "full-access", | |
| 267 | }) | |
| 260 | 268 | .pipe(Effect.result); |
| 261 | 269 | |
| 262 | 270 | assert.equal(result._tag, "Failure"); |
| ... | ... | @@ -290,12 +298,13 @@ describe("CursorAdapterLive", () => { |
| 290 | 298 | |
| 291 | 299 | const session = yield* adapter.startSession({ |
| 292 | 300 | provider: "cursor", |
| 301 | threadId: THREAD_ID, | |
| 293 | 302 | cwd: "/tmp/project", |
| 294 | 303 | runtimeMode: "full-access", |
| 295 | 304 | }); |
| 296 | 305 | |
| 297 | 306 | const turn = yield* adapter.sendTurn({ |
| 298 | sessionId: session.sessionId, | |
| 307 | threadId: session.threadId, | |
| 299 | 308 | input: "hello", |
| 300 | 309 | attachments: [], |
| 301 | 310 | }); |
| ... | ... | @@ -355,6 +364,7 @@ describe("CursorAdapterLive", () => { |
| 355 | 364 | const adapter = yield* CursorAdapter; |
| 356 | 365 | yield* adapter.startSession({ |
| 357 | 366 | provider: "cursor", |
| 367 | threadId: THREAD_ID, | |
| 358 | 368 | model: "composer-1.5", |
| 359 | 369 | runtimeMode: "full-access", |
| 360 | 370 | }); |
| ... | ... | @@ -364,13 +374,13 @@ describe("CursorAdapterLive", () => { |
| 364 | 374 | }); |
| 365 | 375 | |
| 366 | 376 | 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 | }> = []; | |
| 374 | 384 | const fake = new FakeCursorAcpProcess(); |
| 375 | 385 | const layer = makeCursorAdapterLive({ |
| 376 | 386 | createProcess: () => fake as never, |
| ... | ... | @@ -388,13 +398,14 @@ describe("CursorAdapterLive", () => { |
| 388 | 398 | const adapter = yield* CursorAdapter; |
| 389 | 399 | const session = yield* adapter.startSession({ |
| 390 | 400 | provider: "cursor", |
| 401 | threadId: THREAD_ID, | |
| 391 | 402 | cwd: "/tmp/project", |
| 392 | 403 | runtimeMode: "full-access", |
| 393 | 404 | }); |
| 394 | 405 | |
| 395 | 406 | assert.equal(nativeEvents.length > 0, true); |
| 396 | 407 | assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); |
| 397 | assert.equal(nativeEvents.some((record) => record.event?.sessionId === session.sessionId), true); | |
| 408 | assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true); | |
| 398 | 409 | assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); |
| 399 | 410 | }).pipe(Effect.provide(layer)); |
| 400 | 411 | }); |
| ... | ... | @@ -409,6 +420,7 @@ describe("CursorAdapterLive", () => { |
| 409 | 420 | const adapter = yield* CursorAdapter; |
| 410 | 421 | const session = yield* adapter.startSession({ |
| 411 | 422 | provider: "cursor", |
| 423 | threadId: RESUME_THREAD_ID, | |
| 412 | 424 | cwd: "/tmp/project", |
| 413 | 425 | resumeCursor: { |
| 414 | 426 | acpSessionId: "acp-session-resume", |
| ... | ... | @@ -426,7 +438,7 @@ describe("CursorAdapterLive", () => { |
| 426 | 438 | cwd: "/tmp/project", |
| 427 | 439 | mcpServers: [], |
| 428 | 440 | }); |
| 429 | assert.equal(session.threadId, "acp-session-resume"); | |
| 441 | assert.equal(session.threadId, RESUME_THREAD_ID); | |
| 430 | 442 | assert.deepEqual(session.resumeCursor, { |
| 431 | 443 | acpSessionId: "acp-session-resume", |
| 432 | 444 | }); |
| ... | ... | @@ -443,6 +455,7 @@ describe("CursorAdapterLive", () => { |
| 443 | 455 | const adapter = yield* CursorAdapter; |
| 444 | 456 | const session = yield* adapter.startSession({ |
| 445 | 457 | provider: "cursor", |
| 458 | threadId: LEGACY_RESUME_THREAD_ID, | |
| 446 | 459 | cwd: "/tmp/project", |
| 447 | 460 | resumeCursor: { |
| 448 | 461 | sessionId: "acp-session-legacy", |
| ... | ... | @@ -456,7 +469,7 @@ describe("CursorAdapterLive", () => { |
| 456 | 469 | cwd: "/tmp/project", |
| 457 | 470 | mcpServers: [], |
| 458 | 471 | }); |
| 459 | assert.equal(session.threadId, "acp-session-legacy"); | |
| 472 | assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID); | |
| 460 | 473 | assert.deepEqual(session.resumeCursor, { |
| 461 | 474 | acpSessionId: "acp-session-legacy", |
| 462 | 475 | }); |
| ... | ... | @@ -474,6 +487,7 @@ describe("CursorAdapterLive", () => { |
| 474 | 487 | |
| 475 | 488 | const session = yield* adapter.startSession({ |
| 476 | 489 | provider: "cursor", |
| 490 | threadId: THREAD_ID, | |
| 477 | 491 | runtimeMode: "approval-required", |
| 478 | 492 | }); |
| 479 | 493 | |
| ... | ... | @@ -498,7 +512,7 @@ describe("CursorAdapterLive", () => { |
| 498 | 512 | } |
| 499 | 513 | |
| 500 | 514 | yield* adapter.respondToRequest( |
| 501 | session.sessionId, | |
| 515 | session.threadId, | |
| 502 | 516 | ApprovalRequestId.makeUnsafe(runtimeRequestId), |
| 503 | 517 | "acceptForSession", |
| 504 | 518 | ); |
| ... | ... | @@ -528,6 +542,7 @@ describe("CursorAdapterLive", () => { |
| 528 | 542 | |
| 529 | 543 | yield* adapter.startSession({ |
| 530 | 544 | provider: "cursor", |
| 545 | threadId: THREAD_ID, | |
| 531 | 546 | runtimeMode: "full-access", |
| 532 | 547 | }); |
| 533 | 548 | |
| ... | ... | @@ -562,6 +577,7 @@ describe("CursorAdapterLive", () => { |
| 562 | 577 | const adapter = yield* CursorAdapter; |
| 563 | 578 | const session = yield* adapter.startSession({ |
| 564 | 579 | provider: "cursor", |
| 580 | threadId: THREAD_ID, | |
| 565 | 581 | runtimeMode: "full-access", |
| 566 | 582 | }); |
| 567 | 583 | |
| ... | ... | @@ -569,7 +585,7 @@ describe("CursorAdapterLive", () => { |
| 569 | 585 | |
| 570 | 586 | const result = yield* adapter |
| 571 | 587 | .sendTurn({ |
| 572 | sessionId: session.sessionId, | |
| 588 | threadId: session.threadId, | |
| 573 | 589 | input: " ", |
| 574 | 590 | attachments: [], |
| 575 | 591 | }) |
| ... | ... | @@ -608,11 +624,12 @@ describe("CursorAdapterLive", () => { |
| 608 | 624 | |
| 609 | 625 | const session = yield* adapter.startSession({ |
| 610 | 626 | provider: "cursor", |
| 627 | threadId: THREAD_ID, | |
| 611 | 628 | runtimeMode: "full-access", |
| 612 | 629 | }); |
| 613 | 630 | |
| 614 | 631 | yield* adapter.sendTurn({ |
| 615 | sessionId: session.sessionId, | |
| 632 | threadId: session.threadId, | |
| 616 | 633 | input: "hello", |
| 617 | 634 | attachments: [], |
| 618 | 635 | }); |
apps/server/src/provider/Layers/CursorAdapter.ts+96-174| ... | ... | @@ -16,16 +16,12 @@ import { |
| 16 | 16 | type CanonicalItemType, |
| 17 | 17 | type CanonicalRequestType, |
| 18 | 18 | ProviderItemId, |
| 19 | ProviderSessionId, | |
| 20 | 19 | type ProviderRuntimeEvent, |
| 21 | 20 | type ProviderSession, |
| 22 | ProviderThreadId, | |
| 23 | ProviderTurnId, | |
| 24 | 21 | type ProviderTurnStartResult, |
| 25 | 22 | type RuntimeMode, |
| 26 | 23 | RuntimeItemId, |
| 27 | 24 | RuntimeRequestId, |
| 28 | RuntimeSessionId, | |
| 29 | 25 | ThreadId, |
| 30 | 26 | TurnId, |
| 31 | 27 | } from "@t3tools/contracts"; |
| ... | ... | @@ -72,7 +68,7 @@ interface PendingPermission { |
| 72 | 68 | } |
| 73 | 69 | |
| 74 | 70 | interface CursorTurnState { |
| 75 | readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>; | |
| 71 | readonly turnId: TurnId; | |
| 76 | 72 | readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>; |
| 77 | 73 | readonly startedToolCalls: Set<string>; |
| 78 | 74 | readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>; |
| ... | ... | @@ -87,7 +83,7 @@ interface CursorSessionContext { |
| 87 | 83 | readonly pending: Map<string, PendingRequest>; |
| 88 | 84 | readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>; |
| 89 | 85 | readonly turns: Array<{ |
| 90 | id: ReturnType<typeof ProviderTurnId.makeUnsafe>; | |
| 86 | id: TurnId; | |
| 91 | 87 | items: Array<unknown>; |
| 92 | 88 | }>; |
| 93 | 89 | turnState: CursorTurnState | undefined; |
| ... | ... | @@ -114,18 +110,6 @@ function toMessage(cause: unknown, fallback: string): string { |
| 114 | 110 | return fallback; |
| 115 | 111 | } |
| 116 | 112 | |
| 117 | function asRuntimeSessionId(value: ProviderSessionId): RuntimeSessionId { | |
| 118 | return RuntimeSessionId.makeUnsafe(value); | |
| 119 | } | |
| 120 | ||
| 121 | function asRuntimeThreadId(value: ProviderThreadId): ThreadId { | |
| 122 | return ThreadId.makeUnsafe(value); | |
| 123 | } | |
| 124 | ||
| 125 | function asRuntimeTurnId(value: ProviderTurnId): TurnId { | |
| 126 | return TurnId.makeUnsafe(value); | |
| 127 | } | |
| 128 | ||
| 129 | 113 | function asRuntimeItemId(value: string): RuntimeItemId { |
| 130 | 114 | return RuntimeItemId.makeUnsafe(value); |
| 131 | 115 | } |
| ... | ... | @@ -139,21 +123,21 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { |
| 139 | 123 | } |
| 140 | 124 | |
| 141 | 125 | function toSessionError( |
| 142 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 126 | threadId: ThreadId, | |
| 143 | 127 | cause: unknown, |
| 144 | 128 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { |
| 145 | 129 | const normalized = toMessage(cause, "").toLowerCase(); |
| 146 | 130 | if (normalized.includes("unknown session") || normalized.includes("not found")) { |
| 147 | 131 | return new ProviderAdapterSessionNotFoundError({ |
| 148 | 132 | provider: PROVIDER, |
| 149 | sessionId, | |
| 133 | threadId, | |
| 150 | 134 | cause, |
| 151 | 135 | }); |
| 152 | 136 | } |
| 153 | 137 | if (normalized.includes("closed")) { |
| 154 | 138 | return new ProviderAdapterSessionClosedError({ |
| 155 | 139 | provider: PROVIDER, |
| 156 | sessionId, | |
| 140 | threadId, | |
| 157 | 141 | cause, |
| 158 | 142 | }); |
| 159 | 143 | } |
| ... | ... | @@ -161,11 +145,11 @@ function toSessionError( |
| 161 | 145 | } |
| 162 | 146 | |
| 163 | 147 | function toRequestError( |
| 164 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 148 | threadId: ThreadId, | |
| 165 | 149 | method: string, |
| 166 | 150 | cause: unknown, |
| 167 | 151 | ): ProviderAdapterError { |
| 168 | const sessionError = toSessionError(sessionId, cause); | |
| 152 | const sessionError = toSessionError(threadId, cause); | |
| 169 | 153 | if (sessionError) { |
| 170 | 154 | return sessionError; |
| 171 | 155 | } |
| ... | ... | @@ -336,7 +320,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 336 | 320 | const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); |
| 337 | 321 | const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); |
| 338 | 322 | |
| 339 | const sessions = new Map<ProviderSessionId, CursorSessionContext>(); | |
| 323 | const sessions = new Map<ThreadId, CursorSessionContext>(); | |
| 340 | 324 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 341 | 325 | |
| 342 | 326 | const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| ... | ... | @@ -431,19 +415,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 431 | 415 | type: "runtime.warning", |
| 432 | 416 | eventId: stamp.eventId, |
| 433 | 417 | provider: PROVIDER, |
| 434 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 435 | 418 | 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 } : {})), | |
| 438 | 421 | payload: { |
| 439 | 422 | message, |
| 440 | 423 | ...(detail !== undefined ? { detail } : {}), |
| 441 | 424 | }, |
| 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 | : {}), | |
| 447 | 428 | }); |
| 448 | 429 | }); |
| 449 | 430 | |
| ... | ... | @@ -464,10 +445,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 464 | 445 | type: "item.completed", |
| 465 | 446 | eventId: itemStamp.eventId, |
| 466 | 447 | provider: PROVIDER, |
| 467 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 468 | 448 | 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, | |
| 471 | 451 | itemId: asRuntimeItemId(turnState.assistantItemId), |
| 472 | 452 | payload: { |
| 473 | 453 | itemType: "assistant_message", |
| ... | ... | @@ -475,9 +455,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 475 | 455 | title: "Assistant message", |
| 476 | 456 | }, |
| 477 | 457 | providerRefs: { |
| 478 | providerSessionId: context.session.sessionId, | |
| 479 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 480 | providerTurnId: turnState.turnId, | |
| 458 | providerTurnId: String(turnState.turnId), | |
| 481 | 459 | providerItemId: turnState.assistantItemId, |
| 482 | 460 | }, |
| 483 | 461 | }); |
| ... | ... | @@ -492,19 +470,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 492 | 470 | type: "turn.completed", |
| 493 | 471 | eventId: stamp.eventId, |
| 494 | 472 | provider: PROVIDER, |
| 495 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 496 | 473 | 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, | |
| 499 | 476 | payload: { |
| 500 | 477 | state, |
| 501 | 478 | ...(stopReason ? { stopReason } : {}), |
| 502 | 479 | ...(errorMessage ? { errorMessage } : {}), |
| 503 | 480 | }, |
| 504 | 481 | providerRefs: { |
| 505 | providerSessionId: context.session.sessionId, | |
| 506 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 507 | providerTurnId: turnState.turnId, | |
| 482 | providerTurnId: String(turnState.turnId), | |
| 508 | 483 | }, |
| 509 | 484 | }); |
| 510 | 485 | |
| ... | ... | @@ -572,10 +547,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 572 | 547 | type: "request.resolved", |
| 573 | 548 | eventId: stamp.eventId, |
| 574 | 549 | provider: PROVIDER, |
| 575 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 576 | 550 | 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 } : {})), | |
| 579 | 553 | requestId: asRuntimeRequestId(requestId), |
| 580 | 554 | payload: { |
| 581 | 555 | requestType, |
| ... | ... | @@ -586,9 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 586 | 560 | }, |
| 587 | 561 | }, |
| 588 | 562 | 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) } : {}), | |
| 592 | 564 | providerRequestId: String(decoded.id), |
| 593 | 565 | }, |
| 594 | 566 | raw: { |
| ... | ... | @@ -614,10 +586,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 614 | 586 | type: "request.opened", |
| 615 | 587 | eventId: stamp.eventId, |
| 616 | 588 | provider: PROVIDER, |
| 617 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 618 | 589 | 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 } : {})), | |
| 621 | 592 | requestId: asRuntimeRequestId(requestId), |
| 622 | 593 | payload: { |
| 623 | 594 | requestType, |
| ... | ... | @@ -625,9 +596,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 625 | 596 | args: decoded.params, |
| 626 | 597 | }, |
| 627 | 598 | 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) } : {}), | |
| 631 | 600 | providerRequestId: String(decoded.id), |
| 632 | 601 | }, |
| 633 | 602 | raw: { |
| ... | ... | @@ -659,14 +628,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 659 | 628 | |
| 660 | 629 | const base = { |
| 661 | 630 | 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 | : {}), | |
| 670 | 636 | raw: { |
| 671 | 637 | source: "cursor.acp.notification" as const, |
| 672 | 638 | method: decoded.method, |
| ... | ... | @@ -701,7 +667,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 701 | 667 | type: "content.delta", |
| 702 | 668 | eventId: stamp.eventId, |
| 703 | 669 | createdAt: stamp.createdAt, |
| 704 | turnId: asRuntimeTurnId(context.turnState.turnId), | |
| 670 | turnId: context.turnState.turnId, | |
| 705 | 671 | itemId: asRuntimeItemId(context.turnState.assistantItemId), |
| 706 | 672 | payload: { |
| 707 | 673 | streamKind: "reasoning_text", |
| ... | ... | @@ -720,7 +686,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 720 | 686 | type: "content.delta", |
| 721 | 687 | eventId: stamp.eventId, |
| 722 | 688 | createdAt: stamp.createdAt, |
| 723 | turnId: asRuntimeTurnId(context.turnState.turnId), | |
| 689 | turnId: context.turnState.turnId, | |
| 724 | 690 | itemId: asRuntimeItemId(context.turnState.assistantItemId), |
| 725 | 691 | payload: { |
| 726 | 692 | streamKind: "assistant_text", |
| ... | ... | @@ -746,7 +712,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 746 | 712 | type: "item.started", |
| 747 | 713 | eventId: stamp.eventId, |
| 748 | 714 | createdAt: stamp.createdAt, |
| 749 | turnId: asRuntimeTurnId(context.turnState.turnId), | |
| 715 | turnId: context.turnState.turnId, | |
| 750 | 716 | itemId: asRuntimeItemId(update.toolCallId), |
| 751 | 717 | payload: { |
| 752 | 718 | itemType, |
| ... | ... | @@ -765,7 +731,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 765 | 731 | type: "item.updated", |
| 766 | 732 | eventId: stamp.eventId, |
| 767 | 733 | createdAt: stamp.createdAt, |
| 768 | turnId: asRuntimeTurnId(context.turnState.turnId), | |
| 734 | turnId: context.turnState.turnId, | |
| 769 | 735 | itemId: asRuntimeItemId(update.toolCallId), |
| 770 | 736 | payload: { |
| 771 | 737 | itemType, |
| ... | ... | @@ -791,7 +757,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 791 | 757 | type: eventType, |
| 792 | 758 | eventId: stamp.eventId, |
| 793 | 759 | createdAt: stamp.createdAt, |
| 794 | turnId: asRuntimeTurnId(context.turnState.turnId), | |
| 760 | turnId: context.turnState.turnId, | |
| 795 | 761 | itemId: asRuntimeItemId(update.toolCallId), |
| 796 | 762 | payload: { |
| 797 | 763 | itemType, |
| ... | ... | @@ -857,10 +823,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 857 | 823 | id: EventId.makeUnsafe(randomUUID()), |
| 858 | 824 | kind: nativeKind, |
| 859 | 825 | provider: PROVIDER, |
| 860 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 861 | 826 | createdAt: new Date().toISOString(), |
| 862 | 827 | method: nativeMethod, |
| 863 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 828 | threadId: context.session.threadId, | |
| 864 | 829 | ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), |
| 865 | 830 | payload: message, |
| 866 | 831 | }, |
| ... | ... | @@ -956,36 +921,31 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 956 | 921 | |
| 957 | 922 | if (options?.emitExitEvent !== false) { |
| 958 | 923 | 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 | } | |
| 976 | 936 | |
| 977 | sessions.delete(context.session.sessionId); | |
| 937 | sessions.delete(context.session.threadId); | |
| 978 | 938 | }); |
| 979 | 939 | |
| 980 | 940 | const requireSession = ( |
| 981 | sessionId: ReturnType<typeof ProviderSessionId.makeUnsafe>, | |
| 941 | threadId: ThreadId, | |
| 982 | 942 | ): Effect.Effect<CursorSessionContext, ProviderAdapterError> => { |
| 983 | const context = sessions.get(sessionId); | |
| 943 | const context = sessions.get(threadId); | |
| 984 | 944 | if (!context) { |
| 985 | 945 | return Effect.fail( |
| 986 | 946 | new ProviderAdapterSessionNotFoundError({ |
| 987 | 947 | provider: PROVIDER, |
| 988 | sessionId, | |
| 948 | threadId, | |
| 989 | 949 | }), |
| 990 | 950 | ); |
| 991 | 951 | } |
| ... | ... | @@ -993,7 +953,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 993 | 953 | return Effect.fail( |
| 994 | 954 | new ProviderAdapterSessionClosedError({ |
| 995 | 955 | provider: PROVIDER, |
| 996 | sessionId, | |
| 956 | threadId, | |
| 997 | 957 | }), |
| 998 | 958 | ); |
| 999 | 959 | } |
| ... | ... | @@ -1011,7 +971,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1011 | 971 | } |
| 1012 | 972 | |
| 1013 | 973 | const startedAt = yield* nowIso; |
| 1014 | const sessionId = ProviderSessionId.makeUnsafe(yield* Random.nextUUIDv4); | |
| 1015 | 974 | const cwd = input.cwd ?? process.cwd(); |
| 1016 | 975 | const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; |
| 1017 | 976 | const binaryPath = cursorOptions?.binaryPath ?? "agent"; |
| ... | ... | @@ -1028,7 +987,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1028 | 987 | catch: (cause) => |
| 1029 | 988 | new ProviderAdapterProcessError({ |
| 1030 | 989 | provider: PROVIDER, |
| 1031 | sessionId, | |
| 990 | threadId: input.threadId, | |
| 1032 | 991 | detail: toMessage(cause, "Failed to spawn Cursor ACP process."), |
| 1033 | 992 | cause, |
| 1034 | 993 | }), |
| ... | ... | @@ -1037,7 +996,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1037 | 996 | const output = readline.createInterface({ input: child.stdout }); |
| 1038 | 997 | |
| 1039 | 998 | const session: ProviderSession = { |
| 1040 | sessionId, | |
| 999 | threadId: input.threadId, | |
| 1041 | 1000 | provider: PROVIDER, |
| 1042 | 1001 | status: "connecting", |
| 1043 | 1002 | runtimeMode: input.runtimeMode, |
| ... | ... | @@ -1085,10 +1044,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1085 | 1044 | type: "runtime.error", |
| 1086 | 1045 | eventId: stamp.eventId, |
| 1087 | 1046 | provider: PROVIDER, |
| 1088 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1089 | 1047 | 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 } : {})), | |
| 1092 | 1050 | payload: { |
| 1093 | 1051 | message: error.message || "Cursor ACP process error.", |
| 1094 | 1052 | class: "transport_error", |
| ... | ... | @@ -1118,9 +1076,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1118 | 1076 | type: "session.exited", |
| 1119 | 1077 | eventId: stamp.eventId, |
| 1120 | 1078 | provider: PROVIDER, |
| 1121 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1122 | 1079 | createdAt: stamp.createdAt, |
| 1123 | ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), | |
| 1080 | threadId: context.session.threadId, | |
| 1124 | 1081 | payload: { |
| 1125 | 1082 | reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, |
| 1126 | 1083 | exitKind: code === 0 ? "graceful" : "error", |
| ... | ... | @@ -1128,19 +1085,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1128 | 1085 | }, |
| 1129 | 1086 | }); |
| 1130 | 1087 | |
| 1131 | sessions.delete(context.session.sessionId); | |
| 1088 | sessions.delete(context.session.threadId); | |
| 1132 | 1089 | }), |
| 1133 | 1090 | ); |
| 1134 | 1091 | }); |
| 1135 | 1092 | |
| 1136 | sessions.set(sessionId, context); | |
| 1093 | sessions.set(input.threadId, context); | |
| 1137 | 1094 | |
| 1138 | 1095 | const initializeResult = yield* Effect.tryPromise({ |
| 1139 | 1096 | try: async () => |
| 1140 | 1097 | sendRequest(context, "initialize", { |
| 1141 | 1098 | protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, |
| 1142 | 1099 | }), |
| 1143 | catch: (cause) => toRequestError(sessionId, "initialize", cause), | |
| 1100 | catch: (cause) => toRequestError(input.threadId, "initialize", cause), | |
| 1144 | 1101 | }); |
| 1145 | 1102 | const decodedInitialize = yield* Effect.try({ |
| 1146 | 1103 | try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult), |
| ... | ... | @@ -1158,14 +1115,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1158 | 1115 | type: "session.configured", |
| 1159 | 1116 | eventId: initStamp.eventId, |
| 1160 | 1117 | provider: PROVIDER, |
| 1161 | sessionId: asRuntimeSessionId(sessionId), | |
| 1162 | 1118 | createdAt: initStamp.createdAt, |
| 1119 | threadId: input.threadId, | |
| 1163 | 1120 | payload: { |
| 1164 | 1121 | config: decodedInitialize, |
| 1165 | 1122 | }, |
| 1166 | providerRefs: { | |
| 1167 | providerSessionId: sessionId, | |
| 1168 | }, | |
| 1169 | 1123 | raw: { |
| 1170 | 1124 | source: "cursor.acp.response", |
| 1171 | 1125 | method: "initialize", |
| ... | ... | @@ -1179,14 +1133,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1179 | 1133 | type: "auth.status", |
| 1180 | 1134 | eventId: authStartStamp.eventId, |
| 1181 | 1135 | provider: PROVIDER, |
| 1182 | sessionId: asRuntimeSessionId(sessionId), | |
| 1183 | 1136 | createdAt: authStartStamp.createdAt, |
| 1137 | threadId: input.threadId, | |
| 1184 | 1138 | payload: { |
| 1185 | 1139 | isAuthenticating: true, |
| 1186 | 1140 | }, |
| 1187 | providerRefs: { | |
| 1188 | providerSessionId: sessionId, | |
| 1189 | }, | |
| 1190 | 1141 | raw: { |
| 1191 | 1142 | source: "cursor.acp.request", |
| 1192 | 1143 | method: "authenticate", |
| ... | ... | @@ -1196,7 +1147,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1196 | 1147 | |
| 1197 | 1148 | const authenticateResult = yield* Effect.tryPromise({ |
| 1198 | 1149 | try: async () => sendRequest(context, "authenticate", authenticateRequest), |
| 1199 | catch: (cause) => toRequestError(sessionId, "authenticate", cause), | |
| 1150 | catch: (cause) => toRequestError(input.threadId, "authenticate", cause), | |
| 1200 | 1151 | }).pipe( |
| 1201 | 1152 | Effect.match({ |
| 1202 | 1153 | onFailure: (error) => ({ ok: false as const, error }), |
| ... | ... | @@ -1209,15 +1160,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1209 | 1160 | type: "auth.status", |
| 1210 | 1161 | eventId: authEndStamp.eventId, |
| 1211 | 1162 | provider: PROVIDER, |
| 1212 | sessionId: asRuntimeSessionId(sessionId), | |
| 1213 | 1163 | createdAt: authEndStamp.createdAt, |
| 1164 | threadId: input.threadId, | |
| 1214 | 1165 | payload: { |
| 1215 | 1166 | isAuthenticating: false, |
| 1216 | 1167 | error: toMessage(authenticateResult.error, "Cursor authentication failed."), |
| 1217 | 1168 | }, |
| 1218 | providerRefs: { | |
| 1219 | providerSessionId: sessionId, | |
| 1220 | }, | |
| 1221 | 1169 | raw: { |
| 1222 | 1170 | source: "cursor.acp.response", |
| 1223 | 1171 | method: "authenticate", |
| ... | ... | @@ -1231,14 +1179,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1231 | 1179 | type: "auth.status", |
| 1232 | 1180 | eventId: authEndStamp.eventId, |
| 1233 | 1181 | provider: PROVIDER, |
| 1234 | sessionId: asRuntimeSessionId(sessionId), | |
| 1235 | 1182 | createdAt: authEndStamp.createdAt, |
| 1183 | threadId: input.threadId, | |
| 1236 | 1184 | payload: { |
| 1237 | 1185 | isAuthenticating: false, |
| 1238 | 1186 | }, |
| 1239 | providerRefs: { | |
| 1240 | providerSessionId: sessionId, | |
| 1241 | }, | |
| 1242 | 1187 | raw: { |
| 1243 | 1188 | source: "cursor.acp.response", |
| 1244 | 1189 | method: "authenticate", |
| ... | ... | @@ -1273,15 +1218,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1273 | 1218 | const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); |
| 1274 | 1219 | return decoded.sessionId; |
| 1275 | 1220 | }, |
| 1276 | catch: (cause) => toRequestError(sessionId, "session/new|session/load", cause), | |
| 1221 | catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause), | |
| 1277 | 1222 | }); |
| 1278 | 1223 | |
| 1279 | const threadId = ProviderThreadId.makeUnsafe(acpSessionId); | |
| 1280 | 1224 | context.acpSessionId = acpSessionId; |
| 1281 | 1225 | context.session = { |
| 1282 | 1226 | ...context.session, |
| 1283 | 1227 | status: "ready", |
| 1284 | threadId, | |
| 1285 | 1228 | resumeCursor: { |
| 1286 | 1229 | acpSessionId, |
| 1287 | 1230 | }, |
| ... | ... | @@ -1293,14 +1236,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1293 | 1236 | type: "session.started", |
| 1294 | 1237 | eventId: sessionStartedStamp.eventId, |
| 1295 | 1238 | provider: PROVIDER, |
| 1296 | sessionId: asRuntimeSessionId(sessionId), | |
| 1297 | 1239 | createdAt: sessionStartedStamp.createdAt, |
| 1298 | threadId: asRuntimeThreadId(threadId), | |
| 1240 | threadId: input.threadId, | |
| 1299 | 1241 | payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, |
| 1300 | providerRefs: { | |
| 1301 | providerSessionId: sessionId, | |
| 1302 | providerThreadId: threadId, | |
| 1303 | }, | |
| 1304 | 1242 | }); |
| 1305 | 1243 | |
| 1306 | 1244 | const threadStartedStamp = yield* makeEventStamp(); |
| ... | ... | @@ -1308,15 +1246,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1308 | 1246 | type: "thread.started", |
| 1309 | 1247 | eventId: threadStartedStamp.eventId, |
| 1310 | 1248 | provider: PROVIDER, |
| 1311 | sessionId: asRuntimeSessionId(sessionId), | |
| 1312 | 1249 | createdAt: threadStartedStamp.createdAt, |
| 1313 | threadId: asRuntimeThreadId(threadId), | |
| 1250 | threadId: input.threadId, | |
| 1314 | 1251 | payload: { |
| 1315 | providerThreadId: threadId, | |
| 1316 | }, | |
| 1317 | providerRefs: { | |
| 1318 | providerSessionId: sessionId, | |
| 1319 | providerThreadId: threadId, | |
| 1252 | providerThreadId: acpSessionId, | |
| 1320 | 1253 | }, |
| 1321 | 1254 | }); |
| 1322 | 1255 | |
| ... | ... | @@ -1325,16 +1258,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1325 | 1258 | type: "session.state.changed", |
| 1326 | 1259 | eventId: readyStamp.eventId, |
| 1327 | 1260 | provider: PROVIDER, |
| 1328 | sessionId: asRuntimeSessionId(sessionId), | |
| 1329 | 1261 | createdAt: readyStamp.createdAt, |
| 1330 | threadId: asRuntimeThreadId(threadId), | |
| 1262 | threadId: input.threadId, | |
| 1331 | 1263 | payload: { |
| 1332 | 1264 | state: "ready", |
| 1333 | 1265 | }, |
| 1334 | providerRefs: { | |
| 1335 | providerSessionId: sessionId, | |
| 1336 | providerThreadId: threadId, | |
| 1337 | }, | |
| 1338 | 1266 | }); |
| 1339 | 1267 | |
| 1340 | 1268 | return { |
| ... | ... | @@ -1344,13 +1272,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1344 | 1272 | |
| 1345 | 1273 | const sendTurn: CursorAdapterShape["sendTurn"] = (input) => |
| 1346 | 1274 | Effect.gen(function* () { |
| 1347 | const context = yield* requireSession(input.sessionId); | |
| 1275 | const context = yield* requireSession(input.threadId); | |
| 1348 | 1276 | |
| 1349 | 1277 | if (context.turnState) { |
| 1350 | 1278 | return yield* new ProviderAdapterValidationError({ |
| 1351 | 1279 | provider: PROVIDER, |
| 1352 | 1280 | operation: "sendTurn", |
| 1353 | issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, | |
| 1281 | issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, | |
| 1354 | 1282 | }); |
| 1355 | 1283 | } |
| 1356 | 1284 | |
| ... | ... | @@ -1363,7 +1291,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1363 | 1291 | }); |
| 1364 | 1292 | } |
| 1365 | 1293 | |
| 1366 | const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4); | |
| 1294 | const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); | |
| 1367 | 1295 | const turnState: CursorTurnState = { |
| 1368 | 1296 | turnId, |
| 1369 | 1297 | assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), |
| ... | ... | @@ -1385,18 +1313,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1385 | 1313 | type: "turn.started", |
| 1386 | 1314 | eventId: startedStamp.eventId, |
| 1387 | 1315 | provider: PROVIDER, |
| 1388 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1389 | 1316 | createdAt: startedStamp.createdAt, |
| 1390 | ...((context.session.threadId ? { threadId: asRuntimeThreadId(context.session.threadId) } : {})), | |
| 1391 | turnId: asRuntimeTurnId(turnId), | |
| 1317 | threadId: context.session.threadId, | |
| 1318 | turnId, | |
| 1392 | 1319 | payload: { |
| 1393 | 1320 | ...(input.model ? { model: input.model } : {}), |
| 1394 | 1321 | ...(input.effort ? { effort: input.effort } : {}), |
| 1395 | 1322 | }, |
| 1396 | 1323 | providerRefs: { |
| 1397 | providerSessionId: context.session.sessionId, | |
| 1398 | ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), | |
| 1399 | providerTurnId: turnId, | |
| 1324 | providerTurnId: String(turnId), | |
| 1400 | 1325 | }, |
| 1401 | 1326 | }); |
| 1402 | 1327 | |
| ... | ... | @@ -1406,7 +1331,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1406 | 1331 | sessionId: context.acpSessionId, |
| 1407 | 1332 | prompt: [{ type: "text", text: promptText }], |
| 1408 | 1333 | }), |
| 1409 | catch: (cause) => toRequestError(input.sessionId, "session/prompt", cause), | |
| 1334 | catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), | |
| 1410 | 1335 | }); |
| 1411 | 1336 | |
| 1412 | 1337 | const promptResult = yield* Effect.try({ |
| ... | ... | @@ -1438,7 +1363,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1438 | 1363 | }; |
| 1439 | 1364 | |
| 1440 | 1365 | return { |
| 1441 | ...(context.session.threadId ? { threadId: context.session.threadId } : {}), | |
| 1366 | threadId: context.session.threadId, | |
| 1442 | 1367 | turnId, |
| 1443 | 1368 | ...(context.session.resumeCursor !== undefined |
| 1444 | 1369 | ? { resumeCursor: context.session.resumeCursor } |
| ... | ... | @@ -1446,9 +1371,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1446 | 1371 | } satisfies ProviderTurnStartResult; |
| 1447 | 1372 | }); |
| 1448 | 1373 | |
| 1449 | const interruptTurn: CursorAdapterShape["interruptTurn"] = (sessionId, _turnId) => | |
| 1374 | const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) => | |
| 1450 | 1375 | Effect.gen(function* () { |
| 1451 | const context = yield* requireSession(sessionId); | |
| 1376 | const context = yield* requireSession(threadId); | |
| 1452 | 1377 | if (!context.turnState) { |
| 1453 | 1378 | return; |
| 1454 | 1379 | } |
| ... | ... | @@ -1456,7 +1381,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1456 | 1381 | const cancelResult = yield* Effect.tryPromise({ |
| 1457 | 1382 | try: async () => |
| 1458 | 1383 | sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), |
| 1459 | catch: (cause) => toRequestError(sessionId, "session/cancel", cause), | |
| 1384 | catch: (cause) => toRequestError(threadId, "session/cancel", cause), | |
| 1460 | 1385 | }).pipe( |
| 1461 | 1386 | Effect.match({ |
| 1462 | 1387 | onFailure: (error) => ({ ok: false as const, error }), |
| ... | ... | @@ -1475,11 +1400,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1475 | 1400 | yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); |
| 1476 | 1401 | }); |
| 1477 | 1402 | |
| 1478 | const readThread: CursorAdapterShape["readThread"] = (sessionId) => | |
| 1403 | const readThread: CursorAdapterShape["readThread"] = (threadId) => | |
| 1479 | 1404 | Effect.gen(function* () { |
| 1480 | const context = yield* requireSession(sessionId); | |
| 1405 | const context = yield* requireSession(threadId); | |
| 1481 | 1406 | return { |
| 1482 | threadId: ProviderThreadId.makeUnsafe(context.acpSessionId), | |
| 1407 | threadId: context.session.threadId, | |
| 1483 | 1408 | turns: context.turns.map((turn) => ({ |
| 1484 | 1409 | id: turn.id, |
| 1485 | 1410 | items: [...turn.items], |
| ... | ... | @@ -1487,22 +1412,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1487 | 1412 | }; |
| 1488 | 1413 | }); |
| 1489 | 1414 | |
| 1490 | const rollbackThread: CursorAdapterShape["rollbackThread"] = (sessionId, _numTurns) => | |
| 1415 | const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) => | |
| 1491 | 1416 | Effect.fail( |
| 1492 | 1417 | new ProviderAdapterRequestError({ |
| 1493 | 1418 | provider: PROVIDER, |
| 1494 | 1419 | method: "thread/rollback", |
| 1495 | detail: `Cursor ACP does not support thread rollback for session '${sessionId}'.`, | |
| 1420 | detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`, | |
| 1496 | 1421 | }), |
| 1497 | 1422 | ); |
| 1498 | 1423 | |
| 1499 | 1424 | const respondToRequest: CursorAdapterShape["respondToRequest"] = ( |
| 1500 | sessionId, | |
| 1425 | threadId, | |
| 1501 | 1426 | requestId, |
| 1502 | 1427 | decision, |
| 1503 | 1428 | ) => |
| 1504 | 1429 | Effect.gen(function* () { |
| 1505 | const context = yield* requireSession(sessionId); | |
| 1430 | const context = yield* requireSession(threadId); | |
| 1506 | 1431 | const pending = context.pendingPermissions.get(requestId); |
| 1507 | 1432 | if (!pending) { |
| 1508 | 1433 | return yield* new ProviderAdapterRequestError({ |
| ... | ... | @@ -1540,10 +1465,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1540 | 1465 | type: "request.resolved", |
| 1541 | 1466 | eventId: stamp.eventId, |
| 1542 | 1467 | provider: PROVIDER, |
| 1543 | sessionId: asRuntimeSessionId(context.session.sessionId), | |
| 1544 | 1468 | 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 } : {})), | |
| 1547 | 1471 | requestId: asRuntimeRequestId(requestId), |
| 1548 | 1472 | payload: { |
| 1549 | 1473 | requestType: pending.requestType, |
| ... | ... | @@ -1553,9 +1477,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1553 | 1477 | }, |
| 1554 | 1478 | }, |
| 1555 | 1479 | 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) } : {}), | |
| 1559 | 1481 | providerRequestId: String(pending.jsonRpcId), |
| 1560 | 1482 | }, |
| 1561 | 1483 | raw: { |
| ... | ... | @@ -1568,9 +1490,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1568 | 1490 | }); |
| 1569 | 1491 | }); |
| 1570 | 1492 | |
| 1571 | const stopSession: CursorAdapterShape["stopSession"] = (sessionId) => | |
| 1493 | const stopSession: CursorAdapterShape["stopSession"] = (threadId) => | |
| 1572 | 1494 | Effect.gen(function* () { |
| 1573 | const context = yield* requireSession(sessionId); | |
| 1495 | const context = yield* requireSession(threadId); | |
| 1574 | 1496 | yield* stopSessionInternal(context, { |
| 1575 | 1497 | emitExitEvent: true, |
| 1576 | 1498 | }); |
| ... | ... | @@ -1579,9 +1501,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { |
| 1579 | 1501 | const listSessions: CursorAdapterShape["listSessions"] = () => |
| 1580 | 1502 | Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); |
| 1581 | 1503 | |
| 1582 | const hasSession: CursorAdapterShape["hasSession"] = (sessionId) => | |
| 1504 | const hasSession: CursorAdapterShape["hasSession"] = (threadId) => | |
| 1583 | 1505 | Effect.sync(() => { |
| 1584 | const context = sessions.get(sessionId); | |
| 1506 | const context = sessions.get(threadId); | |
| 1585 | 1507 | return context !== undefined && !context.stopping; |
| 1586 | 1508 | }); |
| 1587 | 1509 |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-1| ... | ... | @@ -28,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption |
| 28 | 28 | const adapters = |
| 29 | 29 | options?.adapters !== undefined |
| 30 | 30 | ? options.adapters |
| 31 | : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter]; | |
| 31 | : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter]; | |
| 32 | 32 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 33 | 33 | |
| 34 | 34 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
apps/server/src/provider/Layers/ProviderService.test.ts+79-85| ... | ... | @@ -14,10 +14,8 @@ import { |
| 14 | 14 | EventId, |
| 15 | 15 | type ProviderKind, |
| 16 | 16 | ProviderSessionStartInput, |
| 17 | ProviderSessionId, | |
| 18 | ProviderThreadId, | |
| 19 | ProviderTurnId, | |
| 20 | 17 | ThreadId, |
| 18 | TurnId, | |
| 21 | 19 | } from "@t3tools/contracts"; |
| 22 | 20 | import { it, assert, vi } from "@effect/vitest"; |
| 23 | 21 | import { assertFailure } from "@effect/vitest/utils"; |
| ... | ... | @@ -27,8 +25,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 27 | 25 | |
| 28 | 26 | import { |
| 29 | 27 | ProviderAdapterSessionNotFoundError, |
| 30 | ProviderSessionNotFoundError, | |
| 31 | 28 | ProviderUnsupportedError, |
| 29 | ProviderValidationError, | |
| 32 | 30 | type ProviderAdapterError, |
| 33 | 31 | } from "../Errors.ts"; |
| 34 | 32 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| ... | ... | @@ -45,20 +43,17 @@ import { |
| 45 | 43 | SqlitePersistenceMemory, |
| 46 | 44 | } from "../../persistence/Layers/Sqlite.ts"; |
| 47 | 45 | |
| 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 49 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 50 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 51 | 46 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 52 | 47 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 53 | 48 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| 49 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 54 | 50 | |
| 55 | 51 | type LegacyProviderRuntimeEvent = { |
| 56 | 52 | readonly type: string; |
| 57 | 53 | readonly eventId: EventId; |
| 58 | 54 | readonly provider: "codex" | "claudeCode" | "cursor"; |
| 59 | readonly sessionId: string; | |
| 60 | 55 | readonly createdAt: string; |
| 61 | readonly threadId?: string | undefined; | |
| 56 | readonly threadId: ThreadId; | |
| 62 | 57 | readonly turnId?: string | undefined; |
| 63 | 58 | readonly itemId?: string | undefined; |
| 64 | 59 | readonly requestId?: string | undefined; |
| ... | ... | @@ -67,28 +62,23 @@ type LegacyProviderRuntimeEvent = { |
| 67 | 62 | }; |
| 68 | 63 | |
| 69 | 64 | function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 70 | const sessions = new Map<ProviderSessionId, ProviderSession>(); | |
| 65 | const sessions = new Map<ThreadId, ProviderSession>(); | |
| 71 | 66 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 72 | let nextSession = 1; | |
| 73 | 67 | |
| 74 | 68 | const startSession = vi.fn((input: ProviderSessionStartInput) => |
| 75 | 69 | Effect.sync(() => { |
| 76 | 70 | const now = new Date().toISOString(); |
| 77 | const next = nextSession; | |
| 78 | const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`); | |
| 79 | 71 | const session: ProviderSession = { |
| 80 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), | |
| 81 | 72 | provider, |
| 82 | 73 | status: "ready", |
| 83 | 74 | 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)}` }, | |
| 86 | 77 | cwd: input.cwd ?? process.cwd(), |
| 87 | 78 | createdAt: now, |
| 88 | 79 | updatedAt: now, |
| 89 | 80 | }; |
| 90 | nextSession += 1; | |
| 91 | sessions.set(session.sessionId, session); | |
| 81 | sessions.set(session.threadId, session); | |
| 92 | 82 | return session; |
| 93 | 83 | }), |
| 94 | 84 | ); |
| ... | ... | @@ -97,39 +87,39 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 97 | 87 | ( |
| 98 | 88 | input: ProviderSendTurnInput, |
| 99 | 89 | ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => { |
| 100 | if (!sessions.has(input.sessionId)) { | |
| 90 | if (!sessions.has(input.threadId)) { | |
| 101 | 91 | return Effect.fail( |
| 102 | 92 | new ProviderAdapterSessionNotFoundError({ |
| 103 | 93 | provider, |
| 104 | sessionId: input.sessionId, | |
| 94 | threadId: input.threadId, | |
| 105 | 95 | }), |
| 106 | 96 | ); |
| 107 | 97 | } |
| 108 | 98 | |
| 109 | 99 | 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)}`), | |
| 112 | 102 | }); |
| 113 | 103 | }, |
| 114 | 104 | ); |
| 115 | 105 | |
| 116 | 106 | const interruptTurn = vi.fn( |
| 117 | (_sessionId: string, _turnId?: string): Effect.Effect<void, ProviderAdapterError> => | |
| 107 | (_threadId: ThreadId, _turnId?: TurnId): Effect.Effect<void, ProviderAdapterError> => | |
| 118 | 108 | Effect.void, |
| 119 | 109 | ); |
| 120 | 110 | |
| 121 | 111 | const respondToRequest = vi.fn( |
| 122 | 112 | ( |
| 123 | _sessionId: string, | |
| 113 | _threadId: ThreadId, | |
| 124 | 114 | _requestId: string, |
| 125 | 115 | _decision: ProviderApprovalDecision, |
| 126 | 116 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void, |
| 127 | 117 | ); |
| 128 | 118 | |
| 129 | 119 | const stopSession = vi.fn( |
| 130 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | |
| 120 | (threadId: ThreadId): Effect.Effect<void, ProviderAdapterError> => | |
| 131 | 121 | Effect.sync(() => { |
| 132 | sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); | |
| 122 | sessions.delete(threadId); | |
| 133 | 123 | }), |
| 134 | 124 | ); |
| 135 | 125 | |
| ... | ... | @@ -139,32 +129,31 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 139 | 129 | ); |
| 140 | 130 | |
| 141 | 131 | 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)), | |
| 144 | 133 | ); |
| 145 | 134 | |
| 146 | 135 | const readThread = vi.fn( |
| 147 | 136 | ( |
| 148 | _sessionId: ProviderSessionId, | |
| 137 | threadId: ThreadId, | |
| 149 | 138 | ): Effect.Effect< |
| 150 | 139 | { |
| 151 | threadId: ProviderThreadId; | |
| 152 | turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; | |
| 140 | threadId: ThreadId; | |
| 141 | turns: ReadonlyArray<{ id: TurnId; items: readonly [] }>; | |
| 153 | 142 | }, |
| 154 | 143 | ProviderAdapterError |
| 155 | 144 | > => |
| 156 | 145 | Effect.succeed({ |
| 157 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 146 | threadId, | |
| 158 | 147 | turns: [{ id: asTurnId("turn-1"), items: [] }], |
| 159 | 148 | }), |
| 160 | 149 | ); |
| 161 | 150 | |
| 162 | 151 | const rollbackThread = vi.fn( |
| 163 | 152 | ( |
| 164 | _sessionId: ProviderSessionId, | |
| 153 | threadId: ThreadId, | |
| 165 | 154 | _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: [] }), | |
| 168 | 157 | ); |
| 169 | 158 | |
| 170 | 159 | const stopAll = vi.fn( |
| ... | ... | @@ -277,7 +266,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 277 | 266 | yield* Effect.gen(function* () { |
| 278 | 267 | const directory = yield* ProviderSessionDirectory; |
| 279 | 268 | yield* directory.upsert({ |
| 280 | sessionId: asSessionId("sess-stale"), | |
| 281 | 269 | provider: "codex", |
| 282 | 270 | threadId: ThreadId.makeUnsafe("thread-stale"), |
| 283 | 271 | }); |
| ... | ... | @@ -294,13 +282,13 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 294 | 282 | |
| 295 | 283 | const persistedProvider = yield* Effect.gen(function* () { |
| 296 | 284 | const directory = yield* ProviderSessionDirectory; |
| 297 | return yield* directory.getProvider(asSessionId("sess-stale")); | |
| 285 | return yield* directory.getProvider(asThreadId("thread-stale")); | |
| 298 | 286 | }).pipe(Effect.provide(directoryLayer)); |
| 299 | 287 | assert.equal(persistedProvider, "codex"); |
| 300 | 288 | |
| 301 | 289 | const runtime = yield* Effect.gen(function* () { |
| 302 | 290 | const repository = yield* ProviderSessionRuntimeRepository; |
| 303 | return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") }); | |
| 291 | return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale") }); | |
| 304 | 292 | }).pipe(Effect.provide(runtimeRepositoryLayer)); |
| 305 | 293 | assert.equal(Option.isSome(runtime), true); |
| 306 | 294 | |
| ... | ... | @@ -348,10 +336,12 @@ it.effect( |
| 348 | 336 | |
| 349 | 337 | const startedSession = yield* Effect.gen(function* () { |
| 350 | 338 | const provider = yield* ProviderService; |
| 351 | return yield* provider.startSession(asThreadId("thread-1"), { | |
| 339 | const threadId = asThreadId("thread-1"); | |
| 340 | return yield* provider.startSession(threadId, { | |
| 352 | 341 | provider: "codex", |
| 353 | 342 | cwd: "/tmp/project", |
| 354 | 343 | runtimeMode: "full-access", |
| 344 | threadId, | |
| 355 | 345 | }); |
| 356 | 346 | }).pipe(Effect.provide(firstProviderLayer)); |
| 357 | 347 | |
| ... | ... | @@ -362,9 +352,7 @@ it.effect( |
| 362 | 352 | |
| 363 | 353 | const persistedAfterStopAll = yield* Effect.gen(function* () { |
| 364 | 354 | const repository = yield* ProviderSessionRuntimeRepository; |
| 365 | return yield* repository.getBySessionId({ | |
| 366 | providerSessionId: startedSession.sessionId, | |
| 367 | }); | |
| 355 | return yield* repository.getByThreadId({ threadId: startedSession.threadId }); | |
| 368 | 356 | }).pipe(Effect.provide(runtimeRepositoryLayer)); |
| 369 | 357 | assert.equal(Option.isSome(persistedAfterStopAll), true); |
| 370 | 358 | if (Option.isSome(persistedAfterStopAll)) { |
| ... | ... | @@ -394,7 +382,7 @@ it.effect( |
| 394 | 382 | yield* Effect.gen(function* () { |
| 395 | 383 | const provider = yield* ProviderService; |
| 396 | 384 | yield* provider.rollbackConversation({ |
| 397 | sessionId: startedSession.sessionId, | |
| 385 | threadId: startedSession.threadId, | |
| 398 | 386 | numTurns: 1, |
| 399 | 387 | }); |
| 400 | 388 | }).pipe(Effect.provide(secondProviderLayer)); |
| ... | ... | @@ -430,6 +418,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 430 | 418 | |
| 431 | 419 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 432 | 420 | provider: "codex", |
| 421 | threadId: asThreadId("thread-1"), | |
| 433 | 422 | cwd: "/tmp/project", |
| 434 | 423 | runtimeMode: "full-access", |
| 435 | 424 | }); |
| ... | ... | @@ -439,40 +428,43 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 439 | 428 | assert.equal(sessions.length, 1); |
| 440 | 429 | |
| 441 | 430 | yield* provider.sendTurn({ |
| 442 | sessionId: session.sessionId, | |
| 431 | threadId: session.threadId, | |
| 443 | 432 | input: "hello", |
| 444 | 433 | attachments: [], |
| 445 | 434 | }); |
| 446 | 435 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 447 | 436 | |
| 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]]); | |
| 450 | 439 | |
| 451 | 440 | yield* provider.respondToRequest({ |
| 452 | sessionId: session.sessionId, | |
| 441 | threadId: session.threadId, | |
| 453 | 442 | requestId: asRequestId("req-1"), |
| 454 | 443 | decision: "accept", |
| 455 | 444 | }); |
| 456 | 445 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ |
| 457 | [session.sessionId, asRequestId("req-1"), "accept"], | |
| 446 | [session.threadId, asRequestId("req-1"), "accept"], | |
| 458 | 447 | ]); |
| 459 | 448 | |
| 460 | 449 | yield* provider.rollbackConversation({ |
| 461 | sessionId: session.sessionId, | |
| 450 | threadId: session.threadId, | |
| 462 | 451 | numTurns: 0, |
| 463 | 452 | }); |
| 464 | 453 | |
| 465 | yield* provider.stopSession({ sessionId: session.sessionId }); | |
| 454 | yield* provider.stopSession({ threadId: session.threadId }); | |
| 466 | 455 | const sendAfterStop = yield* Effect.result( |
| 467 | 456 | provider.sendTurn({ |
| 468 | sessionId: session.sessionId, | |
| 457 | threadId: session.threadId, | |
| 469 | 458 | input: "after-stop", |
| 470 | 459 | attachments: [], |
| 471 | 460 | }), |
| 472 | 461 | ); |
| 473 | 462 | assertFailure( |
| 474 | 463 | sendAfterStop, |
| 475 | new ProviderSessionNotFoundError({ sessionId: session.sessionId }), | |
| 464 | new ProviderValidationError({ | |
| 465 | operation: "ProviderService.sendTurn", | |
| 466 | issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, | |
| 467 | }), | |
| 476 | 468 | ); |
| 477 | 469 | }), |
| 478 | 470 | ); |
| ... | ... | @@ -483,6 +475,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 483 | 475 | |
| 484 | 476 | const session = yield* provider.startSession(asThreadId("thread-claude"), { |
| 485 | 477 | provider: "claudeCode", |
| 478 | threadId: asThreadId("thread-claude"), | |
| 486 | 479 | cwd: "/tmp/project-claude", |
| 487 | 480 | runtimeMode: "full-access", |
| 488 | 481 | }); |
| ... | ... | @@ -505,15 +498,16 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 505 | 498 | |
| 506 | 499 | const initial = yield* provider.startSession(asThreadId("thread-1"), { |
| 507 | 500 | provider: "codex", |
| 501 | threadId: asThreadId("thread-1"), | |
| 508 | 502 | cwd: "/tmp/project", |
| 509 | 503 | runtimeMode: "full-access", |
| 510 | 504 | }); |
| 511 | yield* routing.codex.stopSession(initial.sessionId); | |
| 505 | yield* routing.codex.stopSession(initial.threadId); | |
| 512 | 506 | routing.codex.startSession.mockClear(); |
| 513 | 507 | routing.codex.rollbackThread.mockClear(); |
| 514 | 508 | |
| 515 | 509 | yield* provider.rollbackConversation({ |
| 516 | sessionId: initial.sessionId, | |
| 510 | threadId: initial.threadId, | |
| 517 | 511 | numTurns: 1, |
| 518 | 512 | }); |
| 519 | 513 | |
| ... | ... | @@ -544,6 +538,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 544 | 538 | |
| 545 | 539 | const initial = yield* provider.startSession(asThreadId("thread-1"), { |
| 546 | 540 | provider: "codex", |
| 541 | threadId: asThreadId("thread-1"), | |
| 547 | 542 | cwd: "/tmp/project-send-turn", |
| 548 | 543 | runtimeMode: "full-access", |
| 549 | 544 | }); |
| ... | ... | @@ -553,7 +548,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 553 | 548 | routing.codex.sendTurn.mockClear(); |
| 554 | 549 | |
| 555 | 550 | yield* provider.sendTurn({ |
| 556 | sessionId: initial.sessionId, | |
| 551 | threadId: initial.threadId, | |
| 557 | 552 | input: "resume", |
| 558 | 553 | attachments: [], |
| 559 | 554 | }); |
| ... | ... | @@ -583,10 +578,12 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 583 | 578 | |
| 584 | 579 | yield* provider.startSession(asThreadId("thread-1"), { |
| 585 | 580 | provider: "codex", |
| 581 | threadId: asThreadId("thread-1"), | |
| 586 | 582 | runtimeMode: "full-access", |
| 587 | 583 | }); |
| 588 | 584 | yield* provider.startSession(asThreadId("thread-2"), { |
| 589 | 585 | provider: "codex", |
| 586 | threadId: asThreadId("thread-2"), | |
| 590 | 587 | runtimeMode: "full-access", |
| 591 | 588 | }); |
| 592 | 589 | |
| ... | ... | @@ -604,16 +601,17 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 604 | 601 | |
| 605 | 602 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 606 | 603 | provider: "codex", |
| 604 | threadId: asThreadId("thread-1"), | |
| 607 | 605 | runtimeMode: "full-access", |
| 608 | 606 | }); |
| 609 | 607 | yield* provider.sendTurn({ |
| 610 | sessionId: session.sessionId, | |
| 608 | threadId: session.threadId, | |
| 611 | 609 | input: "hello", |
| 612 | 610 | attachments: [], |
| 613 | 611 | }); |
| 614 | 612 | |
| 615 | const runningRuntime = yield* runtimeRepository.getBySessionId({ | |
| 616 | providerSessionId: session.sessionId, | |
| 613 | const runningRuntime = yield* runtimeRepository.getByThreadId({ | |
| 614 | threadId: session.threadId, | |
| 617 | 615 | }); |
| 618 | 616 | assert.equal(Option.isSome(runningRuntime), true); |
| 619 | 617 | if (Option.isSome(runningRuntime)) { |
| ... | ... | @@ -638,8 +636,8 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 638 | 636 | } |
| 639 | 637 | |
| 640 | 638 | yield* provider.stopAll(); |
| 641 | const stoppedRuntime = yield* runtimeRepository.getBySessionId({ | |
| 642 | providerSessionId: session.sessionId, | |
| 639 | const stoppedRuntime = yield* runtimeRepository.getByThreadId({ | |
| 640 | threadId: session.threadId, | |
| 643 | 641 | }); |
| 644 | 642 | assert.equal(Option.isSome(stoppedRuntime), true); |
| 645 | 643 | if (Option.isSome(stoppedRuntime)) { |
| ... | ... | @@ -656,6 +654,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 656 | 654 | const provider = yield* ProviderService; |
| 657 | 655 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 658 | 656 | provider: "codex", |
| 657 | threadId: asThreadId("thread-1"), | |
| 659 | 658 | runtimeMode: "full-access", |
| 660 | 659 | }); |
| 661 | 660 | |
| ... | ... | @@ -669,9 +668,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 669 | 668 | type: "turn.completed", |
| 670 | 669 | eventId: asEventId("evt-1"), |
| 671 | 670 | provider: "codex", |
| 672 | sessionId: session.sessionId, | |
| 673 | 671 | createdAt: new Date().toISOString(), |
| 674 | threadId: asProviderThreadId("thread-1"), | |
| 672 | threadId: session.threadId, | |
| 675 | 673 | turnId: asTurnId("turn-1"), |
| 676 | 674 | status: "completed", |
| 677 | 675 | }; |
| ... | ... | @@ -694,6 +692,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 694 | 692 | const provider = yield* ProviderService; |
| 695 | 693 | const session = yield* provider.startSession(asThreadId("thread-seq"), { |
| 696 | 694 | provider: "codex", |
| 695 | threadId: asThreadId("thread-seq"), | |
| 697 | 696 | runtimeMode: "full-access", |
| 698 | 697 | }); |
| 699 | 698 | |
| ... | ... | @@ -708,9 +707,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 708 | 707 | type: "tool.started", |
| 709 | 708 | eventId: asEventId("evt-seq-1"), |
| 710 | 709 | provider: "codex", |
| 711 | sessionId: session.sessionId, | |
| 712 | 710 | createdAt: new Date().toISOString(), |
| 713 | threadId: asProviderThreadId("thread-1"), | |
| 711 | threadId: session.threadId, | |
| 714 | 712 | turnId: asTurnId("turn-1"), |
| 715 | 713 | toolKind: "command", |
| 716 | 714 | title: "Command run", |
| ... | ... | @@ -719,9 +717,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 719 | 717 | type: "tool.completed", |
| 720 | 718 | eventId: asEventId("evt-seq-2"), |
| 721 | 719 | provider: "codex", |
| 722 | sessionId: session.sessionId, | |
| 723 | 720 | createdAt: new Date().toISOString(), |
| 724 | threadId: asProviderThreadId("thread-1"), | |
| 721 | threadId: session.threadId, | |
| 725 | 722 | turnId: asTurnId("turn-1"), |
| 726 | 723 | toolKind: "command", |
| 727 | 724 | title: "Command run", |
| ... | ... | @@ -730,9 +727,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 730 | 727 | type: "turn.completed", |
| 731 | 728 | eventId: asEventId("evt-seq-3"), |
| 732 | 729 | provider: "codex", |
| 733 | sessionId: session.sessionId, | |
| 734 | 730 | createdAt: new Date().toISOString(), |
| 735 | threadId: asProviderThreadId("thread-1"), | |
| 731 | threadId: session.threadId, | |
| 736 | 732 | turnId: asTurnId("turn-1"), |
| 737 | 733 | status: "completed", |
| 738 | 734 | }); |
| ... | ... | @@ -751,6 +747,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 751 | 747 | const provider = yield* ProviderService; |
| 752 | 748 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 753 | 749 | provider: "codex", |
| 750 | threadId: asThreadId("thread-1"), | |
| 754 | 751 | runtimeMode: "full-access", |
| 755 | 752 | }); |
| 756 | 753 | |
| ... | ... | @@ -775,9 +772,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 775 | 772 | type: "tool.completed", |
| 776 | 773 | eventId: asEventId("evt-ordered-1"), |
| 777 | 774 | provider: "codex", |
| 778 | sessionId: session.sessionId, | |
| 779 | 775 | createdAt: new Date().toISOString(), |
| 780 | threadId: asProviderThreadId("thread-1"), | |
| 776 | threadId: session.threadId, | |
| 781 | 777 | turnId: asTurnId("turn-1"), |
| 782 | 778 | toolKind: "command", |
| 783 | 779 | title: "Command run", |
| ... | ... | @@ -787,9 +783,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 787 | 783 | type: "message.delta", |
| 788 | 784 | eventId: asEventId("evt-ordered-2"), |
| 789 | 785 | provider: "codex", |
| 790 | sessionId: session.sessionId, | |
| 791 | 786 | createdAt: new Date().toISOString(), |
| 792 | threadId: asProviderThreadId("thread-1"), | |
| 787 | threadId: session.threadId, | |
| 793 | 788 | turnId: asTurnId("turn-1"), |
| 794 | 789 | delta: "hello", |
| 795 | 790 | }, |
| ... | ... | @@ -797,9 +792,8 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 797 | 792 | type: "turn.completed", |
| 798 | 793 | eventId: asEventId("evt-ordered-3"), |
| 799 | 794 | provider: "codex", |
| 800 | sessionId: session.sessionId, | |
| 801 | 795 | createdAt: new Date().toISOString(), |
| 802 | threadId: asProviderThreadId("thread-1"), | |
| 796 | threadId: session.threadId, | |
| 803 | 797 | turnId: asTurnId("turn-1"), |
| 804 | 798 | status: "completed", |
| 805 | 799 | }, |
| ... | ... | @@ -854,32 +848,32 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 854 | 848 | Effect.sync(() => { |
| 855 | 849 | const now = new Date().toISOString(); |
| 856 | 850 | return { |
| 857 | sessionId: asSessionId("sess-missing-thread"), | |
| 858 | 851 | provider: "codex", |
| 859 | 852 | status: "ready", |
| 853 | threadId: input.threadId, | |
| 860 | 854 | 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 | }), | |
| 866 | 860 | ); |
| 867 | 861 | |
| 868 | 862 | const session = yield* provider.startSession(asThreadId("thread-missing"), { |
| 869 | 863 | provider: "codex", |
| 864 | threadId: asThreadId("thread-missing"), | |
| 870 | 865 | cwd: "/tmp/project", |
| 871 | 866 | runtimeMode: "full-access", |
| 872 | 867 | }); |
| 873 | 868 | |
| 874 | assert.equal(session.sessionId, asSessionId("sess-missing-thread")); | |
| 875 | assert.equal(session.threadId, undefined); | |
| 869 | assert.equal(session.threadId, asThreadId("thread-missing")); | |
| 876 | 870 | |
| 877 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 878 | providerSessionId: session.sessionId, | |
| 871 | const runtime = yield* runtimeRepository.getByThreadId({ | |
| 872 | threadId: session.threadId, | |
| 879 | 873 | }); |
| 880 | 874 | assert.equal(Option.isSome(runtime), true); |
| 881 | 875 | if (Option.isSome(runtime)) { |
| 882 | assert.equal(runtime.value.providerThreadId, null); | |
| 876 | assert.equal(runtime.value.threadId, session.threadId); | |
| 883 | 877 | } |
| 884 | 878 | }), |
| 885 | 879 | ); |
apps/server/src/provider/Layers/ProviderService.ts+62-255| ... | ... | @@ -11,8 +11,6 @@ |
| 11 | 11 | */ |
| 12 | 12 | import { |
| 13 | 13 | NonNegativeInt, |
| 14 | ProviderSessionId, | |
| 15 | RuntimeSessionId, | |
| 16 | 14 | ThreadId, |
| 17 | 15 | ProviderInterruptTurnInput, |
| 18 | 16 | ProviderRespondToRequestInput, |
| ... | ... | @@ -22,14 +20,14 @@ import { |
| 22 | 20 | type ProviderRuntimeEvent, |
| 23 | 21 | type ProviderSession, |
| 24 | 22 | } from "@t3tools/contracts"; |
| 25 | import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect"; | |
| 23 | import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; | |
| 26 | 24 | |
| 27 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 25 | import { ProviderValidationError } from "../Errors.ts"; | |
| 28 | 26 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 29 | 27 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; |
| 30 | 28 | import { |
| 31 | 29 | ProviderSessionDirectory, |
| 32 | type ProviderSessionBinding, | |
| 30 | type ProviderRuntimeBinding, | |
| 33 | 31 | } from "../Services/ProviderSessionDirectory.ts"; |
| 34 | 32 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 35 | 33 | |
| ... | ... | @@ -39,7 +37,7 @@ export interface ProviderServiceLiveOptions { |
| 39 | 37 | } |
| 40 | 38 | |
| 41 | 39 | const ProviderRollbackConversationInput = Schema.Struct({ |
| 42 | sessionId: ProviderSessionId, | |
| 40 | threadId: ThreadId, | |
| 43 | 41 | numTurns: NonNegativeInt, |
| 44 | 42 | }); |
| 45 | 43 | |
| ... | ... | @@ -96,7 +94,7 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u |
| 96 | 94 | } |
| 97 | 95 | |
| 98 | 96 | function readPersistedCwd( |
| 99 | runtimePayload: ProviderSessionBinding["runtimePayload"], | |
| 97 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], | |
| 100 | 98 | ): string | undefined { |
| 101 | 99 | if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { |
| 102 | 100 | return undefined; |
| ... | ... | @@ -121,47 +119,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 121 | 119 | const directory = yield* ProviderSessionDirectory; |
| 122 | 120 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 123 | 121 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 124 | const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>( | |
| 125 | new Map(), | |
| 126 | ); | |
| 127 | const asRuntimeSessionId = (sessionId: ProviderSessionId): RuntimeSessionId => | |
| 128 | RuntimeSessionId.makeUnsafe(sessionId); | |
| 129 | const asProviderSessionId = (sessionId: RuntimeSessionId): ProviderSessionId => | |
| 130 | ProviderSessionId.makeUnsafe(sessionId); | |
| 131 | ||
| 132 | const canonicalizeRuntimeEventSession = ( | |
| 133 | event: ProviderRuntimeEvent, | |
| 134 | ): Effect.Effect<ProviderRuntimeEvent> => | |
| 135 | Ref.get(routedSessionAliasesRef).pipe( | |
| 136 | Effect.map((aliases) => { | |
| 137 | for (const [staleSessionId, liveSessionId] of aliases) { | |
| 138 | if (asRuntimeSessionId(liveSessionId) === event.sessionId) { | |
| 139 | return { | |
| 140 | ...event, | |
| 141 | sessionId: asRuntimeSessionId(staleSessionId), | |
| 142 | } satisfies ProviderRuntimeEvent; | |
| 143 | } | |
| 144 | } | |
| 145 | return event; | |
| 146 | }), | |
| 147 | ); | |
| 148 | 122 | |
| 149 | 123 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 150 | canonicalizeRuntimeEventSession(event).pipe( | |
| 124 | Effect.succeed(event).pipe( | |
| 151 | 125 | Effect.tap((canonicalEvent) => |
| 152 | 126 | 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) | |
| 165 | 128 | : Effect.void, |
| 166 | 129 | ), |
| 167 | 130 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), |
| ... | ... | @@ -172,56 +135,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 172 | 135 | session: ProviderSession, |
| 173 | 136 | threadId: ThreadId, |
| 174 | 137 | ) => |
| 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), | |
| 225 | 145 | }); |
| 226 | 146 | |
| 227 | 147 | const providers = yield* registry.listProviders(); |
| ... | ... | @@ -244,166 +164,77 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 244 | 164 | ).pipe(Effect.asVoid); |
| 245 | 165 | |
| 246 | 166 | const recoverSessionForThread = (input: { |
| 247 | readonly staleSessionId: ProviderSessionId; | |
| 248 | readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId }; | |
| 167 | readonly binding: ProviderRuntimeBinding; | |
| 249 | 168 | readonly operation: string; |
| 250 | 169 | }) => |
| 251 | 170 | Effect.gen(function* () { |
| 252 | 171 | const adapter = yield* registry.getByProvider(input.binding.provider); |
| 253 | const activeSessions = yield* adapter.listSessions(); | |
| 254 | const resumeThreadId = input.binding.providerThreadId ?? undefined; | |
| 255 | 172 | const hasResumeCursor = |
| 256 | 173 | 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; | |
| 279 | 181 | } |
| 280 | return { | |
| 281 | adapter, | |
| 282 | sessionId: existing.sessionId, | |
| 283 | } as const; | |
| 284 | 182 | } |
| 285 | 183 | |
| 286 | 184 | if (!hasResumeCursor) { |
| 287 | 185 | return yield* toValidationError( |
| 288 | 186 | input.operation, |
| 289 | `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, | |
| 187 | `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, | |
| 290 | 188 | ); |
| 291 | 189 | } |
| 292 | 190 | |
| 293 | 191 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 294 | 192 | |
| 295 | 193 | const resumed = yield* adapter.startSession({ |
| 194 | threadId: input.binding.threadId, | |
| 296 | 195 | provider: input.binding.provider, |
| 297 | 196 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 298 | ...(resumeThreadId ? { resumeThreadId } : {}), | |
| 299 | 197 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 300 | 198 | runtimeMode: input.binding.runtimeMode ?? "full-access", |
| 301 | 199 | }); |
| 302 | 200 | if (resumed.provider !== adapter.provider) { |
| 303 | 201 | return yield* toValidationError( |
| 304 | 202 | input.operation, |
| 305 | `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, | |
| 203 | `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, | |
| 306 | 204 | ); |
| 307 | 205 | } |
| 308 | 206 | |
| 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; | |
| 334 | 209 | }); |
| 335 | 210 | |
| 336 | 211 | const resolveRoutableSession = (input: { |
| 337 | readonly sessionId: ProviderSessionId; | |
| 212 | readonly threadId: ThreadId; | |
| 338 | 213 | readonly operation: string; |
| 339 | 214 | readonly allowRecovery: boolean; |
| 340 | 215 | }) => |
| 341 | 216 | Effect.gen(function* () { |
| 342 | const bindingOption = yield* directory.getBinding(input.sessionId); | |
| 217 | const bindingOption = yield* directory.getBinding(input.threadId); | |
| 343 | 218 | const binding = Option.getOrUndefined(bindingOption); |
| 344 | 219 | if (!binding) { |
| 345 | return yield* new ProviderSessionNotFoundError({ | |
| 346 | sessionId: input.sessionId, | |
| 347 | }); | |
| 348 | } | |
| 349 | if (!binding.threadId) { | |
| 350 | 220 | return yield* toValidationError( |
| 351 | 221 | input.operation, |
| 352 | `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`, | |
| 222 | `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, | |
| 353 | 223 | ); |
| 354 | 224 | } |
| 355 | const bindingWithThreadId: ProviderSessionBinding & { | |
| 356 | readonly threadId: ThreadId; | |
| 357 | } = { | |
| 358 | ...binding, | |
| 359 | threadId: binding.threadId, | |
| 360 | }; | |
| 361 | 225 | const adapter = yield* registry.getByProvider(binding.provider); |
| 362 | 226 | |
| 363 | const hasRequestedSession = yield* adapter.hasSession(input.sessionId); | |
| 227 | const hasRequestedSession = yield* adapter.hasSession(input.threadId); | |
| 364 | 228 | 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; | |
| 386 | 230 | } |
| 387 | 231 | |
| 388 | 232 | 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; | |
| 394 | 234 | } |
| 395 | 235 | |
| 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; | |
| 407 | 238 | }); |
| 408 | 239 | |
| 409 | 240 | const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) => |
| ... | ... | @@ -416,6 +247,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 416 | 247 | |
| 417 | 248 | const input = { |
| 418 | 249 | ...parsed, |
| 250 | threadId, | |
| 419 | 251 | provider: parsed.provider ?? "codex", |
| 420 | 252 | }; |
| 421 | 253 | const adapter = yield* registry.getByProvider(input.provider); |
| ... | ... | @@ -452,28 +284,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 452 | 284 | ); |
| 453 | 285 | } |
| 454 | 286 | const routed = yield* resolveRoutableSession({ |
| 455 | sessionId: input.sessionId, | |
| 287 | threadId: input.threadId, | |
| 456 | 288 | operation: "ProviderService.sendTurn", |
| 457 | 289 | allowRecovery: true, |
| 458 | 290 | }); |
| 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); | |
| 472 | 292 | yield* directory.upsert({ |
| 473 | sessionId: input.sessionId, | |
| 293 | threadId: input.threadId, | |
| 474 | 294 | provider: routed.adapter.provider, |
| 475 | threadId, | |
| 476 | ...(turn.threadId !== undefined ? { providerThreadId: turn.threadId } : {}), | |
| 477 | 295 | status: "running", |
| 478 | 296 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), |
| 479 | 297 | runtimePayload: { |
| ... | ... | @@ -493,11 +311,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 493 | 311 | payload: rawInput, |
| 494 | 312 | }); |
| 495 | 313 | const routed = yield* resolveRoutableSession({ |
| 496 | sessionId: input.sessionId, | |
| 314 | threadId: input.threadId, | |
| 497 | 315 | operation: "ProviderService.interruptTurn", |
| 498 | 316 | allowRecovery: true, |
| 499 | 317 | }); |
| 500 | yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId); | |
| 318 | yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); | |
| 501 | 319 | }); |
| 502 | 320 | |
| 503 | 321 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => |
| ... | ... | @@ -508,11 +326,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 508 | 326 | payload: rawInput, |
| 509 | 327 | }); |
| 510 | 328 | const routed = yield* resolveRoutableSession({ |
| 511 | sessionId: input.sessionId, | |
| 329 | threadId: input.threadId, | |
| 512 | 330 | operation: "ProviderService.respondToRequest", |
| 513 | 331 | allowRecovery: true, |
| 514 | 332 | }); |
| 515 | yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision); | |
| 333 | yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); | |
| 516 | 334 | }); |
| 517 | 335 | |
| 518 | 336 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => |
| ... | ... | @@ -523,19 +341,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 523 | 341 | payload: rawInput, |
| 524 | 342 | }); |
| 525 | 343 | const routed = yield* resolveRoutableSession({ |
| 526 | sessionId: input.sessionId, | |
| 344 | threadId: input.threadId, | |
| 527 | 345 | operation: "ProviderService.stopSession", |
| 528 | 346 | allowRecovery: false, |
| 529 | 347 | }); |
| 530 | 348 | 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); | |
| 536 | 350 | } |
| 537 | yield* directory.remove(input.sessionId); | |
| 538 | yield* clearAliasesReferencing(input.sessionId); | |
| 351 | yield* directory.remove(input.threadId); | |
| 539 | 352 | }); |
| 540 | 353 | |
| 541 | 354 | const listSessions: ProviderServiceShape["listSessions"] = () => |
| ... | ... | @@ -543,39 +356,36 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 543 | 356 | const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions()); |
| 544 | 357 | const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); |
| 545 | 358 | const persistedBindings = yield* directory |
| 546 | .listSessionIds() | |
| 359 | .listThreadIds() | |
| 547 | 360 | .pipe( |
| 548 | Effect.flatMap((sessionIds) => | |
| 361 | Effect.flatMap((threadIds) => | |
| 549 | 362 | 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>()), | |
| 554 | 367 | ), |
| 555 | 368 | { concurrency: "unbounded" }, |
| 556 | 369 | ), |
| 557 | 370 | ), |
| 558 | Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderSessionBinding>>), | |
| 371 | Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>), | |
| 559 | 372 | ); |
| 560 | const bindingsBySessionId = new Map<ProviderSessionId, ProviderSessionBinding>(); | |
| 373 | const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>(); | |
| 561 | 374 | for (const bindingOption of persistedBindings) { |
| 562 | 375 | const binding = Option.getOrUndefined(bindingOption); |
| 563 | 376 | if (binding) { |
| 564 | bindingsBySessionId.set(binding.sessionId, binding); | |
| 377 | bindingsByThreadId.set(binding.threadId, binding); | |
| 565 | 378 | } |
| 566 | 379 | } |
| 567 | 380 | |
| 568 | 381 | return activeSessions.map((session) => { |
| 569 | const binding = bindingsBySessionId.get(session.sessionId); | |
| 382 | const binding = bindingsByThreadId.get(session.threadId); | |
| 570 | 383 | if (!binding) { |
| 571 | 384 | return session; |
| 572 | 385 | } |
| 573 | 386 | |
| 574 | 387 | return { |
| 575 | 388 | ...session, |
| 576 | ...(session.threadId === undefined && binding.providerThreadId | |
| 577 | ? { threadId: binding.providerThreadId } | |
| 578 | : {}), | |
| 579 | 389 | ...(session.resumeCursor === undefined && binding.resumeCursor !== undefined |
| 580 | 390 | ? { resumeCursor: binding.resumeCursor } |
| 581 | 391 | : {}), |
| ... | ... | @@ -598,22 +408,22 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 598 | 408 | return; |
| 599 | 409 | } |
| 600 | 410 | const routed = yield* resolveRoutableSession({ |
| 601 | sessionId: input.sessionId, | |
| 411 | threadId: input.threadId, | |
| 602 | 412 | operation: "ProviderService.rollbackConversation", |
| 603 | 413 | allowRecovery: true, |
| 604 | 414 | }); |
| 605 | yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); | |
| 415 | yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); | |
| 606 | 416 | }); |
| 607 | 417 | |
| 608 | 418 | const stopAll: ProviderServiceShape["stopAll"] = () => |
| 609 | 419 | Effect.gen(function* () { |
| 610 | const sessionIds = yield* directory.listSessionIds(); | |
| 420 | const threadIds = yield* directory.listThreadIds(); | |
| 611 | 421 | 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( | |
| 614 | 424 | Effect.flatMap((provider) => |
| 615 | 425 | directory.upsert({ |
| 616 | sessionId, | |
| 426 | threadId, | |
| 617 | 427 | provider, |
| 618 | 428 | status: "stopped", |
| 619 | 429 | runtimePayload: { |
| ... | ... | @@ -625,9 +435,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 625 | 435 | ), |
| 626 | 436 | ), |
| 627 | 437 | ).pipe(Effect.asVoid); |
| 628 | // Keep persisted session bindings so stale sessions can be resumed after | |
| 629 | // process restart via providerThreadId. | |
| 630 | yield* Ref.set(routedSessionAliasesRef, new Map()); | |
| 631 | 438 | }); |
| 632 | 439 | |
| 633 | 440 | return { |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+44-91| ... | ... | @@ -3,7 +3,7 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { ThreadId } from "@t3tools/contracts"; | |
| 7 | 7 | import { it, assert } from "@effect/vitest"; |
| 8 | 8 | import { assertFailure, assertSome } from "@effect/vitest/utils"; |
| 9 | 9 | import { Effect, Layer, Option } from "effect"; |
| ... | ... | @@ -15,7 +15,7 @@ import { |
| 15 | 15 | } from "../../persistence/Layers/Sqlite.ts"; |
| 16 | 16 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; |
| 17 | 17 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 18 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 18 | import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; | |
| 19 | 19 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 20 | 20 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 21 | 21 | |
| ... | ... | @@ -31,74 +31,59 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { |
| 34 | it("upserts, reads, and removes session bindings", () => | |
| 34 | it("upserts, reads, and removes thread bindings", () => | |
| 35 | 35 | Effect.gen(function* () { |
| 36 | 36 | const directory = yield* ProviderSessionDirectory; |
| 37 | 37 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; |
| 38 | 38 | |
| 39 | const sessionId = ProviderSessionId.makeUnsafe("sess-1"); | |
| 40 | 39 | const initialThreadId = ThreadId.makeUnsafe("thread-1"); |
| 41 | 40 | |
| 42 | 41 | yield* directory.upsert({ |
| 43 | sessionId, | |
| 44 | 42 | provider: "codex", |
| 45 | 43 | threadId: initialThreadId, |
| 46 | 44 | }); |
| 47 | 45 | |
| 48 | const provider = yield* directory.getProvider(sessionId); | |
| 46 | const provider = yield* directory.getProvider(initialThreadId); | |
| 49 | 47 | 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 | } | |
| 52 | 56 | |
| 53 | 57 | const nextThreadId = ThreadId.makeUnsafe("thread-2"); |
| 54 | 58 | |
| 55 | 59 | yield* directory.upsert({ |
| 56 | sessionId: sessionId, | |
| 57 | 60 | provider: "codex", |
| 58 | 61 | threadId: nextThreadId, |
| 59 | 62 | }); |
| 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); | |
| 63 | 64 | assert.equal(Option.isSome(updatedBinding), true); |
| 64 | 65 | if (Option.isSome(updatedBinding)) { |
| 65 | 66 | assert.equal(updatedBinding.value.threadId, nextThreadId); |
| 66 | assert.equal(updatedBinding.value.providerThreadId, null); | |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 70 | providerSessionId: sessionId, | |
| 71 | }); | |
| 69 | const runtime = yield* runtimeRepository.getByThreadId({ threadId: nextThreadId }); | |
| 72 | 70 | assert.equal(Option.isSome(runtime), true); |
| 73 | 71 | if (Option.isSome(runtime)) { |
| 74 | 72 | assert.equal(runtime.value.threadId, nextThreadId); |
| 75 | assert.equal(runtime.value.providerThreadId, null); | |
| 76 | 73 | assert.equal(runtime.value.status, "running"); |
| 77 | assert.equal(runtime.value.adapterKey, "codex"); | |
| 74 | assert.equal(runtime.value.providerName, "codex"); | |
| 78 | 75 | } |
| 79 | 76 | |
| 80 | const sessionIds = yield* directory.listSessionIds(); | |
| 81 | assert.deepEqual(sessionIds, [sessionId]); | |
| 77 | const threadIds = yield* directory.listThreadIds(); | |
| 78 | assert.deepEqual(threadIds, [nextThreadId]); | |
| 82 | 79 | |
| 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); | |
| 97 | 82 | 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}'.`, | |
| 102 | 87 | }), |
| 103 | 88 | ); |
| 104 | 89 | })); |
| ... | ... | @@ -108,15 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 108 | 93 | const directory = yield* ProviderSessionDirectory; |
| 109 | 94 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; |
| 110 | 95 | |
| 111 | const sessionId = ProviderSessionId.makeUnsafe("sess-runtime"); | |
| 112 | 96 | const threadId = ThreadId.makeUnsafe("thread-runtime"); |
| 113 | const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime"); | |
| 114 | 97 | |
| 115 | 98 | yield* directory.upsert({ |
| 116 | sessionId, | |
| 117 | 99 | provider: "codex", |
| 118 | 100 | threadId, |
| 119 | providerThreadId, | |
| 120 | 101 | status: "starting", |
| 121 | 102 | resumeCursor: { |
| 122 | 103 | threadId: "provider-thread-runtime", |
| ... | ... | @@ -128,24 +109,21 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 128 | 109 | }); |
| 129 | 110 | |
| 130 | 111 | yield* directory.upsert({ |
| 131 | sessionId, | |
| 132 | 112 | provider: "codex", |
| 113 | threadId, | |
| 133 | 114 | status: "running", |
| 134 | 115 | runtimePayload: { |
| 135 | 116 | activeTurnId: "turn-1", |
| 136 | 117 | }, |
| 137 | 118 | }); |
| 138 | 119 | |
| 139 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 140 | providerSessionId: sessionId, | |
| 141 | }); | |
| 120 | const runtime = yield* runtimeRepository.getByThreadId({ threadId }); | |
| 142 | 121 | assert.equal(Option.isSome(runtime), true); |
| 143 | 122 | if (Option.isSome(runtime)) { |
| 144 | 123 | assert.equal(runtime.value.threadId, threadId); |
| 145 | assert.equal(runtime.value.providerThreadId, providerThreadId); | |
| 146 | 124 | assert.equal(runtime.value.status, "running"); |
| 147 | 125 | assert.deepEqual(runtime.value.resumeCursor, { |
| 148 | threadId: providerThreadId, | |
| 126 | threadId: "provider-thread-runtime", | |
| 149 | 127 | }); |
| 150 | 128 | assert.deepEqual(runtime.value.runtimePayload, { |
| 151 | 129 | cwd: "/tmp/project", |
| ... | ... | @@ -155,52 +133,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 155 | 133 | } |
| 156 | 134 | })); |
| 157 | 135 | |
| 158 | it("clears providerThreadId when explicitly set to null", () => | |
| 159 | Effect.gen(function* () { | |
| 160 | const directory = yield* ProviderSessionDirectory; | |
| 161 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 162 | ||
| 163 | const sessionId = ProviderSessionId.makeUnsafe("sess-clear-provider-thread-id"); | |
| 164 | const threadId = ThreadId.makeUnsafe("thread-clear-provider-thread-id"); | |
| 165 | const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-to-clear"); | |
| 166 | ||
| 167 | yield* directory.upsert({ | |
| 168 | sessionId, | |
| 169 | provider: "codex", | |
| 170 | threadId, | |
| 171 | adapterKey: "custom-adapter", | |
| 172 | providerThreadId, | |
| 173 | }); | |
| 174 | ||
| 175 | yield* directory.upsert({ | |
| 176 | sessionId, | |
| 177 | provider: "codex", | |
| 178 | providerThreadId: null, | |
| 179 | }); | |
| 180 | ||
| 181 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 182 | providerSessionId: sessionId, | |
| 183 | }); | |
| 184 | assert.equal(Option.isSome(runtime), true); | |
| 185 | if (Option.isSome(runtime)) { | |
| 186 | assert.equal(runtime.value.providerThreadId, null); | |
| 187 | assert.equal(runtime.value.adapterKey, "custom-adapter"); | |
| 188 | } | |
| 189 | })); | |
| 190 | ||
| 191 | 136 | it("rehydrates persisted mappings across layer restart", () => |
| 192 | 137 | Effect.gen(function* () { |
| 193 | 138 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); |
| 194 | 139 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 195 | 140 | const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); |
| 196 | 141 | |
| 197 | const sessionId = ProviderSessionId.makeUnsafe("sess-restart"); | |
| 198 | 142 | const threadId = ThreadId.makeUnsafe("thread-restart"); |
| 199 | 143 | |
| 200 | 144 | yield* Effect.gen(function* () { |
| 201 | 145 | const directory = yield* ProviderSessionDirectory; |
| 202 | 146 | yield* directory.upsert({ |
| 203 | sessionId, | |
| 204 | 147 | provider: "codex", |
| 205 | 148 | threadId, |
| 206 | 149 | }); |
| ... | ... | @@ -209,11 +152,17 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 209 | 152 | yield* Effect.gen(function* () { |
| 210 | 153 | const directory = yield* ProviderSessionDirectory; |
| 211 | 154 | const sql = yield* SqlClient.SqlClient; |
| 212 | const provider = yield* directory.getProvider(sessionId); | |
| 155 | const provider = yield* directory.getProvider(threadId); | |
| 213 | 156 | assert.equal(provider, "codex"); |
| 214 | 157 | |
| 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 | } | |
| 217 | 166 | |
| 218 | 167 | const legacyTableRows = yield* sql<{ readonly name: string }>` |
| 219 | 168 | SELECT name |
| ... | ... | @@ -229,18 +178,22 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 229 | 178 | it("accepts cursor provider bindings", () => |
| 230 | 179 | Effect.gen(function* () { |
| 231 | 180 | const directory = yield* ProviderSessionDirectory; |
| 232 | const sessionId = ProviderSessionId.makeUnsafe("sess-cursor"); | |
| 233 | 181 | const threadId = ThreadId.makeUnsafe("thread-cursor"); |
| 234 | 182 | |
| 235 | 183 | yield* directory.upsert({ |
| 236 | sessionId, | |
| 237 | 184 | provider: "cursor", |
| 238 | 185 | threadId, |
| 239 | 186 | }); |
| 240 | 187 | |
| 241 | const provider = yield* directory.getProvider(sessionId); | |
| 188 | const provider = yield* directory.getProvider(threadId); | |
| 242 | 189 | 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 | } | |
| 245 | 198 | })); |
| 246 | 199 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+27-64| ... | ... | @@ -1,15 +1,14 @@ |
| 1 | import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts"; | |
| 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 5 | 5 | import { |
| 6 | 6 | ProviderSessionDirectoryPersistenceError, |
| 7 | ProviderSessionNotFoundError, | |
| 8 | 7 | ProviderValidationError, |
| 9 | 8 | } from "../Errors.ts"; |
| 10 | 9 | import { |
| 11 | 10 | ProviderSessionDirectory, |
| 12 | type ProviderSessionBinding, | |
| 11 | type ProviderRuntimeBinding, | |
| 13 | 12 | type ProviderSessionDirectoryShape, |
| 14 | 13 | } from "../Services/ProviderSessionDirectory.ts"; |
| 15 | 14 | |
| ... | ... | @@ -49,10 +48,7 @@ function mergeRuntimePayload( |
| 49 | 48 | return existing ?? null; |
| 50 | 49 | } |
| 51 | 50 | if (isRecord(existing) && isRecord(next)) { |
| 52 | return { | |
| 53 | ...existing, | |
| 54 | ...next, | |
| 55 | }; | |
| 51 | return { ...existing, ...next }; | |
| 56 | 52 | } |
| 57 | 53 | return next; |
| 58 | 54 | } |
| ... | ... | @@ -60,21 +56,19 @@ function mergeRuntimePayload( |
| 60 | 56 | const makeProviderSessionDirectory = Effect.gen(function* () { |
| 61 | 57 | const repository = yield* ProviderSessionRuntimeRepository; |
| 62 | 58 | |
| 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")), | |
| 66 | 62 | Effect.flatMap((runtime) => |
| 67 | 63 | Option.match(runtime, { |
| 68 | onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()), | |
| 64 | onNone: () => Effect.succeed(Option.none<ProviderRuntimeBinding>()), | |
| 69 | 65 | onSome: (value) => |
| 70 | 66 | decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( |
| 71 | 67 | Effect.map((provider) => |
| 72 | 68 | Option.some({ |
| 73 | sessionId: value.providerSessionId, | |
| 74 | provider, | |
| 75 | 69 | threadId: value.threadId, |
| 70 | provider, | |
| 76 | 71 | adapterKey: value.adapterKey, |
| 77 | providerThreadId: value.providerThreadId, | |
| 78 | 72 | runtimeMode: value.runtimeMode, |
| 79 | 73 | status: value.status, |
| 80 | 74 | resumeCursor: value.resumeCursor, |
| ... | ... | @@ -88,10 +82,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 88 | 82 | |
| 89 | 83 | const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { |
| 90 | 84 | 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"))); | |
| 95 | 87 | |
| 96 | 88 | const existingRuntime = Option.getOrUndefined(existing); |
| 97 | 89 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; |
| ... | ... | @@ -105,14 +97,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 105 | 97 | const now = new Date().toISOString(); |
| 106 | 98 | yield* repository |
| 107 | 99 | .upsert({ |
| 108 | providerSessionId: binding.sessionId, | |
| 109 | 100 | threadId: resolvedThreadId, |
| 110 | 101 | providerName: binding.provider, |
| 111 | 102 | adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, |
| 112 | providerThreadId: | |
| 113 | binding.providerThreadId !== undefined | |
| 114 | ? binding.providerThreadId | |
| 115 | : (existingRuntime?.providerThreadId ?? null), | |
| 116 | 103 | runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", |
| 117 | 104 | status: binding.status ?? existingRuntime?.status ?? "running", |
| 118 | 105 | lastSeenAt: now, |
| ... | ... | @@ -128,63 +115,39 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 128 | 115 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); |
| 129 | 116 | }); |
| 130 | 117 | |
| 131 | const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => | |
| 132 | getBinding(sessionId).pipe( | |
| 118 | const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => | |
| 119 | getBinding(threadId).pipe( | |
| 133 | 120 | Effect.flatMap((binding) => |
| 134 | 121 | Option.match(binding, { |
| 135 | 122 | 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 | ), | |
| 150 | 130 | }), |
| 151 | 131 | ), |
| 152 | 132 | ); |
| 153 | 133 | |
| 154 | const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => | |
| 134 | const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => | |
| 155 | 135 | 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"))); | |
| 160 | 138 | |
| 161 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => | |
| 139 | const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => | |
| 162 | 140 | 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)), | |
| 179 | 143 | ); |
| 180 | 144 | |
| 181 | 145 | return { |
| 182 | 146 | upsert, |
| 183 | 147 | getProvider, |
| 184 | getBinding: getBindingBySessionId, | |
| 185 | getThreadId, | |
| 148 | getBinding, | |
| 186 | 149 | remove, |
| 187 | listSessionIds, | |
| 150 | listThreadIds, | |
| 188 | 151 | } satisfies ProviderSessionDirectoryShape; |
| 189 | 152 | }); |
| 190 | 153 |
apps/server/src/provider/Services/ProviderAdapter.ts+11-12| ... | ... | @@ -14,11 +14,10 @@ import type { |
| 14 | 14 | ProviderRuntimeEvent, |
| 15 | 15 | ProviderSendTurnInput, |
| 16 | 16 | ProviderSession, |
| 17 | ProviderSessionId, | |
| 18 | 17 | ProviderSessionStartInput, |
| 19 | ProviderThreadId, | |
| 20 | ProviderTurnId, | |
| 18 | ThreadId, | |
| 21 | 19 | ProviderTurnStartResult, |
| 20 | TurnId, | |
| 22 | 21 | } from "@t3tools/contracts"; |
| 23 | 22 | import type { Effect } from "effect"; |
| 24 | 23 | import type { Stream } from "effect"; |
| ... | ... | @@ -33,12 +32,12 @@ export interface ProviderAdapterCapabilities { |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | 34 | export interface ProviderThreadTurnSnapshot { |
| 36 | readonly id: ProviderTurnId; | |
| 35 | readonly id: TurnId; | |
| 37 | 36 | readonly items: ReadonlyArray<unknown>; |
| 38 | 37 | } |
| 39 | 38 | |
| 40 | 39 | export interface ProviderThreadSnapshot { |
| 41 | readonly threadId: ProviderThreadId; | |
| 40 | readonly threadId: ThreadId; | |
| 42 | 41 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; |
| 43 | 42 | } |
| 44 | 43 | |
| ... | ... | @@ -67,15 +66,15 @@ export interface ProviderAdapterShape<TError> { |
| 67 | 66 | * Interrupt an active turn. |
| 68 | 67 | */ |
| 69 | 68 | readonly interruptTurn: ( |
| 70 | sessionId: ProviderSessionId, | |
| 71 | turnId?: ProviderTurnId, | |
| 69 | threadId: ThreadId, | |
| 70 | turnId?: TurnId, | |
| 72 | 71 | ) => Effect.Effect<void, TError>; |
| 73 | 72 | |
| 74 | 73 | /** |
| 75 | 74 | * Respond to an interactive approval request. |
| 76 | 75 | */ |
| 77 | 76 | readonly respondToRequest: ( |
| 78 | sessionId: ProviderSessionId, | |
| 77 | threadId: ThreadId, | |
| 79 | 78 | requestId: ApprovalRequestId, |
| 80 | 79 | decision: ProviderApprovalDecision, |
| 81 | 80 | ) => Effect.Effect<void, TError>; |
| ... | ... | @@ -83,7 +82,7 @@ export interface ProviderAdapterShape<TError> { |
| 83 | 82 | /** |
| 84 | 83 | * Stop one provider session. |
| 85 | 84 | */ |
| 86 | readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>; | |
| 85 | readonly stopSession: (threadId: ThreadId) => Effect.Effect<void, TError>; | |
| 87 | 86 | |
| 88 | 87 | /** |
| 89 | 88 | * List currently active provider sessions for this adapter. |
| ... | ... | @@ -93,20 +92,20 @@ export interface ProviderAdapterShape<TError> { |
| 93 | 92 | /** |
| 94 | 93 | * Check whether this adapter owns an active session id. |
| 95 | 94 | */ |
| 96 | readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>; | |
| 95 | readonly hasSession: (threadId: ThreadId) => Effect.Effect<boolean>; | |
| 97 | 96 | |
| 98 | 97 | /** |
| 99 | 98 | * Read a provider thread snapshot. |
| 100 | 99 | */ |
| 101 | 100 | readonly readThread: ( |
| 102 | sessionId: ProviderSessionId, | |
| 101 | threadId: ThreadId, | |
| 103 | 102 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; |
| 104 | 103 | |
| 105 | 104 | /** |
| 106 | 105 | * Roll back a provider thread by N turns. |
| 107 | 106 | */ |
| 108 | 107 | readonly rollbackThread: ( |
| 109 | sessionId: ProviderSessionId, | |
| 108 | threadId: ThreadId, | |
| 110 | 109 | numTurns: number, |
| 111 | 110 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; |
| 112 | 111 |
apps/server/src/provider/Services/ProviderService.ts+1-2| ... | ... | @@ -18,7 +18,6 @@ import type { |
| 18 | 18 | ProviderRuntimeEvent, |
| 19 | 19 | ProviderSendTurnInput, |
| 20 | 20 | ProviderSession, |
| 21 | ProviderSessionId, | |
| 22 | 21 | ProviderSessionStartInput, |
| 23 | 22 | ProviderStopSessionInput, |
| 24 | 23 | ThreadId, |
| ... | ... | @@ -88,7 +87,7 @@ export interface ProviderServiceShape { |
| 88 | 87 | * Roll back provider conversation state by a number of turns. |
| 89 | 88 | */ |
| 90 | 89 | readonly rollbackConversation: (input: { |
| 91 | readonly sessionId: ProviderSessionId; | |
| 90 | readonly threadId: ThreadId; | |
| 92 | 91 | readonly numTurns: number; |
| 93 | 92 | }) => Effect.Effect<void, ProviderServiceError>; |
| 94 | 93 |
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 | */ | |
| 10 | 1 | import type { |
| 11 | 2 | ProviderKind, |
| 12 | ProviderSessionId, | |
| 13 | 3 | ProviderSessionRuntimeStatus, |
| 14 | ProviderThreadId, | |
| 15 | 4 | RuntimeMode, |
| 16 | 5 | ThreadId, |
| 17 | 6 | } from "@t3tools/contracts"; |
| ... | ... | @@ -20,84 +9,48 @@ import type { Effect } from "effect"; |
| 20 | 9 | |
| 21 | 10 | import type { |
| 22 | 11 | ProviderSessionDirectoryPersistenceError, |
| 23 | ProviderSessionNotFoundError, | |
| 24 | 12 | ProviderValidationError, |
| 25 | 13 | } from "../Errors.ts"; |
| 26 | 14 | |
| 27 | export interface ProviderSessionBinding { | |
| 28 | readonly sessionId: ProviderSessionId; | |
| 15 | export interface ProviderRuntimeBinding { | |
| 16 | readonly threadId: ThreadId; | |
| 29 | 17 | readonly provider: ProviderKind; |
| 30 | readonly threadId?: ThreadId; | |
| 31 | 18 | readonly adapterKey?: string; |
| 32 | readonly providerThreadId?: ProviderThreadId | null; | |
| 33 | 19 | readonly status?: ProviderSessionRuntimeStatus; |
| 34 | 20 | readonly resumeCursor?: unknown | null; |
| 35 | 21 | readonly runtimePayload?: unknown | null; |
| 36 | 22 | readonly runtimeMode?: RuntimeMode; |
| 37 | 23 | } |
| 38 | 24 | |
| 39 | export type ProviderSessionDirectoryReadError = | |
| 40 | | ProviderSessionNotFoundError | |
| 41 | | ProviderSessionDirectoryPersistenceError; | |
| 25 | export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; | |
| 42 | 26 | |
| 43 | 27 | export type ProviderSessionDirectoryWriteError = |
| 44 | 28 | | ProviderValidationError |
| 45 | 29 | | ProviderSessionDirectoryPersistenceError; |
| 46 | 30 | |
| 47 | /** | |
| 48 | * ProviderSessionDirectoryShape - Service API for provider session ownership metadata. | |
| 49 | */ | |
| 50 | 31 | export interface ProviderSessionDirectoryShape { |
| 51 | /** | |
| 52 | * Record or update ownership for one provider session. | |
| 53 | * | |
| 54 | * Preserves existing persisted fields when omitted and shallow-merges | |
| 55 | * runtime payload objects. | |
| 56 | */ | |
| 57 | 32 | readonly upsert: ( |
| 58 | binding: ProviderSessionBinding, | |
| 33 | binding: ProviderRuntimeBinding, | |
| 59 | 34 | ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>; |
| 60 | 35 | |
| 61 | /** | |
| 62 | * Resolve the provider owner for a session id. | |
| 63 | */ | |
| 64 | 36 | readonly getProvider: ( |
| 65 | sessionId: ProviderSessionId, | |
| 37 | threadId: ThreadId, | |
| 66 | 38 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; |
| 67 | 39 | |
| 68 | /** | |
| 69 | * Resolve the full tracked binding for a session id. | |
| 70 | */ | |
| 71 | 40 | 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>; | |
| 81 | 43 | |
| 82 | /** | |
| 83 | * Remove a session binding. | |
| 84 | */ | |
| 85 | 44 | readonly remove: ( |
| 86 | sessionId: ProviderSessionId, | |
| 45 | threadId: ThreadId, | |
| 87 | 46 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; |
| 88 | 47 | |
| 89 | /** | |
| 90 | * List tracked session ids. | |
| 91 | */ | |
| 92 | readonly listSessionIds: () => Effect.Effect< | |
| 93 | ReadonlyArray<ProviderSessionId>, | |
| 48 | readonly listThreadIds: () => Effect.Effect< | |
| 49 | ReadonlyArray<ThreadId>, | |
| 94 | 50 | ProviderSessionDirectoryPersistenceError |
| 95 | 51 | >; |
| 96 | 52 | } |
| 97 | 53 | |
| 98 | /** | |
| 99 | * ProviderSessionDirectory - Service tag for session ownership lookup. | |
| 100 | */ | |
| 101 | 54 | export class ProviderSessionDirectory extends ServiceMap.Service< |
| 102 | 55 | ProviderSessionDirectory, |
| 103 | 56 | ProviderSessionDirectoryShape |
apps/server/src/serverLayers.ts+9-7| ... | ... | @@ -58,14 +58,16 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 58 | 58 | const codexAdapterLayer = makeCodexAdapterLive( |
| 59 | 59 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 60 | 60 | ); |
| 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 | ); | |
| 67 | 67 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 68 | Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer, cursorAdapterLayer)), | |
| 68 | Layer.provide(codexAdapterLayer), | |
| 69 | Layer.provide(claudeAdapterLayer), | |
| 70 | Layer.provide(cursorAdapterLayer), | |
| 69 | 71 | Layer.provideMerge(providerSessionDirectoryLayer), |
| 70 | 72 | ); |
| 71 | 73 | return makeProviderServiceLive( |
apps/server/src/wsServer.test.ts+11-16| ... | ... | @@ -18,9 +18,8 @@ import { |
| 18 | 18 | ORCHESTRATION_WS_CHANNELS, |
| 19 | 19 | ORCHESTRATION_WS_METHODS, |
| 20 | 20 | ProviderItemId, |
| 21 | ProviderSessionId, | |
| 22 | ProviderThreadId, | |
| 23 | ProviderTurnId, | |
| 21 | ThreadId, | |
| 22 | TurnId, | |
| 24 | 23 | WS_CHANNELS, |
| 25 | 24 | WS_METHODS, |
| 26 | 25 | type WebSocketResponse, |
| ... | ... | @@ -60,11 +59,9 @@ interface PendingMessages { |
| 60 | 59 | const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); |
| 61 | 60 | |
| 62 | 61 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 63 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 64 | ProviderSessionId.makeUnsafe(value); | |
| 65 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 66 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 67 | 62 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 63 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 64 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 68 | 65 | |
| 69 | 66 | const defaultOpenService: OpenShape = { |
| 70 | 67 | openBrowser: () => Effect.void, |
| ... | ... | @@ -1146,26 +1143,24 @@ describe("WebSocket Server", () => { |
| 1146 | 1143 | |
| 1147 | 1144 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 1148 | 1145 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 1149 | const sessionId = asProviderSessionId("sess-test"); | |
| 1150 | 1146 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| 1151 | 1147 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); |
| 1152 | 1148 | }; |
| 1153 | 1149 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 1154 | 1150 | const providerService: ProviderServiceShape = { |
| 1155 | startSession: () => | |
| 1151 | startSession: (threadId) => | |
| 1156 | 1152 | Effect.succeed({ |
| 1157 | sessionId, | |
| 1158 | 1153 | provider: "codex", |
| 1159 | 1154 | status: "ready", |
| 1160 | 1155 | runtimeMode: "full-access", |
| 1161 | threadId: asProviderThreadId("provider-thread-1"), | |
| 1156 | threadId, | |
| 1162 | 1157 | createdAt: new Date().toISOString(), |
| 1163 | 1158 | updatedAt: new Date().toISOString(), |
| 1164 | 1159 | }), |
| 1165 | sendTurn: () => | |
| 1160 | sendTurn: ({ threadId }) => | |
| 1166 | 1161 | Effect.succeed({ |
| 1167 | threadId: asProviderThreadId("provider-thread-1"), | |
| 1168 | turnId: asProviderTurnId("provider-turn-1"), | |
| 1162 | threadId, | |
| 1163 | turnId: asTurnId("provider-turn-1"), | |
| 1169 | 1164 | }), |
| 1170 | 1165 | interruptTurn: () => unsupported(), |
| 1171 | 1166 | respondToRequest: () => unsupported(), |
| ... | ... | @@ -1239,9 +1234,9 @@ describe("WebSocket Server", () => { |
| 1239 | 1234 | type: "content.delta", |
| 1240 | 1235 | eventId: asEventId("evt-ws-runtime-message-delta"), |
| 1241 | 1236 | provider: "codex", |
| 1242 | sessionId, | |
| 1237 | threadId: asThreadId("thread-1"), | |
| 1243 | 1238 | createdAt: new Date().toISOString(), |
| 1244 | turnId: asProviderTurnId("turn-1"), | |
| 1239 | turnId: asTurnId("turn-1"), | |
| 1245 | 1240 | itemId: asProviderItemId("item-1"), |
| 1246 | 1241 | payload: { |
| 1247 | 1242 | streamKind: "assistant_text", |
apps/web/src/components/BranchToolbar.tsx+2-3| ... | ... | @@ -52,8 +52,7 @@ export default function BranchToolbar({ |
| 52 | 52 | const api = readNativeApi(); |
| 53 | 53 | // If the effective cwd is about to change, stop the running session so the |
| 54 | 54 | // 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) { | |
| 57 | 56 | void api.orchestration |
| 58 | 57 | .dispatchCommand({ |
| 59 | 58 | type: "thread.session.stop", |
| ... | ... | @@ -89,7 +88,7 @@ export default function BranchToolbar({ |
| 89 | 88 | }, |
| 90 | 89 | [ |
| 91 | 90 | activeThreadId, |
| 92 | serverThread?.session?.sessionId, | |
| 91 | serverThread?.session, | |
| 93 | 92 | activeWorktreePath, |
| 94 | 93 | hasServerThread, |
| 95 | 94 | setThreadBranchAction, |
apps/web/src/components/ChatView.tsx+2| ... | ... | @@ -3324,6 +3324,8 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 3324 | 3324 | <AlertTitle className="text-xs"> |
| 3325 | 3325 | {approval.requestKind === "command" |
| 3326 | 3326 | ? "Command approval requested" |
| 3327 | : approval.requestKind === "file-read" | |
| 3328 | ? "File-read approval requested" | |
| 3327 | 3329 | : "File-change approval requested"} |
| 3328 | 3330 | </AlertTitle> |
| 3329 | 3331 | <AlertDescription |
apps/web/src/session-logic.ts+8-3| ... | ... | @@ -28,7 +28,7 @@ export interface WorkLogEntry { |
| 28 | 28 | |
| 29 | 29 | export interface PendingApproval { |
| 30 | 30 | requestId: ApprovalRequestId; |
| 31 | requestKind: "command" | "file-change"; | |
| 31 | requestKind: "command" | "file-read" | "file-change"; | |
| 32 | 32 | createdAt: string; |
| 33 | 33 | detail?: string; |
| 34 | 34 | } |
| ... | ... | @@ -95,6 +95,8 @@ function requestKindFromRequestType( |
| 95 | 95 | case "command_execution_approval": |
| 96 | 96 | case "exec_command_approval": |
| 97 | 97 | return "command"; |
| 98 | case "file_read_approval": | |
| 99 | return "file-read"; | |
| 98 | 100 | case "file_change_approval": |
| 99 | 101 | case "apply_patch_approval": |
| 100 | 102 | return "file-change"; |
| ... | ... | @@ -119,11 +121,14 @@ export function derivePendingApprovals( |
| 119 | 121 | ? ApprovalRequestId.makeUnsafe(payload.requestId) |
| 120 | 122 | : null; |
| 121 | 123 | const requestKind = |
| 122 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") | |
| 124 | payload && | |
| 125 | (payload.requestKind === "command" || | |
| 126 | payload.requestKind === "file-read" || | |
| 127 | payload.requestKind === "file-change") | |
| 123 | 128 | ? payload.requestKind |
| 124 | 129 | : payload |
| 125 | 130 | ? requestKindFromRequestType(payload.requestType) |
| 126 | : null; | |
| 131 | : null; | |
| 127 | 132 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; |
| 128 | 133 | |
| 129 | 134 | if (activity.kind === "approval.requested" && requestId && requestKind) { |
apps/web/src/store.test.ts-4| ... | ... | @@ -147,8 +147,6 @@ describe("store read model sync", () => { |
| 147 | 147 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 148 | 148 | status: "ready", |
| 149 | 149 | providerName: "claudeCode", |
| 150 | providerSessionId: null, | |
| 151 | providerThreadId: null, | |
| 152 | 150 | runtimeMode: "approval-required", |
| 153 | 151 | activeTurnId: null, |
| 154 | 152 | lastError: null, |
| ... | ... | @@ -171,8 +169,6 @@ describe("store read model sync", () => { |
| 171 | 169 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 172 | 170 | status: "ready", |
| 173 | 171 | providerName: "cursor", |
| 174 | providerSessionId: null, | |
| 175 | providerThreadId: null, | |
| 176 | 172 | runtimeMode: "approval-required", |
| 177 | 173 | activeTurnId: null, |
| 178 | 174 | lastError: null, |
apps/web/src/store.ts+1-6| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | getModelOptions, |
| 5 | 5 | normalizeModelSlug, |
| 6 | 6 | type ProviderKind, |
| 7 | ProviderSessionId, | |
| 8 | 7 | ThreadId, |
| 9 | 8 | type OrchestrationReadModel, |
| 10 | 9 | type OrchestrationSessionStatus, |
| ... | ... | @@ -247,7 +246,7 @@ export function syncServerReadModel( |
| 247 | 246 | const existing = existingThreadById.get(thread.id); |
| 248 | 247 | return { |
| 249 | 248 | id: thread.id, |
| 250 | codexThreadId: thread.session?.providerThreadId ?? null, | |
| 249 | codexThreadId: null, | |
| 251 | 250 | projectId: thread.projectId, |
| 252 | 251 | title: thread.title, |
| 253 | 252 | model: resolveModelSlugForProvider( |
| ... | ... | @@ -260,13 +259,9 @@ export function syncServerReadModel( |
| 260 | 259 | runtimeMode: thread.runtimeMode, |
| 261 | 260 | session: thread.session |
| 262 | 261 | ? { |
| 263 | sessionId: | |
| 264 | thread.session.providerSessionId ?? | |
| 265 | ProviderSessionId.makeUnsafe(`thread:${thread.id}`), | |
| 266 | 262 | provider: toLegacyProvider(thread.session.providerName), |
| 267 | 263 | status: toLegacySessionStatus(thread.session.status), |
| 268 | 264 | orchestrationStatus: thread.session.status, |
| 269 | threadId: thread.session.providerThreadId, | |
| 270 | 265 | activeTurnId: thread.session.activeTurnId ?? undefined, |
| 271 | 266 | createdAt: thread.session.updatedAt, |
| 272 | 267 | updatedAt: thread.session.updatedAt, |
apps/web/src/types.ts+1-5| ... | ... | @@ -8,8 +8,6 @@ import type { |
| 8 | 8 | TurnId, |
| 9 | 9 | MessageId, |
| 10 | 10 | CheckpointRef, |
| 11 | ProviderThreadId, | |
| 12 | ProviderSessionId, | |
| 13 | 11 | ProviderKind, |
| 14 | 12 | } from "@t3tools/contracts"; |
| 15 | 13 | |
| ... | ... | @@ -75,7 +73,7 @@ export interface Project { |
| 75 | 73 | |
| 76 | 74 | export interface Thread { |
| 77 | 75 | id: ThreadId; |
| 78 | codexThreadId: ProviderThreadId | null; | |
| 76 | codexThreadId: string | null; | |
| 79 | 77 | projectId: ProjectId; |
| 80 | 78 | title: string; |
| 81 | 79 | model: string; |
| ... | ... | @@ -93,10 +91,8 @@ export interface Thread { |
| 93 | 91 | } |
| 94 | 92 | |
| 95 | 93 | export interface ThreadSession { |
| 96 | sessionId: ProviderSessionId; | |
| 97 | 94 | provider: ProviderKind; |
| 98 | 95 | status: SessionPhase | "error" | "closed"; |
| 99 | threadId: ProviderThreadId | null; | |
| 100 | 96 | activeTurnId?: TurnId | undefined; |
| 101 | 97 | createdAt: string; |
| 102 | 98 | updatedAt: string; |
packages/contracts/src/baseSchemas.ts-6| ... | ... | @@ -28,12 +28,6 @@ export type MessageId = typeof MessageId.Type; |
| 28 | 28 | export const TurnId = makeEntityId("TurnId"); |
| 29 | 29 | export type TurnId = typeof TurnId.Type; |
| 30 | 30 | |
| 31 | export const ProviderSessionId = makeEntityId("ProviderSessionId"); | |
| 32 | export type ProviderSessionId = typeof ProviderSessionId.Type; | |
| 33 | export const ProviderThreadId = makeEntityId("ProviderThreadId"); | |
| 34 | export type ProviderThreadId = typeof ProviderThreadId.Type; | |
| 35 | export const ProviderTurnId = makeEntityId("ProviderTurnId"); | |
| 36 | export type ProviderTurnId = typeof ProviderTurnId.Type; | |
| 37 | 31 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 38 | 32 | export type ProviderItemId = typeof ProviderItemId.Type; |
| 39 | 33 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); |
packages/contracts/src/orchestration.ts+2-9| ... | ... | @@ -9,9 +9,6 @@ import { |
| 9 | 9 | NonNegativeInt, |
| 10 | 10 | ProjectId, |
| 11 | 11 | ProviderItemId, |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | 12 | ThreadId, |
| 16 | 13 | TrimmedNonEmptyString, |
| 17 | 14 | TurnId, |
| ... | ... | @@ -35,7 +32,7 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 35 | 32 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 36 | 33 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 37 | 34 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 38 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); | |
| 35 | export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); | |
| 39 | 36 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 40 | 37 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); |
| 41 | 38 | export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; |
| ... | ... | @@ -149,8 +146,6 @@ export const OrchestrationSession = Schema.Struct({ |
| 149 | 146 | threadId: ThreadId, |
| 150 | 147 | status: OrchestrationSessionStatus, |
| 151 | 148 | providerName: Schema.NullOr(TrimmedNonEmptyString), |
| 152 | providerSessionId: Schema.NullOr(ProviderSessionId), | |
| 153 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 154 | 149 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 155 | 150 | activeTurnId: Schema.NullOr(TurnId), |
| 156 | 151 | lastError: Schema.NullOr(TrimmedNonEmptyString), |
| ... | ... | @@ -637,9 +632,7 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ |
| 637 | 632 | }); |
| 638 | 633 | |
| 639 | 634 | export const OrchestrationEventMetadata = Schema.Struct({ |
| 640 | providerSessionId: Schema.optional(ProviderSessionId), | |
| 641 | providerThreadId: Schema.optional(ProviderThreadId), | |
| 642 | providerTurnId: Schema.optional(ProviderTurnId), | |
| 635 | providerTurnId: Schema.optional(TrimmedNonEmptyString), | |
| 643 | 636 | providerItemId: Schema.optional(ProviderItemId), |
| 644 | 637 | adapterKey: Schema.optional(TrimmedNonEmptyString), |
| 645 | 638 | requestId: Schema.optional(ApprovalRequestId), |
packages/contracts/src/provider.ts+16-19| ... | ... | @@ -6,10 +6,8 @@ import { |
| 6 | 6 | IsoDateTime, |
| 7 | 7 | NonNegativeInt, |
| 8 | 8 | ProviderItemId, |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | 9 | ThreadId, |
| 10 | TurnId, | |
| 13 | 11 | } from "./baseSchemas"; |
| 14 | 12 | import { |
| 15 | 13 | ChatAttachment, |
| ... | ... | @@ -34,15 +32,14 @@ export const ProviderSessionStatus = Schema.Literals([ |
| 34 | 32 | export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; |
| 35 | 33 | |
| 36 | 34 | export const ProviderSession = Schema.Struct({ |
| 37 | sessionId: ProviderSessionId, | |
| 38 | 35 | provider: ProviderKind, |
| 39 | 36 | status: ProviderSessionStatus, |
| 40 | 37 | runtimeMode: RuntimeMode, |
| 41 | 38 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 42 | 39 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 43 | threadId: Schema.optional(ProviderThreadId), | |
| 40 | threadId: ThreadId, | |
| 44 | 41 | resumeCursor: Schema.optional(Schema.Unknown), |
| 45 | activeTurnId: Schema.optional(ProviderTurnId), | |
| 42 | activeTurnId: Schema.optional(TurnId), | |
| 46 | 43 | createdAt: IsoDateTime, |
| 47 | 44 | updatedAt: IsoDateTime, |
| 48 | 45 | lastError: Schema.optional(TrimmedNonEmptyStringSchema), |
| ... | ... | @@ -75,6 +72,7 @@ export const ProviderStartOptions = Schema.Struct({ |
| 75 | 72 | export type ProviderStartOptions = typeof ProviderStartOptions.Type; |
| 76 | 73 | |
| 77 | 74 | export const ProviderSessionStartInput = Schema.Struct({ |
| 75 | threadId: ThreadId, | |
| 78 | 76 | provider: Schema.optional(ProviderKind), |
| 79 | 77 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 80 | 78 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| ... | ... | @@ -85,7 +83,7 @@ export const ProviderSessionStartInput = Schema.Struct({ |
| 85 | 83 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; |
| 86 | 84 | |
| 87 | 85 | export const ProviderSendTurnInput = Schema.Struct({ |
| 88 | sessionId: ProviderSessionId, | |
| 86 | threadId: ThreadId, | |
| 89 | 87 | input: Schema.optional( |
| 90 | 88 | TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), |
| 91 | 89 | ), |
| ... | ... | @@ -98,25 +96,25 @@ export const ProviderSendTurnInput = Schema.Struct({ |
| 98 | 96 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; |
| 99 | 97 | |
| 100 | 98 | export const ProviderTurnStartResult = Schema.Struct({ |
| 101 | threadId: Schema.optional(ProviderThreadId), | |
| 102 | turnId: ProviderTurnId, | |
| 99 | threadId: ThreadId, | |
| 100 | turnId: TurnId, | |
| 103 | 101 | resumeCursor: Schema.optional(Schema.Unknown), |
| 104 | 102 | }); |
| 105 | 103 | export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; |
| 106 | 104 | |
| 107 | 105 | export const ProviderInterruptTurnInput = Schema.Struct({ |
| 108 | sessionId: ProviderSessionId, | |
| 109 | turnId: Schema.optional(ProviderTurnId), | |
| 106 | threadId: ThreadId, | |
| 107 | turnId: Schema.optional(TurnId), | |
| 110 | 108 | }); |
| 111 | 109 | export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; |
| 112 | 110 | |
| 113 | 111 | export const ProviderStopSessionInput = Schema.Struct({ |
| 114 | sessionId: ProviderSessionId, | |
| 112 | threadId: ThreadId, | |
| 115 | 113 | }); |
| 116 | 114 | export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; |
| 117 | 115 | |
| 118 | 116 | export const ProviderListCheckpointsInput = Schema.Struct({ |
| 119 | sessionId: ProviderSessionId, | |
| 117 | threadId: ThreadId, | |
| 120 | 118 | }); |
| 121 | 119 | export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; |
| 122 | 120 | |
| ... | ... | @@ -137,7 +135,7 @@ export const ProviderListCheckpointsResult = Schema.Struct({ |
| 137 | 135 | export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; |
| 138 | 136 | |
| 139 | 137 | export const ProviderRevertToCheckpointInput = Schema.Struct({ |
| 140 | sessionId: ProviderSessionId, | |
| 138 | threadId: ThreadId, | |
| 141 | 139 | turnCount: NonNegativeInt, |
| 142 | 140 | }); |
| 143 | 141 | export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; |
| ... | ... | @@ -152,7 +150,7 @@ export const ProviderRevertToCheckpointResult = Schema.Struct({ |
| 152 | 150 | export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; |
| 153 | 151 | |
| 154 | 152 | export const ProviderGetCheckpointDiffInput = Schema.Struct({ |
| 155 | sessionId: ProviderSessionId, | |
| 153 | threadId: ThreadId, | |
| 156 | 154 | ...TurnCountRange.fields, |
| 157 | 155 | }); |
| 158 | 156 | export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; |
| ... | ... | @@ -165,7 +163,7 @@ export const ProviderGetCheckpointDiffResult = Schema.Struct({ |
| 165 | 163 | export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; |
| 166 | 164 | |
| 167 | 165 | export const ProviderRespondToRequestInput = Schema.Struct({ |
| 168 | sessionId: ProviderSessionId, | |
| 166 | threadId: ThreadId, | |
| 169 | 167 | requestId: ApprovalRequestId, |
| 170 | 168 | decision: ProviderApprovalDecision, |
| 171 | 169 | }); |
| ... | ... | @@ -178,12 +176,11 @@ export const ProviderEvent = Schema.Struct({ |
| 178 | 176 | id: EventId, |
| 179 | 177 | kind: ProviderEventKind, |
| 180 | 178 | provider: ProviderKind, |
| 181 | sessionId: ProviderSessionId, | |
| 179 | threadId: ThreadId, | |
| 182 | 180 | createdAt: IsoDateTime, |
| 183 | 181 | method: TrimmedNonEmptyStringSchema, |
| 184 | 182 | message: Schema.optional(TrimmedNonEmptyStringSchema), |
| 185 | threadId: Schema.optional(ProviderThreadId), | |
| 186 | turnId: Schema.optional(ProviderTurnId), | |
| 183 | turnId: Schema.optional(TurnId), | |
| 187 | 184 | itemId: Schema.optional(ProviderItemId), |
| 188 | 185 | requestId: Schema.optional(ApprovalRequestId), |
| 189 | 186 | requestKind: Schema.optional(ProviderRequestKind), |
packages/contracts/src/providerRuntime.ts+10-11| ... | ... | @@ -3,12 +3,8 @@ import { |
| 3 | 3 | EventId, |
| 4 | 4 | IsoDateTime, |
| 5 | 5 | ProviderItemId, |
| 6 | ProviderSessionId, | |
| 7 | ProviderThreadId, | |
| 8 | ProviderTurnId, | |
| 9 | 6 | RuntimeItemId, |
| 10 | 7 | RuntimeRequestId, |
| 11 | RuntimeSessionId, | |
| 12 | 8 | RuntimeTaskId, |
| 13 | 9 | ThreadId, |
| 14 | 10 | TrimmedNonEmptyString, |
| ... | ... | @@ -44,9 +40,7 @@ export const ProviderRequestId = TrimmedNonEmptyStringSchema; |
| 44 | 40 | export type ProviderRequestId = typeof ProviderRequestId.Type; |
| 45 | 41 | |
| 46 | 42 | export const ProviderRefs = Schema.Struct({ |
| 47 | providerSessionId: Schema.optional(ProviderSessionId), | |
| 48 | providerThreadId: Schema.optional(ProviderThreadId), | |
| 49 | providerTurnId: Schema.optional(ProviderTurnId), | |
| 43 | providerTurnId: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 50 | 44 | providerItemId: Schema.optional(ProviderItemId), |
| 51 | 45 | providerRequestId: Schema.optional(ProviderRequestId), |
| 52 | 46 | }); |
| ... | ... | @@ -131,6 +125,7 @@ export type CanonicalItemType = typeof CanonicalItemType.Type; |
| 131 | 125 | |
| 132 | 126 | export const CanonicalRequestType = Schema.Literals([ |
| 133 | 127 | "command_execution_approval", |
| 128 | "file_read_approval", | |
| 134 | 129 | "file_change_approval", |
| 135 | 130 | "apply_patch_approval", |
| 136 | 131 | "exec_command_approval", |
| ... | ... | @@ -239,9 +234,8 @@ export const RuntimeErrorType = Schema.Literal("runtime.error"); |
| 239 | 234 | export const ProviderRuntimeEventBase = Schema.Struct({ |
| 240 | 235 | eventId: EventId, |
| 241 | 236 | provider: ProviderKind, |
| 242 | sessionId: RuntimeSessionId, | |
| 237 | threadId: ThreadId, | |
| 243 | 238 | createdAt: IsoDateTime, |
| 244 | threadId: Schema.optional(ThreadId), | |
| 245 | 239 | turnId: Schema.optional(TurnId), |
| 246 | 240 | itemId: Schema.optional(RuntimeItemId), |
| 247 | 241 | requestId: Schema.optional(RuntimeRequestId), |
| ... | ... | @@ -276,7 +270,7 @@ export const SessionExitedPayload = Schema.Struct({ |
| 276 | 270 | export type SessionExitedPayload = typeof SessionExitedPayload.Type; |
| 277 | 271 | |
| 278 | 272 | export const ThreadStartedPayload = Schema.Struct({ |
| 279 | providerThreadId: Schema.optional(ProviderThreadId), | |
| 273 | providerThreadId: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 280 | 274 | }); |
| 281 | 275 | export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; |
| 282 | 276 | |
| ... | ... | @@ -948,7 +942,12 @@ export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolv |
| 948 | 942 | export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; |
| 949 | 943 | |
| 950 | 944 | // Legacy helper aliases retained for adapters/tests. |
| 951 | export const ProviderRuntimeToolKind = Schema.Literals(["command", "file-change", "other"]); | |
| 945 | export const ProviderRuntimeToolKind = Schema.Literals([ | |
| 946 | "command", | |
| 947 | "file-read", | |
| 948 | "file-change", | |
| 949 | "other", | |
| 950 | ]); | |
| 952 | 951 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; |
| 953 | 952 | |
| 954 | 953 | export const ProviderRuntimeTurnStatus = RuntimeTurnState; |
provider-threadless-runtime-migration-plan.md created+281| ... | ... | @@ -0,0 +1,281 @@ |
| 1 | # Provider Runtime Refactor Plan | |
| 2 | ||
| 3 | ## Goal | |
| 4 | ||
| 5 | Remove `providerSessionId` and `providerThreadId` from the core application model and make | |
| 6 | provider continuity depend only on: | |
| 7 | ||
| 8 | - canonical `threadId` | |
| 9 | - provider kind | |
| 10 | - runtime mode | |
| 11 | - opaque `resumeCursor` | |
| 12 | ||
| 13 | Live provider session/process state becomes ephemeral and provider-private. | |
| 14 | ||
| 15 | ## Desired Architecture | |
| 16 | ||
| 17 | ### Durable state | |
| 18 | ||
| 19 | Persist 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 | ||
| 29 | Do **not** persist: | |
| 30 | ||
| 31 | - `providerSessionId` | |
| 32 | - `providerThreadId` | |
| 33 | - live child-process/session handles | |
| 34 | - adapter-private routing aliases | |
| 35 | ||
| 36 | ### Ephemeral state | |
| 37 | ||
| 38 | Each adapter keeps an in-memory map keyed by canonical `threadId`: | |
| 39 | ||
| 40 | - `threadId -> live provider context` | |
| 41 | ||
| 42 | Examples: | |
| 43 | ||
| 44 | - Codex child process context | |
| 45 | - Cursor ACP live session | |
| 46 | - Claude Code live subprocess context | |
| 47 | ||
| 48 | These are disposable and rebuilt from persisted `resumeCursor`. | |
| 49 | ||
| 50 | ### Observability | |
| 51 | ||
| 52 | Provider-native identifiers should live in logs/runtime events only, not in the canonical DB model. | |
| 53 | ||
| 54 | Examples: | |
| 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 | ||
| 64 | Remove 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 | ||
| 71 | Planned 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 | ||
| 79 | Replace thread session projection as a provider-identity carrier with a pure UX/runtime status view. | |
| 80 | ||
| 81 | Possible projected shape: | |
| 82 | ||
| 83 | - `status` | |
| 84 | - `providerName` | |
| 85 | - `runtimeMode` | |
| 86 | - `activeTurnId` | |
| 87 | - `lastError` | |
| 88 | - `updatedAt` | |
| 89 | ||
| 90 | No provider-native ids in the projection. | |
| 91 | ||
| 92 | Files 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 | ||
| 103 | Refactor provider service APIs to route by canonical `threadId`, not `providerSessionId`. | |
| 104 | ||
| 105 | Methods to change: | |
| 106 | ||
| 107 | - `startSession` | |
| 108 | - `sendTurn` | |
| 109 | - `interruptTurn` | |
| 110 | - `respondToRequest` | |
| 111 | - `rollbackConversation` | |
| 112 | - `stopSession` / equivalent | |
| 113 | - `listSessions` | |
| 114 | ||
| 115 | New 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 | ||
| 121 | Files 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 | ||
| 130 | Current `ProviderSessionDirectory` is centered on session ids. | |
| 131 | Replace with a thread-keyed directory/repository. | |
| 132 | ||
| 133 | Persist per canonical thread: | |
| 134 | ||
| 135 | - `threadId` | |
| 136 | - `provider` | |
| 137 | - `runtimeMode` | |
| 138 | - `resumeCursor` | |
| 139 | - `status` | |
| 140 | - optional runtime payload | |
| 141 | ||
| 142 | Likely replacement: | |
| 143 | ||
| 144 | - `ProviderThreadRuntimeDirectory` or similar | |
| 145 | ||
| 146 | Files 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 | ||
| 155 | Perform a clean-slate schema migration to remove provider ids from persistence. | |
| 156 | ||
| 157 | Targets: | |
| 158 | ||
| 159 | - remove `provider_session_id` | |
| 160 | - remove `provider_thread_id` | |
| 161 | - make canonical thread id the primary runtime key where appropriate | |
| 162 | ||
| 163 | Likely tables impacted: | |
| 164 | ||
| 165 | - `provider_session_runtime` | |
| 166 | - `projection_thread_sessions` | |
| 167 | ||
| 168 | Because SQLite column removal is awkward, and app is still early. A destructive migration is preferred. | |
| 169 | Existing users will need to reset their db so migrations run from scratch. | |
| 170 | ||
| 171 | Migration should also preserve: | |
| 172 | ||
| 173 | - `resume_cursor_json` | |
| 174 | - `runtime_mode` | |
| 175 | - `runtime_payload_json` | |
| 176 | - status metadata | |
| 177 | ||
| 178 | ### 6. Orchestration refactor | |
| 179 | ||
| 180 | Remove provider-id assumptions from orchestration. | |
| 181 | ||
| 182 | Affected areas: | |
| 183 | ||
| 184 | - `ProviderCommandReactor` | |
| 185 | - `ProviderRuntimeIngestion` | |
| 186 | - `CheckpointReactor` | |
| 187 | - decider/projector event payloads | |
| 188 | ||
| 189 | Rules: | |
| 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 | ||
| 197 | Each 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 | ||
| 203 | Specific adapters: | |
| 204 | ||
| 205 | - `CodexAdapter` | |
| 206 | - `CursorAdapter` | |
| 207 | - `ClaudeCodeAdapter` | |
| 208 | ||
| 209 | ### 8. Runtime events and logs | |
| 210 | ||
| 211 | Keep provider-native ids in logs and runtime events only when useful for debugging. | |
| 212 | ||
| 213 | Need to review: | |
| 214 | ||
| 215 | - `packages/contracts/src/providerRuntime.ts` | |
| 216 | - `apps/server/src/provider/Layers/*Adapter.ts` | |
| 217 | - log sinks / canonical event logging | |
| 218 | ||
| 219 | Goal: | |
| 220 | ||
| 221 | - provider-native ids optional and observational | |
| 222 | - not required by orchestration/persistence correctness | |
| 223 | ||
| 224 | ### 9. Web/client cleanup | |
| 225 | ||
| 226 | Web should consume only canonical thread status. | |
| 227 | ||
| 228 | Remove use of: | |
| 229 | ||
| 230 | - `providerSessionId` | |
| 231 | - `providerThreadId` | |
| 232 | ||
| 233 | Likely files: | |
| 234 | ||
| 235 | - `apps/web/src/store.ts` | |
| 236 | - `apps/web/src/types.ts` | |
| 237 | - related tests | |
| 238 | ||
| 239 | ### 10. Test migration | |
| 240 | ||
| 241 | Update and simplify tests across: | |
| 242 | ||
| 243 | - provider service tests | |
| 244 | - orchestration tests | |
| 245 | - integration harnesses | |
| 246 | - checkpoint/runtime ingestion tests | |
| 247 | ||
| 248 | Tests 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 | ||
| 256 | 1. Add/adjust plan-approved contracts | |
| 257 | 2. Introduce new thread-keyed provider runtime persistence alongside old code temporarily in implementation | |
| 258 | 3. Refactor provider service/adapters to use canonical `threadId` | |
| 259 | 4. Switch orchestration to thread-keyed APIs | |
| 260 | 5. Switch projections/web contracts | |
| 261 | 6. Remove old provider-id fields and dead code | |
| 262 | 7. 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 | ||
| 274 | 1. Should `thread.session` remain in snapshots at all, or be folded into thread runtime fields? | |
| 275 | 2. Should provider runtime payload remain persisted if not required for resume? | |
| 276 | 3. Do we want one provider runtime row per canonical thread, or a more generic runtime-state table keyed by thread? | |
| 277 | ||
| 278 | ## Recommendation | |
| 279 | ||
| 280 | Proceed with a full clean-slate migration rather than incremental compatibility shims. | |
| 281 | The current model is carrying too much provider-native identity into layers that should only reason about canonical threads and opaque resume state. |