Skip to content

Commit ae39bac

Browse files
mjcjuliusmarminge
andauthored
Handle non-resumable pending user input (pingdotgg#2766)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 1ea1702 commit ae39bac

6 files changed

Lines changed: 157 additions & 7 deletions

File tree

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,6 +1877,142 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
18771877
}),
18781878
);
18791879

1880+
it.effect("clears stale pending user input from projected shell summaries", () =>
1881+
Effect.gen(function* () {
1882+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
1883+
const eventStore = yield* OrchestrationEventStore;
1884+
const sql = yield* SqlClient.SqlClient;
1885+
const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1886+
eventStore
1887+
.append(event)
1888+
.pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1889+
1890+
yield* appendAndProject({
1891+
type: "project.created",
1892+
eventId: EventId.make("evt-stale-user-input-1"),
1893+
aggregateKind: "project",
1894+
aggregateId: ProjectId.make("project-stale-user-input"),
1895+
occurredAt: "2026-02-26T12:35:00.000Z",
1896+
commandId: CommandId.make("cmd-stale-user-input-1"),
1897+
causationEventId: null,
1898+
correlationId: CorrelationId.make("cmd-stale-user-input-1"),
1899+
metadata: {},
1900+
payload: {
1901+
projectId: ProjectId.make("project-stale-user-input"),
1902+
title: "Project Stale User Input",
1903+
workspaceRoot: "/tmp/project-stale-user-input",
1904+
defaultModelSelection: null,
1905+
scripts: [],
1906+
createdAt: "2026-02-26T12:35:00.000Z",
1907+
updatedAt: "2026-02-26T12:35:00.000Z",
1908+
},
1909+
});
1910+
1911+
yield* appendAndProject({
1912+
type: "thread.created",
1913+
eventId: EventId.make("evt-stale-user-input-2"),
1914+
aggregateKind: "thread",
1915+
aggregateId: ThreadId.make("thread-stale-user-input"),
1916+
occurredAt: "2026-02-26T12:35:01.000Z",
1917+
commandId: CommandId.make("cmd-stale-user-input-2"),
1918+
causationEventId: null,
1919+
correlationId: CorrelationId.make("cmd-stale-user-input-2"),
1920+
metadata: {},
1921+
payload: {
1922+
threadId: ThreadId.make("thread-stale-user-input"),
1923+
projectId: ProjectId.make("project-stale-user-input"),
1924+
title: "Thread Stale User Input",
1925+
modelSelection: {
1926+
instanceId: ProviderInstanceId.make("codex"),
1927+
model: "gpt-5-codex",
1928+
},
1929+
runtimeMode: "approval-required",
1930+
interactionMode: "default",
1931+
branch: null,
1932+
worktreePath: null,
1933+
createdAt: "2026-02-26T12:35:01.000Z",
1934+
updatedAt: "2026-02-26T12:35:01.000Z",
1935+
},
1936+
});
1937+
1938+
yield* appendAndProject({
1939+
type: "thread.activity-appended",
1940+
eventId: EventId.make("evt-stale-user-input-3"),
1941+
aggregateKind: "thread",
1942+
aggregateId: ThreadId.make("thread-stale-user-input"),
1943+
occurredAt: "2026-02-26T12:35:02.000Z",
1944+
commandId: CommandId.make("cmd-stale-user-input-3"),
1945+
causationEventId: null,
1946+
correlationId: CorrelationId.make("cmd-stale-user-input-3"),
1947+
metadata: {},
1948+
payload: {
1949+
threadId: ThreadId.make("thread-stale-user-input"),
1950+
activity: {
1951+
id: EventId.make("activity-stale-user-input-requested"),
1952+
tone: "info",
1953+
kind: "user-input.requested",
1954+
summary: "User input requested",
1955+
payload: {
1956+
requestId: "user-input-request-stale-1",
1957+
questions: [
1958+
{
1959+
id: "sandbox_mode",
1960+
header: "Sandbox",
1961+
question: "Which mode should be used?",
1962+
options: [
1963+
{
1964+
label: "workspace-write",
1965+
description: "Allow workspace writes only",
1966+
},
1967+
],
1968+
},
1969+
],
1970+
},
1971+
turnId: null,
1972+
createdAt: "2026-02-26T12:35:02.000Z",
1973+
},
1974+
},
1975+
});
1976+
1977+
yield* appendAndProject({
1978+
type: "thread.activity-appended",
1979+
eventId: EventId.make("evt-stale-user-input-4"),
1980+
aggregateKind: "thread",
1981+
aggregateId: ThreadId.make("thread-stale-user-input"),
1982+
occurredAt: "2026-02-26T12:35:03.000Z",
1983+
commandId: CommandId.make("cmd-stale-user-input-4"),
1984+
causationEventId: null,
1985+
correlationId: CorrelationId.make("cmd-stale-user-input-4"),
1986+
metadata: {},
1987+
payload: {
1988+
threadId: ThreadId.make("thread-stale-user-input"),
1989+
activity: {
1990+
id: EventId.make("activity-stale-user-input-failed"),
1991+
tone: "error",
1992+
kind: "provider.user-input.respond.failed",
1993+
summary: "Provider user input response failed",
1994+
payload: {
1995+
requestId: "user-input-request-stale-1",
1996+
detail:
1997+
"Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1",
1998+
},
1999+
turnId: null,
2000+
createdAt: "2026-02-26T12:35:03.000Z",
2001+
},
2002+
},
2003+
});
2004+
2005+
const threadRows = yield* sql<{
2006+
readonly pendingUserInputCount: number;
2007+
}>`
2008+
SELECT pending_user_input_count AS "pendingUserInputCount"
2009+
FROM projection_threads
2010+
WHERE thread_id = 'thread-stale-user-input'
2011+
`;
2012+
assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]);
2013+
}),
2014+
);
2015+
18802016
it.effect("ignores non-stale provider approval response failures", () =>
18812017
Effect.gen(function* () {
18822018
const projectionPipeline = yield* OrchestrationProjectionPipeline;

apps/server/src/orchestration/Layers/ProjectionPipeline.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ function derivePendingUserInputCountFromActivities(
165165
activity.kind === "provider.user-input.respond.failed" &&
166166
detail !== null &&
167167
(detail.includes("stale pending user-input request") ||
168-
detail.includes("unknown pending user-input request"))
168+
detail.includes("unknown pending user-input request") ||
169+
detail.includes("unknown pending user input request") ||
170+
detail.includes("unknown pending codex user input request"))
169171
) {
170172
openRequestIds.delete(requestId);
171173
}

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,15 +1943,15 @@ describe("ProviderCommandReactor", () => {
19431943
expect(resolvedActivity).toBeUndefined();
19441944
});
19451945

1946-
it("surfaces stale provider user-input failures without faking user-input resolution", async () => {
1946+
it("surfaces non-resumable provider user-input callbacks as stale failures", async () => {
19471947
const harness = await createHarness();
19481948
const now = "2026-01-01T00:00:00.000Z";
19491949
harness.respondToUserInput.mockImplementation(() =>
19501950
Effect.fail(
19511951
new ProviderAdapterRequestError({
19521952
provider: ProviderDriverKind.make("claudeAgent"),
19531953
method: "item/tool/respondToUserInput",
1954-
detail: "Unknown pending user-input request: user-input-request-1",
1954+
detail: "Unknown pending Codex user input request: user-input-request-1",
19551955
}),
19561956
),
19571957
);

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,19 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService
141141
function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
142142
const error = findProviderAdapterRequestError(cause);
143143
if (error) {
144-
return error.detail.toLowerCase().includes("unknown pending user-input request");
144+
const detail = error.detail.toLowerCase();
145+
return (
146+
detail.includes("unknown pending user-input request") ||
147+
detail.includes("unknown pending user input request") ||
148+
detail.includes("unknown pending codex user input request")
149+
);
145150
}
146-
return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");
151+
const message = Cause.pretty(cause).toLowerCase();
152+
return (
153+
message.includes("unknown pending user-input request") ||
154+
message.includes("unknown pending user input request") ||
155+
message.includes("unknown pending codex user input request")
156+
);
147157
}
148158

149159
function stalePendingRequestDetail(

apps/web/src/session-logic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ describe("derivePendingUserInputs", () => {
300300
payload: {
301301
requestId: "req-user-input-stale-1",
302302
detail:
303-
"Stale pending user-input request: req-user-input-stale-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
303+
"Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1",
304304
},
305305
}),
306306
];

apps/web/src/session-logic.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@ function isStalePendingRequestFailureDetail(detail: string | undefined): boolean
346346
normalized.includes("stale pending user-input request") ||
347347
normalized.includes("unknown pending approval request") ||
348348
normalized.includes("unknown pending permission request") ||
349-
normalized.includes("unknown pending user-input request")
349+
normalized.includes("unknown pending user-input request") ||
350+
normalized.includes("unknown pending user input request") ||
351+
normalized.includes("unknown pending codex user input request")
350352
);
351353
}
352354

0 commit comments

Comments
 (0)