diff --git a/tests/mock-api/copilot-proxy.mjs b/tests/mock-api/copilot-proxy.mjs index e3074b44..94ae1940 100644 --- a/tests/mock-api/copilot-proxy.mjs +++ b/tests/mock-api/copilot-proxy.mjs @@ -252,7 +252,7 @@ function handleChatCompletions(socket, body) { { id: chatId, object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(first.input) } }] }, finish_reason: null }] }, { id: chatId, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, "[DONE]", - ]); + ], intent.delay ?? 0); } else { // tool_call — translate mcp__cydo__Foo → cydo-Foo for copilot's MCP tool registry let toolName = intent.name; @@ -265,8 +265,9 @@ function handleChatCompletions(socket, body) { { id: chatId, object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(intent.input) } }] }, finish_reason: null }] }, { id: chatId, object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, "[DONE]", - // Let the real client deliver Answer before its canonical idle event. - ], toolName === "cydo-Answer" ? 500 : 0); + // Let the real client deliver Answer before its canonical idle event, + // and honor an intent delay (ANSWER_DELAY_MS) whenever it is larger. + ], Math.max(intent.delay ?? 0, toolName === "cydo-Answer" ? 500 : 0)); } } diff --git a/tests/mock-api/patterns.mjs b/tests/mock-api/patterns.mjs index 11b0a46f..6468ec0a 100644 --- a/tests/mock-api/patterns.mjs +++ b/tests/mock-api/patterns.mjs @@ -1,6 +1,13 @@ // Shared pattern matching module for mock API servers. // Imported by server.mjs (Claude/Codex) and copilot-proxy.mjs (Copilot). +// An instant Answer collapses the answerer-focused UI state to ~75ms on an +// idle machine (faster than Playwright's visibility polling can observe), so +// specs asserting the intermediate focus time out on a page that already moved +// on correctly. Every Answer response carries this delay so the transient +// state lasts long enough to be testable. +export const ANSWER_DELAY_MS = 400; + // Match a user text against known patterns and return a structured intent. // Returns one of: // { type: "text", text: string } @@ -76,6 +83,7 @@ export function matchPattern(userText) { if (m) return { type: "multi_tool_call", + delay: ANSWER_DELAY_MS, tool_calls: [ { name: "mcp__cydo__Answer", @@ -96,6 +104,7 @@ export function matchPattern(userText) { if (m) return { type: "tool_call", + delay: ANSWER_DELAY_MS, name: "mcp__cydo__Answer", input: { qid: parseInt(m[1]), message: "follow-up-answered" }, }; @@ -105,6 +114,7 @@ export function matchPattern(userText) { if (m) return { type: "tool_call", + delay: ANSWER_DELAY_MS, name: "mcp__cydo__Answer", input: { qid: parseInt(m[1]), message: "non-direct-answer" }, }; @@ -142,6 +152,7 @@ export function matchPattern(userText) { if (m) return { type: "tool_call", + delay: ANSWER_DELAY_MS, name: "mcp__cydo__Answer", input: { qid: parseInt(m[1]), message: "switch-mode-answer" }, }; @@ -348,6 +359,7 @@ export function matchPattern(userText) { if (match) return { type: "tool_call", + delay: ANSWER_DELAY_MS, name: "mcp__cydo__Answer", input: { qid: parseInt(match[1]), message: match[2].trim() }, }; diff --git a/tests/mock-api/server.mjs b/tests/mock-api/server.mjs index 674ab74d..8ebeb01b 100644 --- a/tests/mock-api/server.mjs +++ b/tests/mock-api/server.mjs @@ -82,6 +82,17 @@ function streamTextResponse(res, text, model = "claude-sonnet-4-20250514") { res.end(); } +// Honor an intent's delay (e.g. ANSWER_DELAY_MS on Answer patterns) before +// streaming its response. Cleared if the client disconnects first. +function respondAfterIntentDelay(res, intent, respond) { + if (intent?.delay) { + const timer = setTimeout(respond, intent.delay); + res.on("close", () => clearTimeout(timer)); + } else { + respond(); + } +} + function streamToolUseResponse( res, toolName, @@ -938,7 +949,9 @@ function handleResponses(req, res) { // Codex doesn't support MCP + shell calls in parallel, and the // deferral mechanism is exercised even with a single Answer call. const first = intent.tool_calls[0]; - oaiStreamFunctionCallResponse(res, first.name, first.input); + respondAfterIntentDelay(res, intent, () => + oaiStreamFunctionCallResponse(res, first.name, first.input), + ); } else if (intent.type === "autonomous_compaction_switchmode") { oaiStreamFunctionCallResponse( res, @@ -952,7 +965,9 @@ function handleResponses(req, res) { oaiStreamWebSearchCallResponse(res, intent.query, [intent.query]); } else { // tool_call — names are already correct for the OpenAI/Codex protocol - oaiStreamFunctionCallResponse(res, intent.name, intent.input); + respondAfterIntentDelay(res, intent, () => + oaiStreamFunctionCallResponse(res, intent.name, intent.input), + ); } }); } @@ -1347,7 +1362,9 @@ function handleMessages(req, res) { } else if (intent.type === "multi_tool_call") { const toolNames = intent.tool_calls.map((tc) => tc.name); const inputs = intent.tool_calls.map((tc) => tc.input); - streamMultiToolUseResponse(res, toolNames, inputs, model); + respondAfterIntentDelay(res, intent, () => + streamMultiToolUseResponse(res, toolNames, inputs, model), + ); } else if (intent.type === "eager_ask") { streamEagerAskThenSlowToolResponse( res, @@ -1375,19 +1392,14 @@ function handleMessages(req, res) { model, ); } else if (intent.type === "shell" || intent.type === "background_shell") { - const respond = () => + respondAfterIntentDelay(res, intent, () => streamToolUseResponse( res, "Bash", { command: intent.command, description: "Running command" }, model, - ); - if (intent.delay) { - const timer = setTimeout(respond, intent.delay); - res.on("close", () => clearTimeout(timer)); - } else { - respond(); - } + ), + ); } else { // tool_call — map generic names to Anthropic tool names let toolName = intent.name; @@ -1399,7 +1411,9 @@ function handleMessages(req, res) { toolName = "Read"; input = { file_path: intent.input.path }; } - streamToolUseResponse(res, toolName, input, model); + respondAfterIntentDelay(res, intent, () => + streamToolUseResponse(res, toolName, input, model), + ); } }); }