| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | type OrchestrationCommand, | 20 | type OrchestrationCommand, |
| 21 | ProjectId, | 21 | ProjectId, |
| 22 | ProviderItemId, | 22 | ProviderItemId, |
| | 23 | RuntimeRequestId, |
| 23 | type ServerSettings, | 24 | type ServerSettings, |
| 24 | ThreadId, | 25 | ThreadId, |
| 25 | TurnId, | 26 | TurnId, |
| ... | @@ -30,6 +31,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -30,6 +31,7 @@ import * as Effect from "effect/Effect"; |
| 30 | import * as Exit from "effect/Exit"; | 31 | import * as Exit from "effect/Exit"; |
| 31 | import * as Layer from "effect/Layer"; | 32 | import * as Layer from "effect/Layer"; |
| 32 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 33 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| | 34 | import * as Option from "effect/Option"; |
| 33 | import * as PubSub from "effect/PubSub"; | 35 | import * as PubSub from "effect/PubSub"; |
| 34 | import * as Scope from "effect/Scope"; | 36 | import * as Scope from "effect/Scope"; |
| 35 | import * as Stream from "effect/Stream"; | 37 | import * as Stream from "effect/Stream"; |
| ... | @@ -372,6 +374,12 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -372,6 +374,12 @@ describe("ProviderRuntimeIngestion", () => { |
| 372 | engine, | 374 | engine, |
| 373 | dispatch, | 375 | dispatch, |
| 374 | readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()), | 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 | emit: provider.emit, | 383 | emit: provider.emit, |
| 376 | emitAndDrain, | 384 | emitAndDrain, |
| 377 | sqlCount: sqlCounter.count, | 385 | sqlCount: sqlCounter.count, |
| ... | @@ -2346,6 +2354,185 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -2346,6 +2354,185 @@ describe("ProviderRuntimeIngestion", () => { |
| 2346 | expect(message?.streaming).toBe(false); | 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 | it("keeps streaming while an async question is pending", async () => { | 2536 | it("keeps streaming while an async question is pending", async () => { |
| 2350 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); | 2537 | const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); |
| 2351 | const base = { | 2538 | const base = { |