mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Support MCP elicitation pending requests
This commit is contained in:
parent
e7ac2a4b7b
commit
56802a2021
27 changed files with 1551 additions and 43 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
164
src/features/chat/app-server/requests/mcp-elicitation.ts
Normal file
164
src/features/chat/app-server/requests/mcp-elicitation.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ describe("PendingRequestActions", () => {
|
|||
resolveApproval: vi.fn(),
|
||||
resolveUserInput,
|
||||
cancelUserInput: vi.fn(),
|
||||
resolveMcpElicitation: vi.fn(),
|
||||
},
|
||||
composerHasFocus: () => false,
|
||||
refreshLiveState,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue