diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 97379b94b88..a551987e688 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -233,7 +233,7 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const interruptTurn = vi.fn((_: unknown) => Effect.void); + const interruptTurn = vi.fn(() => Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); const stopSession = vi.fn((input: unknown) => @@ -2937,4 +2937,228 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + it("forwards approval declines without interrupting the provider turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-approval-decline"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-decline"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "decline", + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToRequest.mock.calls.length === 1); + await harness.drain(); + expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "approval-request-1", + decision: "decline", + }); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + }); + + it("responds to approval cancellation before interrupting the provider turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const callOrder: Array<"respond" | "interrupt"> = []; + harness.respondToRequest.mockImplementation(() => + Effect.sync(() => { + callOrder.push("respond"); + }), + ); + harness.interruptTurn.mockImplementation(() => + Effect.sync(() => { + callOrder.push("interrupt"); + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-approval-cancel"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-cancel"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "cancel", + createdAt: now, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + await harness.drain(); + expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "approval-request-1", + decision: "cancel", + }); + expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + }); + expect(callOrder).toEqual(["respond", "interrupt"]); + }); + + it("does not interrupt when the approval cancellation response fails", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.respondToRequest.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: ProviderDriverKind.make("codex"), + method: "session/request_permission", + detail: "Approval response failed", + }), + ), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-failed-approval-cancel"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-cancel-response-failed"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "cancel", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.approval.respond.failed", + ) ?? false + ); + }); + await harness.drain(); + + expect(harness.respondToRequest).toHaveBeenCalledTimes(1); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + }); + + it("records a failed interrupt after a successful approval cancellation", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.interruptTurn.mockImplementation(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: ProviderDriverKind.make("codex"), + method: "turn/interrupt", + detail: "Turn interrupt failed", + }), + ), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-failed-approval-interrupt"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-cancel-interrupt-failed"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "cancel", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed") ?? + false + ); + }); + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(harness.respondToRequest).toHaveBeenCalledTimes(1); + expect(harness.interruptTurn).toHaveBeenCalledTimes(1); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ + summary: "Provider turn interrupt failed", + turnId: "turn-1", + payload: { + detail: expect.stringContaining("Turn interrupt failed"), + }, + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179..3a09928730c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1227,13 +1227,14 @@ const make = Effect.gen(function* () { }); } - yield* providerService + const responseSucceeded = yield* providerService .respondToRequest({ threadId: event.payload.threadId, requestId: event.payload.requestId, decision: event.payload.decision, }) .pipe( + Effect.as(true), Effect.catchCause((cause) => appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -1245,9 +1246,24 @@ const make = Effect.gen(function* () { turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, + }).pipe(Effect.as(false)), + ), + ); + + if (responseSucceeded && event.payload.decision === "cancel") { + yield* providerService.interruptTurn({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail: Cause.pretty(cause), + turnId: thread.session?.activeTurnId ?? null, + createdAt: event.payload.createdAt, }), ), ); + } }); const processUserInputResponseRequested = Effect.fn("processUserInputResponseRequested")(