diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index 09c52620..c97370cd 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -1030,6 +1030,73 @@ describe("ChatInboundHandler", () => { ]); }); + it.each([ + { + name: "approval response", + actions: { respondToServerRequest: vi.fn(() => false) }, + exercise(handler: TestChatInboundHandler) { + handler.handleServerRequest(expectPresent(supportedApprovalRequests()[2])); + handler.resolveApproval(12, "decline"); + }, + expectedMessages: ["Could not send approval response because Codex app-server is not connected."], + pending(handler: TestChatInboundHandler) { + return handler.currentState().requests.approvals.length; + }, + }, + { + name: "user-input cancellation", + actions: { rejectServerRequest: vi.fn(() => false) }, + exercise(handler: TestChatInboundHandler) { + handler.handleServerRequest(userInputRequest(56)); + handler.cancelUserInput(56); + }, + expectedMessages: ["Could not cancel user input because Codex app-server is not connected."], + pending(handler: TestChatInboundHandler) { + return handler.currentState().requests.pendingUserInputs.length; + }, + }, + { + name: "MCP response", + actions: { respondToServerRequest: vi.fn(() => false) }, + exercise(handler: TestChatInboundHandler) { + handler.handleServerRequest(mcpElicitationRequest(57)); + handler.resolveMcpElicitation(57, "cancel"); + }, + expectedMessages: ["Could not send MCP request response because Codex app-server is not connected."], + pending(handler: TestChatInboundHandler) { + return handler.currentState().requests.pendingMcpElicitations.length; + }, + }, + { + name: "current-time response", + actions: { respondToServerRequest: vi.fn(() => false) }, + exercise(handler: TestChatInboundHandler) { + handler.handleServerRequest(currentTimeRequest(58, "thread")); + }, + expectedMessages: ["Could not send current time because Codex app-server is not connected."], + pending: null, + }, + { + name: "unsupported-request rejection", + actions: { rejectServerRequest: vi.fn(() => false) }, + exercise(handler: TestChatInboundHandler) { + handler.handleServerRequest(unsupportedToolCallRequest(59)); + }, + expectedMessages: [ + "Rejected unsupported app-server request: item/tool/call", + "Could not reject app-server request because Codex app-server is not connected.", + ], + pending: null, + }, + ])("reports failed $name delivery without resolving pending actions", ({ actions, exercise, expectedMessages, pending }) => { + const handler = handlerForState(chatStateFixture(), actions); + + exercise(handler); + + if (pending) expect(pending(handler)).toBe(1); + expect(chatStateThreadStreamItems(handler.currentState()).map((item) => ("text" in item ? item.text : ""))).toEqual(expectedMessages); + }); + it("clears pending request state when app-server resolves a request", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-active" } }); @@ -2071,6 +2138,14 @@ function currentTimeRequest(id: number, threadId: string): ServerRequest { }; } +function unsupportedToolCallRequest(id: number): ServerRequest { + return { + id, + method: "item/tool/call", + params: { threadId: "thread", turnId: "turn", callId: "call", namespace: null, tool: "tool", arguments: {} }, + }; +} + function unknownRequest(): ServerRequest { return { id: 27, diff --git a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx index 29979e13..615a551c 100644 --- a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx @@ -604,6 +604,107 @@ describe("pending request renderer decisions", () => { unmountUiRootInAct(parent); }); + it("keeps mixed MCP form fields wired to their drafts and supports declining the request", () => { + const parent = document.createElement("div"); + const resolveMcpElicitation = vi.fn(); + const setMcpElicitationDraft = vi.fn(); + const elicitation = pendingMcpElicitationSnapshot({ + requestId: 54, + params: { + threadId: "thread", + turnId: null, + serverName: "github", + mode: "form", + message: "Configure the issue", + meta: null, + fields: [ + { id: "notify", title: "Notify", description: null, type: "boolean", required: false, defaultValue: false }, + { + id: "priority", + title: "Priority", + description: null, + type: "single-select", + required: true, + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + defaultValue: "low", + }, + { id: "estimate", title: "Estimate", description: null, type: "number", required: false, defaultValue: 1.5 }, + { id: "note", title: "Note", description: null, type: "string", required: false, defaultValue: "" }, + ], + }, + }); + + renderThreadStreamBlocksInAct( + parent, + threadStreamBlocks({ + items: [ + { id: "a1", kind: "dialogue", role: "assistant", text: "Done", dialogueKind: "assistantResponse", dialogueState: "completed" }, + ], + pendingRequests: pendingRequestContext({ + signature: "mcp:54", + snapshot: emptyPendingRequestBlockSnapshot({ pendingMcpElicitations: [elicitation] }), + actions: pendingRequestActions({ resolveMcpElicitation, setMcpElicitationDraft }), + }), + }), + ); + + const fields = [...parent.querySelectorAll(".codex-panel__mcp-elicitation-field")]; + actEvent(() => { + expectPresent(fields[0]?.querySelector(".codex-panel__mcp-elicitation-checkbox")).click(); + expectPresent(fields[1]?.querySelectorAll(".codex-panel__mcp-elicitation-radio").item(1)).click(); + changeInputValue(expectPresent(fields[2]?.querySelector(".codex-panel__mcp-elicitation-input")), "3.5"); + changeInputValue(expectPresent(fields[3]?.querySelector(".codex-panel__mcp-elicitation-input")), "Ship today"); + expectPresent( + [...parent.querySelectorAll(".codex-panel__pending-request-button")].find( + (button) => button.textContent === "Decline", + ), + ).click(); + }); + + expect(setMcpElicitationDraft.mock.calls).toEqual([ + ["54:mcp:notify", "true"], + ["54:mcp:priority", "high"], + ["54:mcp:estimate", "3.5"], + ["54:mcp:note", "Ship today"], + ]); + expect(resolveMcpElicitation).toHaveBeenCalledWith(54, "decline"); + unmountUiRootInAct(parent); + }); + + it("routes user-input cancellation and approval detail expansion from the shared request block", () => { + const parent = document.createElement("div"); + const cancelUserInput = vi.fn(); + const setApprovalDetailsExpanded = vi.fn(); + const approval = pendingApproval(); + const input = pendingUserInput(); + + renderPendingRequestNode( + parent, + [approval], + [input], + { values: new Map() }, + new Set(), + pendingRequestActions({ cancelUserInput, setApprovalDetailsExpanded }), + ); + + const details = expectPresent(parent.querySelector(".codex-panel__approval-details")); + actEvent(() => { + details.open = true; + details.dispatchEvent(new Event("toggle", { bubbles: true })); + expectPresent( + [...parent.querySelectorAll(".codex-panel__user-input .codex-panel__pending-request-button")].find( + (button) => button.textContent === "Cancel", + ), + ).click(); + }); + + expect(setApprovalDetailsExpanded).toHaveBeenCalledWith(approval.requestId, true); + expect(cancelUserInput).toHaveBeenCalledWith(input.requestId); + }); + it("does not consume pending request autofocus while building thread stream blocks", () => { const consumeAutoFocus = vi.fn(() => true);