diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..fe79696bcce 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2174,6 +2174,144 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); + effectIt.effect( + "marks user-input responses stale when no provider session is bound to the thread", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-user-input-requested-no-session"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-user-input-requested-no-session"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "fix_scope", + header: "Fix scope", + question: "How should I handle this?", + options: [{ label: "Delete now", description: "Remove the garbage" }], + }, + ], + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.make("cmd-user-input-respond-no-session"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + fix_scope: "Delete now", + }, + createdAt: now, + }); + + yield* Effect.promise(() => + 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.user-input.respond.failed", + ) === true + ); + }), + ); + + expect(harness.respondToUserInput).not.toHaveBeenCalled(); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + const detail = (failureActivity?.payload as Record | undefined)?.detail; + expect(failureActivity?.payload).toMatchObject({ requestId: "user-input-request-1" }); + expect(detail).toContain("No active provider session is bound to this thread."); + // The stale marker is what every pending-request accounting keys off, so a + // dead session clears the prompt instead of leaving it answerable forever. + // Clearing itself is covered by session-logic / threadActivity tests. + expect(detail).toContain("Stale pending user-input request: user-input-request-1"); + }), + ); + + effectIt.effect( + "marks approval responses stale when no provider session is bound to the thread", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-approval-requested-no-session"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-approval-requested-no-session"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-1", + requestKind: "command", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-respond-no-session"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "acceptForSession", + createdAt: now, + }); + + yield* Effect.promise(() => + 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", + ) === true + ); + }), + ); + + expect(harness.respondToRequest).not.toHaveBeenCalled(); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.approval.respond.failed", + ); + const detail = (failureActivity?.payload as Record | undefined)?.detail; + expect(failureActivity?.payload).toMatchObject({ requestId: "approval-request-1" }); + expect(detail).toContain("No active provider session is bound to this thread."); + expect(detail).toContain("Stale pending approval request: approval-request-1"); + }), + ); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d..4490f77729f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -166,6 +166,19 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } +// A response can only reach the provider through the live session holding the +// pending callback, so once that session is gone the request is permanently +// unanswerable — exactly the stale-request case. Reuse the stale detail so the +// pending accounting that keys off it (web/mobile derivePending*, the decider's +// settle guard, ProjectionPipeline) clears the request instead of leaving an +// answerable card that appends an identical failure on every submit. +function noActiveSessionRequestDetail( + requestKind: "approval" | "user-input", + requestId: string, +): string { + return `No active provider session is bound to this thread. ${stalePendingRequestDetail(requestKind, requestId)}`; +} + function buildGeneratedWorktreeBranchName(raw: string): string { const normalized = raw .trim() @@ -929,7 +942,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.approval.respond.failed", summary: "Provider approval response failed", - detail: "No active provider session is bound to this thread.", + detail: noActiveSessionRequestDetail("approval", event.payload.requestId), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, @@ -973,7 +986,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.user-input.respond.failed", summary: "Provider user input response failed", - detail: "No active provider session is bound to this thread.", + detail: noActiveSessionRequestDetail("user-input", event.payload.requestId), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..707f53ea7a0 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -307,6 +307,51 @@ describe("derivePendingUserInputs", () => { expect(derivePendingUserInputs(activities)).toEqual([]); }); + + it("clears pending user-input prompts once no provider session is bound to the thread", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-no-session", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-no-session-1", + questions: [ + { + id: "fix_scope", + header: "Fix scope", + question: "How should I handle this?", + options: [ + { + label: "Delete now", + description: "Remove the garbage", + }, + ], + multiSelect: false, + }, + ], + }, + }), + makeActivity({ + id: "user-input-failed-no-session", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-no-session-1", + detail: + "No active provider session is bound to this thread. Stale pending user-input request: req-user-input-no-session-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", + }, + }), + ]; + + // The prompt's callback died with the session, so leaving the card + // answerable only lets every submit append another identical failure. + expect(derivePendingUserInputs(activities)).toEqual([]); + }); }); describe("deriveActivePlanState", () => {