test(chat): cover pending request UI boundaries

This commit is contained in:
murashit 2026-07-18 14:44:42 +09:00
parent 93c4ba50a1
commit 46ecf3addd
2 changed files with 176 additions and 0 deletions

View file

@ -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,

View file

@ -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<HTMLElement>(".codex-panel__mcp-elicitation-field")];
actEvent(() => {
expectPresent(fields[0]?.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-checkbox")).click();
expectPresent(fields[1]?.querySelectorAll<HTMLInputElement>(".codex-panel__mcp-elicitation-radio").item(1)).click();
changeInputValue(expectPresent(fields[2]?.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input")), "3.5");
changeInputValue(expectPresent(fields[3]?.querySelector<HTMLInputElement>(".codex-panel__mcp-elicitation-input")), "Ship today");
expectPresent(
[...parent.querySelectorAll<HTMLButtonElement>(".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<HTMLDetailsElement>(".codex-panel__approval-details"));
actEvent(() => {
details.open = true;
details.dispatchEvent(new Event("toggle", { bubbles: true }));
expectPresent(
[...parent.querySelectorAll<HTMLButtonElement>(".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);