History

fix(threads): keep completed requests closed across clients (#10123)

Theo Browne committed 2 months ago

Commit e63ddb41 parent bccad279 files · +711 −985Browse files

Unified · Split

9 files
  1. apps/mobile/src/lib/threadActivity.test.ts +3 −119
  2. apps/mobile/src/lib/threadActivity.ts +5 −221
  3. apps/mobile/src/state/use-selected-thread-requests.ts +4 −15
  4. apps/web/src/components/ChatView.tsx +3 −8
  5. apps/web/src/session-logic.test.ts +0 −397
  6. apps/web/src/session-logic.ts +6 −225
  7. packages/client-runtime/package.json +4 −0
  8. packages/client-runtime/src/pendingRequests.test.ts +497 −0
  9. packages/client-runtime/src/pendingRequests.ts +189 −0
apps/mobile/src/lib/threadActivity.test.ts+3−119
@@ -1,3 +1,4 @@
1+import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
12 import { describe, expect, it } from "vite-plus/test";
23 import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
34
@@ -16,8 +17,6 @@ import { · 12 unmodified lines
1617 agentSpawnSummary,
1718 buildPendingUserInputAnswers,
1819 buildThreadFeed,
19− derivePendingApprovals,
20− derivePendingUserInputs,
2120 deriveThreadFeedPresentation,
2221 isPendingUserInputOptionSelected,
2322 setPendingUserInputCustomAnswer,
@@ -107,7 +106,7 @@ describe("pending user input answers", () => { · 83 unmodified lines
107106 createdAt: "2026-09-03T00:00:00.000Z",
108107 payload: { requestId: "async-1", responseMode: "message", questions: [question] },
109108 });
110− const questions = derivePendingUserInputs([requested])[0]?.questions;
109+ const questions = derivePendingRequests([requested]).userInputs[0]?.questions;
111110 expect(questions).toEqual([question]);
112111 expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({
113112 "0": "Example",
@@ -126,7 +125,7 @@ describe("pending user input answers", () => { · 12 unmodified lines
126125 },
127126 });
128127
129− expect(derivePendingUserInputs([requested])).toEqual([
128+ expect(derivePendingRequests([requested]).userInputs).toEqual([
130129 {
131130 requestId: "interaction_1",
132131 createdAt: requested.createdAt,
@@ -265,121 +264,6 @@ describe("pending user input answers", () => { · 132 unmodified lines
265264 });
266265 });
267266
268−describe("pending approvals", () => {
269− it.each([{}, { requestType: "unknown" }])(
270− "exposes legacy OpenCode approvals without a known request kind: %j",
271− (legacyPayload) => {
272− const requested = makeActivity({
273− id: EventId.make("approval-legacy"),
274− kind: "approval.requested",
275− summary: "Approval requested",
276− createdAt: "2026-08-24T00:00:00.000Z",
277− payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
278− });
279−
280− expect(derivePendingApprovals([requested])).toEqual([
281− {
282− requestId: "per-legacy",
283− requestKind: "command",
284− createdAt: requested.createdAt,
285− detail: "*",
286− },
287− ]);
288− },
289− );
290−
291− it.each(["tool_user_input", "auth_tokens_refresh"])(
292− "does not turn %s into an approval",
293− (requestType) => {
294− const activity = makeActivity({
295− id: EventId.make("approval-non-approval"),
296− kind: "approval.requested",
297− summary: "Approval requested",
298− createdAt: "2026-08-24T00:00:00.000Z",
299− payload: { requestId: "not-an-approval", requestType },
300− });
301−
302− expect(derivePendingApprovals([activity])).toEqual([]);
303− },
304− );
305−
306− it.each(["approval.resolved", "provider.approval.respond.failed"])(
307− "removes legacy approvals after %s",
308− (kind) => {
309− const requested = makeActivity({
310− id: EventId.make("approval-legacy-open"),
311− kind: "approval.requested",
312− summary: "Approval requested",
313− createdAt: "2026-08-24T00:00:00.000Z",
314− payload: { requestId: "per-legacy", requestType: "unknown" },
315− });
316− const resolved = makeActivity({
317− id: EventId.make("approval-legacy-resolved"),
318− kind,
319− summary: "Approval resolved",
320− createdAt: "2026-08-24T00:00:01.000Z",
321− payload: {
322− requestId: "per-legacy",
323− detail: "Unknown pending permission request: per-legacy",
324− },
325− });
326−
327− expect(derivePendingApprovals([requested, resolved])).toEqual([]);
328− },
329− );
330−
331− it("keeps app access approvals and persistence choices from remote environments", () => {
332− const options = [
333− { decision: "decline", label: "Decline" },
334− { decision: "acceptAlways", label: "Always allow Safari" },
335− { decision: "accept", label: "Approve" },
336− ];
337− const activity = makeActivity({
338− id: EventId.make("approval-safari"),
339− kind: "approval.requested",
340− summary: "App access approval requested",
341− createdAt: "2026-08-24T00:00:00.000Z",
342− payload: {
343− requestId: "req-safari",
344− requestType: "mcp_elicitation_approval",
345− detail: "Allow ChatGPT to use Safari?",
346− appName: "Safari",
347− options,
348− },
349− });
350−
351− expect(derivePendingApprovals([activity])).toEqual([
352− {
353− requestId: "req-safari",
354− requestKind: "mcp-elicitation",
355− createdAt: "2026-08-24T00:00:00.000Z",
356− detail: "Allow ChatGPT to use Safari?",
357− appName: "Safari",
358− options,
359− },
360− ]);
361− });
362−
363− it("removes an app access approval after a remote client rejects it", () => {
364− const requested = makeActivity({
365− id: EventId.make("approval-safari-open"),
366− kind: "approval.requested",
367− summary: "App access approval requested",
368− createdAt: "2026-08-24T00:00:00.000Z",
369− payload: { requestId: "req-safari", requestKind: "mcp-elicitation" },
370− });
371− const resolved = makeActivity({
372− id: EventId.make("approval-safari-resolved"),
373− kind: "approval.resolved",
374− summary: "Approval resolved",
375− createdAt: "2026-08-24T00:00:01.000Z",
376− payload: { requestId: "req-safari", decision: "decline" },
377− });
378−
379− expect(derivePendingApprovals([requested, resolved])).toEqual([]);
380− });
381−});
382−
383267 function makeActivity(
384268 input: Partial<OrchestrationThreadActivity> &
385269 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+5−221
@@ -1,9 +1,8 @@
11 import {
2− ApprovalRequestId,
3− isToolLifecycleItemType,
4− ProviderApprovalOption,
5− ProviderRequestKind,
6−} from "@t3tools/contracts";
2+ requestKindFromRequestType,
3+ type PendingApproval,
4+} from "@t3tools/client-runtime/pending-requests";
5+import { isToolLifecycleItemType } from "@t3tools/contracts";
76 import type {
87 OrchestrationLatestTurn,
98 OrchestrationThread,
@@ -36,25 +35,8 @@ import { commandProgramName } from "@t3tools/client-runtime/work-log/command-lab · 26 unmodified lines
3635
3736 import * as Arr from "effect/Array";
3837 import * as Order from "effect/Order";
39−import * as Schema from "effect/Schema";
4038
41−export interface PendingApproval {
42− readonly requestId: ApprovalRequestId;
43− readonly requestKind: ProviderRequestKind;
44− readonly createdAt: string;
45− readonly detail?: string;
46− readonly appName?: string;
47− readonly options?: ReadonlyArray<ProviderApprovalOption>;
48−}
49−
50−const isProviderRequestKind = Schema.is(ProviderRequestKind);
51−const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
52−
53−export interface PendingUserInput {
54− readonly requestId: ApprovalRequestId;
55− readonly createdAt: string;
56− readonly questions: ReadonlyArray<UserInputQuestion>;
57−}
39+export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
5840
5941 export interface PendingUserInputDraftAnswer {
6042 readonly selectedOptionValues?: ReadonlyArray<string>;
@@ -276,94 +258,6 @@ export function isContextCompactionActivityGroup( · 215 unmodified lines
276258 );
277259 }
278260
279−function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
280− switch (requestType) {
281− case "command_execution_approval":
282− case "exec_command_approval":
283− return "command";
284− case "file_read_approval":
285− return "file-read";
286− case "file_change_approval":
287− case "apply_patch_approval":
288− return "file-change";
289− case "mcp_elicitation_approval":
290− return "mcp-elicitation";
291− default:
292− return null;
293− }
294−}
295−
296−function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
297− const normalized = detail?.toLowerCase();
298− if (!normalized) {
299− return false;
300− }
301− return (
302− normalized.includes("stale pending approval request") ||
303− normalized.includes("stale pending user-input request") ||
304− normalized.includes("unknown pending approval request") ||
305− normalized.includes("unknown pending permission request") ||
306− normalized.includes("unknown pending user-input request")
307− );
308−}
309−
310−function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
311− return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
312−}
313−
314−function parseUserInputQuestions(
315− payload: Record<string, unknown> | null,
316−): ReadonlyArray<UserInputQuestion> | null {
317− const questions = payload?.questions;
318− if (!Array.isArray(questions)) {
319− return null;
320− }
321−
322− const parsed = questions
323− .map<UserInputQuestion | null>((entry) => {
324− if (!entry || typeof entry !== "object") return null;
325− const question = entry as Record<string, unknown>;
326− if (
327− typeof question.id !== "string" ||
328− typeof question.header !== "string" ||
329− typeof question.question !== "string" ||
330− !Array.isArray(question.options)
331− ) {
332− return null;
333− }
334− const options = question.options
335− .map<UserInputQuestion["options"][number] | null>((option) => {
336− if (!option || typeof option !== "object") return null;
337− const record = option as Record<string, unknown>;
338− if (typeof record.label !== "string" || typeof record.description !== "string") {
339− return null;
340− }
341− return {
342− label: record.label,
343− description: record.description,
344− ...(typeof record.value === "string" ? { value: record.value } : {}),
345− };
346− })
347− .filter((option): option is UserInputQuestion["options"][number] => option !== null);
348− if (options.length === 0 && question.allowCustomAnswer === false) {
349− return null;
350− }
351− return {
352− id: question.id,
353− header: question.header,
354− question: question.question,
355− options,
356− multiSelect: question.multiSelect === true,
357− ...(typeof question.allowCustomAnswer === "boolean"
358− ? { allowCustomAnswer: question.allowCustomAnswer }
359− : {}),
360− };
361− })
362− .filter((question): question is UserInputQuestion => question !== null);
363−
364− return parsed.length > 0 ? parsed : null;
365−}
366−
367261 function normalizeDraftAnswer(value: string | undefined): string | null {
368262 if (typeof value !== "string") {
369263 return null;
@@ -2169,116 +2063,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo · 1799 unmodified lines
21692063 return activity.detail ?? activity.summary;
21702064 }
21712065
2172−/**
2173− * Sorts activities into lifecycle order. `derivePendingApprovals` and
2174− * `derivePendingUserInputs` both expect this ordering; sorting once and
2175− * passing the result to both avoids re-sorting the full activity history
2176− * per derivation.
2177− */
2178−export function sortThreadActivities(
2179− activities: ReadonlyArray<OrchestrationThreadActivity>,
2180−): ReadonlyArray<OrchestrationThreadActivity> {
2181− return Arr.sort(activities, activityOrder);
2182−}
2183−
2184−export function derivePendingApprovals(
2185− sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
2186−): PendingApproval[] {
2187− const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
2188−
2189− for (const activity of sortedActivities) {
2190− const payload =
2191− activity.payload && typeof activity.payload === "object"
2192− ? (activity.payload as Record<string, unknown>)
2193− : null;
2194− const requestId = parseApprovalRequestId(payload?.requestId);
2195− const requestKind = isProviderRequestKind(payload?.requestKind)
2196− ? payload.requestKind
2197− : requestKindFromRequestType(payload?.requestType);
2198− const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
2199− const appName = typeof payload?.appName === "string" ? payload.appName : undefined;
2200− const options = Array.isArray(payload?.options)
2201− ? payload.options.filter(isProviderApprovalOption)
2202− : undefined;
2203−
2204− if (
2205− activity.kind === "approval.requested" &&
2206− requestId &&
2207− payload?.requestType !== "tool_user_input" &&
2208− payload?.requestType !== "auth_tokens_refresh"
2209− ) {
2210− openByRequestId.set(requestId, {
2211− requestId,
2212− // Older OpenCode requests can have no recognized approval kind.
2213− requestKind: requestKind ?? "command",
2214− createdAt: activity.createdAt,
2215− ...(detail ? { detail } : {}),
2216− ...(appName ? { appName } : {}),
2217− ...(options && options.length > 0 ? { options } : {}),
2218− });
2219− continue;
2220− }
2221−
2222− if (activity.kind === "approval.resolved" && requestId) {
2223− openByRequestId.delete(requestId);
2224− continue;
2225− }
2226−
2227− if (
2228− activity.kind === "provider.approval.respond.failed" &&
2229− requestId &&
2230− isStalePendingRequestFailureDetail(detail)
2231− ) {
2232− openByRequestId.delete(requestId);
2233− }
2234− }
2235−
2236− return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date);
2237−}
2238−
2239−export function derivePendingUserInputs(
2240− sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
2241−): PendingUserInput[] {
2242− const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
2243−
2244− for (const activity of sortedActivities) {
2245− const payload =
2246− activity.payload && typeof activity.payload === "object"
2247− ? (activity.payload as Record<string, unknown>)
2248− : null;
2249− const requestId = parseApprovalRequestId(payload?.requestId);
2250− const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
2251−
2252− if (activity.kind === "user-input.requested" && requestId) {
2253− const questions = parseUserInputQuestions(payload);
2254− if (!questions) {
2255− continue;
2256− }
2257− openByRequestId.set(requestId, {
2258− requestId,
2259− createdAt: activity.createdAt,
2260− questions,
2261− });
2262− continue;
2263− }
2264−
2265− if (activity.kind === "user-input.resolved" && requestId) {
2266− openByRequestId.delete(requestId);
2267− continue;
2268− }
2269−
2270− if (
2271− activity.kind === "provider.user-input.respond.failed" &&
2272− requestId &&
2273− isStalePendingRequestFailureDetail(detail)
2274− ) {
2275− openByRequestId.delete(requestId);
2276− }
2277− }
2278−
2279− return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date);
2280−}
2281−
22822066 export function setPendingUserInputCustomAnswer(
22832067 question: UserInputQuestion,
22842068 draft: PendingUserInputDraftAnswer | undefined,
apps/mobile/src/state/use-selected-thread-requests.ts+4−15
@@ -1,3 +1,4 @@
1+import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
12 import { useAtomValue } from "@effect/atom-react";
23 import { useCallback, useMemo, useState } from "react";
34
@@ -12,10 +13,7 @@ import { threadEnvironment } from "../state/threads"; · 8 unmodified lines
1213 import { scopedRequestKey } from "../lib/scopedEntities";
1314 import {
1415 buildPendingUserInputAnswers,
15− derivePendingApprovals,
16− derivePendingUserInputs,
1716 setPendingUserInputCustomAnswer,
18− sortThreadActivities,
1917 togglePendingUserInputOptionSelection,
2018 type PendingUserInputDraftAnswer,
2119 } from "../lib/threadActivity";
@@ -83,20 +81,11 @@ export function useSelectedThreadRequests() { · 61 unmodified lines
8381 null,
8482 );
8583
86− // Sort once; both derivations expect the same lifecycle ordering.
87− const sortedActivities = useMemo(
88− () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []),
89− [selectedThread],
90− );
91− const activePendingApprovals = useMemo(
92− () => derivePendingApprovals(sortedActivities),
93− [sortedActivities],
84+ const { approvals: activePendingApprovals, userInputs: activePendingUserInputs } = useMemo(
85+ () => derivePendingRequests(selectedThread?.activities ?? []),
86+ [selectedThread?.activities],
9487 );
9588 const activePendingApproval = activePendingApprovals[0] ?? null;
96− const activePendingUserInputs = useMemo(
97− () => derivePendingUserInputs(sortedActivities),
98− [sortedActivities],
99− );
10089 const activePendingUserInput = activePendingUserInputs[0] ?? null;
10190 const activePendingUserInputDrafts =
10291 activePendingUserInput && selectedThreadShell
apps/web/src/components/ChatView.tsx+3−8
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 isUsageLimitsCommand,
77 } from "@t3tools/shared/usageLimits";
88 import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
9+import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
910 import {
1011 type AssistantCitation,
1112 type ApprovalRequestId,
@@ -106,8 +107,6 @@ import { · 94 unmodified lines
106107 } from "../composer-logic";
107108 import {
108109 createMessageAttachmentPreviewProjector,
109− derivePendingApprovals,
110− derivePendingUserInputs,
111110 derivePhase,
112111 deriveTimelineEntriesWithState,
113112 deriveActiveWorkStartedAt,
@@ -2586,12 +2585,8 @@ export default function ChatView(props: ChatViewProps) { · 2472 unmodified lines
25862585 }),
25872586 [agentSessionLive, threadActivities],
25882587 );
2589− const pendingApprovals = useMemo(
2590− () => derivePendingApprovals(threadActivities),
2591− [threadActivities],
2592− );
2593− const pendingUserInputs = useMemo(
2594− () => derivePendingUserInputs(threadActivities),
2588+ const { approvals: pendingApprovals, userInputs: pendingUserInputs } = useMemo(
2589+ () => derivePendingRequests(threadActivities),
25952590 [threadActivities],
25962591 );
25972592 const activePendingUserInput = pendingUserInputs[0] ?? null;
apps/web/src/session-logic.test.ts+0−397
@@ -13,8 +13,6 @@ import { · 12 unmodified lines
1313 createMessageAttachmentPreviewProjector,
1414 deriveActiveWorkStartedAt,
1515 deriveActivePlanState,
16− derivePendingApprovals,
17− derivePendingUserInputs,
1816 deriveTimelineEntries,
1917 deriveTimelineEntriesWithState,
2018 deriveWorkLogEntries,
@@ -63,401 +61,6 @@ function makeActivity(overrides: { · 42 unmodified lines
6361 };
6462 }
6563
66−describe("derivePendingApprovals", () => {
67− it.each([{}, { requestType: "unknown" }])(
68− "exposes legacy OpenCode approvals without a known request kind: %j",
69− (legacyPayload) => {
70− const requested = makeActivity({
71− kind: "approval.requested",
72− payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
73− });
74−
75− expect(derivePendingApprovals([requested])).toEqual([
76− {
77− requestId: "per-legacy",
78− requestKind: "command",
79− createdAt: requested.createdAt,
80− detail: "*",
81− },
82− ]);
83− },
84− );
85−
86− it.each(["tool_user_input", "auth_tokens_refresh"])(
87− "does not turn %s into an approval",
88− (requestType) => {
89− const activity = makeActivity({
90− kind: "approval.requested",
91− payload: { requestId: "not-an-approval", requestType },
92− });
93−
94− expect(derivePendingApprovals([activity])).toEqual([]);
95− },
96− );
97−
98− it("tracks open approvals and removes resolved ones", () => {
99− const activities: OrchestrationThreadActivity[] = [
100− makeActivity({
101− id: "approval-open",
102− createdAt: "2026-02-23T00:00:01.000Z",
103− kind: "approval.requested",
104− summary: "Command approval requested",
105− tone: "approval",
106− payload: {
107− requestId: "req-1",
108− requestKind: "command",
109− detail: "bun run lint",
110− },
111− }),
112− makeActivity({
113− id: "approval-close",
114− createdAt: "2026-02-23T00:00:02.000Z",
115− kind: "approval.resolved",
116− summary: "Approval resolved",
117− tone: "info",
118− payload: { requestId: "req-2" },
119− }),
120− makeActivity({
121− id: "approval-closed-request",
122− createdAt: "2026-02-23T00:00:01.500Z",
123− kind: "approval.requested",
124− summary: "File-change approval requested",
125− tone: "approval",
126− payload: { requestId: "req-2", requestType: "unknown" },
127− }),
128− ];
129−
130− expect(derivePendingApprovals(activities)).toEqual([
131− {
132− requestId: "req-1",
133− requestKind: "command",
134− createdAt: "2026-02-23T00:00:01.000Z",
135− detail: "bun run lint",
136− },
137− ]);
138− });
139−
140− it("maps canonical requestType payloads into pending approvals", () => {
141− const activities: OrchestrationThreadActivity[] = [
142− makeActivity({
143− id: "approval-open-request-type",
144− createdAt: "2026-02-23T00:00:01.000Z",
145− kind: "approval.requested",
146− summary: "Command approval requested",
147− tone: "approval",
148− payload: {
149− requestId: "req-request-type",
150− requestType: "command_execution_approval",
151− detail: "pwd",
152− },
153− }),
154− ];
155−
156− expect(derivePendingApprovals(activities)).toEqual([
157− {
158− requestId: "req-request-type",
159− requestKind: "command",
160− createdAt: "2026-02-23T00:00:01.000Z",
161− detail: "pwd",
162− },
163− ]);
164− });
165−
166− it("keeps app access approvals and persistence choices from remote activities", () => {
167− const options = [
168− { decision: "decline", label: "Decline" },
169− { decision: "acceptAlways", label: "Always allow Safari" },
170− { decision: "accept", label: "Approve" },
171− ];
172− const activities = [
173− makeActivity({
174− kind: "approval.requested",
175− summary: "App access approval requested",
176− tone: "approval",
177− payload: {
178− requestId: "req-safari",
179− requestType: "mcp_elicitation_approval",
180− detail: "Allow ChatGPT to use Safari?",
181− appName: "Safari",
182− options,
183− },
184− }),
185− ];
186−
187− expect(derivePendingApprovals(activities)).toEqual([
188− {
189− requestId: "req-safari",
190− requestKind: "mcp-elicitation",
191− createdAt: "2026-02-23T00:00:00.000Z",
192− detail: "Allow ChatGPT to use Safari?",
193− appName: "Safari",
194− options,
195− },
196− ]);
197− });
198−
199− it("derives dynamic tool requests as actionable generic approvals", () => {
200− const activities: OrchestrationThreadActivity[] = [
201− makeActivity({
202− id: "approval-open-dynamic-tool",
203− createdAt: "2026-02-23T00:00:01.000Z",
204− kind: "approval.requested",
205− summary: "Approval requested",
206− tone: "approval",
207− payload: {
208− requestId: "req-dynamic-tool",
209− requestType: "dynamic_tool_call",
210− detail: "Search the web",
211− },
212− }),
213− ];
214−
215− expect(derivePendingApprovals(activities)).toEqual([
216− {
217− requestId: "req-dynamic-tool",
218− requestKind: "command",
219− createdAt: "2026-02-23T00:00:01.000Z",
220− detail: "Search the web",
221− },
222− ]);
223− });
224−
225− it("clears stale pending approvals when provider reports unknown pending request", () => {
226− const activities: OrchestrationThreadActivity[] = [
227− makeActivity({
228− id: "approval-open-stale",
229− createdAt: "2026-02-23T00:00:01.000Z",
230− kind: "approval.requested",
231− summary: "Command approval requested",
232− tone: "approval",
233− payload: {
234− requestId: "req-stale-1",
235− requestType: "unknown",
236− },
237− }),
238− makeActivity({
239− id: "approval-failed-stale",
240− createdAt: "2026-02-23T00:00:02.000Z",
241− kind: "provider.approval.respond.failed",
242− summary: "Provider approval response failed",
243− tone: "error",
244− payload: {
245− requestId: "req-stale-1",
246− detail: "Unknown pending permission request: req-stale-1",
247− },
248− }),
249− ];
250−
251− expect(derivePendingApprovals(activities)).toEqual([]);
252− });
253−
254− it("clears stale pending approvals when the backend marks them stale after restart", () => {
255− const activities: OrchestrationThreadActivity[] = [
256− makeActivity({
257− id: "approval-open-stale-restart",
258− createdAt: "2026-02-23T00:00:01.000Z",
259− kind: "approval.requested",
260− summary: "Command approval requested",
261− tone: "approval",
262− payload: {
263− requestId: "req-stale-restart-1",
264− requestKind: "command",
265− },
266− }),
267− makeActivity({
268− id: "approval-failed-stale-restart",
269− createdAt: "2026-02-23T00:00:02.000Z",
270− kind: "provider.approval.respond.failed",
271− summary: "Provider approval response failed",
272− tone: "error",
273− payload: {
274− requestId: "req-stale-restart-1",
275− detail:
276− "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
277− },
278− }),
279− ];
280−
281− expect(derivePendingApprovals(activities)).toEqual([]);
282− });
283−});
284−
285−describe("derivePendingUserInputs", () => {
286− it("keeps free-text questions without suggested answers", () => {
287− const question = {
288− id: "0",
289− header: "Question",
290− question: "What should it be named?",
291− options: [],
292− allowCustomAnswer: true,
293− multiSelect: false,
294− };
295− const activities = [
296− makeActivity({
297− id: "async-question",
298− kind: "user-input.requested",
299− summary: "User input requested",
300− payload: { requestId: "async-1", responseMode: "message", questions: [question] },
301− }),
302− ];
303− expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]);
304− });
305−
306− it("preserves native choice values and the custom-answer restriction", () => {
307− const question = {
308− id: "interaction-result",
309− header: "Result",
310− question: "Which result should be used?",
311− options: [
312− { value: " first\t", label: "Result", description: "First result" },
313− { value: "second", label: "Result", description: "Second result" },
314− ],
315− allowCustomAnswer: false,
316− multiSelect: false,
317− };
318− const activities = [
319− makeActivity({
320− id: "native-user-input",
321− kind: "user-input.requested",
322− summary: "User input requested",
323− payload: { requestId: "req-native-choice", questions: [question] },
324− }),
325− ];
326−
327− expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]);
328− });
329−
330− it("tracks open structured prompts and removes resolved ones", () => {
331− const activities: OrchestrationThreadActivity[] = [
332− makeActivity({
333− id: "user-input-open",
334− createdAt: "2026-02-23T00:00:01.000Z",
335− kind: "user-input.requested",
336− summary: "User input requested",
337− tone: "info",
338− payload: {
339− requestId: "req-user-input-1",
340− questions: [
341− {
342− id: "sandbox_mode",
343− header: "Sandbox",
344− question: "Which mode should be used?",
345− options: [
346− {
347− label: "workspace-write",
348− description: "Allow workspace writes only",
349− },
350− ],
351− multiSelect: true,
352− },
353− ],
354− },
355− }),
356− makeActivity({
357− id: "user-input-resolved",
358− createdAt: "2026-02-23T00:00:02.000Z",
359− kind: "user-input.resolved",
360− summary: "User input submitted",
361− tone: "info",
362− payload: {
363− requestId: "req-user-input-2",
364− answers: {
365− sandbox_mode: "workspace-write",
366− },
367− },
368− }),
369− makeActivity({
370− id: "user-input-open-2",
371− createdAt: "2026-02-23T00:00:01.500Z",
372− kind: "user-input.requested",
373− summary: "User input requested",
374− tone: "info",
375− payload: {
376− requestId: "req-user-input-2",
377− questions: [
378− {
379− id: "approval",
380− header: "Approval",
381− question: "Continue?",
382− options: [
383− {
384− label: "yes",
385− description: "Continue execution",
386− },
387− ],
388− multiSelect: false,
389− },
390− ],
391− },
392− }),
393− ];
394−
395− expect(derivePendingUserInputs(activities)).toEqual([
396− {
397− requestId: "req-user-input-1",
398− createdAt: "2026-02-23T00:00:01.000Z",
399− questions: [
400− {
401− id: "sandbox_mode",
402− header: "Sandbox",
403− question: "Which mode should be used?",
404− options: [
405− {
406− label: "workspace-write",
407− description: "Allow workspace writes only",
408− },
409− ],
410− multiSelect: true,
411− },
412− ],
413− },
414− ]);
415− });
416−
417− it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
418− const activities: OrchestrationThreadActivity[] = [
419− makeActivity({
420− id: "user-input-open-stale",
421− createdAt: "2026-02-23T00:00:01.000Z",
422− kind: "user-input.requested",
423− summary: "User input requested",
424− tone: "info",
425− payload: {
426− requestId: "req-user-input-stale-1",
427− questions: [
428− {
429− id: "sandbox_mode",
430− header: "Sandbox",
431− question: "Which mode should be used?",
432− options: [
433− {
434− label: "workspace-write",
435− description: "Allow workspace writes only",
436− },
437− ],
438− multiSelect: false,
439− },
440− ],
441− },
442− }),
443− makeActivity({
444− id: "user-input-failed-stale",
445− createdAt: "2026-02-23T00:00:02.000Z",
446− kind: "provider.user-input.respond.failed",
447− summary: "Provider user input response failed",
448− tone: "error",
449− payload: {
450− requestId: "req-user-input-stale-1",
451− detail:
452− "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1",
453− },
454− }),
455− ];
456−
457− expect(derivePendingUserInputs(activities)).toEqual([]);
458− });
459−});
460−
46164 describe("deriveActivePlanState", () => {
46265 it("returns the latest plan update for the active turn", () => {
46366 const activities: OrchestrationThreadActivity[] = [
apps/web/src/session-logic.ts+6−225
@@ -1,6 +1,9 @@
1+import {
2+ requestKindFromRequestType,
3+ type PendingApproval,
4+} from "@t3tools/client-runtime/pending-requests";
15 import * as Option from "effect/Option";
26 import * as Arr from "effect/Array";
3−import * as Schema from "effect/Schema";
47 import { shallow } from "zustand/vanilla/shallow";
58 import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
69 import {
@@ -15,16 +18,12 @@ import { · 8 unmodified lines
1518 } from "@t3tools/client-runtime/work-log/presentation";
1619 import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1720 import {
18− ApprovalRequestId,
1921 isToolLifecycleItemType,
2022 type AssetResource,
2123 type OrchestrationLatestTurn,
2224 type OrchestrationThreadActivity,
2325 type OrchestrationProposedPlanId,
24− ProviderApprovalOption,
25− ProviderRequestKind,
2626 type ToolLifecycleItemType,
27− type UserInputQuestion,
2827 type ThreadId,
2928 type TurnId,
3029 } from "@t3tools/contracts";
@@ -40,6 +39,8 @@ import { · 9 unmodified lines
4039 type TurnDiffSummary,
4140 } from "./types";
4241
42+export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
43+
4344 export { formatDuration } from "@t3tools/shared/orchestrationTiming";
4445
4546 export {
@@ -106,24 +107,6 @@ const derivedWorkLogEntryByActivity = new WeakMap< · 60 unmodified lines
106107 DerivedWorkLogEntry
107108 >();
108109
109−export interface PendingApproval {
110− requestId: ApprovalRequestId;
111− requestKind: ProviderRequestKind;
112− createdAt: string;
113− detail?: string;
114− appName?: string;
115− options?: ReadonlyArray<ProviderApprovalOption>;
116−}
117−
118−const isProviderRequestKind = Schema.is(ProviderRequestKind);
119−const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
120−
121−export interface PendingUserInput {
122− requestId: ApprovalRequestId;
123− createdAt: string;
124− questions: ReadonlyArray<UserInputQuestion>;
125−}
126−
127110 export interface ActivePlanState {
128111 createdAt: string;
129112 turnId: TurnId | null;
@@ -236,208 +219,6 @@ export function deriveActiveWorkStartedAt( · 106 unmodified lines
236219 return sendStartedAt;
237220 }
238221
239−function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
240− switch (requestType) {
241− case "command_execution_approval":
242− case "exec_command_approval":
243− case "dynamic_tool_call":
244− return "command";
245− case "file_read_approval":
246− return "file-read";
247− case "file_change_approval":
248− case "apply_patch_approval":
249− return "file-change";
250− case "mcp_elicitation_approval":
251− return "mcp-elicitation";
252− default:
253− return null;
254− }
255−}
256−
257−function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
258− const normalized = detail?.toLowerCase();
259− if (!normalized) {
260− return false;
261− }
262− return (
263− normalized.includes("stale pending approval request") ||
264− normalized.includes("stale pending user-input request") ||
265− normalized.includes("unknown pending approval request") ||
266− normalized.includes("unknown pending permission request") ||
267− normalized.includes("unknown pending user-input request") ||
268− normalized.includes("unknown pending user input request") ||
269− normalized.includes("unknown pending codex user input request")
270− );
271−}
272−
273−export function derivePendingApprovals(
274− activities: ReadonlyArray<OrchestrationThreadActivity>,
275−): PendingApproval[] {
276− const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
277− const ordered = [...activities].toSorted(compareActivitiesByOrder);
278−
279− for (const activity of ordered) {
280− const payload =
281− activity.payload && typeof activity.payload === "object"
282− ? (activity.payload as Record<string, unknown>)
283− : null;
284− const requestId =
285− payload && typeof payload.requestId === "string"
286− ? ApprovalRequestId.make(payload.requestId)
287− : null;
288− const requestKind =
289− payload && isProviderRequestKind(payload.requestKind)
290− ? payload.requestKind
291− : payload
292− ? requestKindFromRequestType(payload.requestType)
293− : null;
294− const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
295− const appName = payload && typeof payload.appName === "string" ? payload.appName : undefined;
296− const options = Array.isArray(payload?.options)
297− ? payload.options.filter(isProviderApprovalOption)
298− : undefined;
299−
300− if (
301− activity.kind === "approval.requested" &&
302− requestId &&
303− payload?.requestType !== "tool_user_input" &&
304− payload?.requestType !== "auth_tokens_refresh"
305− ) {
306− openByRequestId.set(requestId, {
307− requestId,
308− // Older OpenCode requests can have no recognized approval kind.
309− requestKind: requestKind ?? "command",
310− createdAt: activity.createdAt,
311− ...(detail ? { detail } : {}),
312− ...(appName ? { appName } : {}),
313− ...(options && options.length > 0 ? { options } : {}),
314− });
315− continue;
316− }
317−
318− if (activity.kind === "approval.resolved" && requestId) {
319− openByRequestId.delete(requestId);
320− continue;
321− }
322−
323− if (
324− activity.kind === "provider.approval.respond.failed" &&
325− requestId &&
326− isStalePendingRequestFailureDetail(detail)
327− ) {
328− openByRequestId.delete(requestId);
329− continue;
330− }
331− }
332−
333− return [...openByRequestId.values()].toSorted((left, right) =>
334− left.createdAt.localeCompare(right.createdAt),
335− );
336−}
337−
338−function parseUserInputQuestions(
339− payload: Record<string, unknown> | null,
340−): ReadonlyArray<UserInputQuestion> | null {
341− const questions = payload?.questions;
342− if (!Array.isArray(questions)) {
343− return null;
344− }
345− const parsed = questions
346− .map<UserInputQuestion | null>((entry) => {
347− if (!entry || typeof entry !== "object") return null;
348− const question = entry as Record<string, unknown>;
349− if (
350− typeof question.id !== "string" ||
351− typeof question.header !== "string" ||
352− typeof question.question !== "string" ||
353− !Array.isArray(question.options)
354− ) {
355− return null;
356− }
357− const options = question.options
358− .map<UserInputQuestion["options"][number] | null>((option) => {
359− if (!option || typeof option !== "object") return null;
360− const optionRecord = option as Record<string, unknown>;
361− if (
362− typeof optionRecord.label !== "string" ||
363− typeof optionRecord.description !== "string"
364− ) {
365− return null;
366− }
367− return {
368− label: optionRecord.label,
369− description: optionRecord.description,
370− ...(typeof optionRecord.value === "string" ? { value: optionRecord.value } : {}),
371− };
372− })
373− .filter((option): option is UserInputQuestion["options"][number] => option !== null);
374− if (options.length === 0 && question.allowCustomAnswer === false) {
375− return null;
376− }
377− return {
378− id: question.id,
379− header: question.header,
380− question: question.question,
381− options,
382− multiSelect: question.multiSelect === true,
383− ...(typeof question.allowCustomAnswer === "boolean"
384− ? { allowCustomAnswer: question.allowCustomAnswer }
385− : {}),
386− };
387− })
388− .filter((question): question is UserInputQuestion => question !== null);
389− return parsed.length > 0 ? parsed : null;
390−}
391−
392−export function derivePendingUserInputs(
393− activities: ReadonlyArray<OrchestrationThreadActivity>,
394−): PendingUserInput[] {
395− const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();
396− const ordered = [...activities].toSorted(compareActivitiesByOrder);
397−
398− for (const activity of ordered) {
399− const payload =
400− activity.payload && typeof activity.payload === "object"
401− ? (activity.payload as Record<string, unknown>)
402− : null;
403− const requestId =
404− payload && typeof payload.requestId === "string"
405− ? ApprovalRequestId.make(payload.requestId)
406− : null;
407− const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
408−
409− if (activity.kind === "user-input.requested" && requestId) {
410− const questions = parseUserInputQuestions(payload);
411− if (!questions) {
412− continue;
413− }
414− openByRequestId.set(requestId, {
415− requestId,
416− createdAt: activity.createdAt,
417− questions,
418− });
419− continue;
420− }
421−
422− if (activity.kind === "user-input.resolved" && requestId) {
423− openByRequestId.delete(requestId);
424− continue;
425− }
426−
427− if (
428− activity.kind === "provider.user-input.respond.failed" &&
429− requestId &&
430− isStalePendingRequestFailureDetail(detail)
431− ) {
432− openByRequestId.delete(requestId);
433− }
434− }
435−
436− return [...openByRequestId.values()].toSorted((left, right) =>
437− left.createdAt.localeCompare(right.createdAt),
438− );
439−}
440−
441222 function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null {
442223 const payload =
443224 activity.payload && typeof activity.payload === "object"
packages/client-runtime/package.json+4−0
@@ -11,6 +11,10 @@ · 10 unmodified lines
1111 "types": "./src/projectFaviconCache.ts",
1212 "default": "./src/projectFaviconCache.ts"
1313 },
14+ "./pending-requests": {
15+ "types": "./src/pendingRequests.ts",
16+ "default": "./src/pendingRequests.ts"
17+ },
1418 "./connection": {
1519 "types": "./src/connection/index.ts",
1620 "default": "./src/connection/index.ts"
packages/client-runtime/src/pendingRequests.test.ts+497−0
@@ -0,0 +1,497 @@
1+import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+import { derivePendingRequests } from "./pendingRequests.ts";
4+
5+let nextActivityId = 0;
6+
7+function makeActivity(overrides: {
8+ id?: string;
9+ createdAt?: string;
10+ kind?: string;
11+ summary?: string;
12+ tone?: OrchestrationThreadActivity["tone"];
13+ payload?: Record<string, unknown>;
14+ turnId?: string;
15+ sequence?: number;
16+}): OrchestrationThreadActivity {
17+ return {
18+ id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),
19+ createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",
20+ kind: overrides.kind ?? "tool.started",
21+ summary: overrides.summary ?? "Tool call",
22+ tone: overrides.tone ?? "tool",
23+ payload: overrides.payload ?? {},
24+ turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null,
25+ ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}),
26+ };
27+}
28+
29+describe("pending approvals", () => {
30+ it.each([{}, { requestType: "unknown" }])(
31+ "exposes legacy OpenCode approvals without a known request kind: %j",
32+ (legacyPayload) => {
33+ const requested = makeActivity({
34+ kind: "approval.requested",
35+ payload: { requestId: "per-legacy", detail: "*", ...legacyPayload },
36+ });
37+
38+ expect(derivePendingRequests([requested]).approvals).toEqual([
39+ {
40+ requestId: "per-legacy",
41+ requestKind: "command",
42+ createdAt: requested.createdAt,
43+ detail: "*",
44+ },
45+ ]);
46+ },
47+ );
48+
49+ it.each(["tool_user_input", "auth_tokens_refresh"])(
50+ "does not turn %s into an approval",
51+ (requestType) => {
52+ const activity = makeActivity({
53+ kind: "approval.requested",
54+ payload: { requestId: "not-an-approval", requestType },
55+ });
56+
57+ expect(derivePendingRequests([activity]).approvals).toEqual([]);
58+ },
59+ );
60+
61+ it("tracks open approvals and removes resolved ones", () => {
62+ const activities: OrchestrationThreadActivity[] = [
63+ makeActivity({
64+ id: "approval-open",
65+ createdAt: "2026-02-23T00:00:01.000Z",
66+ kind: "approval.requested",
67+ summary: "Command approval requested",
68+ tone: "approval",
69+ payload: {
70+ requestId: "req-1",
71+ requestKind: "command",
72+ detail: "bun run lint",
73+ },
74+ }),
75+ makeActivity({
76+ id: "approval-close",
77+ createdAt: "2026-02-23T00:00:02.000Z",
78+ kind: "approval.resolved",
79+ summary: "Approval resolved",
80+ tone: "info",
81+ payload: { requestId: "req-2" },
82+ }),
83+ makeActivity({
84+ id: "approval-closed-request",
85+ createdAt: "2026-02-23T00:00:01.500Z",
86+ kind: "approval.requested",
87+ summary: "File-change approval requested",
88+ tone: "approval",
89+ payload: { requestId: "req-2", requestType: "unknown" },
90+ }),
91+ ];
92+
93+ expect(derivePendingRequests(activities).approvals).toEqual([
94+ {
95+ requestId: "req-1",
96+ requestKind: "command",
97+ createdAt: "2026-02-23T00:00:01.000Z",
98+ detail: "bun run lint",
99+ },
100+ ]);
101+ });
102+
103+ it("maps canonical requestType payloads into pending approvals", () => {
104+ const activities: OrchestrationThreadActivity[] = [
105+ makeActivity({
106+ id: "approval-open-request-type",
107+ createdAt: "2026-02-23T00:00:01.000Z",
108+ kind: "approval.requested",
109+ summary: "Command approval requested",
110+ tone: "approval",
111+ payload: {
112+ requestId: "req-request-type",
113+ requestType: "command_execution_approval",
114+ detail: "pwd",
115+ },
116+ }),
117+ ];
118+
119+ expect(derivePendingRequests(activities).approvals).toEqual([
120+ {
121+ requestId: "req-request-type",
122+ requestKind: "command",
123+ createdAt: "2026-02-23T00:00:01.000Z",
124+ detail: "pwd",
125+ },
126+ ]);
127+ });
128+
129+ it("keeps app access approvals and persistence choices from remote activities", () => {
130+ const options = [
131+ { decision: "decline", label: "Decline" },
132+ { decision: "acceptAlways", label: "Always allow Safari" },
133+ { decision: "accept", label: "Approve" },
134+ ];
135+ const activities = [
136+ makeActivity({
137+ kind: "approval.requested",
138+ summary: "App access approval requested",
139+ tone: "approval",
140+ payload: {
141+ requestId: "req-safari",
142+ requestType: "mcp_elicitation_approval",
143+ detail: "Allow ChatGPT to use Safari?",
144+ appName: "Safari",
145+ options,
146+ },
147+ }),
148+ ];
149+
150+ expect(derivePendingRequests(activities).approvals).toEqual([
151+ {
152+ requestId: "req-safari",
153+ requestKind: "mcp-elicitation",
154+ createdAt: "2026-02-23T00:00:00.000Z",
155+ detail: "Allow ChatGPT to use Safari?",
156+ appName: "Safari",
157+ options,
158+ },
159+ ]);
160+ });
161+
162+ it("derives dynamic tool requests as actionable generic approvals", () => {
163+ const activities: OrchestrationThreadActivity[] = [
164+ makeActivity({
165+ id: "approval-open-dynamic-tool",
166+ createdAt: "2026-02-23T00:00:01.000Z",
167+ kind: "approval.requested",
168+ summary: "Approval requested",
169+ tone: "approval",
170+ payload: {
171+ requestId: "req-dynamic-tool",
172+ requestType: "dynamic_tool_call",
173+ detail: "Search the web",
174+ },
175+ }),
176+ ];
177+
178+ expect(derivePendingRequests(activities).approvals).toEqual([
179+ {
180+ requestId: "req-dynamic-tool",
181+ requestKind: "command",
182+ createdAt: "2026-02-23T00:00:01.000Z",
183+ detail: "Search the web",
184+ },
185+ ]);
186+ });
187+
188+ it("clears stale pending approvals when provider reports unknown pending request", () => {
189+ const activities: OrchestrationThreadActivity[] = [
190+ makeActivity({
191+ id: "approval-open-stale",
192+ createdAt: "2026-02-23T00:00:01.000Z",
193+ kind: "approval.requested",
194+ summary: "Command approval requested",
195+ tone: "approval",
196+ payload: {
197+ requestId: "req-stale-1",
198+ requestType: "unknown",
199+ },
200+ }),
201+ makeActivity({
202+ id: "approval-failed-stale",
203+ createdAt: "2026-02-23T00:00:02.000Z",
204+ kind: "provider.approval.respond.failed",
205+ summary: "Provider approval response failed",
206+ tone: "error",
207+ payload: {
208+ requestId: "req-stale-1",
209+ detail: "Unknown pending permission request: req-stale-1",
210+ },
211+ }),
212+ ];
213+
214+ expect(derivePendingRequests(activities).approvals).toEqual([]);
215+ });
216+
217+ it("clears stale pending approvals when the backend marks them stale after restart", () => {
218+ const activities: OrchestrationThreadActivity[] = [
219+ makeActivity({
220+ id: "approval-open-stale-restart",
221+ createdAt: "2026-02-23T00:00:01.000Z",
222+ kind: "approval.requested",
223+ summary: "Command approval requested",
224+ tone: "approval",
225+ payload: {
226+ requestId: "req-stale-restart-1",
227+ requestKind: "command",
228+ },
229+ }),
230+ makeActivity({
231+ id: "approval-failed-stale-restart",
232+ createdAt: "2026-02-23T00:00:02.000Z",
233+ kind: "provider.approval.respond.failed",
234+ summary: "Provider approval response failed",
235+ tone: "error",
236+ payload: {
237+ requestId: "req-stale-restart-1",
238+ detail:
239+ "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
240+ },
241+ }),
242+ ];
243+
244+ expect(derivePendingRequests(activities).approvals).toEqual([]);
245+ });
246+});
247+
248+describe("pending questions", () => {
249+ it("preserves native answer keys while ignoring malformed options", () => {
250+ const question = {
251+ id: " Which path?\n",
252+ header: " Path ",
253+ question: " Which path?\n",
254+ options: [{ label: " Keep spaces ", description: "", value: " native\t" }],
255+ multiSelect: false,
256+ };
257+ const requested = makeActivity({
258+ kind: "user-input.requested",
259+ payload: {
260+ requestId: "native-question",
261+ questions: [null, { ...question, options: [...question.options, { label: 42 }] }],
262+ },
263+ });
264+
265+ expect(derivePendingRequests([requested]).userInputs[0]?.questions).toEqual([question]);
266+ });
267+
268+ it("keeps free-text questions without suggested answers", () => {
269+ const question = {
270+ id: "0",
271+ header: "Question",
272+ question: "What should it be named?",
273+ options: [],
274+ allowCustomAnswer: true,
275+ multiSelect: false,
276+ };
277+ const activities = [
278+ makeActivity({
279+ id: "async-question",
280+ kind: "user-input.requested",
281+ summary: "User input requested",
282+ payload: { requestId: "async-1", responseMode: "message", questions: [question] },
283+ }),
284+ ];
285+ expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]);
286+ });
287+
288+ it("preserves native choice values and the custom-answer restriction", () => {
289+ const question = {
290+ id: "interaction-result",
291+ header: "Result",
292+ question: "Which result should be used?",
293+ options: [
294+ { value: " first\t", label: "Result", description: "First result" },
295+ { value: "second", label: "Result", description: "Second result" },
296+ ],
297+ allowCustomAnswer: false,
298+ multiSelect: false,
299+ };
300+ const activities = [
301+ makeActivity({
302+ id: "native-user-input",
303+ kind: "user-input.requested",
304+ summary: "User input requested",
305+ payload: { requestId: "req-native-choice", questions: [question] },
306+ }),
307+ ];
308+
309+ expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]);
310+ });
311+
312+ it("tracks open structured prompts and removes resolved ones", () => {
313+ const activities: OrchestrationThreadActivity[] = [
314+ makeActivity({
315+ id: "user-input-open",
316+ createdAt: "2026-02-23T00:00:01.000Z",
317+ kind: "user-input.requested",
318+ summary: "User input requested",
319+ tone: "info",
320+ payload: {
321+ requestId: "req-user-input-1",
322+ questions: [
323+ {
324+ id: "sandbox_mode",
325+ header: "Sandbox",
326+ question: "Which mode should be used?",
327+ options: [
328+ {
329+ label: "workspace-write",
330+ description: "Allow workspace writes only",
331+ },
332+ ],
333+ multiSelect: true,
334+ },
335+ ],
336+ },
337+ }),
338+ makeActivity({
339+ id: "user-input-resolved",
340+ createdAt: "2026-02-23T00:00:02.000Z",
341+ kind: "user-input.resolved",
342+ summary: "User input submitted",
343+ tone: "info",
344+ payload: {
345+ requestId: "req-user-input-2",
346+ answers: {
347+ sandbox_mode: "workspace-write",
348+ },
349+ },
350+ }),
351+ makeActivity({
352+ id: "user-input-open-2",
353+ createdAt: "2026-02-23T00:00:01.500Z",
354+ kind: "user-input.requested",
355+ summary: "User input requested",
356+ tone: "info",
357+ payload: {
358+ requestId: "req-user-input-2",
359+ questions: [
360+ {
361+ id: "approval",
362+ header: "Approval",
363+ question: "Continue?",
364+ options: [
365+ {
366+ label: "yes",
367+ description: "Continue execution",
368+ },
369+ ],
370+ multiSelect: false,
371+ },
372+ ],
373+ },
374+ }),
375+ ];
376+
377+ expect(derivePendingRequests(activities).userInputs).toEqual([
378+ {
379+ requestId: "req-user-input-1",
380+ createdAt: "2026-02-23T00:00:01.000Z",
381+ questions: [
382+ {
383+ id: "sandbox_mode",
384+ header: "Sandbox",
385+ question: "Which mode should be used?",
386+ options: [
387+ {
388+ label: "workspace-write",
389+ description: "Allow workspace writes only",
390+ },
391+ ],
392+ multiSelect: true,
393+ },
394+ ],
395+ },
396+ ]);
397+ });
398+
399+ it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
400+ const activities: OrchestrationThreadActivity[] = [
401+ makeActivity({
402+ id: "user-input-open-stale",
403+ createdAt: "2026-02-23T00:00:01.000Z",
404+ kind: "user-input.requested",
405+ summary: "User input requested",
406+ tone: "info",
407+ payload: {
408+ requestId: "req-user-input-stale-1",
409+ questions: [
410+ {
411+ id: "sandbox_mode",
412+ header: "Sandbox",
413+ question: "Which mode should be used?",
414+ options: [
415+ {
416+ label: "workspace-write",
417+ description: "Allow workspace writes only",
418+ },
419+ ],
420+ multiSelect: false,
421+ },
422+ ],
423+ },
424+ }),
425+ makeActivity({
426+ id: "user-input-failed-stale",
427+ createdAt: "2026-02-23T00:00:02.000Z",
428+ kind: "provider.user-input.respond.failed",
429+ summary: "Provider user input response failed",
430+ tone: "error",
431+ payload: {
432+ requestId: "req-user-input-stale-1",
433+ detail:
434+ "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1",
435+ },
436+ }),
437+ ];
438+
439+ expect(derivePendingRequests(activities).userInputs).toEqual([]);
440+ });
441+});
442+
443+describe.each(["approval", "user-input"])("%s request completion", (requestKind) => {
444+ const requested = makeActivity({
445+ id: `${requestKind}-requested`,
446+ kind: `${requestKind}.requested`,
447+ sequence: 42,
448+ payload: {
449+ requestId: "request-1",
450+ requestKind: "command",
451+ questions: [{ id: "answer", header: "Answer", question: "Continue?", options: [] }],
452+ },
453+ });
454+
455+ it.each([`${requestKind}.resolved`, `provider.${requestKind}.respond.failed`])(
456+ "keeps %s final across reordered and repeated activities",
457+ (kind) => {
458+ const closed = makeActivity({
459+ id: `${requestKind}-closed`,
460+ kind,
461+ createdAt: "2026-02-23T00:00:01.000Z",
462+ payload: {
463+ requestId: "request-1",
464+ detail: `Unknown pending ${requestKind} request: request-1`,
465+ },
466+ });
467+ const replayedRequest = { ...requested, id: EventId.make("replayed-request"), sequence: 43 };
468+
469+ for (const activities of [
470+ [requested, closed, replayedRequest],
471+ [closed, requested, replayedRequest],
472+ ]) {
473+ expect(derivePendingRequests(activities)).toEqual({ approvals: [], userInputs: [] });
474+ }
475+ },
476+ );
477+
478+ it("keeps a failed reply retryable unless the text names a stale request", () => {
479+ const failed = makeActivity({
480+ kind: `provider.${requestKind}.respond.failed`,
481+ payload: { requestId: "request-1", detail: "Provider adapter request failed: timeout" },
482+ });
483+ const pending = derivePendingRequests([requested, failed]);
484+ expect(
485+ [...pending.approvals, ...pending.userInputs].map((request) => request.requestId),
486+ ).toEqual(["request-1"]);
487+
488+ const retried = makeActivity({
489+ kind: `${requestKind}.resolved`,
490+ payload: { requestId: "request-1" },
491+ });
492+ expect(derivePendingRequests([requested, failed, retried, failed])).toEqual({
493+ approvals: [],
494+ userInputs: [],
495+ });
496+ });
497+});
packages/client-runtime/src/pendingRequests.ts+189−0
@@ -0,0 +1,189 @@
1+import {
2+ ApprovalRequestId,
3+ type OrchestrationThreadActivity,
4+ ProviderApprovalOption,
5+ ProviderRequestKind,
6+ UserInputQuestion,
7+} from "@t3tools/contracts";
8+import * as Option from "effect/Option";
9+import * as Predicate from "effect/Predicate";
10+import * as Schema from "effect/Schema";
11+
12+export interface PendingApproval {
13+ readonly requestId: ApprovalRequestId;
14+ readonly requestKind: ProviderRequestKind;
15+ readonly createdAt: string;
16+ readonly detail?: string;
17+ readonly appName?: string;
18+ readonly options?: ReadonlyArray<ProviderApprovalOption>;
19+}
20+
21+export interface PendingUserInput {
22+ readonly requestId: ApprovalRequestId;
23+ readonly createdAt: string;
24+ readonly questions: ReadonlyArray<UserInputQuestion>;
25+}
26+
27+const isRequestId = Schema.is(ApprovalRequestId);
28+const isProviderRequestKind = Schema.is(ProviderRequestKind);
29+const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
30+const QuestionOption = Schema.Struct({
31+ ...UserInputQuestion.fields.options.value.fields,
32+ label: Schema.String,
33+});
34+const isQuestionOption = Schema.is(QuestionOption);
35+// Native question IDs and option labels can be answer keys. Do not trim them.
36+const decodeQuestion = Schema.decodeUnknownOption(
37+ Schema.Struct({
38+ ...UserInputQuestion.fields,
39+ id: Schema.String,
40+ header: Schema.String,
41+ question: Schema.String,
42+ options: Schema.Array(QuestionOption),
43+ }),
44+);
45+
46+/** Older activities use native request types instead of a request kind. */
47+export function requestKindFromRequestType(requestType: unknown): ProviderRequestKind | null {
48+ switch (requestType) {
49+ case "command_execution_approval":
50+ case "exec_command_approval":
51+ case "dynamic_tool_call":
52+ return "command";
53+ case "file_read_approval":
54+ return "file-read";
55+ case "file_change_approval":
56+ case "apply_patch_approval":
57+ return "file-change";
58+ case "mcp_elicitation_approval":
59+ return "mcp-elicitation";
60+ default:
61+ return null;
62+ }
63+}
64+
65+function parseQuestions(value: unknown): UserInputQuestion[] {
66+ if (!Array.isArray(value)) return [];
67+ return value.flatMap((question) => {
68+ if (!Predicate.isObject(question) || !Array.isArray(question.options)) return [];
69+ const options = question.options.filter(isQuestionOption);
70+ if (options.length === 0 && question.allowCustomAnswer === false) return [];
71+ const parsed = decodeQuestion({
72+ id: question.id,
73+ header: question.header,
74+ question: question.question,
75+ options,
76+ multiSelect: question.multiSelect === true,
77+ ...(typeof question.allowCustomAnswer === "boolean"
78+ ? { allowCustomAnswer: question.allowCustomAnswer }
79+ : {}),
80+ });
81+ return Option.isSome(parsed) ? [parsed.value] : [];
82+ });
83+}
84+
85+const requestActivityKinds = new Set([
86+ "approval.requested",
87+ "approval.resolved",
88+ "provider.approval.respond.failed",
89+ "user-input.requested",
90+ "user-input.resolved",
91+ "provider.user-input.respond.failed",
92+]);
93+
94+// The server reports a stale or unknown request through the failure text.
95+// A failed reply with any other text stays open so the user can retry.
96+const staleRequestFailureDetails = {
97+ "provider.approval.respond.failed": [
98+ "stale pending approval request",
99+ "unknown pending approval request",
100+ "unknown pending permission request",
101+ "unknown pending codex approval request",
102+ ],
103+ "provider.user-input.respond.failed": [
104+ "stale pending user-input request",
105+ "unknown pending user-input request",
106+ "unknown pending user input request",
107+ "unknown pending codex user input request",
108+ ],
109+} as const;
110+
111+function isStaleRequestFailure(
112+ kind: keyof typeof staleRequestFailureDetails,
113+ payload: Record<string, unknown>,
114+): boolean {
115+ const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : "";
116+ return staleRequestFailureDetails[kind].some((fragment) => detail.includes(fragment));
117+}
118+
119+/** Reduces request state once for web, desktop, and mobile. Layout stays with each client. */
120+export function derivePendingRequests(activities: ReadonlyArray<OrchestrationThreadActivity>) {
121+ const approvals = new Map<ApprovalRequestId, PendingApproval>();
122+ const userInputs = new Map<ApprovalRequestId, PendingUserInput>();
123+ const closedApprovals = new Set<ApprovalRequestId>();
124+ const closedUserInputs = new Set<ApprovalRequestId>();
125+
126+ // Request IDs are unique. A terminal event stays final even when provider
127+ // sequences and server-generated activities arrive in a different order.
128+ for (const activity of activities) {
129+ if (!requestActivityKinds.has(activity.kind)) continue;
130+ const payload = Predicate.isObject(activity.payload) ? activity.payload : undefined;
131+ if (!payload || !isRequestId(payload.requestId)) continue;
132+ const requestId = payload.requestId;
133+
134+ if (activity.kind === "approval.requested") {
135+ if (
136+ closedApprovals.has(requestId) ||
137+ payload.requestType === "tool_user_input" ||
138+ payload.requestType === "auth_tokens_refresh"
139+ ) {
140+ continue;
141+ }
142+ const requestKind = isProviderRequestKind(payload.requestKind)
143+ ? payload.requestKind
144+ : requestKindFromRequestType(payload.requestType);
145+ const options = Array.isArray(payload.options)
146+ ? payload.options.filter(isProviderApprovalOption)
147+ : [];
148+ approvals.set(requestId, {
149+ requestId,
150+ // Older OpenCode approvals do not always include a recognized kind.
151+ requestKind: requestKind ?? "command",
152+ createdAt: activity.createdAt,
153+ ...(typeof payload.detail === "string" && payload.detail ? { detail: payload.detail } : {}),
154+ ...(typeof payload.appName === "string" && payload.appName
155+ ? { appName: payload.appName }
156+ : {}),
157+ ...(options.length > 0 ? { options } : {}),
158+ });
159+ } else if (activity.kind === "user-input.requested") {
160+ if (closedUserInputs.has(requestId)) continue;
161+ const questions = parseQuestions(payload.questions);
162+ if (questions.length === 0) continue;
163+ userInputs.set(requestId, { requestId, createdAt: activity.createdAt, questions });
164+ } else if (
165+ activity.kind === "approval.resolved" ||
166+ (activity.kind === "provider.approval.respond.failed" &&
167+ isStaleRequestFailure(activity.kind, payload))
168+ ) {
169+ closedApprovals.add(requestId);
170+ approvals.delete(requestId);
171+ } else if (
172+ activity.kind === "user-input.resolved" ||
173+ (activity.kind === "provider.user-input.respond.failed" &&
174+ isStaleRequestFailure(activity.kind, payload))
175+ ) {
176+ closedUserInputs.add(requestId);
177+ userInputs.delete(requestId);
178+ }
179+ }
180+
181+ const byCreatedAt = (
182+ left: { readonly createdAt: string },
183+ right: { readonly createdAt: string },
184+ ) => left.createdAt.localeCompare(right.createdAt);
185+ return {
186+ approvals: [...approvals.values()].sort(byCreatedAt),
187+ userInputs: [...userInputs.values()].sort(byCreatedAt),
188+ };
189+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t