| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | type OrchestrationCommand, |
| 21 | 21 | ProjectId, |
| 22 | 22 | ProviderItemId, |
| 23 | RuntimeRequestId, |
| 23 | 24 | type ServerSettings, |
| 24 | 25 | ThreadId, |
| 25 | 26 | TurnId, |
| ... | ... | @@ -30,6 +31,7 @@ import * as Effect from "effect/Effect"; |
| 30 | 31 | import * as Exit from "effect/Exit"; |
| 31 | 32 | import * as Layer from "effect/Layer"; |
| 32 | 33 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 34 | import * as Option from "effect/Option"; |
| 33 | 35 | import * as PubSub from "effect/PubSub"; |
| 34 | 36 | import * as Scope from "effect/Scope"; |
| 35 | 37 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -372,6 +374,12 @@ describe("ProviderRuntimeIngestion", () => { |
| 372 | 374 | engine, |
| 373 | 375 | dispatch, |
| 374 | 376 | readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()), |
| 377 | readThreadShell: () => |
| 378 | testRuntime.runPromise( |
| 379 | snapshotQuery |
| 380 | .getThreadShellById(asThreadId("thread-1")) |
| 381 | .pipe(Effect.map(Option.getOrThrow)), |
| 382 | ), |
| 375 | 383 | emit: provider.emit, |
| 376 | 384 | emitAndDrain, |
| 377 | 385 | sqlCount: sqlCounter.count, |
| ... | ... | @@ -2346,6 +2354,185 @@ describe("ProviderRuntimeIngestion", () => { |
| 2346 | 2354 | expect(message?.streaming).toBe(false); |
| 2347 | 2355 | }); |
| 2348 | 2356 | |
| 2357 | function userInputEvent( |
| 2358 | turnId: string, |
| 2359 | requestId: string, |
| 2360 | responseMode?: "message", |
| 2361 | ): ProviderRuntimeEvent { |
| 2362 | return { |
| 2363 | type: "user-input.requested", |
| 2364 | eventId: asEventId(`requested:${requestId}`), |
| 2365 | provider: ProviderDriverKind.make("codex"), |
| 2366 | threadId: asThreadId("thread-1"), |
| 2367 | turnId: asTurnId(turnId), |
| 2368 | requestId: RuntimeRequestId.make(requestId), |
| 2369 | createdAt: "2026-01-01T00:00:01.000Z", |
| 2370 | payload: { |
| 2371 | ...(responseMode ? { responseMode } : {}), |
| 2372 | questions: ["first", "second"].map((id) => ({ |
| 2373 | id, |
| 2374 | header: id, |
| 2375 | question: `Choose ${id}`, |
| 2376 | options: [{ label: "yes", description: "Continue" }], |
| 2377 | multiSelect: false, |
| 2378 | })), |
| 2379 | }, |
| 2380 | }; |
| 2381 | } |
| 2382 | |
| 2383 | it.each(["completed", "interrupted", "failed", "aborted"] as const)( |
| 2384 | "resolves native questions when their turn is %s", |
| 2385 | async (state) => { |
| 2386 | const harness = await createHarness(); |
| 2387 | const request = userInputEvent("question-turn", "question-request"); |
| 2388 | await harness.emitAndDrain([ |
| 2389 | { |
| 2390 | type: "turn.started", |
| 2391 | eventId: asEventId("question-started"), |
| 2392 | provider: request.provider, |
| 2393 | threadId: request.threadId, |
| 2394 | turnId: request.turnId, |
| 2395 | createdAt: request.createdAt, |
| 2396 | }, |
| 2397 | request, |
| 2398 | ]); |
| 2399 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(true); |
| 2400 | if (state === "interrupted") { |
| 2401 | await harness.dispatch({ |
| 2402 | type: "thread.turn.interrupt", |
| 2403 | commandId: CommandId.make("question-interrupt"), |
| 2404 | threadId: request.threadId, |
| 2405 | createdAt: "2026-01-01T00:00:02.000Z", |
| 2406 | }); |
| 2407 | } |
| 2408 | const completed: ProviderRuntimeEvent = { |
| 2409 | ...(state === "aborted" |
| 2410 | ? ({ type: "turn.aborted", payload: { reason: "Interrupted by user." } } as const) |
| 2411 | : ({ type: "turn.completed", payload: { state } } as const)), |
| 2412 | eventId: asEventId("question-completed"), |
| 2413 | provider: request.provider, |
| 2414 | threadId: request.threadId, |
| 2415 | turnId: request.turnId, |
| 2416 | createdAt: "2026-01-01T00:00:03.000Z", |
| 2417 | }; |
| 2418 | await harness.emitAndDrain([completed]); |
| 2419 | const thread = (await harness.readModel()).threads[0]!; |
| 2420 | expect(thread.session?.activeTurnId).toBeNull(); |
| 2421 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(false); |
| 2422 | expect( |
| 2423 | thread.activities.filter((activity) => activity.kind === "user-input.resolved"), |
| 2424 | ).toMatchObject([{ turnId: request.turnId, payload: { requestId: request.requestId } }]); |
| 2425 | |
| 2426 | await harness.emitAndDrain([completed]); |
| 2427 | expect( |
| 2428 | (await harness.readModel()).threads[0]!.activities.filter( |
| 2429 | (activity) => activity.kind === "user-input.resolved", |
| 2430 | ), |
| 2431 | ).toHaveLength(1); |
| 2432 | }, |
| 2433 | ); |
| 2434 | |
| 2435 | it("keeps a stale request dismissed when its turn later completes", async () => { |
| 2436 | const harness = await createHarness(); |
| 2437 | const request = userInputEvent("stale-turn", "stale-question"); |
| 2438 | await harness.emitAndDrain([request]); |
| 2439 | await harness.dispatch({ |
| 2440 | type: "thread.activity.append", |
| 2441 | commandId: CommandId.make("stale-question-response"), |
| 2442 | threadId: request.threadId, |
| 2443 | activity: { |
| 2444 | id: asEventId("stale-question-failed"), |
| 2445 | kind: "provider.user-input.respond.failed", |
| 2446 | tone: "error", |
| 2447 | summary: "User input response failed", |
| 2448 | turnId: request.turnId ?? null, |
| 2449 | payload: { |
| 2450 | requestId: request.requestId, |
| 2451 | detail: "Unknown pending user input request", |
| 2452 | }, |
| 2453 | createdAt: "2026-01-01T00:00:02.000Z", |
| 2454 | }, |
| 2455 | createdAt: "2026-01-01T00:00:02.000Z", |
| 2456 | }); |
| 2457 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(false); |
| 2458 | await harness.emitAndDrain([ |
| 2459 | { |
| 2460 | type: "turn.completed", |
| 2461 | eventId: asEventId("stale-turn-completed"), |
| 2462 | provider: request.provider, |
| 2463 | threadId: request.threadId, |
| 2464 | turnId: request.turnId, |
| 2465 | createdAt: "2026-01-01T00:00:03.000Z", |
| 2466 | payload: { state: "completed" }, |
| 2467 | }, |
| 2468 | ]); |
| 2469 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(false); |
| 2470 | expect( |
| 2471 | (await harness.readModel()).threads[0]!.activities.filter( |
| 2472 | (activity) => activity.kind === "user-input.resolved", |
| 2473 | ), |
| 2474 | ).toMatchObject([{ payload: { requestId: request.requestId } }]); |
| 2475 | }); |
| 2476 | |
| 2477 | it("preserves answered questions and leaves newer, child and async questions pending", async () => { |
| 2478 | const harness = await createHarness(); |
| 2479 | const answered = userInputEvent("old-turn", "answered-question"); |
| 2480 | const unresolved = userInputEvent("old-turn", "old-question"); |
| 2481 | const newer = userInputEvent("new-turn", "new-question"); |
| 2482 | const child = userInputEvent("child-turn", "child-question"); |
| 2483 | const asynchronous = userInputEvent("old-turn", "async-question", "message"); |
| 2484 | const answer: ProviderRuntimeEvent = { |
| 2485 | type: "user-input.resolved", |
| 2486 | eventId: asEventId("normal-answer"), |
| 2487 | provider: answered.provider, |
| 2488 | threadId: answered.threadId, |
| 2489 | turnId: answered.turnId, |
| 2490 | requestId: answered.requestId, |
| 2491 | createdAt: "2026-01-01T00:00:02.000Z", |
| 2492 | payload: { answers: { first: "yes", second: "yes" } }, |
| 2493 | }; |
| 2494 | await harness.emitAndDrain([ |
| 2495 | answered, |
| 2496 | unresolved, |
| 2497 | newer, |
| 2498 | child, |
| 2499 | asynchronous, |
| 2500 | answer, |
| 2501 | { |
| 2502 | type: "turn.started", |
| 2503 | eventId: asEventId("new-turn-started"), |
| 2504 | provider: newer.provider, |
| 2505 | threadId: newer.threadId, |
| 2506 | turnId: newer.turnId, |
| 2507 | createdAt: "2026-01-01T00:00:03.000Z", |
| 2508 | }, |
| 2509 | ]); |
| 2510 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(true); |
| 2511 | await harness.emitAndDrain([ |
| 2512 | { |
| 2513 | type: "turn.completed", |
| 2514 | eventId: asEventId("old-turn-completed"), |
| 2515 | provider: answered.provider, |
| 2516 | threadId: answered.threadId, |
| 2517 | turnId: answered.turnId, |
| 2518 | createdAt: "2026-01-01T00:00:04.000Z", |
| 2519 | payload: { state: "interrupted" }, |
| 2520 | }, |
| 2521 | ]); |
| 2522 | const thread = (await harness.readModel()).threads[0]!; |
| 2523 | expect(thread.session?.activeTurnId).toBe(newer.turnId); |
| 2524 | expect((await harness.readThreadShell()).hasPendingUserInput).toBe(true); |
| 2525 | expect( |
| 2526 | thread.activities.filter((activity) => activity.kind === "user-input.resolved"), |
| 2527 | ).toMatchObject([ |
| 2528 | { |
| 2529 | id: answer.eventId, |
| 2530 | payload: { requestId: answered.requestId, answers: answer.payload.answers }, |
| 2531 | }, |
| 2532 | { turnId: unresolved.turnId, payload: { requestId: unresolved.requestId } }, |
| 2533 | ]); |
| 2534 | }); |
| 2535 | |
| 2349 | 2536 | it("keeps streaming while an async question is pending", async () => { |
| 2350 | 2537 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); |
| 2351 | 2538 | const base = { |