| ... | @@ -150,6 +150,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -150,6 +150,8 @@ describe("ProviderCommandReactor", () => { |
| 150 | readonly requiresNewThreadForModelChange?: boolean; | 150 | readonly requiresNewThreadForModelChange?: boolean; |
| 151 | readonly titleRegenerationCompletionDispatchFailures?: number; | 151 | readonly titleRegenerationCompletionDispatchFailures?: number; |
| 152 | readonly titleRegenerationBeforeStart?: "one" | "two"; | 152 | readonly titleRegenerationBeforeStart?: "one" | "two"; |
| | 153 | readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| | 154 | readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>; |
| 153 | readonly startSessionEffect?: ( | 155 | readonly startSessionEffect?: ( |
| 154 | session: ProviderSession, | 156 | session: ProviderSession, |
| 155 | ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>; | 157 | ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>; |
| ... | @@ -235,23 +237,27 @@ describe("ProviderCommandReactor", () => { | ... | @@ -235,23 +237,27 @@ describe("ProviderCommandReactor", () => { |
| 235 | turnId: asTurnId("turn-1"), | 237 | turnId: asTurnId("turn-1"), |
| 236 | }), | 238 | }), |
| 237 | ); | 239 | ); |
| 238 | const interruptTurn = vi.fn((_: unknown) => Effect.void); | 240 | const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); |
| 239 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); | 241 | const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void); |
| 240 | const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void); | 242 | const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void); |
| 241 | const stopSession = vi.fn((input: unknown) => | 243 | const stopSession = vi.fn((stopInput: unknown) => |
| 242 | Effect.sync(() => { | 244 | (input?.stopSessionEffect?.() ?? Effect.void).pipe( |
| 243 | const threadId = | 245 | Effect.tap(() => |
| 244 | typeof input === "object" && input !== null && "threadId" in input | 246 | Effect.sync(() => { |
| 245 | ? (input as { threadId?: ThreadId }).threadId | 247 | const threadId = |
| 246 | : undefined; | 248 | typeof stopInput === "object" && stopInput !== null && "threadId" in stopInput |
| 247 | if (!threadId) { | 249 | ? (stopInput as { threadId?: ThreadId }).threadId |
| 248 | return; | 250 | : undefined; |
| 249 | } | 251 | if (!threadId) { |
| 250 | const index = runtimeSessions.findIndex((session) => session.threadId === threadId); | 252 | return; |
| 251 | if (index >= 0) { | 253 | } |
| 252 | runtimeSessions.splice(index, 1); | 254 | const index = runtimeSessions.findIndex((session) => session.threadId === threadId); |
| 253 | } | 255 | if (index >= 0) { |
| 254 | }), | 256 | runtimeSessions.splice(index, 1); |
| | 257 | } |
| | 258 | }), |
| | 259 | ), |
| | 260 | ), |
| 255 | ); | 261 | ); |
| 256 | const renameBranch = vi.fn((input: unknown) => | 262 | const renameBranch = vi.fn((input: unknown) => |
| 257 | Effect.succeed({ | 263 | Effect.succeed({ |
| ... | @@ -2487,6 +2493,218 @@ describe("ProviderCommandReactor", () => { | ... | @@ -2487,6 +2493,218 @@ describe("ProviderCommandReactor", () => { |
| 2487 | }); | 2493 | }); |
| 2488 | }); | 2494 | }); |
| 2489 | | 2495 | |
| | 2496 | effectIt.effect( |
| | 2497 | "stops a running session and records the failure when provider interrupt fails", |
| | 2498 | () => |
| | 2499 | Effect.gen(function* () { |
| | 2500 | const harness = yield* Effect.promise(() => |
| | 2501 | createHarness({ |
| | 2502 | interruptTurnEffect: () => |
| | 2503 | Effect.fail( |
| | 2504 | new ProviderAdapterRequestError({ |
| | 2505 | provider: "codex", |
| | 2506 | method: "thread.interrupt", |
| | 2507 | detail: "provider session disappeared", |
| | 2508 | }), |
| | 2509 | ), |
| | 2510 | stopSessionEffect: () => |
| | 2511 | Effect.fail( |
| | 2512 | new ProviderAdapterRequestError({ |
| | 2513 | provider: "codex", |
| | 2514 | method: "session.stop", |
| | 2515 | detail: "provider process already exited", |
| | 2516 | }), |
| | 2517 | ), |
| | 2518 | }), |
| | 2519 | ); |
| | 2520 | const now = "2026-01-01T00:00:00.000Z"; |
| | 2521 | |
| | 2522 | yield* harness.engine.dispatch({ |
| | 2523 | type: "thread.session.set", |
| | 2524 | commandId: CommandId.make("cmd-session-set-interrupt-failure"), |
| | 2525 | threadId: ThreadId.make("thread-1"), |
| | 2526 | session: { |
| | 2527 | threadId: ThreadId.make("thread-1"), |
| | 2528 | status: "running", |
| | 2529 | providerName: "codex", |
| | 2530 | runtimeMode: "approval-required", |
| | 2531 | activeTurnId: asTurnId("turn-1"), |
| | 2532 | lastError: null, |
| | 2533 | updatedAt: now, |
| | 2534 | }, |
| | 2535 | createdAt: now, |
| | 2536 | }); |
| | 2537 | |
| | 2538 | yield* harness.engine.dispatch({ |
| | 2539 | type: "thread.turn.interrupt", |
| | 2540 | commandId: CommandId.make("cmd-turn-interrupt-provider-failure"), |
| | 2541 | threadId: ThreadId.make("thread-1"), |
| | 2542 | turnId: asTurnId("turn-1"), |
| | 2543 | createdAt: now, |
| | 2544 | }); |
| | 2545 | |
| | 2546 | yield* Effect.promise(() => |
| | 2547 | waitFor(async () => { |
| | 2548 | const thread = (await harness.readModel()).threads.find( |
| | 2549 | (entry) => entry.id === ThreadId.make("thread-1"), |
| | 2550 | ); |
| | 2551 | return thread?.session?.status === "stopped"; |
| | 2552 | }), |
| | 2553 | ); |
| | 2554 | |
| | 2555 | const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| | 2556 | (entry) => entry.id === ThreadId.make("thread-1"), |
| | 2557 | ); |
| | 2558 | expect(thread?.session).toMatchObject({ |
| | 2559 | status: "stopped", |
| | 2560 | activeTurnId: null, |
| | 2561 | lastError: "provider session disappeared", |
| | 2562 | }); |
| | 2563 | expect( |
| | 2564 | thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), |
| | 2565 | ).toMatchObject({ |
| | 2566 | summary: "Provider turn interrupt failed", |
| | 2567 | payload: { detail: "provider session disappeared" }, |
| | 2568 | }); |
| | 2569 | expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); |
| | 2570 | }), |
| | 2571 | ); |
| | 2572 | |
| | 2573 | effectIt.effect("stops a starting session without a bound turn when interrupt fails", () => |
| | 2574 | Effect.gen(function* () { |
| | 2575 | const harness = yield* Effect.promise(() => |
| | 2576 | createHarness({ |
| | 2577 | interruptTurnEffect: () => |
| | 2578 | Effect.fail( |
| | 2579 | new ProviderAdapterRequestError({ |
| | 2580 | provider: "codex", |
| | 2581 | method: "thread.interrupt", |
| | 2582 | detail: "provider session disappeared", |
| | 2583 | }), |
| | 2584 | ), |
| | 2585 | }), |
| | 2586 | ); |
| | 2587 | const now = "2026-01-01T00:00:00.000Z"; |
| | 2588 | |
| | 2589 | yield* harness.engine.dispatch({ |
| | 2590 | type: "thread.session.set", |
| | 2591 | commandId: CommandId.make("cmd-session-set-interrupt-starting"), |
| | 2592 | threadId: ThreadId.make("thread-1"), |
| | 2593 | session: { |
| | 2594 | threadId: ThreadId.make("thread-1"), |
| | 2595 | status: "starting", |
| | 2596 | providerName: "codex", |
| | 2597 | runtimeMode: "approval-required", |
| | 2598 | activeTurnId: null, |
| | 2599 | lastError: null, |
| | 2600 | updatedAt: now, |
| | 2601 | }, |
| | 2602 | createdAt: now, |
| | 2603 | }); |
| | 2604 | |
| | 2605 | yield* harness.engine.dispatch({ |
| | 2606 | type: "thread.turn.interrupt", |
| | 2607 | commandId: CommandId.make("cmd-turn-interrupt-starting-provider-failure"), |
| | 2608 | threadId: ThreadId.make("thread-1"), |
| | 2609 | createdAt: now, |
| | 2610 | }); |
| | 2611 | |
| | 2612 | yield* Effect.promise(() => harness.drain()); |
| | 2613 | |
| | 2614 | const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| | 2615 | (entry) => entry.id === ThreadId.make("thread-1"), |
| | 2616 | ); |
| | 2617 | expect(thread?.session).toMatchObject({ |
| | 2618 | status: "stopped", |
| | 2619 | activeTurnId: null, |
| | 2620 | lastError: "provider session disappeared", |
| | 2621 | }); |
| | 2622 | expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); |
| | 2623 | expect( |
| | 2624 | thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), |
| | 2625 | ).toMatchObject({ payload: { detail: "provider session disappeared" } }); |
| | 2626 | }), |
| | 2627 | ); |
| | 2628 | |
| | 2629 | effectIt.effect("does not overwrite a session that became ready while an interrupt failed", () => |
| | 2630 | Effect.gen(function* () { |
| | 2631 | const harness = yield* Effect.promise(() => createHarness()); |
| | 2632 | const now = "2026-01-01T00:00:00.000Z"; |
| | 2633 | const completedAt = "2026-01-01T00:00:01.000Z"; |
| | 2634 | |
| | 2635 | yield* harness.engine.dispatch({ |
| | 2636 | type: "thread.session.set", |
| | 2637 | commandId: CommandId.make("cmd-session-set-interrupt-race"), |
| | 2638 | threadId: ThreadId.make("thread-1"), |
| | 2639 | session: { |
| | 2640 | threadId: ThreadId.make("thread-1"), |
| | 2641 | status: "running", |
| | 2642 | providerName: "codex", |
| | 2643 | runtimeMode: "approval-required", |
| | 2644 | activeTurnId: asTurnId("turn-1"), |
| | 2645 | lastError: null, |
| | 2646 | updatedAt: now, |
| | 2647 | }, |
| | 2648 | createdAt: now, |
| | 2649 | }); |
| | 2650 | |
| | 2651 | harness.interruptTurn.mockImplementation(() => |
| | 2652 | harness.engine |
| | 2653 | .dispatch({ |
| | 2654 | type: "thread.session.set", |
| | 2655 | commandId: CommandId.make("cmd-session-set-natural-completion"), |
| | 2656 | threadId: ThreadId.make("thread-1"), |
| | 2657 | session: { |
| | 2658 | threadId: ThreadId.make("thread-1"), |
| | 2659 | status: "ready", |
| | 2660 | providerName: "codex", |
| | 2661 | runtimeMode: "approval-required", |
| | 2662 | activeTurnId: null, |
| | 2663 | lastError: null, |
| | 2664 | updatedAt: completedAt, |
| | 2665 | }, |
| | 2666 | createdAt: completedAt, |
| | 2667 | }) |
| | 2668 | .pipe( |
| | 2669 | Effect.catchCause((cause) => Effect.die(cause)), |
| | 2670 | Effect.andThen( |
| | 2671 | Effect.fail( |
| | 2672 | new ProviderAdapterRequestError({ |
| | 2673 | provider: "codex", |
| | 2674 | method: "thread.interrupt", |
| | 2675 | detail: "provider session disappeared", |
| | 2676 | }), |
| | 2677 | ), |
| | 2678 | ), |
| | 2679 | ), |
| | 2680 | ); |
| | 2681 | |
| | 2682 | yield* harness.engine.dispatch({ |
| | 2683 | type: "thread.turn.interrupt", |
| | 2684 | commandId: CommandId.make("cmd-turn-interrupt-race"), |
| | 2685 | threadId: ThreadId.make("thread-1"), |
| | 2686 | turnId: asTurnId("turn-1"), |
| | 2687 | createdAt: now, |
| | 2688 | }); |
| | 2689 | |
| | 2690 | yield* Effect.promise(() => harness.drain()); |
| | 2691 | |
| | 2692 | const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( |
| | 2693 | (entry) => entry.id === ThreadId.make("thread-1"), |
| | 2694 | ); |
| | 2695 | expect(thread?.session).toMatchObject({ |
| | 2696 | status: "ready", |
| | 2697 | activeTurnId: null, |
| | 2698 | lastError: null, |
| | 2699 | updatedAt: completedAt, |
| | 2700 | }); |
| | 2701 | expect(harness.stopSession).not.toHaveBeenCalled(); |
| | 2702 | expect( |
| | 2703 | thread?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed"), |
| | 2704 | ).toBe(false); |
| | 2705 | }), |
| | 2706 | ); |
| | 2707 | |
| 2490 | it("starts a fresh session when only projected session state exists", async () => { | 2708 | it("starts a fresh session when only projected session state exists", async () => { |
| 2491 | const harness = await createHarness(); | 2709 | const harness = await createHarness(); |
| 2492 | const now = "2026-01-01T00:00:00.000Z"; | 2710 | const now = "2026-01-01T00:00:00.000Z"; |