From 71b09a54e6d554f4b76a5cde2ba331627d32be5f Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:07:06 -0700 Subject: [PATCH] test(mock): delay Answer responses so transient focus states are observable The router focuses the answering task while a question is delivered and focuses back when the answer returns. With the mock answering instantly, the answerer-focused sidebar state lasts about 75ms on an idle machine, less than Playwright's visibility polling can reliably observe, so every spec asserting the intermediate focus times out on a page that already moved on correctly. The ask/follow-up spec family therefore failed near-deterministically in serial runs on a quiet machine while passing under load, long misread as random flakiness. Give every Answer response a 400ms delay (ANSWER_DELAY_MS) carried on the intent and honored by all three protocol emulations before the tool call streams, so the focused state comfortably outlives the polling interval. The copilot proxy already delayed single Answer calls by 500ms for its own event-ordering reasons; it now honors the larger of the two delays on both tool-call branches. --- tests/mock-api/copilot-proxy.mjs | 7 +++--- tests/mock-api/patterns.mjs | 12 ++++++++++ tests/mock-api/server.mjs | 38 ++++++++++++++++++++++---------- 3 files changed, 42 insertions(+), 15 deletions(-) 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), + ); } }); }