authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:56:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:56:39-07:00
loge63ddb48e2fd23854a0b4a480b32cbf33e601981
treee9399ce0ce3d2fc45bff359add25a987d78d9212
parentbccad270466a039e254167b1b4da06e344d750a4
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

9 files changed, 711 insertions(+), 985 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+3-119
......@@ -1,3 +1,4 @@
1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
12import { describe, expect, it } from "vite-plus/test";
23import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
34
......@@ -16,8 +17,6 @@ import {
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", () => {
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", () => {
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", () => {
265264 });
266265});
267266
268describe("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
383267function makeActivity(
384268 input: Partial<OrchestrationThreadActivity> &
385269 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+5-221
......@@ -1,9 +1,8 @@
11import {
2 ApprovalRequestId,
3 isToolLifecycleItemType,
4 ProviderApprovalOption,
5 ProviderRequestKind,
6} from "@t3tools/contracts";
2 requestKindFromRequestType,
3 type PendingApproval,
4} from "@t3tools/client-runtime/pending-requests";
5import { isToolLifecycleItemType } from "@t3tools/contracts";
76import type {
87 OrchestrationLatestTurn,
98 OrchestrationThread,
......@@ -36,25 +35,8 @@ import { commandProgramName } from "@t3tools/client-runtime/work-log/command-lab
3635
3736import * as Arr from "effect/Array";
3837import * as Order from "effect/Order";
39import * as Schema from "effect/Schema";
4038
41export 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
50const isProviderRequestKind = Schema.is(ProviderRequestKind);
51const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
52
53export interface PendingUserInput {
54 readonly requestId: ApprovalRequestId;
55 readonly createdAt: string;
56 readonly questions: ReadonlyArray<UserInputQuestion>;
57}
39export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
5840
5941export interface PendingUserInputDraftAnswer {
6042 readonly selectedOptionValues?: ReadonlyArray<string>;
......@@ -276,94 +258,6 @@ export function isContextCompactionActivityGroup(
276258 );
277259}
278260
279function 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
296function 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
310function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
311 return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
312}
313
314function 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
367261function normalizeDraftAnswer(value: string | undefined): string | null {
368262 if (typeof value !== "string") {
369263 return null;
......@@ -2169,116 +2063,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo
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 */
2178export function sortThreadActivities(
2179 activities: ReadonlyArray<OrchestrationThreadActivity>,
2180): ReadonlyArray<OrchestrationThreadActivity> {
2181 return Arr.sort(activities, activityOrder);
2182}
2183
2184export 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
2239export 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
22822066export function setPendingUserInputCustomAnswer(
22832067 question: UserInputQuestion,
22842068 draft: PendingUserInputDraftAnswer | undefined,
apps/mobile/src/state/use-selected-thread-requests.ts+4-15
......@@ -1,3 +1,4 @@
1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
12import { useAtomValue } from "@effect/atom-react";
23import { useCallback, useMemo, useState } from "react";
34
......@@ -12,10 +13,7 @@ import { threadEnvironment } from "../state/threads";
1213import { scopedRequestKey } from "../lib/scopedEntities";
1314import {
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() {
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 {
66 isUsageLimitsCommand,
77} from "@t3tools/shared/usageLimits";
88import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
9import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
910import {
1011 type AssistantCitation,
1112 type ApprovalRequestId,
......@@ -106,8 +107,6 @@ import {
106107} from "../composer-logic";
107108import {
108109 createMessageAttachmentPreviewProjector,
109 derivePendingApprovals,
110 derivePendingUserInputs,
111110 derivePhase,
112111 deriveTimelineEntriesWithState,
113112 deriveActiveWorkStartedAt,
......@@ -2586,12 +2585,8 @@ export default function ChatView(props: ChatViewProps) {
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-397
......@@ -13,8 +13,6 @@ import {
1313 createMessageAttachmentPreviewProjector,
1414 deriveActiveWorkStartedAt,
1515 deriveActivePlanState,
16 derivePendingApprovals,
17 derivePendingUserInputs,
1816 deriveTimelineEntries,
1917 deriveTimelineEntriesWithState,
2018 deriveWorkLogEntries,
......@@ -63,401 +61,6 @@ function makeActivity(overrides: {
6361 };
6462}
6563
66describe("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
285describe("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
46164describe("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 @@
1import {
2 requestKindFromRequestType,
3 type PendingApproval,
4} from "@t3tools/client-runtime/pending-requests";
15import * as Option from "effect/Option";
26import * as Arr from "effect/Array";
3import * as Schema from "effect/Schema";
47import { shallow } from "zustand/vanilla/shallow";
58import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
69import {
......@@ -15,16 +18,12 @@ import {
1518} from "@t3tools/client-runtime/work-log/presentation";
1619import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1720import {
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 {
4039 type TurnDiffSummary,
4140} from "./types";
4241
42export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests";
43
4344export { formatDuration } from "@t3tools/shared/orchestrationTiming";
4445
4546export {
......@@ -106,24 +107,6 @@ const derivedWorkLogEntryByActivity = new WeakMap<
106107 DerivedWorkLogEntry
107108>();
108109
109export interface PendingApproval {
110 requestId: ApprovalRequestId;
111 requestKind: ProviderRequestKind;
112 createdAt: string;
113 detail?: string;
114 appName?: string;
115 options?: ReadonlyArray<ProviderApprovalOption>;
116}
117
118const isProviderRequestKind = Schema.is(ProviderRequestKind);
119const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
120
121export interface PendingUserInput {
122 requestId: ApprovalRequestId;
123 createdAt: string;
124 questions: ReadonlyArray<UserInputQuestion>;
125}
126
127110export interface ActivePlanState {
128111 createdAt: string;
129112 turnId: TurnId | null;
......@@ -236,208 +219,6 @@ export function deriveActiveWorkStartedAt(
236219 return sendStartedAt;
237220}
238221
239function 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
257function 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
273export 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
338function 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
392export 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
441222function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null {
442223 const payload =
443224 activity.payload && typeof activity.payload === "object"
packages/client-runtime/package.json+4
......@@ -11,6 +11,10 @@
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 created+497
......@@ -0,0 +1,497 @@
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { derivePendingRequests } from "./pendingRequests.ts";
4
5let nextActivityId = 0;
6
7function 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
29describe("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
248describe("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
443describe.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 created+189
......@@ -0,0 +1,189 @@
1import {
2 ApprovalRequestId,
3 type OrchestrationThreadActivity,
4 ProviderApprovalOption,
5 ProviderRequestKind,
6 UserInputQuestion,
7} from "@t3tools/contracts";
8import * as Option from "effect/Option";
9import * as Predicate from "effect/Predicate";
10import * as Schema from "effect/Schema";
11
12export 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
21export interface PendingUserInput {
22 readonly requestId: ApprovalRequestId;
23 readonly createdAt: string;
24 readonly questions: ReadonlyArray<UserInputQuestion>;
25}
26
27const isRequestId = Schema.is(ApprovalRequestId);
28const isProviderRequestKind = Schema.is(ProviderRequestKind);
29const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
30const QuestionOption = Schema.Struct({
31 ...UserInputQuestion.fields.options.value.fields,
32 label: Schema.String,
33});
34const isQuestionOption = Schema.is(QuestionOption);
35// Native question IDs and option labels can be answer keys. Do not trim them.
36const 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. */
47export 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
65function 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
85const 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.
96const 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
111function 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. */
120export 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}