Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions tests/mock-api/copilot-proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}
}

Expand Down
12 changes: 12 additions & 0 deletions tests/mock-api/patterns.mjs
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
};
Expand All @@ -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" },
};
Expand Down Expand Up @@ -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" },
};
Expand Down Expand Up @@ -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() },
};
Expand Down
38 changes: 26 additions & 12 deletions tests/mock-api/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
);
}
});
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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),
);
}
});
}
Expand Down