Support MCP elicitation pending requests

This commit is contained in:
murashit 2026-06-19 14:05:19 +09:00
parent e7ac2a4b7b
commit 56802a2021
27 changed files with 1551 additions and 43 deletions

View file

@ -8,10 +8,22 @@ import { activeTurnId, type ChatAction, type ChatState } from "../../application
import type { ChatStateStore } from "../../application/state/store";
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "../../domain/message-stream/factories/system-items";
import type { ApprovalAction, PendingApproval, PendingUserInput } from "../../domain/pending-requests/model";
import {
contentForPendingMcpElicitation,
type ApprovalAction,
type McpElicitationAction,
type PendingApproval,
type PendingMcpElicitation,
type PendingUserInput,
} from "../../domain/pending-requests/model";
import { approvalResponse } from "../requests/approval";
import { mcpElicitationResponse } from "../requests/mcp-elicitation";
import { userInputResponse } from "../requests/user-input";
import { createApprovalResultItem, createUserInputResultItem } from "../../domain/pending-requests/result-items";
import {
createApprovalResultItem,
createMcpElicitationResultItem,
createUserInputResultItem,
} from "../../domain/pending-requests/result-items";
import { planChatNotification, type ChatNotificationEffect } from "./notification-plan";
import { routeServerRequest } from "./routing";
@ -27,6 +39,10 @@ function cannotCancelUserInputMessage(): string {
return "Could not cancel user input because Codex app-server is not connected.";
}
function cannotSendMcpElicitationMessage(): string {
return "Could not send MCP request response because Codex app-server is not connected.";
}
function userCancelledInputRequestMessage(): string {
return "User cancelled input request.";
}
@ -57,6 +73,7 @@ export interface ChatInboundHandler {
resolveApproval(approval: PendingApproval, action: ApprovalAction): void;
resolveUserInput(input: PendingUserInput, answers: Record<string, string>): void;
cancelUserInput(input: PendingUserInput): void;
resolveMcpElicitation(elicitation: PendingMcpElicitation, action: McpElicitationAction): void;
addSystemMessage(text: string): void;
addStructuredSystemMessage(text: string, details: MessageStreamNoticeSection[]): void;
addDedupedSystemMessage(text: string): void;
@ -93,6 +110,9 @@ export function createChatInboundHandler(
cancelUserInput: (input) => {
cancelUserInput(context, input);
},
resolveMcpElicitation: (elicitation, action) => {
resolveMcpElicitation(context, elicitation, action);
},
addSystemMessage: (text) => {
addSystemMessage(context, text);
},
@ -128,6 +148,9 @@ function handleServerRequest(context: ChatInboundHandlerContext, request: Server
case "userInput":
queueUserInputRequest(context, route.input);
return;
case "mcpElicitation":
queueMcpElicitationRequest(context, route.elicitation);
return;
case "inactive":
rejectServerRequest(context, request, `Rejected inactive app-server request: ${request.method}`);
return;
@ -185,6 +208,20 @@ function cancelUserInput(context: ChatInboundHandlerContext, input: PendingUserI
});
}
function resolveMcpElicitation(context: ChatInboundHandlerContext, elicitation: PendingMcpElicitation, action: McpElicitationAction): void {
if (!state(context).requests.pendingMcpElicitations.includes(elicitation)) return;
const content = action === "accept" ? contentForPendingMcpElicitation(elicitation, state(context).requests.mcpElicitationDrafts) : null;
if (!context.actions.respondToServerRequest(elicitation.requestId, mcpElicitationResponse(action, content))) {
addSystemMessage(context, cannotSendMcpElicitationMessage());
return;
}
dispatch(context, {
type: "request/resolved",
requestId: elicitation.requestId,
resultItem: createMcpElicitationResultItem(elicitation, action, content),
});
}
function addSystemMessage(context: ChatInboundHandlerContext, text: string): void {
dispatch(context, { type: "message-stream/system-item-added", item: createSystemItem(localItemId(context, "system"), text) });
}
@ -208,6 +245,10 @@ function queueUserInputRequest(context: ChatInboundHandlerContext, userInput: Pe
dispatch(context, { type: "request/user-input-queued", input: userInput });
}
function queueMcpElicitationRequest(context: ChatInboundHandlerContext, elicitation: PendingMcpElicitation): void {
dispatch(context, { type: "request/mcp-elicitation-queued", elicitation });
}
function activeRouteScope(context: ChatInboundHandlerContext): { activeThreadId: string | null; activeTurnId: string | null } {
const current = state(context);
return {

View file

@ -1,6 +1,7 @@
import type { ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
import type { PendingApproval, PendingUserInput } from "../../domain/pending-requests/model";
import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../domain/pending-requests/model";
import { toPendingApproval } from "../requests/approval";
import { toPendingMcpElicitation } from "../requests/mcp-elicitation";
import { toPendingUserInput } from "../requests/user-input";
export interface ActiveRouteScope {
@ -11,6 +12,7 @@ export interface ActiveRouteScope {
export type ServerRequestRoute =
| { kind: "approval"; request: ServerRequest; approval: PendingApproval }
| { kind: "userInput"; request: ServerRequest; input: PendingUserInput }
| { kind: "mcpElicitation"; request: ServerRequest; elicitation: PendingMcpElicitation }
| { kind: "unsupported"; request: ServerRequest }
| { kind: "unknown"; request: ServerRequest }
| { kind: "inactive"; request: ServerRequest };
@ -213,7 +215,7 @@ const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
"item/fileChange/requestApproval": "approval",
"item/permissions/requestApproval": "approval",
"item/tool/requestUserInput": "userInput",
"mcpServer/elicitation/request": "unsupported",
"mcpServer/elicitation/request": "mcpElicitation",
"item/tool/call": "unsupported",
"account/chatgptAuthTokens/refresh": "unsupported",
"attestation/generate": "unsupported",
@ -241,6 +243,11 @@ export function routeServerRequest(request: ServerRequest, scope: ActiveRouteSco
if (input) return { kind: "userInput", request, input };
return { kind: "unsupported", request };
}
case "mcpElicitation": {
const elicitation = toPendingMcpElicitation(request);
if (elicitation) return { kind: "mcpElicitation", request, elicitation };
return { kind: "unsupported", request };
}
case "unsupported":
return { kind: "unsupported", request };
}

View file

@ -0,0 +1,164 @@
import type { ServerRequest } from "../../../../app-server/connection/rpc-messages";
import type {
McpElicitationAction,
McpElicitationContentValue,
PendingMcpElicitation,
PendingMcpElicitationField,
PendingMcpElicitationOption,
PendingRequestId,
} from "../../domain/pending-requests/model";
type AppServerMcpElicitationRequest = Extract<ServerRequest, { method: "mcpServer/elicitation/request" }>;
type AppServerMcpElicitationFormParams = Extract<AppServerMcpElicitationRequest["params"], { mode: "form" }>;
type McpElicitationPrimitiveSchema = NonNullable<AppServerMcpElicitationFormParams["requestedSchema"]["properties"][string]>;
interface McpServerElicitationRequestResponse {
action: McpElicitationAction;
content: unknown;
_meta: unknown;
}
interface McpElicitationRequestLike {
id: PendingRequestId;
method: string;
params: unknown;
}
export function toPendingMcpElicitation(request: McpElicitationRequestLike): PendingMcpElicitation | null {
if (request.method !== "mcpServer/elicitation/request") return null;
const mcpRequest = request as AppServerMcpElicitationRequest;
const params = mcpRequest.params;
if (params.mode === "url") {
return {
requestId: mcpRequest.id,
method: mcpRequest.method,
params: {
threadId: params.threadId,
turnId: params.turnId,
serverName: params.serverName,
mode: "url",
message: params.message,
meta: params._meta,
url: params.url,
elicitationId: params.elicitationId,
},
};
}
return {
requestId: mcpRequest.id,
method: mcpRequest.method,
params: {
threadId: params.threadId,
turnId: params.turnId,
serverName: params.serverName,
mode: "form",
message: params.message,
meta: params._meta,
fields: fieldsFromSchema(params.requestedSchema.properties, new Set(params.requestedSchema.required ?? [])),
},
};
}
export function mcpElicitationResponse(
action: McpElicitationAction,
content: Record<string, McpElicitationContentValue> | null,
): McpServerElicitationRequestResponse {
return {
action,
content: action === "accept" ? toJsonContent(content) : null,
_meta: null,
};
}
function fieldsFromSchema(
properties: Record<string, McpElicitationPrimitiveSchema | undefined>,
required: ReadonlySet<string>,
): PendingMcpElicitationField[] {
return Object.entries(properties).flatMap(([id, schema]) => {
if (!schema) return [];
return [fieldFromSchema(id, schema, required.has(id))];
});
}
function fieldFromSchema(id: string, schema: McpElicitationPrimitiveSchema, required: boolean): PendingMcpElicitationField {
const base = {
id,
title: schema.title ?? id,
description: schema.description ?? null,
required,
};
if (schema.type === "boolean") {
return { ...base, type: "boolean", defaultValue: schema.default ?? false };
}
if (schema.type === "number" || schema.type === "integer") {
return {
...base,
type: schema.type,
minimum: schema.minimum ?? null,
maximum: schema.maximum ?? null,
defaultValue: schema.default ?? null,
};
}
if (schema.type === "array") {
return {
...base,
type: "multi-select",
options: multiSelectOptions(schema),
minItems: bigintToNumberOrNull(schema.minItems),
maxItems: bigintToNumberOrNull(schema.maxItems),
defaultValue: schema.default ?? [],
};
}
if ("oneOf" in schema || "enum" in schema) {
const options = singleSelectOptions(schema);
return {
...base,
type: "single-select",
options,
defaultValue: schema.default ?? options[0]?.value ?? "",
};
}
const stringSchema = schema as Extract<McpElicitationPrimitiveSchema, { type: "string" }>;
return {
...base,
type: "string",
format: "format" in stringSchema ? (stringSchema.format ?? null) : null,
minLength: "minLength" in stringSchema ? (stringSchema.minLength ?? null) : null,
maxLength: "maxLength" in stringSchema ? (stringSchema.maxLength ?? null) : null,
defaultValue: typeof stringSchema.default === "string" ? stringSchema.default : "",
};
}
function singleSelectOptions(schema: Extract<McpElicitationPrimitiveSchema, { type: "string" }>): PendingMcpElicitationOption[] {
if ("oneOf" in schema) return schema.oneOf.map((option) => ({ value: option.const, label: option.title }));
if ("enum" in schema) {
const enumNames = "enumNames" in schema ? schema.enumNames : undefined;
if (enumNames) return schema.enum.map((value, index) => ({ value, label: enumNames[index] ?? value }));
return schema.enum.map((value) => ({ value, label: value }));
}
return [];
}
function multiSelectOptions(schema: Extract<McpElicitationPrimitiveSchema, { type: "array" }>): PendingMcpElicitationOption[] {
if ("anyOf" in schema.items) return schema.items.anyOf.map((option) => ({ value: option.const, label: option.title }));
return schema.items.enum.map((value) => ({ value, label: value }));
}
function bigintToNumberOrNull(value: bigint | number | undefined): number | null {
if (typeof value === "bigint") return Number(value);
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function toJsonContent(content: Record<string, McpElicitationContentValue> | null): unknown {
if (!content) return null;
return Object.fromEntries(Object.entries(content).map(([key, value]) => [key, toJsonValue(value)]));
}
function toJsonValue(value: McpElicitationContentValue): unknown {
if (isReadonlyStringArray(value)) return [...value];
return value;
}
function isReadonlyStringArray(value: McpElicitationContentValue): value is readonly string[] {
return Array.isArray(value);
}

View file

@ -1,11 +1,20 @@
import type { ApprovalAction, PendingApproval, PendingRequestId, PendingUserInput } from "../../domain/pending-requests/model";
import type {
ApprovalAction,
McpElicitationAction,
PendingApproval,
PendingMcpElicitation,
PendingRequestId,
PendingUserInput,
} from "../../domain/pending-requests/model";
export type { PendingRequestId } from "../../domain/pending-requests/model";
export interface PendingRequestBlockState {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
pendingMcpElicitations: readonly PendingMcpElicitation[];
userInputDrafts: ReadonlyMap<string, string>;
mcpElicitationDrafts: ReadonlyMap<string, string>;
approvalDetails: ReadonlySet<string>;
}
@ -13,6 +22,8 @@ export interface PendingRequestBlockActions {
resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void;
resolveUserInput: (requestId: PendingRequestId) => void;
cancelUserInput: (requestId: PendingRequestId) => void;
resolveMcpElicitation: (requestId: PendingRequestId, action: McpElicitationAction) => void;
setApprovalDetailsExpanded?: (requestId: PendingRequestId, expanded: boolean) => void;
setUserInputDraft: (key: string, value: string) => void;
setMcpElicitationDraft: (key: string, value: string) => void;
}

View file

@ -4,7 +4,9 @@ import {
approvalDetailsDisclosureId,
answersForPendingUserInput,
type ApprovalAction,
type McpElicitationAction,
type PendingApproval,
type PendingMcpElicitation,
type PendingUserInput,
} from "../../domain/pending-requests/model";
import type { PendingRequestBlockActions, PendingRequestBlockState, PendingRequestId } from "./block";
@ -14,6 +16,7 @@ interface PendingRequestResponder {
resolveApproval: (approval: PendingApproval, action: ApprovalAction) => void;
resolveUserInput: (input: PendingUserInput, answers: Record<string, string>) => void;
cancelUserInput: (input: PendingUserInput) => void;
resolveMcpElicitation: (elicitation: PendingMcpElicitation, action: McpElicitationAction) => void;
}
export interface PendingRequestActionsHost {
@ -29,6 +32,7 @@ export interface PendingRequestActions {
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
resolveUserInput(requestId: PendingRequestId): void;
cancelUserInput(requestId: PendingRequestId): void;
resolveMcpElicitation(requestId: PendingRequestId, action: McpElicitationAction): void;
consumeAutoFocus(): boolean;
}
@ -59,6 +63,13 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
commitRequestAction(host);
};
const resolveMcpElicitation = (requestId: PendingRequestId, action: McpElicitationAction): void => {
const elicitation = pendingMcpElicitation(host, requestId);
if (!elicitation) return;
host.responder.resolveMcpElicitation(elicitation, action);
commitRequestAction(host);
};
const blockActions: PendingRequestBlockActions = {
resolveApproval: (requestId, approvalAction) => {
resolveApproval(requestId, approvalAction);
@ -69,6 +80,9 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
cancelUserInput: (requestId) => {
cancelUserInput(requestId);
},
resolveMcpElicitation: (requestId, action) => {
resolveMcpElicitation(requestId, action);
},
setApprovalDetailsExpanded: (requestId, expanded) => {
host.stateStore.dispatch({
type: "ui/disclosure-set",
@ -80,6 +94,9 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
setUserInputDraft: (key, value) => {
host.stateStore.dispatch({ type: "request/user-input-draft-set", key, value });
},
setMcpElicitationDraft: (key, value) => {
host.stateStore.dispatch({ type: "request/mcp-elicitation-draft-set", key, value });
},
};
return {
@ -94,10 +111,15 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe
resolveApproval,
resolveUserInput,
cancelUserInput,
resolveMcpElicitation,
consumeAutoFocus(): boolean {
const state = host.stateStore.getState();
const signature = pendingRequestFocusSignature(state.requests.approvals, state.requests.pendingUserInputs);
const signature = pendingRequestFocusSignature(
state.requests.approvals,
state.requests.pendingUserInputs,
state.requests.pendingMcpElicitations,
);
if (!signature) {
lastFocusSignature = "";
return false;
@ -113,7 +135,9 @@ function pendingRequestBlockState(state: ChatState): PendingRequestBlockState {
return {
approvals: state.requests.approvals,
pendingUserInputs: state.requests.pendingUserInputs,
pendingMcpElicitations: state.requests.pendingMcpElicitations,
userInputDrafts: state.requests.userInputDrafts,
mcpElicitationDrafts: state.requests.mcpElicitationDrafts,
approvalDetails: state.ui.disclosures.approvalDetails,
};
}
@ -126,6 +150,10 @@ function pendingUserInput(host: PendingRequestActionsHost, requestId: PendingReq
return host.stateStore.getState().requests.pendingUserInputs.find((input) => input.requestId === requestId) ?? null;
}
function pendingMcpElicitation(host: PendingRequestActionsHost, requestId: PendingRequestId): PendingMcpElicitation | null {
return host.stateStore.getState().requests.pendingMcpElicitations.find((elicitation) => elicitation.requestId === requestId) ?? null;
}
function commitRequestAction(host: PendingRequestActionsHost): void {
host.refreshLiveState();
}

View file

@ -1,7 +1,9 @@
import {
mcpElicitationDraftKey,
userInputDraftKey,
userInputOtherDraftKey,
type PendingApproval,
type PendingMcpElicitation,
type PendingRequestId,
type PendingUserInput,
} from "../../domain/pending-requests/model";
@ -9,19 +11,25 @@ import {
export interface ChatRequestState {
readonly approvals: readonly PendingApproval[];
readonly pendingUserInputs: readonly PendingUserInput[];
readonly pendingMcpElicitations: readonly PendingMcpElicitation[];
readonly userInputDrafts: ReadonlyMap<string, string>;
readonly mcpElicitationDrafts: ReadonlyMap<string, string>;
}
export type RequestAction =
| { type: "request/approval-queued"; approval: PendingApproval }
| { type: "request/user-input-queued"; input: PendingUserInput }
| { type: "request/user-input-draft-set"; key: string; value: string };
| { type: "request/mcp-elicitation-queued"; elicitation: PendingMcpElicitation }
| { type: "request/user-input-draft-set"; key: string; value: string }
| { type: "request/mcp-elicitation-draft-set"; key: string; value: string };
export function isRequestAction(action: { type: string }): action is RequestAction {
switch (action.type) {
case "request/approval-queued":
case "request/user-input-queued":
case "request/mcp-elicitation-queued":
case "request/user-input-draft-set":
case "request/mcp-elicitation-draft-set":
return true;
default:
return false;
@ -32,7 +40,9 @@ export function initialChatRequestState(): ChatRequestState {
return {
approvals: [],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
};
}
@ -44,15 +54,21 @@ export function reduceRequestSlice(state: ChatRequestState, action: RequestActio
case "request/user-input-queued":
if (state.pendingUserInputs.some((existing) => existing.requestId === action.input.requestId)) return state;
return patchObject(state, { pendingUserInputs: [...state.pendingUserInputs, action.input] });
case "request/mcp-elicitation-queued":
if (state.pendingMcpElicitations.some((existing) => existing.requestId === action.elicitation.requestId)) return state;
return patchObject(state, { pendingMcpElicitations: [...state.pendingMcpElicitations, action.elicitation] });
case "request/user-input-draft-set":
return setUserInputDraftSlice(state, action.key, action.value);
case "request/mcp-elicitation-draft-set":
return setMcpElicitationDraftSlice(state, action.key, action.value);
}
}
export function resolveChatRequest(state: ChatRequestState, requestId: PendingRequestId): ChatRequestState {
const resolvedInputs = state.pendingUserInputs.filter((input) => input.requestId === requestId);
const resolvedApprovals = state.approvals.filter((approval) => approval.requestId === requestId);
if (resolvedApprovals.length === 0 && resolvedInputs.length === 0) return state;
const resolvedMcpElicitations = state.pendingMcpElicitations.filter((elicitation) => elicitation.requestId === requestId);
if (resolvedApprovals.length === 0 && resolvedInputs.length === 0 && resolvedMcpElicitations.length === 0) return state;
const draftKeys = new Set(
resolvedInputs.flatMap((input) =>
@ -64,10 +80,21 @@ export function resolveChatRequest(state: ChatRequestState, requestId: PendingRe
);
const userInputDrafts =
draftKeys.size === 0 ? state.userInputDrafts : new Map([...state.userInputDrafts].filter(([key]) => !draftKeys.has(key)));
const mcpDraftKeys = new Set(
resolvedMcpElicitations.flatMap((elicitation) =>
elicitation.params.mode === "form" ? elicitation.params.fields.map((field) => mcpElicitationDraftKey(requestId, field.id)) : [],
),
);
const mcpElicitationDrafts =
mcpDraftKeys.size === 0
? state.mcpElicitationDrafts
: new Map([...state.mcpElicitationDrafts].filter(([key]) => !mcpDraftKeys.has(key)));
return patchObject(state, {
approvals: state.approvals.filter((approval) => approval.requestId !== requestId),
pendingUserInputs: state.pendingUserInputs.filter((input) => input.requestId !== requestId),
pendingMcpElicitations: state.pendingMcpElicitations.filter((elicitation) => elicitation.requestId !== requestId),
userInputDrafts,
mcpElicitationDrafts,
});
}
@ -76,6 +103,11 @@ function setUserInputDraftSlice(state: ChatRequestState, key: string, value: str
return patchObject(state, { userInputDrafts: new Map([...state.userInputDrafts, [key, value]]) });
}
function setMcpElicitationDraftSlice(state: ChatRequestState, key: string, value: string): ChatRequestState {
if (state.mcpElicitationDrafts.get(key) === value) return state;
return patchObject(state, { mcpElicitationDrafts: new Map([...state.mcpElicitationDrafts, [key, value]]) });
}
function patchObject<T extends object>(current: T, patch: Partial<T>): T {
if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current;
return { ...current, ...patch };

View file

@ -698,7 +698,9 @@ export function cloneChatState(state: ChatState): ChatState {
requests: {
approvals: [...state.requests.approvals],
pendingUserInputs: [...state.requests.pendingUserInputs],
pendingMcpElicitations: [...state.requests.pendingMcpElicitations],
userInputDrafts: new Map(state.requests.userInputDrafts),
mcpElicitationDrafts: new Map(state.requests.mcpElicitationDrafts),
},
composer: {
...state.composer,

View file

@ -106,6 +106,80 @@ export interface PendingUserInput {
params: PendingUserInputParams;
}
export type McpElicitationAction = "accept" | "decline" | "cancel";
export type McpElicitationContentValue = string | number | boolean | readonly string[] | null;
export interface PendingMcpElicitationOption {
value: string;
label: string;
}
interface PendingMcpElicitationFieldBase {
id: string;
title: string;
description: string | null;
required: boolean;
}
export type PendingMcpElicitationField =
| (PendingMcpElicitationFieldBase & {
type: "string";
format: string | null;
minLength: number | null;
maxLength: number | null;
defaultValue: string;
})
| (PendingMcpElicitationFieldBase & {
type: "number" | "integer";
minimum: number | null;
maximum: number | null;
defaultValue: number | null;
})
| (PendingMcpElicitationFieldBase & {
type: "boolean";
defaultValue: boolean;
})
| (PendingMcpElicitationFieldBase & {
type: "single-select";
options: readonly PendingMcpElicitationOption[];
defaultValue: string;
})
| (PendingMcpElicitationFieldBase & {
type: "multi-select";
options: readonly PendingMcpElicitationOption[];
minItems: number | null;
maxItems: number | null;
defaultValue: readonly string[];
});
interface PendingMcpElicitationFormParams {
threadId: string;
turnId: string | null;
serverName: string;
mode: "form";
message: string;
meta: unknown;
fields: readonly PendingMcpElicitationField[];
}
interface PendingMcpElicitationUrlParams {
threadId: string;
turnId: string | null;
serverName: string;
mode: "url";
message: string;
meta: unknown;
url: string;
elicitationId: string;
}
export interface PendingMcpElicitation {
requestId: PendingRequestId;
method: "mcpServer/elicitation/request";
params: PendingMcpElicitationFormParams | PendingMcpElicitationUrlParams;
}
export function approvalActionKind(action: ApprovalAction): "accept" | "accept-session" | "decline" | "cancel" {
if (!isCommandDecisionAction(action)) return action;
const decision = action.decision;
@ -140,6 +214,10 @@ export function userInputOtherDraftKey(requestId: PendingRequestId, questionId:
return pendingRequestDerivedKey(requestId, `${questionId}:other`);
}
export function mcpElicitationDraftKey(requestId: PendingRequestId, fieldId: string): string {
return pendingRequestDerivedKey(requestId, `mcp:${fieldId}`);
}
export function approvalDetailsDisclosureId(requestId: PendingRequestId): string {
return pendingRequestDerivedKey(requestId, "details");
}
@ -160,3 +238,71 @@ export function answersForPendingUserInput(input: PendingUserInput, drafts: Read
]),
);
}
export function contentForPendingMcpElicitation(
elicitation: PendingMcpElicitation,
drafts: ReadonlyMap<string, string>,
): Record<string, McpElicitationContentValue> | null {
if (elicitation.params.mode !== "form") return null;
return Object.fromEntries(
elicitation.params.fields.map((field) => [
field.id,
mcpElicitationFieldValue(field, drafts.get(mcpElicitationDraftKey(elicitation.requestId, field.id))),
]),
);
}
export function mcpElicitationFieldDefaultDraft(field: PendingMcpElicitationField): string {
switch (field.type) {
case "boolean":
return field.defaultValue ? "true" : "false";
case "multi-select":
return JSON.stringify(field.defaultValue);
case "number":
case "integer":
return field.defaultValue === null ? "" : String(field.defaultValue);
default:
return field.defaultValue;
}
}
function mcpElicitationFieldValue(field: PendingMcpElicitationField, draftValue: string | undefined): McpElicitationContentValue {
const draft = draftValue ?? mcpElicitationFieldDefaultDraft(field);
switch (field.type) {
case "boolean":
return draft === "true";
case "number":
case "integer":
return numericMcpElicitationFieldValue(field, draft);
case "multi-select":
return multiSelectMcpElicitationFieldValue(field, draft);
default:
return draft;
}
}
function numericMcpElicitationFieldValue(
field: Extract<PendingMcpElicitationField, { type: "number" | "integer" }>,
draft: string,
): number | null {
if (draft.trim() === "") return null;
const parsed = field.type === "integer" ? Number.parseInt(draft, 10) : Number(draft);
if (Number.isFinite(parsed)) return parsed;
return field.defaultValue;
}
function multiSelectMcpElicitationFieldValue(
field: Extract<PendingMcpElicitationField, { type: "multi-select" }>,
draft: string,
): readonly string[] {
try {
const parsed = JSON.parse(draft) as unknown;
if (Array.isArray(parsed)) {
const allowed = new Set(field.options.map((option) => option.value));
return parsed.filter((value): value is string => typeof value === "string" && allowed.has(value));
}
} catch {
// Fall through to schema default when a stale draft cannot be parsed.
}
return field.defaultValue;
}

View file

@ -1,6 +1,14 @@
import { approvalActionKind, type ApprovalAction, type PendingApproval, type PendingUserInput } from "./model";
import {
approvalActionKind,
type ApprovalAction,
type McpElicitationAction,
type McpElicitationContentValue,
type PendingApproval,
type PendingMcpElicitation,
type PendingUserInput,
} from "./model";
import { approvalDetails, approvalResultSummary, approvalTitle } from "./approval";
import type { MessageStreamItem } from "../message-stream/items";
import type { MessageStreamItem, MessageStreamUserInputQuestionResult } from "../message-stream/items";
import { definedProp } from "../../../../utils";
export function createApprovalResultItem(approval: PendingApproval, action: ApprovalAction): MessageStreamItem {
@ -49,6 +57,24 @@ export function createUserInputResultItem(
};
}
export function createMcpElicitationResultItem(
elicitation: PendingMcpElicitation,
action: McpElicitationAction,
content: Record<string, McpElicitationContentValue> | null,
): MessageStreamItem {
const accepted = action === "accept";
return {
id: `mcp-elicitation-${action}-${String(elicitation.requestId)}`,
kind: "userInputResult",
role: "tool",
text: mcpElicitationResultText(elicitation, action),
...definedProp("turnId", elicitation.params.turnId ?? undefined),
provenance: { source: "localUser", channel: "response", interaction: "userInputResponse", sourceId: String(elicitation.requestId) },
executionState: accepted ? "completed" : "failed",
questions: mcpElicitationResultQuestions(elicitation, accepted ? content : null),
};
}
function approvalResultText(approval: PendingApproval, action: ApprovalAction): string {
return `${approvalResultPrefix(action)}: ${approvalResultSummary(approval)}`;
}
@ -73,3 +99,38 @@ function approvalTurnId(approval: PendingApproval): string | undefined {
const params = approval.params as { turnId?: unknown };
return typeof params.turnId === "string" ? params.turnId : undefined;
}
function mcpElicitationResultText(elicitation: PendingMcpElicitation, action: McpElicitationAction): string {
const label = `MCP request from ${elicitation.params.serverName}`;
if (action === "accept") return `${label} accepted.`;
if (action === "decline") return `${label} declined.`;
return `${label} cancelled.`;
}
function mcpElicitationResultQuestions(
elicitation: PendingMcpElicitation,
content: Record<string, McpElicitationContentValue> | null,
): readonly MessageStreamUserInputQuestionResult[] {
if (elicitation.params.mode === "url") {
return [
{
id: "url",
header: "URL",
question: elicitation.params.message,
...(content ? { answer: elicitation.params.url } : {}),
},
];
}
return elicitation.params.fields.map((field) => ({
id: field.id,
header: field.title || field.id,
question: field.description ?? field.title,
...(content ? { answer: formatMcpElicitationContentValue(content[field.id]) } : {}),
}));
}
function formatMcpElicitationContentValue(value: McpElicitationContentValue | undefined): string {
if (Array.isArray(value)) return value.join(", ");
if (value === null || typeof value === "undefined") return "";
return String(value);
}

View file

@ -1,11 +1,13 @@
import type { PendingApproval, PendingUserInput } from "./model";
import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "./model";
export function pendingRequestsSignature(
approvals: readonly PendingApproval[],
inputs: readonly PendingUserInput[],
mcpElicitations: readonly PendingMcpElicitation[],
drafts: ReadonlyMap<string, string>,
mcpDrafts: ReadonlyMap<string, string>,
): string {
if (approvals.length === 0 && inputs.length === 0) return "";
if (approvals.length === 0 && inputs.length === 0 && mcpElicitations.length === 0) return "";
return JSON.stringify({
approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })),
inputs: inputs.map((input) => ({
@ -17,14 +19,35 @@ export function pendingRequestsSignature(
options: question.options?.map((option) => option.label) ?? null,
})),
})),
mcpElicitations: mcpElicitations.map((elicitation) => ({
id: elicitation.requestId,
mode: elicitation.params.mode,
serverName: elicitation.params.serverName,
message: elicitation.params.message,
fields:
elicitation.params.mode === "form"
? elicitation.params.fields.map((field) => ({
id: field.id,
type: field.type,
title: field.title,
options: "options" in field ? field.options.map((option) => option.value) : null,
}))
: [],
})),
drafts: Array.from(drafts.entries()).sort(([left], [right]) => left.localeCompare(right)),
mcpDrafts: Array.from(mcpDrafts.entries()).sort(([left], [right]) => left.localeCompare(right)),
});
}
export function pendingRequestFocusSignature(approvals: readonly PendingApproval[], inputs: readonly PendingUserInput[]): string {
if (approvals.length === 0 && inputs.length === 0) return "";
export function pendingRequestFocusSignature(
approvals: readonly PendingApproval[],
inputs: readonly PendingUserInput[],
mcpElicitations: readonly PendingMcpElicitation[],
): string {
if (approvals.length === 0 && inputs.length === 0 && mcpElicitations.length === 0) return "";
return JSON.stringify({
approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })),
inputs: inputs.map((input) => ({ id: input.requestId, method: input.method })),
mcpElicitations: mcpElicitations.map((elicitation) => ({ id: elicitation.requestId, method: elicitation.method })),
});
}

View file

@ -965,7 +965,13 @@ function createSurfacesAndPresenter(
requests: {
pendingSignature: () => {
const state = stateStore.getState();
return pendingRequestsSignature(state.requests.approvals, state.requests.pendingUserInputs, state.requests.userInputDrafts);
return pendingRequestsSignature(
state.requests.approvals,
state.requests.pendingUserInputs,
state.requests.pendingMcpElicitations,
state.requests.userInputDrafts,
state.requests.mcpElicitationDrafts,
);
},
pendingSnapshot: () => pendingRequests.snapshot(),
pendingActions: () => pendingRequests.actions(),

View file

@ -1,22 +1,28 @@
import type { PendingApproval, PendingUserInput } from "../../domain/pending-requests/model";
import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../domain/pending-requests/model";
import {
pendingApprovalViewModel,
pendingMcpElicitationViewModel,
pendingUserInputViewModel,
type PendingApprovalViewModel,
type PendingMcpElicitationViewModel,
type PendingUserInputViewModel,
} from "./view-model";
export interface PendingRequestBlockSnapshot {
approvals: readonly PendingApprovalViewModel[];
pendingUserInputs: readonly PendingUserInputViewModel[];
pendingMcpElicitations: readonly PendingMcpElicitationViewModel[];
userInputDrafts: ReadonlyMap<string, string>;
mcpElicitationDrafts: ReadonlyMap<string, string>;
approvalDetails: ReadonlySet<string>;
}
export interface PendingRequestBlockSnapshotSource {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
pendingMcpElicitations: readonly PendingMcpElicitation[];
userInputDrafts: ReadonlyMap<string, string>;
mcpElicitationDrafts: ReadonlyMap<string, string>;
approvalDetails: ReadonlySet<string>;
}
@ -24,7 +30,9 @@ export function pendingRequestBlockSnapshotFromState(source: PendingRequestBlock
return {
approvals: source.approvals.map(pendingApprovalViewModel),
pendingUserInputs: source.pendingUserInputs.map(pendingUserInputViewModel),
pendingMcpElicitations: source.pendingMcpElicitations.map(pendingMcpElicitationViewModel),
userInputDrafts: source.userInputDrafts,
mcpElicitationDrafts: source.mcpElicitationDrafts,
approvalDetails: source.approvalDetails,
};
}

View file

@ -2,8 +2,12 @@ import { approvalDetails, approvalSummary, approvalTitle } from "../../domain/pe
import { approvalActionOptions, type ApprovalActionOption } from "./approval-view";
import {
type PendingApproval,
type PendingMcpElicitation,
type PendingMcpElicitationField,
type PendingRequestId as DomainPendingRequestId,
type PendingUserInput,
mcpElicitationDraftKey,
mcpElicitationFieldDefaultDraft,
questionDefaultAnswer,
userInputDraftKey,
userInputOtherDraftKey,
@ -50,6 +54,40 @@ export interface PendingUserInputViewModel {
questions: PendingUserInputQuestionViewModel[];
}
interface PendingMcpElicitationOptionViewModel {
value: string;
label: string;
}
export interface PendingMcpElicitationFieldViewModel {
id: string;
title: string;
description: string | null;
type: PendingMcpElicitationField["type"];
required: boolean;
defaultDraft: string;
draftKey: string;
options: readonly PendingMcpElicitationOptionViewModel[] | null;
format?: string | null;
minimum?: number | null;
maximum?: number | null;
minLength?: number | null;
maxLength?: number | null;
minItems?: number | null;
maxItems?: number | null;
}
export interface PendingMcpElicitationViewModel {
requestId: PendingRequestId;
title: string;
body: string;
mode: "form" | "url";
serverName: string;
message: string;
fields: readonly PendingMcpElicitationFieldViewModel[];
url: string | null;
}
export function pendingApprovalViewModel(approval: PendingApproval): PendingApprovalViewModel {
return {
requestId: approval.requestId,
@ -78,3 +116,53 @@ export function pendingUserInputViewModel(input: PendingUserInput): PendingUserI
})),
};
}
export function pendingMcpElicitationViewModel(elicitation: PendingMcpElicitation): PendingMcpElicitationViewModel {
const title = `MCP request from ${elicitation.params.serverName}`;
if (elicitation.params.mode === "url") {
return {
requestId: elicitation.requestId,
title,
body: elicitation.params.message,
mode: "url",
serverName: elicitation.params.serverName,
message: elicitation.params.message,
fields: [],
url: elicitation.params.url,
};
}
return {
requestId: elicitation.requestId,
title,
body: elicitation.params.message,
mode: "form",
serverName: elicitation.params.serverName,
message: elicitation.params.message,
fields: elicitation.params.fields.map((field) => ({
id: field.id,
title: field.title,
description: field.description,
type: field.type,
required: field.required,
defaultDraft: mcpElicitationFieldDefaultDraft(field),
draftKey: mcpElicitationDraftKey(elicitation.requestId, field.id),
options: "options" in field ? field.options : null,
...mcpElicitationFieldConstraints(field),
})),
url: null,
};
}
function mcpElicitationFieldConstraints(field: PendingMcpElicitationField): Partial<PendingMcpElicitationFieldViewModel> {
switch (field.type) {
case "string":
return { format: field.format, minLength: field.minLength, maxLength: field.maxLength };
case "number":
case "integer":
return { minimum: field.minimum, maximum: field.maximum };
case "multi-select":
return { minItems: field.minItems, maxItems: field.maxItems };
default:
return {};
}
}

View file

@ -1,6 +1,6 @@
import type { ComponentChild as UiNode } from "preact";
import type { ApprovalAction, PendingRequestId } from "../../domain/pending-requests/model";
import type { ApprovalAction, McpElicitationAction, PendingRequestId } from "../../domain/pending-requests/model";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/snapshot";
import type { MessageStreamForkTarget, MessageStreamPlanImplementationTarget } from "../../presentation/message-stream/text-view";
import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
@ -31,8 +31,10 @@ export interface PendingRequestBlockActions {
resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void;
resolveUserInput: (requestId: PendingRequestId) => void;
cancelUserInput: (requestId: PendingRequestId) => void;
resolveMcpElicitation: (requestId: PendingRequestId, action: McpElicitationAction) => void;
setApprovalDetailsExpanded?: (requestId: PendingRequestId, expanded: boolean) => void;
setUserInputDraft: (key: string, value: string) => void;
setMcpElicitationDraft: (key: string, value: string) => void;
}
export interface TextItemDetailStateContext {

View file

@ -4,6 +4,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { approvalDetailsDisclosureId } from "../../domain/pending-requests/model";
import {
type PendingApprovalViewModel,
type PendingMcpElicitationFieldViewModel,
type PendingMcpElicitationViewModel,
type PendingUserInputQuestionViewModel,
type PendingUserInputViewModel,
} from "../../presentation/pending-requests/view-model";
@ -13,7 +15,9 @@ import { createStatusMessageClassName } from "./status";
export function pendingRequestBlockNode(
approvals: readonly PendingApprovalViewModel[],
pendingUserInputs: readonly PendingUserInputViewModel[],
pendingMcpElicitations: readonly PendingMcpElicitationViewModel[],
userInputDrafts: ReadonlyMap<string, string>,
mcpElicitationDrafts: ReadonlyMap<string, string>,
approvalDetails: ReadonlySet<string>,
actions: PendingRequestBlockActions,
autoFocusRequested = false,
@ -24,7 +28,9 @@ export function pendingRequestBlockNode(
<PendingRequestBlock
approvals={approvals}
pendingUserInputs={pendingUserInputs}
pendingMcpElicitations={pendingMcpElicitations}
userInputDrafts={userInputDrafts}
mcpElicitationDrafts={mcpElicitationDrafts}
approvalDetails={approvalDetails}
actions={actions}
autoFocusRequested={autoFocusRequested}
@ -37,7 +43,9 @@ export function pendingRequestBlockNode(
function PendingRequestBlock({
approvals,
pendingUserInputs,
pendingMcpElicitations,
userInputDrafts,
mcpElicitationDrafts,
approvalDetails,
actions,
autoFocusRequested,
@ -46,7 +54,9 @@ function PendingRequestBlock({
}: {
approvals: readonly PendingApprovalViewModel[];
pendingUserInputs: readonly PendingUserInputViewModel[];
pendingMcpElicitations: readonly PendingMcpElicitationViewModel[];
userInputDrafts: ReadonlyMap<string, string>;
mcpElicitationDrafts: ReadonlyMap<string, string>;
approvalDetails: ReadonlySet<string>;
actions: PendingRequestBlockActions;
autoFocusRequested: boolean;
@ -60,7 +70,7 @@ function PendingRequestBlock({
if (!shouldFocus) return;
focusPendingRequestControl(requestRef.current);
}, [autoFocusRequested, consumeAutoFocus, autoFocusSignature]);
if (approvals.length === 0 && pendingUserInputs.length === 0) return null;
if (approvals.length === 0 && pendingUserInputs.length === 0 && pendingMcpElicitations.length === 0) return null;
return (
<div ref={requestRef} className={createStatusMessageClassName("codex-panel__pending-request-block", "warning")}>
<div className="codex-panel__message-role">Request</div>
@ -70,6 +80,14 @@ function PendingRequestBlock({
{pendingUserInputs.map((input) => (
<UserInputCard key={String(input.requestId)} input={input} userInputDrafts={userInputDrafts} actions={actions} />
))}
{pendingMcpElicitations.map((elicitation) => (
<McpElicitationCard
key={String(elicitation.requestId)}
elicitation={elicitation}
mcpElicitationDrafts={mcpElicitationDrafts}
actions={actions}
/>
))}
</div>
);
}
@ -79,6 +97,10 @@ function focusPendingRequestControl(container: HTMLElement | null): void {
for (const selector of [
".codex-panel__user-input-radio:checked",
".codex-panel__user-input-text",
".codex-panel__mcp-elicitation-input",
".codex-panel__mcp-elicitation-checkbox",
".codex-panel__mcp-elicitation-radio:checked",
".codex-panel__mcp-elicitation-radio",
".codex-panel__user-input-radio",
".codex-panel__pending-request-button.mod-cta",
".codex-panel__pending-request-button",
@ -90,6 +112,99 @@ function focusPendingRequestControl(container: HTMLElement | null): void {
}
}
function McpElicitationCard({
elicitation,
mcpElicitationDrafts,
actions,
}: {
elicitation: PendingMcpElicitationViewModel;
mcpElicitationDrafts: ReadonlyMap<string, string>;
actions: PendingRequestBlockActions;
}): UiNode {
const formRef = useRef<HTMLFormElement | null>(null);
const accept = () => {
if (elicitation.mode === "form" && !validateMcpElicitationForm(formRef.current, elicitation, mcpElicitationDrafts)) return;
actions.resolveMcpElicitation(elicitation.requestId, "accept");
};
return (
<PendingRequestCard className="codex-panel__mcp-elicitation">
<div className="codex-panel__pending-request-info">
<div className="codex-panel__pending-request-title">{elicitation.title}</div>
<div className="codex-panel__pending-request-body">{elicitation.body}</div>
{elicitation.mode === "url" && elicitation.url ? (
<a className="codex-panel__mcp-elicitation-url" href={elicitation.url} target="_blank" rel="noreferrer">
{elicitation.url}
</a>
) : (
<form
ref={formRef}
className="codex-panel__mcp-elicitation-form"
onSubmit={(event) => {
event.preventDefault();
accept();
}}
>
<McpElicitationFields fields={elicitation.fields} drafts={mcpElicitationDrafts} actions={actions} />
</form>
)}
</div>
<div className="codex-panel__pending-request-actions">
<ActionButton label="Accept" className="mod-cta" onClick={accept} />
<ActionButton
label="Decline"
className=""
onClick={() => {
actions.resolveMcpElicitation(elicitation.requestId, "decline");
}}
/>
<ActionButton
label="Cancel"
className=""
onClick={() => {
actions.resolveMcpElicitation(elicitation.requestId, "cancel");
}}
/>
</div>
</PendingRequestCard>
);
}
function validateMcpElicitationForm(
form: HTMLFormElement | null,
elicitation: PendingMcpElicitationViewModel,
drafts: ReadonlyMap<string, string>,
): boolean {
if (!form) return true;
clearMcpElicitationCustomValidity(form);
for (const field of elicitation.fields) {
if (field.type !== "multi-select") continue;
const selectedCount = selectedMcpElicitationValues(drafts.get(field.draftKey) ?? field.defaultDraft).size;
const message = mcpElicitationMultiSelectValidationMessage(field, selectedCount);
if (!message) continue;
const input = Array.from(form.querySelectorAll<HTMLInputElement>("input[data-mcp-elicitation-field]")).find(
(candidate) => candidate.dataset["mcpElicitationField"] === field.id,
);
input?.setCustomValidity(message);
}
return form.reportValidity();
}
function clearMcpElicitationCustomValidity(form: HTMLFormElement): void {
form.querySelectorAll<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox").forEach((input) => {
input.setCustomValidity("");
});
}
function mcpElicitationMultiSelectValidationMessage(field: PendingMcpElicitationFieldViewModel, selectedCount: number): string | null {
if (typeof field.minItems === "number" && selectedCount < field.minItems) {
return `Select at least ${String(field.minItems)} option${field.minItems === 1 ? "" : "s"}.`;
}
if (typeof field.maxItems === "number" && selectedCount > field.maxItems) {
return `Select no more than ${String(field.maxItems)} option${field.maxItems === 1 ? "" : "s"}.`;
}
return null;
}
function ApprovalCard({
approval,
approvalDetails,
@ -253,6 +368,163 @@ function UserInputQuestions({
);
}
function McpElicitationFields({
fields,
drafts,
actions,
}: {
fields: readonly PendingMcpElicitationFieldViewModel[];
drafts: ReadonlyMap<string, string>;
actions: PendingRequestBlockActions;
}): UiNode {
return (
<div className="codex-panel__mcp-elicitation-fields">
{fields.map((field) => (
<McpElicitationField key={field.id} field={field} drafts={drafts} actions={actions} />
))}
</div>
);
}
function McpElicitationField({
field,
drafts,
actions,
}: {
field: PendingMcpElicitationFieldViewModel;
drafts: ReadonlyMap<string, string>;
actions: PendingRequestBlockActions;
}): UiNode {
const current = drafts.get(field.draftKey) ?? field.defaultDraft;
return (
<div className="codex-panel__mcp-elicitation-field">
<label className="codex-panel__mcp-elicitation-label">
<span>{field.title}</span>
{field.required ? <span className="codex-panel__mcp-elicitation-required">Required</span> : null}
</label>
{field.description ? <div className="codex-panel__mcp-elicitation-description">{field.description}</div> : null}
<McpElicitationFieldControl field={field} current={current} actions={actions} />
</div>
);
}
function McpElicitationFieldControl({
field,
current,
actions,
}: {
field: PendingMcpElicitationFieldViewModel;
current: string;
actions: PendingRequestBlockActions;
}): UiNode {
switch (field.type) {
case "boolean":
return (
<label className="codex-panel__mcp-elicitation-option">
<input
className="codex-panel__mcp-elicitation-checkbox"
type="checkbox"
checked={current === "true"}
onChange={(event) => {
actions.setMcpElicitationDraft(field.draftKey, event.currentTarget.checked ? "true" : "false");
}}
/>
<span>Enabled</span>
</label>
);
case "single-select":
return (
<div className="codex-panel__mcp-elicitation-options">
{field.options?.map((option) => (
<label key={option.value} className="codex-panel__mcp-elicitation-option">
<input
className="codex-panel__mcp-elicitation-radio"
type="radio"
name={`codex-panel-mcp-${field.draftKey}`}
value={option.value}
required={field.required}
checked={current === option.value}
onChange={(event) => {
if (event.currentTarget.checked) actions.setMcpElicitationDraft(field.draftKey, option.value);
}}
/>
<span>{option.label}</span>
</label>
))}
</div>
);
case "multi-select":
return (
<div className="codex-panel__mcp-elicitation-options">
{field.options?.map((option) => {
const selected = selectedMcpElicitationValues(current);
return (
<label key={option.value} className="codex-panel__mcp-elicitation-option">
<input
className="codex-panel__mcp-elicitation-checkbox"
type="checkbox"
value={option.value}
data-mcp-elicitation-field={field.id}
checked={selected.has(option.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.currentTarget.checked) {
next.add(option.value);
} else {
next.delete(option.value);
}
actions.setMcpElicitationDraft(field.draftKey, JSON.stringify([...next]));
}}
/>
<span>{option.label}</span>
</label>
);
})}
</div>
);
case "number":
case "integer":
return (
<input
className="codex-panel__mcp-elicitation-input"
type="number"
step={field.type === "integer" ? "1" : "any"}
min={field.minimum ?? undefined}
max={field.maximum ?? undefined}
required={field.required}
value={current}
onInput={(event) => {
actions.setMcpElicitationDraft(field.draftKey, event.currentTarget.value);
}}
/>
);
default:
return (
<input
className="codex-panel__mcp-elicitation-input"
type={field.format === "email" ? "email" : field.format === "uri" ? "url" : field.format === "date" ? "date" : "text"}
minLength={field.minLength ?? undefined}
maxLength={field.maxLength ?? undefined}
required={field.required}
value={current}
onInput={(event) => {
actions.setMcpElicitationDraft(field.draftKey, event.currentTarget.value);
}}
/>
);
}
}
function selectedMcpElicitationValues(draft: string): Set<string> {
try {
const values = JSON.parse(draft) as unknown;
if (Array.isArray(values)) return new Set(values.filter((value): value is string => typeof value === "string"));
} catch {
return new Set();
}
return new Set();
}
function userInputRadioGroupName(requestId: PendingUserInputViewModel["requestId"], questionId: string): string {
return `codex-panel-${String(requestId)}-${questionId}`;
}

View file

@ -50,7 +50,9 @@ function presentationBlockNode(block: MessageStreamViewBlock, context: MessageSt
node: pendingRequestBlockNode(
block.snapshot.approvals,
block.snapshot.pendingUserInputs,
block.snapshot.pendingMcpElicitations,
block.snapshot.userInputDrafts,
block.snapshot.mcpElicitationDrafts,
block.snapshot.approvalDetails,
pendingRequests.actions(),
false,

View file

@ -124,3 +124,85 @@
.codex-panel__user-input-other-text {
grid-column: 2;
}
.codex-panel__mcp-elicitation.codex-panel__pending-request-card .codex-panel__pending-request-actions {
margin-top: var(--codex-panel-section-gap);
}
.codex-panel__mcp-elicitation-url {
display: inline-block;
margin-top: var(--codex-panel-control-gap);
overflow-wrap: anywhere;
font-size: var(--font-ui-small);
}
.codex-panel__mcp-elicitation-fields {
display: grid;
gap: var(--codex-panel-item-gap);
margin-top: var(--codex-panel-item-gap);
}
.codex-panel__mcp-elicitation-field {
display: grid;
gap: var(--codex-panel-control-gap);
}
.codex-panel__mcp-elicitation-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--codex-panel-item-gap);
color: var(--codex-panel-item-title-color);
font-size: var(--codex-panel-item-title-size);
font-weight: var(--codex-panel-item-title-weight);
}
.codex-panel__mcp-elicitation-required {
color: var(--text-faint);
font-size: var(--font-ui-smaller);
font-weight: var(--font-normal);
}
.codex-panel__mcp-elicitation-description {
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: var(--line-height-tight);
}
.codex-panel__mcp-elicitation-input {
width: 100%;
min-height: var(--codex-panel-size-user-input-text-min-height);
min-width: 0;
font-size: var(--font-ui-small);
}
.codex-panel__mcp-elicitation-options {
display: grid;
gap: var(--codex-panel-control-gap);
}
.codex-panel__mcp-elicitation-option {
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: var(--codex-panel-control-gap) var(--codex-panel-item-gap);
align-items: start;
padding: 0 var(--codex-panel-control-gap);
border-radius: var(--radius-s);
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: var(--line-height-tight);
cursor: default;
}
.codex-panel__mcp-elicitation-option:hover {
color: var(--nav-item-color-hover, var(--text-normal));
background: var(--background-modifier-hover);
}
.codex-panel__mcp-elicitation-radio,
.codex-panel__mcp-elicitation-checkbox {
grid-column: 1;
align-self: start;
margin: var(--codex-panel-panel-gap) 0 0;
}

View file

@ -22,6 +22,7 @@ describe("PendingRequestActions", () => {
resolveApproval: vi.fn(),
resolveUserInput,
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
},
composerHasFocus: () => false,
refreshLiveState,

View file

@ -31,7 +31,9 @@ describe("chat pending request state", () => {
},
],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map([["2:note", "draft"]]),
mcpElicitationDrafts: new Map(),
};
const next = resolveChatRequest(state, 1);

View file

@ -593,12 +593,21 @@ function messageStreamSurfaceContext(options: {
},
requests: {
pendingSignature: () => "",
pendingSnapshot: () => ({ approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), approvalDetails: new Set() }),
pendingSnapshot: () => ({
approvals: [],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
approvalDetails: new Set(),
}),
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
@ -705,12 +714,21 @@ function messageStreamPresenter(
},
requests: {
pendingSignature: () => "",
pendingSnapshot: () => ({ approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), approvalDetails: new Set() }),
pendingSnapshot: () => ({
approvals: [],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
approvalDetails: new Set(),
}),
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},

View file

@ -725,7 +725,15 @@ describe("ChatInboundHandler", () => {
describe("interactive server requests", () => {
it("queues and resolves requestUserInput server requests", () => {
const state = chatStateFixture();
let state = chatStateFixture();
state = chatStateWith(state, {
requests: {
mcpElicitationDrafts: new Map([
["45:mcp:title", "Fix tests"],
["45:mcp:notify", "false"],
]),
},
});
const respondToServerRequest = vi.fn(() => true);
const handler = handlerForState(state, { respondToServerRequest });
@ -791,6 +799,91 @@ describe("ChatInboundHandler", () => {
});
});
it("queues and accepts MCP elicitation form server requests", () => {
let state = chatStateFixture();
state = chatStateWith(state, {
requests: {
mcpElicitationDrafts: new Map([
["45:mcp:title", "Fix tests"],
["45:mcp:notify", "false"],
]),
},
});
const respondToServerRequest = vi.fn(() => true);
const handler = handlerForState(state, { respondToServerRequest });
handler.handleServerRequest({
id: 45,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread-active",
turnId: null,
serverName: "github",
mode: "form",
_meta: null,
message: "Provide issue details",
requestedSchema: {
type: "object",
required: ["title"],
properties: {
title: { type: "string", title: "Title", default: "Issue" },
notify: { type: "boolean", title: "Notify", default: true },
},
},
},
});
expect(handler.currentState().requests.pendingMcpElicitations).toHaveLength(1);
handler.resolveMcpElicitation(expectPresent(handler.currentState().requests.pendingMcpElicitations[0]), "accept");
expect(respondToServerRequest).toHaveBeenCalledWith(45, {
action: "accept",
content: { title: "Fix tests", notify: false },
_meta: null,
});
expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]);
expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({
kind: "userInputResult",
role: "tool",
text: "MCP request from github accepted.",
questions: [
expect.objectContaining({ id: "title", answer: "Fix tests" }),
expect.objectContaining({ id: "notify", answer: "false" }),
],
});
});
it("declines MCP elicitation URL server requests", () => {
const state = chatStateFixture();
const respondToServerRequest = vi.fn(() => true);
const handler = handlerForState(state, { respondToServerRequest });
handler.handleServerRequest({
id: 46,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread-active",
turnId: "turn-active",
serverName: "github",
mode: "url",
_meta: null,
message: "Confirm in browser",
url: "https://example.com/confirm",
elicitationId: "elicit-1",
},
});
handler.resolveMcpElicitation(expectPresent(handler.currentState().requests.pendingMcpElicitations[0]), "decline");
expect(respondToServerRequest).toHaveBeenCalledWith(46, { action: "decline", content: null, _meta: null });
expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]);
expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({
kind: "userInputResult",
text: "MCP request from github declined.",
executionState: "failed",
});
});
it("ignores stale requestUserInput objects with a reused request id", () => {
const state = chatStateFixture();
const respondToServerRequest = vi.fn(() => true);
@ -810,6 +903,22 @@ describe("ChatInboundHandler", () => {
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
it("ignores stale MCP elicitation objects with a reused request id", () => {
const state = chatStateFixture();
const respondToServerRequest = vi.fn(() => true);
const handler = handlerForState(state, { respondToServerRequest });
handler.handleServerRequest(mcpElicitationRequest(47));
const current = expectPresent(handler.currentState().requests.pendingMcpElicitations[0]);
const stale = { ...current };
handler.resolveMcpElicitation(stale, "accept");
expect(respondToServerRequest).not.toHaveBeenCalled();
expect(handler.currentState().requests.pendingMcpElicitations).toEqual([current]);
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
it("records manual permission approvals as colored result items", () => {
const state = chatStateFixture();
const respondToServerRequest = vi.fn(() => true);
@ -864,6 +973,7 @@ describe("ChatInboundHandler", () => {
handler.handleServerRequest(request);
}
handler.handleServerRequest(userInputRequest(20));
handler.handleServerRequest(mcpElicitationRequest(21));
const unsupported = unsupportedRequests();
for (const request of unsupported) {
handler.handleServerRequest(request);
@ -872,6 +982,7 @@ describe("ChatInboundHandler", () => {
expect(handler.currentState().requests.approvals.map((approval) => approval.requestId)).toEqual([10, 11, 12]);
expect(handler.currentState().requests.pendingUserInputs.map((input) => input.requestId)).toEqual([20]);
expect(handler.currentState().requests.pendingMcpElicitations.map((elicitation) => elicitation.requestId)).toEqual([21]);
const unsupportedMessages = unsupported.map((request) => `Rejected unsupported app-server request: ${request.method}`);
expect(rejectServerRequest).toHaveBeenCalledTimes(unsupportedMessages.length + 1);
for (const [index, request] of unsupported.entries()) {
@ -1021,7 +1132,43 @@ describe("ChatInboundHandler", () => {
],
},
});
state = chatStateWith(state, { requests: { userInputDrafts: new Map([["50:note", "draft"]]) } });
state = chatStateWith(state, {
requests: {
pendingMcpElicitations: [
{
requestId: 50,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread-active",
turnId: null,
serverName: "github",
mode: "form",
message: "Need input",
meta: null,
fields: [
{
id: "title",
title: "Title",
description: null,
type: "string",
required: true,
format: null,
minLength: null,
maxLength: null,
defaultValue: "",
},
],
},
},
],
},
});
state = chatStateWith(state, {
requests: {
userInputDrafts: new Map([["50:note", "draft"]]),
mcpElicitationDrafts: new Map([["50:mcp:title", "draft"]]),
},
});
const handler = handlerForState(state);
handler.handleNotification({
@ -1031,7 +1178,9 @@ describe("ChatInboundHandler", () => {
expect(handler.currentState().requests.approvals).toEqual([]);
expect(handler.currentState().requests.pendingUserInputs).toEqual([]);
expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]);
expect(handler.currentState().requests.userInputDrafts.size).toBe(0);
expect(handler.currentState().requests.mcpElicitationDrafts.size).toBe(0);
});
});
@ -1898,6 +2047,22 @@ function userInputRequest(id: number): ServerRequest {
};
}
function mcpElicitationRequest(id: number): ServerRequest {
return {
id,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,
serverName: "server",
mode: "form",
_meta: null,
message: "Need input",
requestedSchema: { type: "object", properties: {} },
},
};
}
function appServerThread(id: string, cwd: string): ThreadStartedNotification["params"]["thread"] {
return {
id,
@ -1955,19 +2120,6 @@ function promptSubmitHookRun(id: string, startedAt: bigint): Extract<ServerNotif
function unsupportedRequests(): ServerRequest[] {
return [
{
id: 21,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: "turn",
serverName: "server",
mode: "form",
_meta: null,
message: "Need input",
requestedSchema: { type: "object", properties: {} },
},
},
{
id: 22,
method: "item/tool/call",

View file

@ -62,7 +62,7 @@ describe("chat inbound routing", () => {
{ request: fileChangeApprovalRequest(), kind: "approval" },
{ request: permissionsApprovalRequest(), kind: "approval" },
{ request: userInputRequest({ threadId: "thread-active" }), kind: "userInput" },
{ request: mcpElicitationRequest(), kind: "unsupported" },
{ request: mcpElicitationRequest(), kind: "mcpElicitation" },
{ request: dynamicToolCallRequest(), kind: "unsupported" },
] as const;
@ -146,7 +146,7 @@ describe("chat inbound routing", () => {
it("classifies supported server request families before unsupported requests", () => {
expect(routeServerRequest(commandApprovalRequest(), { activeThreadId: null, activeTurnId: null }).kind).toBe("approval");
expect(routeServerRequest(userInputRequest(), { activeThreadId: null, activeTurnId: null }).kind).toBe("userInput");
expect(routeServerRequest(mcpElicitationRequest(), { activeThreadId: null, activeTurnId: null }).kind).toBe("unsupported");
expect(routeServerRequest(mcpElicitationRequest(), { activeThreadId: null, activeTurnId: null }).kind).toBe("mcpElicitation");
});
it("classifies inactive requests before request-family handling", () => {

View file

@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import type { ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
import { toPendingMcpElicitation, mcpElicitationResponse } from "../../../../../src/features/chat/app-server/requests/mcp-elicitation";
import { contentForPendingMcpElicitation, mcpElicitationDraftKey } from "../../../../../src/features/chat/domain/pending-requests/model";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}
describe("MCP elicitation request model", () => {
it("maps form schema fields and builds accepted content", () => {
const input = expectPresent(toPendingMcpElicitation(formRequest()));
expect(input).toMatchObject({
requestId: 42,
method: "mcpServer/elicitation/request",
params: {
mode: "form",
threadId: "thread",
turnId: null,
serverName: "github",
},
});
if (input.params.mode !== "form") throw new Error("Expected form mode");
expect(input.params.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "title", type: "string", title: "Title", required: true, defaultValue: "Issue" }),
expect.objectContaining({ id: "notify", type: "boolean", defaultValue: true }),
expect.objectContaining({ id: "count", type: "integer", defaultValue: 2 }),
expect.objectContaining({ id: "ratio", type: "number", defaultValue: null }),
expect.objectContaining({
id: "priority",
type: "single-select",
options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
],
}),
expect.objectContaining({
id: "labels",
type: "multi-select",
minItems: 1,
maxItems: 2,
options: [
{ value: "bug", label: "Bug" },
{ value: "docs", label: "Docs" },
],
}),
]),
);
const drafts = new Map([
[mcpElicitationDraftKey(42, "title"), "Fix bug"],
[mcpElicitationDraftKey(42, "priority"), "high"],
[mcpElicitationDraftKey(42, "labels"), JSON.stringify(["bug", "docs"])],
[mcpElicitationDraftKey(42, "notify"), "false"],
[mcpElicitationDraftKey(42, "count"), "3"],
]);
const content = contentForPendingMcpElicitation(input, drafts);
expect(content).toEqual({
title: "Fix bug",
priority: "high",
labels: ["bug", "docs"],
notify: false,
count: 3,
ratio: null,
});
expect(mcpElicitationResponse("accept", content)).toEqual({
action: "accept",
content,
_meta: null,
});
expect(mcpElicitationResponse("decline", content)).toEqual({ action: "decline", content: null, _meta: null });
});
it("maps url mode separately from form content", () => {
const input = expectPresent(toPendingMcpElicitation(urlRequest()));
expect(input).toMatchObject({
requestId: 43,
params: {
mode: "url",
url: "https://example.com/confirm",
elicitationId: "elicit-1",
},
});
expect(contentForPendingMcpElicitation(input, new Map())).toBeNull();
});
});
function formRequest(): ServerRequest {
return {
id: 42,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,
serverName: "github",
mode: "form",
_meta: null,
message: "Provide issue details",
requestedSchema: {
type: "object",
required: ["title"],
properties: {
title: { type: "string", title: "Title", description: "Issue title", default: "Issue" },
priority: {
type: "string",
title: "Priority",
oneOf: [
{ const: "low", title: "Low" },
{ const: "high", title: "High" },
],
default: "low",
},
labels: {
type: "array",
title: "Labels",
minItems: 1,
maxItems: 2,
items: {
anyOf: [
{ const: "bug", title: "Bug" },
{ const: "docs", title: "Docs" },
],
},
default: ["bug"],
},
notify: { type: "boolean", title: "Notify", default: true },
count: { type: "integer", title: "Count", default: 2 },
ratio: { type: "number", title: "Ratio" },
},
},
},
} as unknown as ServerRequest;
}
function urlRequest(): ServerRequest {
return {
id: 43,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: "turn",
serverName: "github",
mode: "url",
_meta: null,
message: "Confirm in browser",
url: "https://example.com/confirm",
elicitationId: "elicit-1",
},
};
}

View file

@ -74,15 +74,16 @@ describe("user input model", () => {
["a", "first"],
]);
expect(pendingRequestsSignature([], [], drafts)).toBe("");
expect(pendingRequestFocusSignature([], [])).toBe("");
expect(pendingRequestFocusSignature([], [input])).toBe(
expect(pendingRequestsSignature([], [], [], drafts, new Map())).toBe("");
expect(pendingRequestFocusSignature([], [], [])).toBe("");
expect(pendingRequestFocusSignature([], [input], [])).toBe(
JSON.stringify({
approvals: [],
inputs: [{ id: 7, method: "item/tool/requestUserInput" }],
mcpElicitations: [],
}),
);
expect(pendingRequestsSignature([], [input], drafts)).toBe(
expect(pendingRequestsSignature([], [input], [], drafts, new Map())).toBe(
JSON.stringify({
approvals: [],
inputs: [
@ -98,10 +99,12 @@ describe("user input model", () => {
],
},
],
mcpElicitations: [],
drafts: [
["a", "first"],
["z", "last"],
],
mcpDrafts: [],
}),
);
});

View file

@ -3,8 +3,15 @@
import { describe, expect, it, vi } from "vitest";
import type { PendingRequestBlockSnapshot } from "../../../../../src/features/chat/presentation/pending-requests/snapshot";
import { pendingApprovalViewModel } from "../../../../../src/features/chat/presentation/pending-requests/view-model";
import type { PendingApproval, PendingUserInput } from "../../../../../src/features/chat/domain/pending-requests/model";
import {
pendingApprovalViewModel,
pendingMcpElicitationViewModel,
} from "../../../../../src/features/chat/presentation/pending-requests/view-model";
import type {
PendingApproval,
PendingMcpElicitation,
PendingUserInput,
} from "../../../../../src/features/chat/domain/pending-requests/model";
import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/message-stream/context";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { changeInputValue } from "../../../../support/dom";
@ -497,6 +504,123 @@ describe("pending request renderer decisions", () => {
expect(expectPresent(blocks[1]).node).not.toBeUndefined();
});
it("renders MCP elicitation fields and resolves them separately from Plan mode input", () => {
const parent = document.createElement("div");
const resolveMcpElicitation = vi.fn();
const setMcpElicitationDraft = vi.fn();
renderMessageStreamBlocksInAct(
parent,
messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
items: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
disclosures: emptyDisclosures(),
forkMenuItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "mcp:51",
snapshot: emptyPendingRequestBlockSnapshot({
pendingMcpElicitations: [pendingMcpElicitationViewModel(pendingMcpElicitation())],
}),
actions: pendingRequestActions({ resolveMcpElicitation, setMcpElicitationDraft }),
}),
}),
);
expect(parent.querySelector(".codex-panel__pending-request-title")?.textContent).toBe("MCP request from github");
changeInputValue(expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input")), "Updated");
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});
expect(setMcpElicitationDraft).toHaveBeenCalledWith("51:mcp:title", "Updated");
expect(resolveMcpElicitation).toHaveBeenCalledWith(51, "accept");
unmountUiRootInAct(parent);
});
it("does not accept invalid MCP elicitation forms", () => {
const parent = document.createElement("div");
const resolveMcpElicitation = vi.fn();
renderMessageStreamBlocksInAct(
parent,
messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
items: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
disclosures: emptyDisclosures(),
forkMenuItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "mcp:52",
snapshot: emptyPendingRequestBlockSnapshot({
pendingMcpElicitations: [pendingMcpElicitation({ requestId: 52, defaultValue: "" })].map(pendingMcpElicitationViewModel),
}),
actions: pendingRequestActions({ resolveMcpElicitation }),
}),
}),
);
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});
expect(resolveMcpElicitation).not.toHaveBeenCalled();
changeInputValue(expectPresent(parent.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input")), "Ready");
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});
expect(resolveMcpElicitation).toHaveBeenCalledWith(52, "accept");
unmountUiRootInAct(parent);
});
it("does not accept MCP multi-select fields below minItems", () => {
const parent = document.createElement("div");
const resolveMcpElicitation = vi.fn();
renderMessageStreamBlocksInAct(
parent,
messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: idleTurnLifecycle(),
historyCursor: null,
loadingHistory: false,
items: [
{ id: "a1", kind: "message", role: "assistant", text: "Done", messageKind: "assistantResponse", messageState: "completed" },
],
disclosures: emptyDisclosures(),
forkMenuItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (element, text) => element.createDiv({ text }),
pendingRequests: pendingRequestContext({
signature: "mcp:53",
snapshot: emptyPendingRequestBlockSnapshot({
pendingMcpElicitations: [pendingMcpMultiSelectElicitation()],
}),
actions: pendingRequestActions({ resolveMcpElicitation }),
}),
}),
);
actEvent(() => {
expectPresent(parent.querySelector<HTMLButtonElement>(".codex-panel__pending-request-button.mod-cta")).click();
});
expect(resolveMcpElicitation).not.toHaveBeenCalled();
unmountUiRootInAct(parent);
});
it("does not consume pending request autofocus while building message stream blocks", () => {
const consumeAutoFocus = vi.fn(() => true);
@ -639,8 +763,75 @@ function emptyPendingRequestBlockSnapshot(overrides: Partial<PendingRequestBlock
return {
approvals: [],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
approvalDetails: new Set(),
...overrides,
};
}
function pendingMcpElicitation({
requestId = 51,
defaultValue = "Issue",
}: {
requestId?: PendingMcpElicitation["requestId"];
defaultValue?: string;
} = {}): PendingMcpElicitation {
return {
requestId,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,
serverName: "github",
mode: "form",
message: "Provide issue details",
meta: null,
fields: [
{
id: "title",
title: "Title",
description: "Issue title",
type: "string",
required: true,
format: null,
minLength: null,
maxLength: null,
defaultValue,
},
],
},
};
}
function pendingMcpMultiSelectElicitation(): ReturnType<typeof pendingMcpElicitationViewModel> {
return pendingMcpElicitationViewModel({
requestId: 53,
method: "mcpServer/elicitation/request",
params: {
threadId: "thread",
turnId: null,
serverName: "github",
mode: "form",
message: "Choose labels",
meta: null,
fields: [
{
id: "labels",
title: "Labels",
description: null,
type: "multi-select",
required: true,
options: [
{ value: "bug", label: "Bug" },
{ value: "docs", label: "Docs" },
],
minItems: 1,
maxItems: 2,
defaultValue: [],
},
],
},
});
}

View file

@ -427,14 +427,18 @@ describe("message stream item renderer decisions", () => {
snapshot: () => ({
approvals: [],
pendingUserInputs: [],
pendingMcpElicitations: [],
userInputDrafts: new Map(),
mcpElicitationDrafts: new Map(),
approvalDetails: new Set(),
}),
actions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumeAutoFocus: () => false,
},

View file

@ -208,7 +208,9 @@ export function renderPendingRequestNode(
const snapshot = pendingRequestBlockSnapshotFromState({
approvals,
pendingUserInputs,
pendingMcpElicitations: [],
userInputDrafts: drafts.values,
mcpElicitationDrafts: new Map(),
approvalDetails,
});
renderUiRootInAct(
@ -216,7 +218,9 @@ export function renderPendingRequestNode(
pendingRequestBlockNode(
snapshot.approvals,
snapshot.pendingUserInputs,
snapshot.pendingMcpElicitations,
snapshot.userInputDrafts,
snapshot.mcpElicitationDrafts,
snapshot.approvalDetails,
actions,
autoFocusRequested,
@ -231,7 +235,9 @@ export function pendingRequestActions(overrides: Partial<PendingRequestBlockActi
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
...overrides,
};
}