Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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";
Expand Down
17 changes: 15 additions & 2 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading