authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 00:58:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 00:58:18-07:00
log7c6163c67aa122fa7c13466536c012888db102c6
tree36602b9b41371f8f6050662c68c0b0127699b661
parent10626c537886b41b6d9fc404521512f4f3702157
signature Signed by PGP key B5690EEEBB952194

fix(codex): show app access approval prompts (#8058)


24 files changed, 1096 insertions(+), 95 deletions(-)

apps/mobile/src/features/threads/PendingApprovalCard.tsx+39-25
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts";1import type {
2 ApprovalRequestId,
3 ProviderApprovalDecision,
4 ProviderApprovalOption,
5} from "@t3tools/contracts";
2import { Pressable, View } from "react-native";6import { Pressable, View } from "react-native";
37
4import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
...@@ -13,7 +17,14 @@ export interface PendingApprovalCardProps {...@@ -13,7 +17,14 @@ export interface PendingApprovalCardProps {
13 ) => Promise<unknown>;17 ) => Promise<unknown>;
14}18}
1519
20const DEFAULT_APPROVAL_OPTIONS = [
21 { decision: "accept", label: "Allow once" },
22 { decision: "acceptForSession", label: "Allow session" },
23 { decision: "decline", label: "Decline" },
24] satisfies ReadonlyArray<ProviderApprovalOption>;
25
16export function PendingApprovalCard(props: PendingApprovalCardProps) {26export function PendingApprovalCard(props: PendingApprovalCardProps) {
27 const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS;
17 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed28 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
18 // behind this card, so a translucent surface bleeds messages through it.29 // behind this card, so a translucent surface bleeds messages through it.
19 return (30 return (
...@@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {...@@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
22 Approval needed33 Approval needed
23 </Text>34 </Text>
24 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">35 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
25 {props.approval.requestKind}36 {props.approval.appName ?? props.approval.requestKind}
26 </Text>37 </Text>
27 {props.approval.detail ? (38 {props.approval.detail ? (
28 <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">39 <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
...@@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {...@@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
30 </Text>41 </Text>
31 ) : null}42 ) : null}
32 <View className="flex-row flex-wrap gap-2.5">43 <View className="flex-row flex-wrap gap-2.5">
33 <Pressable44 {options.map((option) => (
34 className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3"45 <Pressable
35 disabled={props.respondingApprovalId === props.approval.requestId}46 key={option.decision}
36 onPress={() => void props.onRespond(props.approval.requestId, "accept")}47 className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${
37 >48 option.decision === "accept"
38 <Text className="font-t3-extrabold text-sm text-white">Allow once</Text>49 ? "bg-blue-500"
39 </Pressable>50 : option.decision === "decline"
40 <Pressable51 ? "bg-rose-100 dark:bg-rose-500/18"
41 className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800"52 : "bg-neutral-200 dark:bg-neutral-800"
42 disabled={props.respondingApprovalId === props.approval.requestId}53 }`}
43 onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")}54 disabled={props.respondingApprovalId === props.approval.requestId}
44 >55 onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
45 <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50">56 >
46 Allow session57 <Text
47 </Text>58 className={`text-sm ${
48 </Pressable>59 option.decision === "accept"
49 <Pressable60 ? "font-t3-extrabold text-white"
50 className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18"61 : option.decision === "decline"
51 disabled={props.respondingApprovalId === props.approval.requestId}62 ? "font-t3-bold text-rose-700 dark:text-rose-300"
52 onPress={() => void props.onRespond(props.approval.requestId, "decline")}63 : "font-t3-bold text-neutral-950 dark:text-neutral-50"
53 >64 }`}
54 <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text>65 >
55 </Pressable>66 {option.label}
67 </Text>
68 </Pressable>
69 ))}
56 </View>70 </View>
57 </View>71 </View>
58 );72 );
apps/mobile/src/lib/threadActivity.test.ts+54
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15import {15import {
16 buildPendingUserInputAnswers,16 buildPendingUserInputAnswers,
17 buildThreadFeed,17 buildThreadFeed,
18 derivePendingApprovals,
18 deriveThreadFeedPresentation,19 deriveThreadFeedPresentation,
19 isPendingUserInputOptionSelected,20 isPendingUserInputOptionSelected,
20 setPendingUserInputCustomAnswer,21 setPendingUserInputCustomAnswer,
...@@ -142,6 +143,59 @@ describe("pending user input answers", () => {...@@ -142,6 +143,59 @@ describe("pending user input answers", () => {
142 });143 });
143});144});
144145
146describe("pending approvals", () => {
147 it("keeps app access approvals and persistence choices from remote environments", () => {
148 const options = [
149 { decision: "decline", label: "Decline" },
150 { decision: "acceptAlways", label: "Always allow Safari" },
151 { decision: "accept", label: "Approve" },
152 ];
153 const activity = makeActivity({
154 id: EventId.make("approval-safari"),
155 kind: "approval.requested",
156 summary: "App access approval requested",
157 createdAt: "2026-08-24T00:00:00.000Z",
158 payload: {
159 requestId: "req-safari",
160 requestType: "mcp_elicitation_approval",
161 detail: "Allow ChatGPT to use Safari?",
162 appName: "Safari",
163 options,
164 },
165 });
166
167 expect(derivePendingApprovals([activity])).toEqual([
168 {
169 requestId: "req-safari",
170 requestKind: "mcp-elicitation",
171 createdAt: "2026-08-24T00:00:00.000Z",
172 detail: "Allow ChatGPT to use Safari?",
173 appName: "Safari",
174 options,
175 },
176 ]);
177 });
178
179 it("removes an app access approval after a remote client rejects it", () => {
180 const requested = makeActivity({
181 id: EventId.make("approval-safari-open"),
182 kind: "approval.requested",
183 summary: "App access approval requested",
184 createdAt: "2026-08-24T00:00:00.000Z",
185 payload: { requestId: "req-safari", requestKind: "mcp-elicitation" },
186 });
187 const resolved = makeActivity({
188 id: EventId.make("approval-safari-resolved"),
189 kind: "approval.resolved",
190 summary: "Approval resolved",
191 createdAt: "2026-08-24T00:00:01.000Z",
192 payload: { requestId: "req-safari", decision: "decline" },
193 });
194
195 expect(derivePendingApprovals([requested, resolved])).toEqual([]);
196 });
197});
198
145function makeActivity(199function makeActivity(
146 input: Partial<OrchestrationThreadActivity> &200 input: Partial<OrchestrationThreadActivity> &
147 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,201 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+24-8
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";1import {
2 ApprovalRequestId,
3 isToolLifecycleItemType,
4 ProviderApprovalOption,
5 ProviderRequestKind,
6} from "@t3tools/contracts";
2import type {7import type {
3 OrchestrationLatestTurn,8 OrchestrationLatestTurn,
4 OrchestrationThread,9 OrchestrationThread,
...@@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming";...@@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1116
12import * as Arr from "effect/Array";17import * as Arr from "effect/Array";
13import * as Order from "effect/Order";18import * as Order from "effect/Order";
19import * as Schema from "effect/Schema";
1420
15export interface PendingApproval {21export interface PendingApproval {
16 readonly requestId: ApprovalRequestId;22 readonly requestId: ApprovalRequestId;
17 readonly requestKind: "command" | "file-read" | "file-change";23 readonly requestKind: ProviderRequestKind;
18 readonly createdAt: string;24 readonly createdAt: string;
19 readonly detail?: string;25 readonly detail?: string;
26 readonly appName?: string;
27 readonly options?: ReadonlyArray<ProviderApprovalOption>;
20}28}
2129
30const isProviderRequestKind = Schema.is(ProviderRequestKind);
31const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
32
22export interface PendingUserInput {33export interface PendingUserInput {
23 readonly requestId: ApprovalRequestId;34 readonly requestId: ApprovalRequestId;
24 readonly createdAt: string;35 readonly createdAt: string;
...@@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ...@@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
147 case "file_change_approval":158 case "file_change_approval":
148 case "apply_patch_approval":159 case "apply_patch_approval":
149 return "file-change";160 return "file-change";
161 case "mcp_elicitation_approval":
162 return "mcp-elicitation";
150 default:163 default:
151 return null;164 return null;
152 }165 }
...@@ -1375,13 +1388,14 @@ export function derivePendingApprovals(...@@ -1375,13 +1388,14 @@ export function derivePendingApprovals(
1375 ? (activity.payload as Record<string, unknown>)1388 ? (activity.payload as Record<string, unknown>)
1376 : null;1389 : null;
1377 const requestId = parseApprovalRequestId(payload?.requestId);1390 const requestId = parseApprovalRequestId(payload?.requestId);
1378 const requestKind =1391 const requestKind = isProviderRequestKind(payload?.requestKind)
1379 payload?.requestKind === "command" ||1392 ? payload.requestKind
1380 payload?.requestKind === "file-read" ||1393 : requestKindFromRequestType(payload?.requestType);
1381 payload?.requestKind === "file-change"
1382 ? payload.requestKind
1383 : requestKindFromRequestType(payload?.requestType);
1384 const detail = typeof payload?.detail === "string" ? payload.detail : undefined;1394 const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
1395 const appName = typeof payload?.appName === "string" ? payload.appName : undefined;
1396 const options = Array.isArray(payload?.options)
1397 ? payload.options.filter(isProviderApprovalOption)
1398 : undefined;
13851399
1386 if (activity.kind === "approval.requested" && requestId && requestKind) {1400 if (activity.kind === "approval.requested" && requestId && requestKind) {
1387 openByRequestId.set(requestId, {1401 openByRequestId.set(requestId, {
...@@ -1389,6 +1403,8 @@ export function derivePendingApprovals(...@@ -1389,6 +1403,8 @@ export function derivePendingApprovals(
1389 requestKind,1403 requestKind,
1390 createdAt: activity.createdAt,1404 createdAt: activity.createdAt,
1391 ...(detail ? { detail } : {}),1405 ...(detail ? { detail } : {}),
1406 ...(appName ? { appName } : {}),
1407 ...(options && options.length > 0 ? { options } : {}),
1392 });1408 });
1393 continue;1409 continue;
1394 }1410 }
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-4
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 ProviderDriverKind,8 ProviderDriverKind,
9 type OrchestrationEvent,9 type OrchestrationEvent,
10 type OrchestrationThread,10 type OrchestrationThread,
11 type ProviderApprovalDecision,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";14import * as Exit from "effect/Exit";
...@@ -199,14 +200,14 @@ export interface OrchestrationIntegrationHarness {...@@ -199,14 +200,14 @@ export interface OrchestrationIntegrationHarness {
199 requestId: string,200 requestId: string,
200 predicate: (row: {201 predicate: (row: {
201 readonly status: "pending" | "resolved";202 readonly status: "pending" | "resolved";
202 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;203 readonly decision: ProviderApprovalDecision | null;
203 readonly resolvedAt: string | null;204 readonly resolvedAt: string | null;
204 }) => boolean,205 }) => boolean,
205 timeoutMs?: number,206 timeoutMs?: number,
206 ) => Effect.Effect<207 ) => Effect.Effect<
207 {208 {
208 readonly status: "pending" | "resolved";209 readonly status: "pending" | "resolved";
209 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;210 readonly decision: ProviderApprovalDecision | null;
210 readonly resolvedAt: string | null;211 readonly resolvedAt: string | null;
211 },212 },
212 never213 never
...@@ -493,7 +494,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -493,7 +494,7 @@ export const makeOrchestrationIntegrationHarness = (
493 row,494 row,
494 ): row is {495 ): row is {
495 readonly status: "pending" | "resolved";496 readonly status: "pending" | "resolved";
496 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;497 readonly decision: ProviderApprovalDecision | null;
497 readonly resolvedAt: string | null;498 readonly resolvedAt: string | null;
498 } => row !== null && predicate(row),499 } => row !== null && predicate(row),
499 `pending approval '${requestId}'`,500 `pending approval '${requestId}'`,
...@@ -501,7 +502,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -501,7 +502,7 @@ export const makeOrchestrationIntegrationHarness = (
501 ) as Effect.Effect<502 ) as Effect.Effect<
502 {503 {
503 readonly status: "pending" | "resolved";504 readonly status: "pending" | "resolved";
504 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;505 readonly decision: ProviderApprovalDecision | null;
505 readonly resolvedAt: string | null;506 readonly resolvedAt: string | null;
506 },507 },
507 never508 never
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1
...@@ -1507,6 +1507,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1507,6 +1507,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1507 const resolvedDecision =1507 const resolvedDecision =
1508 resolvedDecisionRaw === "accept" ||1508 resolvedDecisionRaw === "accept" ||
1509 resolvedDecisionRaw === "acceptForSession" ||1509 resolvedDecisionRaw === "acceptForSession" ||
1510 resolvedDecisionRaw === "acceptAlways" ||
1510 resolvedDecisionRaw === "decline" ||1511 resolvedDecisionRaw === "decline" ||
1511 resolvedDecisionRaw === "cancel"1512 resolvedDecisionRaw === "cancel"
1512 ? resolvedDecisionRaw1513 ? resolvedDecisionRaw
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts+37
...@@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => {...@@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => {
30 expect(activity?.kind).toBe("approval.requested");30 expect(activity?.kind).toBe("approval.requested");
31 expect((activity?.payload as Record<string, unknown> | undefined)?.detail).toBe(detail);31 expect((activity?.payload as Record<string, unknown> | undefined)?.detail).toBe(detail);
32 });32 });
33
34 it("keeps app details and approval options available to remote clients", () => {
35 const options = [
36 { decision: "decline", label: "Decline" },
37 { decision: "acceptAlways", label: "Always allow Safari" },
38 { decision: "accept", label: "Approve" },
39 ] as const;
40 const event = {
41 type: "request.opened",
42 eventId: EventId.make("evt-mcp-elicitation"),
43 provider: ProviderDriverKind.make("codex"),
44 createdAt: "2026-08-24T00:00:00.000Z",
45 threadId: ThreadId.make("thread-1"),
46 requestId: RuntimeRequestId.make("approval-safari"),
47 payload: {
48 requestType: "mcp_elicitation_approval",
49 detail: "Allow ChatGPT to use Safari?",
50 appName: "Safari",
51 options,
52 },
53 } satisfies ProviderRuntimeEvent;
54
55 const [activity] = runtimeEventToActivities(event);
56
57 expect(activity).toMatchObject({
58 kind: "approval.requested",
59 summary: "App access approval requested",
60 payload: {
61 requestId: "approval-safari",
62 requestKind: "mcp-elicitation",
63 requestType: "mcp_elicitation_approval",
64 detail: "Allow ChatGPT to use Safari?",
65 appName: "Safari",
66 options,
67 },
68 });
69 });
33});70});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-2
...@@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn(...@@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn(
298298
299function requestKindFromCanonicalRequestType(299function requestKindFromCanonicalRequestType(
300 requestType: string | undefined,300 requestType: string | undefined,
301): "command" | "file-read" | "file-change" | undefined {301): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined {
302 switch (requestType) {302 switch (requestType) {
303 case "command_execution_approval":303 case "command_execution_approval":
304 case "exec_command_approval":304 case "exec_command_approval":
...@@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType(...@@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType(
308 case "file_change_approval":308 case "file_change_approval":
309 case "apply_patch_approval":309 case "apply_patch_approval":
310 return "file-change";310 return "file-change";
311 case "mcp_elicitation_approval":
312 return "mcp-elicitation";
311 default:313 default:
312 return undefined;314 return undefined;
313 }315 }
...@@ -388,12 +390,16 @@ export function runtimeEventToActivities(...@@ -388,12 +390,16 @@ export function runtimeEventToActivities(
388 ? "File-read approval requested"390 ? "File-read approval requested"
389 : requestKind === "file-change"391 : requestKind === "file-change"
390 ? "File-change approval requested"392 ? "File-change approval requested"
391 : "Approval requested",393 : requestKind === "mcp-elicitation"
394 ? "App access approval requested"
395 : "Approval requested",
392 payload: {396 payload: {
393 requestId: toApprovalRequestId(event.requestId),397 requestId: toApprovalRequestId(event.requestId),
394 ...(requestKind ? { requestKind } : {}),398 ...(requestKind ? { requestKind } : {}),
395 requestType: event.payload.requestType,399 requestType: event.payload.requestType,
396 ...(event.payload.detail ? { detail: event.payload.detail } : {}),400 ...(event.payload.detail ? { detail: event.payload.detail } : {}),
401 ...(event.payload.appName ? { appName: event.payload.appName } : {}),
402 ...(event.payload.options ? { options: event.payload.options } : {}),
397 },403 },
398 turnId: toTurnId(event.turnId) ?? null,404 turnId: toTurnId(event.turnId) ?? null,
399 ...maybeSequence,405 ...maybeSequence,
apps/server/src/provider/Layers/CodexAdapter.test.ts+73
...@@ -1066,6 +1066,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -1066,6 +1066,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
1066 }),1066 }),
1067 );1067 );
10681068
1069 it.effect("maps MCP elicitation requests into app access approvals", () =>
1070 Effect.gen(function* () {
1071 const { adapter, runtime } = yield* startLifecycleRuntime();
1072 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
1073
1074 yield* runtime.emit({
1075 id: asEventId("evt-mcp-elicitation"),
1076 kind: "request",
1077 provider: ProviderDriverKind.make("codex"),
1078 threadId: asThreadId("thread-1"),
1079 createdAt: "2026-08-24T00:00:00.000Z",
1080 method: "mcpServer/elicitation/request",
1081 requestKind: "mcp-elicitation",
1082 requestId: ApprovalRequestId.make("req-safari"),
1083 turnId: asTurnId("turn-1"),
1084 payload: {
1085 mode: "form",
1086 message: "Allow ChatGPT to use Safari?",
1087 serverName: "computer-use",
1088 threadId: "provider-thread-1",
1089 turnId: "turn-1",
1090 _meta: { app_name: "Safari", persist: ["session", "always"] },
1091 requestedSchema: { type: "object", properties: {} },
1092 },
1093 } satisfies ProviderEvent);
1094
1095 const firstEvent = yield* Fiber.join(firstEventFiber);
1096
1097 NodeAssert.equal(firstEvent._tag, "Some");
1098 if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.opened") {
1099 return;
1100 }
1101 NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval");
1102 NodeAssert.equal(firstEvent.value.payload.appName, "Safari");
1103 NodeAssert.equal(firstEvent.value.payload.detail, "Allow ChatGPT to use Safari?");
1104 NodeAssert.deepStrictEqual(firstEvent.value.payload.options, [
1105 { decision: "cancel", label: "Cancel" },
1106 { decision: "decline", label: "Decline" },
1107 { decision: "acceptForSession", label: "Always allow this session" },
1108 { decision: "acceptAlways", label: "Always allow" },
1109 { decision: "accept", label: "Approve" },
1110 ]);
1111 }),
1112 );
1113
1114 it.effect("preserves MCP elicitation type when an app access request resolves", () =>
1115 Effect.gen(function* () {
1116 const { adapter, runtime } = yield* startLifecycleRuntime();
1117 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
1118
1119 yield* runtime.emit({
1120 id: asEventId("evt-mcp-elicitation-resolved"),
1121 kind: "notification",
1122 provider: ProviderDriverKind.make("codex"),
1123 threadId: asThreadId("thread-1"),
1124 createdAt: "2026-08-24T00:00:00.000Z",
1125 method: "item/requestApproval/decision",
1126 requestKind: "mcp-elicitation",
1127 requestId: ApprovalRequestId.make("req-safari"),
1128 payload: { decision: "acceptAlways" },
1129 } satisfies ProviderEvent);
1130
1131 const firstEvent = yield* Fiber.join(firstEventFiber);
1132
1133 NodeAssert.equal(firstEvent._tag, "Some");
1134 if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.resolved") {
1135 return;
1136 }
1137 NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval");
1138 NodeAssert.equal(firstEvent.value.payload.decision, "acceptAlways");
1139 }),
1140 );
1141
1069 it.effect("preserves file-read request type when mapping serverRequest/resolved", () =>1142 it.effect("preserves file-read request type when mapping serverRequest/resolved", () =>
1070 Effect.gen(function* () {1143 Effect.gen(function* () {
1071 const { adapter, runtime } = yield* startLifecycleRuntime();1144 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+18
...@@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts";...@@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts";
57import {57import {
58 CodexResumeCursorSchema,58 CodexResumeCursorSchema,
59 CodexSessionRuntimeThreadIdMissingError,59 CodexSessionRuntimeThreadIdMissingError,
60 describeMcpElicitation,
60 makeCodexSessionRuntime,61 makeCodexSessionRuntime,
61 type CodexSessionRuntimeError,62 type CodexSessionRuntimeError,
62 type CodexSessionRuntimeOptions,63 type CodexSessionRuntimeOptions,
...@@ -302,6 +303,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {...@@ -302,6 +303,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {
302 return "file_read_approval";303 return "file_read_approval";
303 case "item/fileChange/requestApproval":304 case "item/fileChange/requestApproval":
304 return "file_change_approval";305 return "file_change_approval";
306 case "mcpServer/elicitation/request":
307 return "mcp_elicitation_approval";
305 case "applyPatchApproval":308 case "applyPatchApproval":
306 return "apply_patch_approval";309 return "apply_patch_approval";
307 case "execCommandApproval":310 case "execCommandApproval":
...@@ -325,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical...@@ -325,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical
325 return "file_read_approval";328 return "file_read_approval";
326 case "file-change":329 case "file-change":
327 return "file_change_approval";330 return "file_change_approval";
331 case "mcp-elicitation":
332 return "mcp_elicitation_approval";
328 default:333 default:
329 return "unknown";334 return "unknown";
330 }335 }
...@@ -800,6 +805,11 @@ function mapToRuntimeEvents(...@@ -800,6 +805,11 @@ function mapToRuntimeEvents(
800 ];805 ];
801 }806 }
802807
808 const elicitation =
809 event.method === "mcpServer/elicitation/request"
810 ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload)
811 : undefined;
812 const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined;
803 const detail = (() => {813 const detail = (() => {
804 switch (event.method) {814 switch (event.method) {
805 case "item/commandExecution/requestApproval": {815 case "item/commandExecution/requestApproval": {
...@@ -816,6 +826,8 @@ function mapToRuntimeEvents(...@@ -816,6 +826,8 @@ function mapToRuntimeEvents(
816 );826 );
817 return payload?.reason ?? undefined;827 return payload?.reason ?? undefined;
818 }828 }
829 case "mcpServer/elicitation/request":
830 return elicitation?.message;
819 case "applyPatchApproval": {831 case "applyPatchApproval": {
820 const payload = readPayload(832 const payload = readPayload(
821 EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams,833 EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams,
...@@ -849,6 +861,12 @@ function mapToRuntimeEvents(...@@ -849,6 +861,12 @@ function mapToRuntimeEvents(
849 payload: {861 payload: {
850 requestType: toRequestTypeFromMethod(event.method),862 requestType: toRequestTypeFromMethod(event.method),
851 ...(detail ? { detail } : {}),863 ...(detail ? { detail } : {}),
864 ...(elicitationApproval
865 ? {
866 appName: elicitationApproval.appName,
867 options: elicitationApproval.options,
868 }
869 : {}),
852 ...(event.payload !== undefined ? { args: event.payload } : {}),870 ...(event.payload !== undefined ? { args: event.payload } : {}),
853 },871 },
854 },872 },
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+123-1
...@@ -13,9 +13,11 @@ import * as NodePath from "node:path";...@@ -13,9 +13,11 @@ import * as NodePath from "node:path";
1313
14import * as NodeServices from "@effect/platform-node/NodeServices";14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { it } from "@effect/vitest";15import { it } from "@effect/vitest";
16import { ThreadId } from "@t3tools/contracts";16import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts";
17import * as Deferred from "effect/Deferred";
17import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
18import * as Fiber from "effect/Fiber";19import * as Fiber from "effect/Fiber";
20import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
20import { assert, describe } from "vite-plus/test";22import { assert, describe } from "vite-plus/test";
2123
...@@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";...@@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
25const ROOT = wireFixture.rootThreadId;27const ROOT = wireFixture.rootThreadId;
26const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];28const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
27const MEMORY = "memory-consolidation-thread";29const MEMORY = "memory-consolidation-thread";
30const decodeMcpElicitationResponse = Schema.decodeUnknownEffect(
31 Schema.fromJsonString(
32 Schema.Struct({
33 id: Schema.Number,
34 result: Schema.Unknown,
35 }),
36 ),
37);
2838
29/**39/**
30 * The captured sequence, extended with the shapes the live capture didn't40 * The captured sequence, extended with the shapes the live capture didn't
...@@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => {...@@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => {
328 yield* runtime.close;338 yield* runtime.close;
329 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),339 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
330 );340 );
341
342 const elicitationCases = [
343 {
344 decision: "accept",
345 response: { action: "accept", content: { approval: "once" } },
346 },
347 {
348 decision: "acceptForSession",
349 response: {
350 action: "accept",
351 _meta: { persist: "session" },
352 content: { approval: "session" },
353 },
354 },
355 {
356 decision: "acceptAlways",
357 response: {
358 action: "accept",
359 _meta: { persist: "always" },
360 content: { approval: "always" },
361 },
362 },
363 { decision: "decline", response: { action: "decline" } },
364 { decision: "cancel", response: { action: "cancel" } },
365 ] satisfies ReadonlyArray<{
366 readonly decision: ProviderApprovalDecision;
367 readonly response: Record<string, unknown>;
368 }>;
369
370 for (const { decision, response } of elicitationCases) {
371 it.live(`returns the MCP elicitation ${decision} response to Codex`, () =>
372 Effect.gen(function* () {
373 const scriptedRequest = {
374 id: 7001,
375 method: "mcpServer/elicitation/request",
376 params: {
377 mode: "form",
378 message: "Allow ChatGPT to use Safari?",
379 serverName: "computer-use",
380 threadId: ROOT,
381 turnId: wireFixture.responses.turnStart.turn.id,
382 _meta: { app_name: "Safari", persist: ["session", "always"] },
383 requestedSchema: {
384 type: "object",
385 properties: {
386 approval: {
387 type: "string",
388 enum: ["once", "session", "always"],
389 },
390 },
391 required: ["approval"],
392 },
393 },
394 };
395 const script = {
396 rootThreadId: ROOT,
397 holdTurnOpen: true,
398 completeTurnOnServerResponse: true,
399 notifications: [],
400 serverRequests: [scriptedRequest],
401 };
402 const responsesPath = `${scriptPath}.responses`;
403 // @effect-diagnostics-next-line preferSchemaOverJson:off
404 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
405 NodeFS.rmSync(responsesPath, { force: true });
406 yield* Effect.addFinalizer(() =>
407 Effect.sync(() => {
408 NodeFS.rmSync(scriptPath, { force: true });
409 NodeFS.rmSync(responsesPath, { force: true });
410 }),
411 );
412
413 const runtime = yield* makeCodexSessionRuntime({
414 threadId: ThreadId.make("thread-codex-mcp-elicitation"),
415 binaryPath: peerPath,
416 cwd: "/tmp",
417 runtimeMode: "auto",
418 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
419 });
420 const approvalRequested = yield* Deferred.make<ProviderEvent>();
421 const turnCompleted = yield* Deferred.make<void>();
422 yield* runtime.events.pipe(
423 Stream.runForEach((event) =>
424 event.method === "mcpServer/elicitation/request"
425 ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid)
426 : event.method === "turn/completed"
427 ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid)
428 : Effect.void,
429 ),
430 Effect.forkScoped,
431 );
432
433 yield* runtime.start();
434 yield* runtime.sendTurn({ input: "Open Safari" });
435 const approval = yield* Deferred.await(approvalRequested);
436 assert.equal(approval.requestKind, "mcp-elicitation");
437 assert.isDefined(approval.requestId);
438 if (approval.requestId === undefined) return;
439
440 yield* runtime.respondToRequest(approval.requestId, decision);
441 yield* Deferred.await(turnCompleted);
442
443 const recordedResponse = yield* decodeMcpElicitationResponse(
444 NodeFS.readFileSync(responsesPath, "utf8"),
445 );
446 assert.equal(recordedResponse.id, scriptedRequest.id);
447 assert.deepEqual(recordedResponse.result, response);
448
449 yield* runtime.close;
450 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
451 );
452 }
331});453});
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+204
...@@ -17,10 +17,12 @@ import {...@@ -17,10 +17,12 @@ import {
17import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";17import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
18import {18import {
19 buildTurnStartParams,19 buildTurnStartParams,
20 describeMcpElicitation,
20 hasConfiguredMcpServer,21 hasConfiguredMcpServer,
21 isRecoverableThreadResumeError,22 isRecoverableThreadResumeError,
22 makeMemoryConsolidationNotificationFilter,23 makeMemoryConsolidationNotificationFilter,
23 openCodexThread,24 openCodexThread,
25 toMcpElicitationResponse,
24} from "./CodexSessionRuntime.ts";26} from "./CodexSessionRuntime.ts";
25const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);27const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);
2628
...@@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => {...@@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => {
248 });250 });
249});251});
250252
253describe("Codex MCP elicitation approvals", () => {
254 const request = {
255 mode: "form",
256 message: "Allow ChatGPT to use Safari?",
257 serverName: "computer-use",
258 threadId: "provider-thread-1",
259 turnId: "turn-1",
260 _meta: {
261 app_name: "Safari",
262 persist: ["session", "always"],
263 },
264 requestedSchema: {
265 type: "object",
266 properties: {
267 approval: {
268 type: "string",
269 oneOf: [
270 { const: "once", title: "Allow once" },
271 { const: "session", title: "Allow for this session" },
272 { const: "always", title: "Always allow Safari" },
273 ],
274 },
275 },
276 required: ["approval"],
277 },
278 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
279
280 it("preserves the app name and advertised persistence choices", () => {
281 NodeAssert.deepStrictEqual(describeMcpElicitation(request), {
282 appName: "Safari",
283 options: [
284 { decision: "cancel", label: "Cancel" },
285 { decision: "decline", label: "Decline" },
286 { decision: "acceptForSession", label: "Allow for this session" },
287 { decision: "acceptAlways", label: "Always allow Safari" },
288 { decision: "accept", label: "Approve" },
289 ],
290 });
291 });
292
293 it("extracts the app name from a Computer Use request without metadata", () => {
294 const { _meta, ...requestWithoutMetadata } = request;
295
296 NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari");
297 });
298
299 it("returns the accepted form option to Codex", () => {
300 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), {
301 action: "accept",
302 content: { approval: "once" },
303 });
304 });
305
306 it("returns session-scoped approval in the MCP response", () => {
307 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), {
308 action: "accept",
309 _meta: { persist: "session" },
310 content: { approval: "session" },
311 });
312 });
313
314 it("returns persistent approval in the MCP response", () => {
315 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), {
316 action: "accept",
317 _meta: { persist: "always" },
318 content: { approval: "always" },
319 });
320 });
321
322 it("returns rejection without form content", () => {
323 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), {
324 action: "decline",
325 });
326 });
327
328 it("returns cancellation without form content", () => {
329 NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), {
330 action: "cancel",
331 });
332 });
333
334 it("supports boolean permanent-approval fields", () => {
335 const booleanRequest = {
336 ...request,
337 _meta: { app_name: "Safari" },
338 requestedSchema: {
339 type: "object",
340 properties: {
341 always: { type: "boolean", title: "Always allow Safari" },
342 },
343 },
344 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
345
346 NodeAssert.ok(
347 describeMcpElicitation(booleanRequest).options.some(
348 (option) => option.decision === "acceptAlways",
349 ),
350 );
351 NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), {
352 action: "accept",
353 _meta: { persist: "always" },
354 content: { always: true },
355 });
356 });
357
358 it("preserves valid nullable MCP form fields and persistence choices", () => {
359 const nullableRequest = {
360 ...request,
361 _meta: {
362 app_name: null,
363 appName: "Safari",
364 connector_name: null,
365 persist: null,
366 target: null,
367 tool_params: null,
368 },
369 requestedSchema: {
370 type: "object",
371 properties: {
372 approval: {
373 type: "string",
374 title: null,
375 description: null,
376 default: null,
377 enum: ["once", "always"],
378 enumNames: null,
379 },
380 },
381 required: ["approval"],
382 },
383 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
384
385 NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari");
386 NodeAssert.ok(
387 describeMcpElicitation(nullableRequest).options.some(
388 (option) => option.decision === "acceptAlways",
389 ),
390 );
391 NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), {
392 action: "accept",
393 _meta: { persist: "always" },
394 content: { approval: "always" },
395 });
396 });
397
398 it("declines required form fields that an approval prompt cannot collect", () => {
399 const inputRequest = {
400 ...request,
401 requestedSchema: {
402 type: "object",
403 properties: {
404 email: { type: "string", format: "email" },
405 },
406 required: ["email"],
407 },
408 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
409
410 NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), {
411 action: "decline",
412 });
413 });
414
415 it("does not approve URL elicitations without opening their requested URL", () => {
416 const urlRequest = {
417 mode: "url",
418 message: "Finish signing in to continue.",
419 serverName: "computer-use",
420 threadId: "provider-thread-1",
421 turnId: "turn-1",
422 elicitationId: "sign-in-1",
423 url: "https://example.com/authorize",
424 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
425
426 NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), {
427 action: "decline",
428 });
429 });
430
431 it("omits persistence choices that cannot satisfy required form fields", () => {
432 const onceOnlyRequest = {
433 ...request,
434 _meta: { app_name: "Safari", persist: ["session", "always"] },
435 requestedSchema: {
436 type: "object",
437 properties: {
438 approval: {
439 type: "string",
440 enum: ["once"],
441 },
442 },
443 required: ["approval"],
444 },
445 } satisfies EffectCodexSchema.McpServerElicitationRequestParams;
446
447 NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [
448 { decision: "cancel", label: "Cancel" },
449 { decision: "decline", label: "Decline" },
450 { decision: "accept", label: "Approve" },
451 ]);
452 });
453});
454
251describe("buildCodexDeveloperInstructions", () => {455describe("buildCodexDeveloperInstructions", () => {
252 it("appends runtime info after the mode instructions", () => {456 it("appends runtime info after the mode instructions", () => {
253 const instructions = buildCodexDeveloperInstructions("default", {457 const instructions = buildCodexDeveloperInstructions("default", {
apps/server/src/provider/Layers/CodexSessionRuntime.ts+286-2
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ProviderItemId,6 ProviderItemId,
7 type ProviderInstanceId,7 type ProviderInstanceId,
8 type ProviderApprovalDecision,8 type ProviderApprovalDecision,
9 type ProviderApprovalOption,
9 type ProviderEvent,10 type ProviderEvent,
10 type ProviderInteractionMode,11 type ProviderInteractionMode,
11 type ProviderRequestKind,12 type ProviderRequestKind,
...@@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({...@@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({
73});74});
74const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);75const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);
75const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject);76const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject);
77const NullableMcpElicitationString = Schema.NullOr(Schema.String);
78const McpElicitationMetadata = Schema.Struct({
79 app: Schema.optionalKey(NullableMcpElicitationString),
80 app_name: Schema.optionalKey(NullableMcpElicitationString),
81 appName: Schema.optionalKey(NullableMcpElicitationString),
82 connector_name: Schema.optionalKey(NullableMcpElicitationString),
83 connectorName: Schema.optionalKey(NullableMcpElicitationString),
84 allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)),
85 persist: Schema.optionalKey(
86 Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
87 ),
88 target: Schema.optionalKey(
89 Schema.NullOr(
90 Schema.Struct({
91 app: Schema.optionalKey(NullableMcpElicitationString),
92 name: Schema.optionalKey(NullableMcpElicitationString),
93 }),
94 ),
95 ),
96 tool_params: Schema.optionalKey(
97 Schema.NullOr(
98 Schema.Struct({
99 app: Schema.optionalKey(NullableMcpElicitationString),
100 app_name: Schema.optionalKey(NullableMcpElicitationString),
101 }),
102 ),
103 ),
104});
105const McpElicitationFormField = Schema.Struct({
106 type: Schema.optionalKey(NullableMcpElicitationString),
107 title: Schema.optionalKey(NullableMcpElicitationString),
108 description: Schema.optionalKey(NullableMcpElicitationString),
109 default: Schema.optionalKey(Schema.Unknown),
110 enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
111 enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
112 oneOf: Schema.optionalKey(
113 Schema.NullOr(
114 Schema.Array(
115 Schema.Struct({
116 const: Schema.String,
117 title: Schema.optionalKey(NullableMcpElicitationString),
118 }),
119 ),
120 ),
121 ),
122});
123const McpElicitationForm = Schema.Struct({
124 properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)),
125 required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
126});
127const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata);
128const isMcpElicitationForm = Schema.is(McpElicitationForm);
76129
77// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated130// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated
78// `V2TurnStartParams` schema includes `collaborationMode` directly.131// `V2TurnStartParams` schema includes `collaborationMode` directly.
...@@ -231,6 +284,172 @@ interface PendingUserInput {...@@ -231,6 +284,172 @@ interface PendingUserInput {
231 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;284 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
232}285}
233286
287type McpElicitationPersistenceDecision = Extract<
288 ProviderApprovalDecision,
289 "acceptForSession" | "acceptAlways"
290>;
291
292function mcpElicitationPersistenceDecision(
293 value: string,
294): McpElicitationPersistenceDecision | null {
295 const normalized = value.toLowerCase();
296 if (normalized.includes("session")) return "acceptForSession";
297 if (
298 normalized.includes("always") ||
299 normalized.includes("permanent") ||
300 normalized.includes("forever") ||
301 normalized.includes("persistent")
302 ) {
303 return "acceptAlways";
304 }
305 return null;
306}
307
308function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) {
309 if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) {
310 return undefined;
311 }
312 return payload.requestedSchema;
313}
314
315function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) {
316 if (field.oneOf) {
317 return field.oneOf.map((option) => ({ value: option.const, label: option.title }));
318 }
319 return (field.enum ?? []).map((value, index) => ({
320 value,
321 label: field.enumNames?.[index],
322 }));
323}
324
325function isMcpElicitationPersistenceField(
326 key: string,
327 field: typeof McpElicitationFormField.Type,
328): boolean {
329 return (
330 mcpElicitationPersistenceDecision(key) !== null ||
331 key.toLowerCase() === "persist" ||
332 mcpElicitationPersistenceDecision(field.title ?? "") !== null ||
333 mcpElicitationPersistenceDecision(field.description ?? "") !== null
334 );
335}
336
337/** Returns the app and approval choices advertised by an MCP elicitation. */
338export function describeMcpElicitation(
339 payload: EffectCodexSchema.McpServerElicitationRequestParams,
340): { readonly appName: string; readonly options: ReadonlyArray<ProviderApprovalOption> } {
341 const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined;
342 const appName =
343 metadata?.app_name ??
344 metadata?.appName ??
345 metadata?.app ??
346 metadata?.target?.app ??
347 metadata?.target?.name ??
348 metadata?.tool_params?.app_name ??
349 metadata?.tool_params?.app ??
350 payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ??
351 metadata?.connector_name ??
352 metadata?.connectorName ??
353 payload.serverName;
354 const persistenceOptions = new Map<McpElicitationPersistenceDecision, string>();
355 const persist = metadata?.persist;
356 for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
357 const decision = mcpElicitationPersistenceDecision(value);
358 if (decision) persistenceOptions.set(decision, "");
359 }
360 if (metadata?.allowPersistentApproval) {
361 persistenceOptions.set("acceptAlways", "");
362 }
363
364 const form = mcpElicitationFormFields(payload);
365 for (const [key, field] of Object.entries(form?.properties ?? {})) {
366 for (const option of mcpElicitationFieldOptions(field)) {
367 const decision = mcpElicitationPersistenceDecision(option.value);
368 if (decision) persistenceOptions.set(decision, option.label ?? "");
369 }
370 if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
371 persistenceOptions.set("acceptAlways", field.title ?? "");
372 }
373 }
374
375 return {
376 appName,
377 options: [
378 { decision: "cancel", label: "Cancel" },
379 { decision: "decline", label: "Decline" },
380 ...(persistenceOptions.has("acceptForSession") &&
381 toMcpElicitationResponse(payload, "acceptForSession").action === "accept"
382 ? [
383 {
384 decision: "acceptForSession" as const,
385 label: persistenceOptions.get("acceptForSession") || "Always allow this session",
386 },
387 ]
388 : []),
389 ...(persistenceOptions.has("acceptAlways") &&
390 toMcpElicitationResponse(payload, "acceptAlways").action === "accept"
391 ? [
392 {
393 decision: "acceptAlways" as const,
394 label: persistenceOptions.get("acceptAlways") || "Always allow",
395 },
396 ]
397 : []),
398 { decision: "accept", label: "Approve" },
399 ],
400 };
401}
402
403/** Converts a T3 approval decision into the MCP elicitation wire response. */
404export function toMcpElicitationResponse(
405 payload: EffectCodexSchema.McpServerElicitationRequestParams,
406 decision: ProviderApprovalDecision,
407): EffectCodexSchema.McpServerElicitationRequestResponse {
408 if (decision === "decline" || decision === "cancel") {
409 return { action: decision };
410 }
411
412 if (payload.mode === "url") {
413 return { action: "decline" };
414 }
415
416 const persist =
417 decision === "acceptForSession"
418 ? "session"
419 : decision === "acceptAlways"
420 ? "always"
421 : undefined;
422 const form = mcpElicitationFormFields(payload);
423 const content: Record<string, unknown> = {};
424
425 for (const [key, field] of Object.entries(form?.properties ?? {})) {
426 const options = mcpElicitationFieldOptions(field);
427 const chosenOption = options.find((option) =>
428 persist
429 ? mcpElicitationPersistenceDecision(option.value) === decision
430 : /once|accept|approve|allow/i.test(option.value) &&
431 mcpElicitationPersistenceDecision(option.value) === null,
432 );
433 if (chosenOption) {
434 content[key] = chosenOption.value;
435 } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
436 content[key] = decision === "acceptAlways";
437 } else if (field.default !== undefined && field.default !== null) {
438 content[key] = field.default;
439 }
440 }
441
442 if (form?.required?.some((key) => !Object.hasOwn(content, key))) {
443 return { action: "decline" };
444 }
445
446 return {
447 action: "accept",
448 ...(persist ? { _meta: { persist } } : {}),
449 ...(form ? { content } : {}),
450 };
451}
452
234type CodexServerNotification = {453type CodexServerNotification = {
235 readonly [M in CodexRpc.ServerNotificationMethod]: {454 readonly [M in CodexRpc.ServerNotificationMethod]: {
236 readonly method: M;455 readonly method: M;
...@@ -1544,7 +1763,7 @@ export const makeCodexSessionRuntime = (...@@ -1544,7 +1763,7 @@ export const makeCodexSessionRuntime = (
1544 ),1763 ),
1545 );1764 );
1546 return {1765 return {
1547 decision: resolved,1766 decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
1548 } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse;1767 } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse;
1549 }),1768 }),
1550 );1769 );
...@@ -1602,11 +1821,76 @@ export const makeCodexSessionRuntime = (...@@ -1602,11 +1821,76 @@ export const makeCodexSessionRuntime = (
1602 ),1821 ),
1603 );1822 );
1604 return {1823 return {
1605 decision: resolved,1824 decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
1606 } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse;1825 } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse;
1607 }),1826 }),
1608 );1827 );
16091828
1829 yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) =>
1830 Effect.gen(function* () {
1831 if (toMcpElicitationResponse(payload, "accept").action !== "accept") {
1832 yield* Effect.logWarning("Declined an unsupported MCP elicitation.", {
1833 serverName: payload.serverName,
1834 mode: payload.mode,
1835 });
1836 return {
1837 action: "decline",
1838 } satisfies EffectCodexSchema.McpServerElicitationRequestResponse;
1839 }
1840
1841 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request"));
1842 const turnId = payload.turnId
1843 ? TurnId.make(payload.turnId)
1844 : (yield* Ref.get(sessionRef)).activeTurnId;
1845 const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId;
1846 const decision = yield* Deferred.make<ProviderApprovalDecision>();
1847
1848 yield* Ref.update(pendingApprovalsRef, (current) => {
1849 const next = new Map(current);
1850 next.set(requestId, {
1851 requestId,
1852 jsonRpcId,
1853 requestKind: "mcp-elicitation",
1854 turnId,
1855 itemId: undefined,
1856 decision,
1857 });
1858 return next;
1859 });
1860 yield* Ref.update(approvalCorrelationsRef, (current) => {
1861 const next = new Map(current);
1862 next.set(jsonRpcId, {
1863 requestId,
1864 requestKind: "mcp-elicitation",
1865 turnId,
1866 itemId: undefined,
1867 });
1868 return next;
1869 });
1870
1871 yield* emitEvent({
1872 kind: "request",
1873 threadId: options.threadId,
1874 method: "mcpServer/elicitation/request",
1875 requestId,
1876 requestKind: "mcp-elicitation",
1877 ...(turnId ? { turnId } : {}),
1878 payload,
1879 });
1880
1881 const resolved = yield* Deferred.await(decision).pipe(
1882 Effect.ensuring(
1883 Ref.update(pendingApprovalsRef, (current) => {
1884 const next = new Map(current);
1885 next.delete(requestId);
1886 return next;
1887 }),
1888 ),
1889 );
1890 return toMcpElicitationResponse(payload, resolved);
1891 }),
1892 );
1893
1610 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>1894 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
1611 Effect.gen(function* () {1895 Effect.gen(function* () {
1612 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));1896 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+22
...@@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT...@@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT
1717
18const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);18const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
19let turnStartCount = 0;19let turnStartCount = 0;
20let activeTurn;
2021
21const rl = NodeReadline.createInterface({ input: process.stdin });22const rl = NodeReadline.createInterface({ input: process.stdin });
22rl.on("line", (line) => {23rl.on("line", (line) => {
...@@ -27,6 +28,23 @@ rl.on("line", (line) => {...@@ -27,6 +28,23 @@ rl.on("line", (line) => {
27 return;28 return;
28 }29 }
29 const { id, method } = message;30 const { id, method } = message;
31 if (method === undefined && script.serverRequests?.some((request) => request.id === id)) {
32 NodeFS.appendFileSync(
33 `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`,
34 `${JSON.stringify({ id, result: message.result, error: message.error })}\n`,
35 );
36 if (script.completeTurnOnServerResponse && activeTurn) {
37 write({
38 jsonrpc: "2.0",
39 method: "turn/completed",
40 params: {
41 threadId: script.rootThreadId,
42 turn: { ...activeTurn, status: "completed" },
43 },
44 });
45 }
46 return;
47 }
30 if (method === "initialize") {48 if (method === "initialize") {
31 write({49 write({
32 id,50 id,
...@@ -48,6 +66,7 @@ rl.on("line", (line) => {...@@ -48,6 +66,7 @@ rl.on("line", (line) => {
48 const turn = turnId66 const turn = turnId
49 ? { ...fixture.responses.turnStart.turn, id: turnId }67 ? { ...fixture.responses.turnStart.turn, id: turnId }
50 : fixture.responses.turnStart.turn;68 : fixture.responses.turnStart.turn;
69 activeTurn = turn;
51 turnStartCount += 1;70 turnStartCount += 1;
52 write({ id, result: { ...fixture.responses.turnStart, turn } });71 write({ id, result: { ...fixture.responses.turnStart, turn } });
53 const rootThreadId = script.rootThreadId;72 const rootThreadId = script.rootThreadId;
...@@ -61,6 +80,9 @@ rl.on("line", (line) => {...@@ -61,6 +80,9 @@ rl.on("line", (line) => {
61 for (const notification of script.notifications) {80 for (const notification of script.notifications) {
62 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });81 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
63 }82 }
83 for (const request of script.serverRequests ?? []) {
84 write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params });
85 }
64 if (script.holdTurnOpen !== true) {86 if (script.holdTurnOpen !== true) {
65 write({87 write({
66 jsonrpc: "2.0",88 jsonrpc: "2.0",
apps/web/src/components/chat/ChatComposer.tsx+2
...@@ -2872,6 +2872,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2872,6 +2872,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2872 <ComposerPendingApprovalActions2872 <ComposerPendingApprovalActions
2873 requestId={activePendingApproval.requestId}2873 requestId={activePendingApproval.requestId}
2874 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}2874 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2875 options={activePendingApproval.options}
2875 onRespondToApproval={onRespondToApproval}2876 onRespondToApproval={onRespondToApproval}
2876 />2877 />
2877 </div>2878 </div>
...@@ -2901,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2901,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2901 <ComposerPendingApprovalActions2902 <ComposerPendingApprovalActions
2902 requestId={activePendingApproval.requestId}2903 requestId={activePendingApproval.requestId}
2903 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}2904 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2905 options={activePendingApproval.options}
2904 onRespondToApproval={onRespondToApproval}2906 onRespondToApproval={onRespondToApproval}
2905 />2907 />
2906 </div>2908 </div>
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+34
...@@ -21,4 +21,38 @@ describe("ComposerPendingApprovalActions", () => {...@@ -21,4 +21,38 @@ describe("ComposerPendingApprovalActions", () => {
21 expect(markup).toContain("sm:text-[11px]");21 expect(markup).toContain("sm:text-[11px]");
22 expect(markup).not.toContain("sm:h-6");22 expect(markup).not.toContain("sm:h-6");
23 });23 });
24
25 it("shows only the approval choices advertised by an MCP server", () => {
26 const markup = renderToStaticMarkup(
27 <ComposerPendingApprovalActions
28 requestId={ApprovalRequestId.make("approval-safari")}
29 isResponding={false}
30 options={[
31 { decision: "decline", label: "Decline" },
32 { decision: "acceptAlways", label: "Always allow Safari" },
33 { decision: "accept", label: "Approve" },
34 ]}
35 onRespondToApproval={async () => undefined}
36 />,
37 );
38
39 expect(markup).toContain("Always allow Safari");
40 expect(markup).toContain(">Approve<");
41 expect(markup).not.toContain("Always allow this session");
42 });
43
44 it("limits provider-supplied approval labels so narrow rows can wrap", () => {
45 const label = "Allow ".repeat(40).trim();
46 const markup = renderToStaticMarkup(
47 <ComposerPendingApprovalActions
48 requestId={ApprovalRequestId.make("approval-long-label")}
49 isResponding={false}
50 options={[{ decision: "acceptAlways", label }]}
51 onRespondToApproval={async () => undefined}
52 />,
53 );
54
55 expect(markup).toContain('class="max-w-40 truncate"');
56 expect(markup).toContain(label);
57 });
24});58});
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+31-37
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";1import {
2 type ApprovalRequestId,
3 type ProviderApprovalDecision,
4 type ProviderApprovalOption,
5} from "@t3tools/contracts";
2import { memo } from "react";6import { memo } from "react";
3import { Button } from "../ui/button";7import { Button } from "../ui/button";
48
5interface ComposerPendingApprovalActionsProps {9interface ComposerPendingApprovalActionsProps {
6 requestId: ApprovalRequestId;10 requestId: ApprovalRequestId;
7 isResponding: boolean;11 isResponding: boolean;
12 options?: ReadonlyArray<ProviderApprovalOption> | undefined;
8 onRespondToApproval: (13 onRespondToApproval: (
9 requestId: ApprovalRequestId,14 requestId: ApprovalRequestId,
10 decision: ProviderApprovalDecision,15 decision: ProviderApprovalDecision,
...@@ -12,50 +17,39 @@ interface ComposerPendingApprovalActionsProps {...@@ -12,50 +17,39 @@ interface ComposerPendingApprovalActionsProps {
12}17}
1318
14const APPROVAL_ACTION_CLASS_NAME = "font-normal";19const APPROVAL_ACTION_CLASS_NAME = "font-normal";
20const DEFAULT_APPROVAL_OPTIONS = [
21 { decision: "cancel", label: "Cancel" },
22 { decision: "decline", label: "Decline" },
23 { decision: "acceptForSession", label: "Always allow this session" },
24 { decision: "accept", label: "Approve" },
25] satisfies ReadonlyArray<ProviderApprovalOption>;
1526
16export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({27export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
17 requestId,28 requestId,
18 isResponding,29 isResponding,
30 options = DEFAULT_APPROVAL_OPTIONS,
19 onRespondToApproval,31 onRespondToApproval,
20}: ComposerPendingApprovalActionsProps) {32}: ComposerPendingApprovalActionsProps) {
21 return (33 return (
22 <>34 <>
23 <Button35 {options.map((option) => (
24 size="micro"36 <Button
25 variant="ghost-muted"37 key={option.decision}
26 className={APPROVAL_ACTION_CLASS_NAME}38 size="micro"
27 disabled={isResponding}39 variant="ghost-muted"
28 onClick={() => void onRespondToApproval(requestId, "cancel")}40 className={`${APPROVAL_ACTION_CLASS_NAME}${
29 >41 option.decision === "decline"
30 Cancel42 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
31 </Button>43 : option.decision === "accept"
32 <Button44 ? " text-foreground"
33 size="micro"45 : ""
34 variant="ghost-muted"46 }`}
35 className={`${APPROVAL_ACTION_CLASS_NAME} text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground`}47 disabled={isResponding}
36 disabled={isResponding}48 onClick={() => void onRespondToApproval(requestId, option.decision)}
37 onClick={() => void onRespondToApproval(requestId, "decline")}49 >
38 >50 <span className="max-w-40 truncate">{option.label}</span>
39 Decline51 </Button>
40 </Button>52 ))}
41 <Button
42 size="micro"
43 variant="ghost-muted"
44 className={APPROVAL_ACTION_CLASS_NAME}
45 disabled={isResponding}
46 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
47 >
48 Always allow this session
49 </Button>
50 <Button
51 size="micro"
52 variant="ghost-muted"
53 className={`${APPROVAL_ACTION_CLASS_NAME} text-foreground`}
54 disabled={isResponding}
55 onClick={() => void onRespondToApproval(requestId, "accept")}
56 >
57 Approve
58 </Button>
59 </>53 </>
60 );54 );
61});55});
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+42
...@@ -50,4 +50,46 @@ describe("ComposerPendingApprovalPanel", () => {...@@ -50,4 +50,46 @@ describe("ComposerPendingApprovalPanel", () => {
5050
51 expect(markup).toContain("File read approval");51 expect(markup).toContain("File read approval");
52 });52 });
53
54 it("shows the app name and message for an MCP access request", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerPendingApprovalPanel
57 approval={{
58 requestId: ApprovalRequestId.make("approval-safari"),
59 requestKind: "mcp-elicitation",
60 createdAt: "2026-08-24T00:00:00.000Z",
61 appName: "Safari",
62 detail: "Allow ChatGPT to use Safari?",
63 }}
64 pendingCount={1}
65 />,
66 );
67
68 expect(markup).toContain('aria-label="App access approval"');
69 expect(markup).toContain('aria-label="App access request"');
70 expect(markup).toContain(">Safari<");
71 expect(markup).toContain("Allow ChatGPT to use Safari?");
72 });
73
74 it("limits long app names so the complete approval message stays readable", () => {
75 const appName = "A".repeat(200);
76 const detail = "Allow ChatGPT to access the selected application?";
77 const markup = renderToStaticMarkup(
78 <ComposerPendingApprovalPanel
79 approval={{
80 requestId: ApprovalRequestId.make("approval-long-app-name"),
81 requestKind: "mcp-elicitation",
82 createdAt: "2026-08-24T00:00:00.000Z",
83 appName,
84 detail,
85 }}
86 pendingCount={1}
87 />,
88 );
89
90 expect(markup).toContain("max-w-32 shrink truncate");
91 expect(markup).toContain(appName);
92 expect(markup).toContain('data-approval-detail="complete"');
93 expect(markup).toContain(detail);
94 });
53});95});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+19-10
...@@ -14,17 +14,21 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -14,17 +14,21 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
14 className,14 className,
15}: ComposerPendingApprovalPanelProps) {15}: ComposerPendingApprovalPanelProps) {
16 const fallbackLabel =16 const fallbackLabel =
17 approval.requestKind === "command"17 approval.requestKind === "mcp-elicitation"
18 ? "Command approval"18 ? "App access approval"
19 : approval.requestKind === "file-read"19 : approval.requestKind === "command"
20 ? "File read approval"20 ? "Command approval"
21 : "File change approval";21 : approval.requestKind === "file-read"
22 ? "File read approval"
23 : "File change approval";
22 const detailAriaLabel =24 const detailAriaLabel =
23 approval.requestKind === "command"25 approval.requestKind === "mcp-elicitation"
24 ? "Command"26 ? "App access request"
25 : approval.requestKind === "file-read"27 : approval.requestKind === "command"
26 ? "File to read"28 ? "Command"
27 : "File change";29 : approval.requestKind === "file-read"
30 ? "File to read"
31 : "File change";
2832
29 return (33 return (
30 <div34 <div
...@@ -32,6 +36,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -32,6 +36,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
32 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}36 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
33 role="group"37 role="group"
34 >38 >
39 {approval.appName ? (
40 <span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground">
41 {approval.appName}
42 </span>
43 ) : null}
35 <code44 <code
36 aria-label={detailAriaLabel}45 aria-label={detailAriaLabel}
37 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5"46 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5"
apps/web/src/session-logic.test.ts+33
...@@ -130,6 +130,39 @@ describe("derivePendingApprovals", () => {...@@ -130,6 +130,39 @@ describe("derivePendingApprovals", () => {
130 ]);130 ]);
131 });131 });
132132
133 it("keeps app access approvals and persistence choices from remote activities", () => {
134 const options = [
135 { decision: "decline", label: "Decline" },
136 { decision: "acceptAlways", label: "Always allow Safari" },
137 { decision: "accept", label: "Approve" },
138 ];
139 const activities = [
140 makeActivity({
141 kind: "approval.requested",
142 summary: "App access approval requested",
143 tone: "approval",
144 payload: {
145 requestId: "req-safari",
146 requestType: "mcp_elicitation_approval",
147 detail: "Allow ChatGPT to use Safari?",
148 appName: "Safari",
149 options,
150 },
151 }),
152 ];
153
154 expect(derivePendingApprovals(activities)).toEqual([
155 {
156 requestId: "req-safari",
157 requestKind: "mcp-elicitation",
158 createdAt: "2026-02-23T00:00:00.000Z",
159 detail: "Allow ChatGPT to use Safari?",
160 appName: "Safari",
161 options,
162 },
163 ]);
164 });
165
133 it("derives dynamic tool requests as actionable generic approvals", () => {166 it("derives dynamic tool requests as actionable generic approvals", () => {
134 const activities: OrchestrationThreadActivity[] = [167 const activities: OrchestrationThreadActivity[] = [
135 makeActivity({168 makeActivity({
apps/web/src/session-logic.ts+18-5
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Option from "effect/Option";1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Schema from "effect/Schema";
3import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";4import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
4import {5import {
5 ApprovalRequestId,6 ApprovalRequestId,
...@@ -8,6 +9,8 @@ import {...@@ -8,6 +9,8 @@ import {
8 type OrchestrationThreadActivity,9 type OrchestrationThreadActivity,
9 type OrchestrationProposedPlanId,10 type OrchestrationProposedPlanId,
10 ProviderDriverKind,11 ProviderDriverKind,
12 ProviderApprovalOption,
13 ProviderRequestKind,
11 type ToolLifecycleItemType,14 type ToolLifecycleItemType,
12 type UserInputQuestion,15 type UserInputQuestion,
13 type ThreadId,16 type ThreadId,
...@@ -116,11 +119,16 @@ const derivedWorkLogEntryByActivity = new WeakMap<...@@ -116,11 +119,16 @@ const derivedWorkLogEntryByActivity = new WeakMap<
116119
117export interface PendingApproval {120export interface PendingApproval {
118 requestId: ApprovalRequestId;121 requestId: ApprovalRequestId;
119 requestKind: "command" | "file-read" | "file-change";122 requestKind: ProviderRequestKind;
120 createdAt: string;123 createdAt: string;
121 detail?: string;124 detail?: string;
125 appName?: string;
126 options?: ReadonlyArray<ProviderApprovalOption>;
122}127}
123128
129const isProviderRequestKind = Schema.is(ProviderRequestKind);
130const isProviderApprovalOption = Schema.is(ProviderApprovalOption);
131
124export interface PendingUserInput {132export interface PendingUserInput {
125 requestId: ApprovalRequestId;133 requestId: ApprovalRequestId;
126 createdAt: string;134 createdAt: string;
...@@ -385,6 +393,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ...@@ -385,6 +393,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
385 case "file_change_approval":393 case "file_change_approval":
386 case "apply_patch_approval":394 case "apply_patch_approval":
387 return "file-change";395 return "file-change";
396 case "mcp_elicitation_approval":
397 return "mcp-elicitation";
388 default:398 default:
389 return null;399 return null;
390 }400 }
...@@ -422,15 +432,16 @@ export function derivePendingApprovals(...@@ -422,15 +432,16 @@ export function derivePendingApprovals(
422 ? ApprovalRequestId.make(payload.requestId)432 ? ApprovalRequestId.make(payload.requestId)
423 : null;433 : null;
424 const requestKind =434 const requestKind =
425 payload &&435 payload && isProviderRequestKind(payload.requestKind)
426 (payload.requestKind === "command" ||
427 payload.requestKind === "file-read" ||
428 payload.requestKind === "file-change")
429 ? payload.requestKind436 ? payload.requestKind
430 : payload437 : payload
431 ? requestKindFromRequestType(payload.requestType)438 ? requestKindFromRequestType(payload.requestType)
432 : null;439 : null;
433 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;440 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
441 const appName = payload && typeof payload.appName === "string" ? payload.appName : undefined;
442 const options = Array.isArray(payload?.options)
443 ? payload.options.filter(isProviderApprovalOption)
444 : undefined;
434445
435 if (activity.kind === "approval.requested" && requestId && requestKind) {446 if (activity.kind === "approval.requested" && requestId && requestKind) {
436 openByRequestId.set(requestId, {447 openByRequestId.set(requestId, {
...@@ -438,6 +449,8 @@ export function derivePendingApprovals(...@@ -438,6 +449,8 @@ export function derivePendingApprovals(
438 requestKind,449 requestKind,
439 createdAt: activity.createdAt,450 createdAt: activity.createdAt,
440 ...(detail ? { detail } : {}),451 ...(detail ? { detail } : {}),
452 ...(appName ? { appName } : {}),
453 ...(options && options.length > 0 ? { options } : {}),
441 });454 });
442 continue;455 continue;
443 }456 }
docs/user/providers-codex.md+6
...@@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr...@@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr
34issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy34issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy
35and share with OpenAI employees.35and share with OpenAI employees.
3636
37## Approve access to other apps
38
39When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for
40approval. You can approve, decline, or cancel the request from the desktop app, web app, or mobile
41app. Some tools also offer approval for the current session or permanent approval.
42
37## I Want Work And Personal Codex Accounts43## I Want Work And Personal Codex Accounts
3844
39Use one real Codex home and one shadow home.45Use one real Codex home and one shadow home.
packages/contracts/src/orchestration.ts+12-1
...@@ -128,17 +128,28 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";...@@ -128,17 +128,28 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
128export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);128export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
129export type ProviderInteractionMode = typeof ProviderInteractionMode.Type;129export type ProviderInteractionMode = typeof ProviderInteractionMode.Type;
130export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";130export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";
131export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]);131export const ProviderRequestKind = Schema.Literals([
132 "command",
133 "file-read",
134 "file-change",
135 "mcp-elicitation",
136]);
132export type ProviderRequestKind = typeof ProviderRequestKind.Type;137export type ProviderRequestKind = typeof ProviderRequestKind.Type;
133export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);138export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
134export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;139export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
135export const ProviderApprovalDecision = Schema.Literals([140export const ProviderApprovalDecision = Schema.Literals([
136 "accept",141 "accept",
137 "acceptForSession",142 "acceptForSession",
143 "acceptAlways",
138 "decline",144 "decline",
139 "cancel",145 "cancel",
140]);146]);
141export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;147export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
148export const ProviderApprovalOption = Schema.Struct({
149 decision: ProviderApprovalDecision,
150 label: TrimmedNonEmptyString,
151});
152export type ProviderApprovalOption = typeof ProviderApprovalOption.Type;
142export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);153export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
143export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;154export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
144155
packages/contracts/src/providerRuntime.ts+4
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 TurnId,14 TurnId,
15} from "./baseSchemas.ts";15} from "./baseSchemas.ts";
16import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";16import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts";
17import { ProviderApprovalOption } from "./orchestration.ts";
1718
18const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;19const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
19const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);20const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
...@@ -138,6 +139,7 @@ export const CanonicalRequestType = Schema.Literals([...@@ -138,6 +139,7 @@ export const CanonicalRequestType = Schema.Literals([
138 "file_change_approval",139 "file_change_approval",
139 "apply_patch_approval",140 "apply_patch_approval",
140 "exec_command_approval",141 "exec_command_approval",
142 "mcp_elicitation_approval",
141 "tool_user_input",143 "tool_user_input",
142 "dynamic_tool_call",144 "dynamic_tool_call",
143 "auth_tokens_refresh",145 "auth_tokens_refresh",
...@@ -430,6 +432,8 @@ export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;...@@ -430,6 +432,8 @@ export type ContentDeltaPayload = typeof ContentDeltaPayload.Type;
430const RequestOpenedPayload = Schema.Struct({432const RequestOpenedPayload = Schema.Struct({
431 requestType: CanonicalRequestType,433 requestType: CanonicalRequestType,
432 detail: Schema.optional(TrimmedNonEmptyStringSchema),434 detail: Schema.optional(TrimmedNonEmptyStringSchema),
435 appName: Schema.optional(TrimmedNonEmptyStringSchema),
436 options: Schema.optional(Schema.Array(ProviderApprovalOption)),
433 args: Schema.optional(Schema.Unknown),437 args: Schema.optional(Schema.Unknown),
434});438});
435export type RequestOpenedPayload = typeof RequestOpenedPayload.Type;439export type RequestOpenedPayload = typeof RequestOpenedPayload.Type;
packages/effect-codex-app-server/src/errors.ts+1
...@@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([...@@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([
118 "provider-event",118 "provider-event",
119 "command-approval-request",119 "command-approval-request",
120 "file-change-approval-request",120 "file-change-approval-request",
121 "mcp-elicitation-request",
121 "user-input-request",122 "user-input-request",
122]);123]);
123export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;124export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;