From 56802a2021aa612d49ee47597dabf37b613a5dc5 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 19 Jun 2026 14:05:19 +0900 Subject: [PATCH] Support MCP elicitation pending requests --- .../chat/app-server/inbound/handler.ts | 45 ++- .../chat/app-server/inbound/routing.ts | 11 +- .../app-server/requests/mcp-elicitation.ts | 164 +++++++++++ .../application/pending-requests/block.ts | 13 +- .../pending-request-actions.ts | 30 +- .../application/pending-requests/state.ts | 36 ++- .../chat/application/state/root-reducer.ts | 2 + .../chat/domain/pending-requests/model.ts | 146 ++++++++++ .../domain/pending-requests/result-items.ts | 65 ++++- .../domain/pending-requests/signatures.ts | 31 +- src/features/chat/host/session-graph.ts | 8 +- .../presentation/pending-requests/snapshot.ts | 10 +- .../pending-requests/view-model.ts | 88 ++++++ .../chat/ui/message-stream/context.ts | 4 +- .../message-stream/pending-request-block.tsx | 274 +++++++++++++++++- .../chat/ui/message-stream/stream-blocks.tsx | 2 + src/styles/33-chat-pending-requests.css | 82 ++++++ .../pending-request-actions.test.ts | 1 + .../pending-requests/state.test.ts | 2 + .../surface/message-stream-presenter.test.ts | 22 +- .../chat/protocol/inbound/handler.test.ts | 182 +++++++++++- .../chat/protocol/inbound/routing.test.ts | 4 +- .../server-requests/mcp-elicitation.test.ts | 156 ++++++++++ .../server-requests/user-input.test.ts | 11 +- .../message-stream/pending-requests.test.tsx | 195 ++++++++++++- .../ui/message-stream/stream-items.test.tsx | 4 + .../chat/ui/message-stream/test-helpers.tsx | 6 + 27 files changed, 1551 insertions(+), 43 deletions(-) create mode 100644 src/features/chat/app-server/requests/mcp-elicitation.ts create mode 100644 tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 65f279ec..6dadb0b1 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -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): 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 { diff --git a/src/features/chat/app-server/inbound/routing.ts b/src/features/chat/app-server/inbound/routing.ts index f14c67cd..9d44cad0 100644 --- a/src/features/chat/app-server/inbound/routing.ts +++ b/src/features/chat/app-server/inbound/routing.ts @@ -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 }; } diff --git a/src/features/chat/app-server/requests/mcp-elicitation.ts b/src/features/chat/app-server/requests/mcp-elicitation.ts new file mode 100644 index 00000000..2c402b05 --- /dev/null +++ b/src/features/chat/app-server/requests/mcp-elicitation.ts @@ -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; +type AppServerMcpElicitationFormParams = Extract; +type McpElicitationPrimitiveSchema = NonNullable; + +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 | null, +): McpServerElicitationRequestResponse { + return { + action, + content: action === "accept" ? toJsonContent(content) : null, + _meta: null, + }; +} + +function fieldsFromSchema( + properties: Record, + required: ReadonlySet, +): 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; + 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): 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): 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 | 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); +} diff --git a/src/features/chat/application/pending-requests/block.ts b/src/features/chat/application/pending-requests/block.ts index ca6c58b1..9b622d9e 100644 --- a/src/features/chat/application/pending-requests/block.ts +++ b/src/features/chat/application/pending-requests/block.ts @@ -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; + mcpElicitationDrafts: ReadonlyMap; approvalDetails: ReadonlySet; } @@ -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; } diff --git a/src/features/chat/application/pending-requests/pending-request-actions.ts b/src/features/chat/application/pending-requests/pending-request-actions.ts index db1ce04a..3a89fb41 100644 --- a/src/features/chat/application/pending-requests/pending-request-actions.ts +++ b/src/features/chat/application/pending-requests/pending-request-actions.ts @@ -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) => 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(); } diff --git a/src/features/chat/application/pending-requests/state.ts b/src/features/chat/application/pending-requests/state.ts index e453e682..2244106a 100644 --- a/src/features/chat/application/pending-requests/state.ts +++ b/src/features/chat/application/pending-requests/state.ts @@ -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; + readonly mcpElicitationDrafts: ReadonlyMap; } 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(current: T, patch: Partial): T { if (Object.entries(patch).every(([key, value]) => Object.is(current[key as keyof T], value))) return current; return { ...current, ...patch }; diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 6a9e4ed2..66e22912 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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, diff --git a/src/features/chat/domain/pending-requests/model.ts b/src/features/chat/domain/pending-requests/model.ts index 4e8ac49a..3ea6725c 100644 --- a/src/features/chat/domain/pending-requests/model.ts +++ b/src/features/chat/domain/pending-requests/model.ts @@ -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, +): Record | 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, + 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, + 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; +} diff --git a/src/features/chat/domain/pending-requests/result-items.ts b/src/features/chat/domain/pending-requests/result-items.ts index 3cbdd2b2..ec46c7c8 100644 --- a/src/features/chat/domain/pending-requests/result-items.ts +++ b/src/features/chat/domain/pending-requests/result-items.ts @@ -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 | 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 | 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); +} diff --git a/src/features/chat/domain/pending-requests/signatures.ts b/src/features/chat/domain/pending-requests/signatures.ts index 802b5e71..30a5986c 100644 --- a/src/features/chat/domain/pending-requests/signatures.ts +++ b/src/features/chat/domain/pending-requests/signatures.ts @@ -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, + mcpDrafts: ReadonlyMap, ): 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 })), }); } diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index b73255d6..5c874f55 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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(), diff --git a/src/features/chat/presentation/pending-requests/snapshot.ts b/src/features/chat/presentation/pending-requests/snapshot.ts index b148a67d..0cbda7dc 100644 --- a/src/features/chat/presentation/pending-requests/snapshot.ts +++ b/src/features/chat/presentation/pending-requests/snapshot.ts @@ -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; + mcpElicitationDrafts: ReadonlyMap; approvalDetails: ReadonlySet; } export interface PendingRequestBlockSnapshotSource { approvals: readonly PendingApproval[]; pendingUserInputs: readonly PendingUserInput[]; + pendingMcpElicitations: readonly PendingMcpElicitation[]; userInputDrafts: ReadonlyMap; + mcpElicitationDrafts: ReadonlyMap; approvalDetails: ReadonlySet; } @@ -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, }; } diff --git a/src/features/chat/presentation/pending-requests/view-model.ts b/src/features/chat/presentation/pending-requests/view-model.ts index 185e44a4..4ed93035 100644 --- a/src/features/chat/presentation/pending-requests/view-model.ts +++ b/src/features/chat/presentation/pending-requests/view-model.ts @@ -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 { + 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 {}; + } +} diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 7974b779..5ab67c12 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -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 { diff --git a/src/features/chat/ui/message-stream/pending-request-block.tsx b/src/features/chat/ui/message-stream/pending-request-block.tsx index 60c908d1..04768846 100644 --- a/src/features/chat/ui/message-stream/pending-request-block.tsx +++ b/src/features/chat/ui/message-stream/pending-request-block.tsx @@ -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, + mcpElicitationDrafts: ReadonlyMap, approvalDetails: ReadonlySet, actions: PendingRequestBlockActions, autoFocusRequested = false, @@ -24,7 +28,9 @@ export function pendingRequestBlockNode( ; + mcpElicitationDrafts: ReadonlyMap; approvalDetails: ReadonlySet; 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 (
Request
@@ -70,6 +80,14 @@ function PendingRequestBlock({ {pendingUserInputs.map((input) => ( ))} + {pendingMcpElicitations.map((elicitation) => ( + + ))}
); } @@ -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; + actions: PendingRequestBlockActions; +}): UiNode { + const formRef = useRef(null); + const accept = () => { + if (elicitation.mode === "form" && !validateMcpElicitationForm(formRef.current, elicitation, mcpElicitationDrafts)) return; + actions.resolveMcpElicitation(elicitation.requestId, "accept"); + }; + return ( + +
+
{elicitation.title}
+
{elicitation.body}
+ {elicitation.mode === "url" && elicitation.url ? ( + + {elicitation.url} + + ) : ( +
{ + event.preventDefault(); + accept(); + }} + > + + + )} +
+
+ + { + actions.resolveMcpElicitation(elicitation.requestId, "decline"); + }} + /> + { + actions.resolveMcpElicitation(elicitation.requestId, "cancel"); + }} + /> +
+
+ ); +} + +function validateMcpElicitationForm( + form: HTMLFormElement | null, + elicitation: PendingMcpElicitationViewModel, + drafts: ReadonlyMap, +): 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("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(".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; + actions: PendingRequestBlockActions; +}): UiNode { + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +} + +function McpElicitationField({ + field, + drafts, + actions, +}: { + field: PendingMcpElicitationFieldViewModel; + drafts: ReadonlyMap; + actions: PendingRequestBlockActions; +}): UiNode { + const current = drafts.get(field.draftKey) ?? field.defaultDraft; + return ( +
+ + {field.description ?
{field.description}
: null} + +
+ ); +} + +function McpElicitationFieldControl({ + field, + current, + actions, +}: { + field: PendingMcpElicitationFieldViewModel; + current: string; + actions: PendingRequestBlockActions; +}): UiNode { + switch (field.type) { + case "boolean": + return ( + + ); + case "single-select": + return ( +
+ {field.options?.map((option) => ( + + ))} +
+ ); + case "multi-select": + return ( +
+ {field.options?.map((option) => { + const selected = selectedMcpElicitationValues(current); + return ( + + ); + })} +
+ ); + case "number": + case "integer": + return ( + { + actions.setMcpElicitationDraft(field.draftKey, event.currentTarget.value); + }} + /> + ); + default: + return ( + { + actions.setMcpElicitationDraft(field.draftKey, event.currentTarget.value); + }} + /> + ); + } +} + +function selectedMcpElicitationValues(draft: string): Set { + 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}`; } diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index 028e14fe..b5f8e1e6 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -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, diff --git a/src/styles/33-chat-pending-requests.css b/src/styles/33-chat-pending-requests.css index 18afcce2..4ae69db3 100644 --- a/src/styles/33-chat-pending-requests.css +++ b/src/styles/33-chat-pending-requests.css @@ -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; +} diff --git a/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts b/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts index be666e82..b5a3927a 100644 --- a/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts +++ b/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts @@ -22,6 +22,7 @@ describe("PendingRequestActions", () => { resolveApproval: vi.fn(), resolveUserInput, cancelUserInput: vi.fn(), + resolveMcpElicitation: vi.fn(), }, composerHasFocus: () => false, refreshLiveState, diff --git a/tests/features/chat/conversation/pending-requests/state.test.ts b/tests/features/chat/conversation/pending-requests/state.test.ts index 1eaa93ca..6dfb34d2 100644 --- a/tests/features/chat/conversation/pending-requests/state.test.ts +++ b/tests/features/chat/conversation/pending-requests/state.test.ts @@ -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); diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index adb17d2d..473b9abc 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -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, }, diff --git a/tests/features/chat/protocol/inbound/handler.test.ts b/tests/features/chat/protocol/inbound/handler.test.ts index 6fe2c956..9fe0da18 100644 --- a/tests/features/chat/protocol/inbound/handler.test.ts +++ b/tests/features/chat/protocol/inbound/handler.test.ts @@ -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 { { 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", () => { diff --git a/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts b/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts new file mode 100644 index 00000000..b6b706c1 --- /dev/null +++ b/tests/features/chat/protocol/server-requests/mcp-elicitation.test.ts @@ -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(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", + }, + }; +} diff --git a/tests/features/chat/protocol/server-requests/user-input.test.ts b/tests/features/chat/protocol/server-requests/user-input.test.ts index be036faa..b2d47aa1 100644 --- a/tests/features/chat/protocol/server-requests/user-input.test.ts +++ b/tests/features/chat/protocol/server-requests/user-input.test.ts @@ -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: [], }), ); }); diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 4d512ced..12aaf38a 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -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(".codex-panel__mcp-elicitation-input")), "Updated"); + actEvent(() => { + expectPresent(parent.querySelector(".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(".codex-panel__pending-request-button.mod-cta")).click(); + }); + expect(resolveMcpElicitation).not.toHaveBeenCalled(); + + changeInputValue(expectPresent(parent.querySelector(".codex-panel__mcp-elicitation-input")), "Ready"); + actEvent(() => { + expectPresent(parent.querySelector(".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(".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 { + 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: [], + }, + ], + }, + }); +} diff --git a/tests/features/chat/ui/message-stream/stream-items.test.tsx b/tests/features/chat/ui/message-stream/stream-items.test.tsx index ac97c2d6..7a771983 100644 --- a/tests/features/chat/ui/message-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/message-stream/stream-items.test.tsx @@ -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, }, diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index 0c16fad7..30c38da3 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -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