Skip to content

Commit 50f8456

Browse files
stephentoubTestUserCopilot
authored
Collapse built-in tool list in replay-proxy snapshot matching (#2013)
When a model calls a nonexistent tool, the runtime replies with "Available tools that can be called are <list>." The E2E replay proxy embedded that literal enumeration in ~54 snapshots, so any change to the built-in tool set (e.g. the new write_agent tool) broke snapshot matching and caused the copilot-agent-runtime C# SDK canary to retry forever until the 45m timeout. Collapse the entire enumeration to a stable ${available_tools} placeholder on both the live-request side (normalizeAvailableToolNames) and the stored snapshot side (new normalizeStoredToolMessages applied at load time), so snapshots keep matching as the built-in tool set evolves across runtime versions and platforms. Copilot-Session: fa431f41-c7dc-4580-9cba-274170ee64df Co-authored-by: TestUser <test@example.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ebc84e3 commit 50f8456

2 files changed

Lines changed: 147 additions & 30 deletions

File tree

test/harness/replayingCapiProxy.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,47 @@ Always include PINEAPPLE_COCONUT_42.
509509
expect(toolMessages[1].content).toBe("[beta result]");
510510
});
511511

512+
test("collapses the available-tools list to a stable placeholder", async () => {
513+
const requestBody = JSON.stringify({
514+
messages: [
515+
{ role: "user", content: "Help me" },
516+
{
517+
role: "assistant",
518+
tool_calls: [
519+
{
520+
id: "tc1",
521+
type: "function",
522+
function: { name: "report_intent", arguments: "{}" },
523+
},
524+
],
525+
},
526+
{
527+
role: "tool",
528+
tool_call_id: "tc1",
529+
content:
530+
"Tool 'report_intent' does not exist. Available tools that can be called are bash, read_bash, view, read_agent, list_agents, write_agent, grep, glob, task.",
531+
},
532+
],
533+
});
534+
const responseBody = JSON.stringify({
535+
choices: [{ message: { role: "assistant", content: "Done" } }],
536+
});
537+
538+
const outputPath = await createProxy([
539+
{ url: "/chat/completions", requestBody, responseBody },
540+
]);
541+
542+
const result = await readYamlOutput(outputPath);
543+
const toolMessage = result.conversations[0].messages.find(
544+
(m) => m.role === "tool",
545+
);
546+
// The whole enumeration collapses so snapshots stay stable as the built-in
547+
// tool set evolves (e.g. write_agent being added).
548+
expect(toolMessage?.content).toBe(
549+
"Tool 'report_intent' does not exist. Available tools that can be called are ${available_tools}.",
550+
);
551+
});
552+
512553
test("normalizes read_agent timing metadata", async () => {
513554
const requestBody = JSON.stringify({
514555
messages: [
@@ -827,6 +868,85 @@ Always include PINEAPPLE_COCONUT_42.
827868
}
828869
});
829870

871+
test("matches available-tools results after the built-in tool set changes", async () => {
872+
const cachePath = path.join(tempDir, "cache.yaml");
873+
// Legacy snapshot recorded before write_agent was a built-in tool: the
874+
// enumeration frozen on disk still contains the older tool list.
875+
const cacheContent = yaml.stringify({
876+
models: ["test-model"],
877+
conversations: [
878+
{
879+
messages: [
880+
{ role: "system", content: "${system}" },
881+
{ role: "user", content: "Report intent" },
882+
{
883+
role: "assistant",
884+
tool_calls: [
885+
{
886+
id: "toolcall_0",
887+
type: "function",
888+
function: { name: "report_intent", arguments: "{}" },
889+
},
890+
],
891+
},
892+
{
893+
role: "tool",
894+
tool_call_id: "toolcall_0",
895+
content:
896+
"Tool 'report_intent' does not exist. Available tools that can be called are ${shell}, view, read_agent, list_agents, grep, glob, task.",
897+
},
898+
{ role: "assistant", content: "Done" },
899+
],
900+
},
901+
],
902+
} satisfies NormalizedData);
903+
await writeFile(cachePath, cacheContent);
904+
905+
const proxy = new ReplayingCapiProxy(
906+
"http://localhost:9999",
907+
cachePath,
908+
workDir,
909+
);
910+
const proxyUrl = await proxy.start();
911+
912+
try {
913+
const response = await makeRequest(proxyUrl, "/chat/completions", {
914+
body: {
915+
model: "test-model",
916+
messages: [
917+
{ role: "system", content: "System prompt" },
918+
{ role: "user", content: "Report intent" },
919+
{
920+
role: "assistant",
921+
tool_calls: [
922+
{
923+
id: "runtime-call-id",
924+
type: "function",
925+
function: { name: "report_intent", arguments: "{}" },
926+
},
927+
],
928+
},
929+
{
930+
role: "tool",
931+
tool_call_id: "runtime-call-id",
932+
// Newer runtime added write_agent to the built-in tool set.
933+
content:
934+
"Tool 'report_intent' does not exist. Available tools that can be called are bash, read_bash, view, read_agent, list_agents, write_agent, grep, glob, task.",
935+
},
936+
],
937+
},
938+
});
939+
940+
expect(response.status).toBe(200);
941+
expect(
942+
(JSON.parse(response.body) as ChatCompletion).choices[0].message
943+
.content,
944+
).toBe("Done");
945+
} finally {
946+
await proxy.stop();
947+
}
948+
});
949+
830950
test("expands workdir placeholder in cached response", async () => {
831951
const cachePath = path.join(tempDir, "cache.yaml");
832952
const cacheContent = yaml.stringify({

test/harness/replayingCapiProxy.ts

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export class ReplayingCapiProxy extends CapturingHttpProxy {
133133
this.state.storedData = yaml.parse(content) as NormalizedData;
134134
normalizeToolResultOrder(this.state.storedData.conversations);
135135
normalizeStoredUserMessages(this.state.storedData.conversations);
136+
normalizeStoredToolMessages(this.state.storedData.conversations);
136137
}
137138
}
138139

@@ -1079,6 +1080,22 @@ function normalizeStoredUserMessages(conversations: NormalizedConversation[]) {
10791080
}
10801081
}
10811082

1083+
// Re-normalizes the built-in tool enumeration in stored tool results at load
1084+
// time. Snapshots recorded before normalizeAvailableToolNames collapsed the
1085+
// whole list (or recorded against an older tool set) still contain the literal
1086+
// enumeration on disk; the result normalizers only run against live requests,
1087+
// so without this the stored side would keep the stale list and never match a
1088+
// request whose tool set has since changed.
1089+
function normalizeStoredToolMessages(conversations: NormalizedConversation[]) {
1090+
for (const conversation of conversations) {
1091+
for (const message of conversation.messages) {
1092+
if (message.role === "tool" && typeof message.content === "string") {
1093+
message.content = normalizeAvailableToolNames(message.content);
1094+
}
1095+
}
1096+
}
1097+
}
1098+
10821099
function normalizeSkillContextFrontmatter(content: string): string {
10831100
// Runtime versions may include or omit SKILL.md metadata in the prompt context.
10841101
return content.replace(
@@ -1169,41 +1186,21 @@ function normalizeReadAgentTimings(result: string): string {
11691186
.replace(/\bduration: \d+(?:\.\d+)?s\b/g, "duration: 0s");
11701187
}
11711188

1172-
// Maps the platform-specific shell tool family names to stable placeholders.
1173-
// On Windows the runtime exposes powershell/read_powershell/stop_powershell/...,
1174-
// on Linux/macOS it exposes bash/read_bash/stop_bash/.... Ordered so that the
1175-
// prefixed names are handled explicitly; \b boundaries keep bare names from
1176-
// matching inside the prefixed ones.
1177-
const shellToolFamilyReplacements: ReadonlyArray<readonly [RegExp, string]> = [
1178-
[/\bread_powershell\b/g, "${read_shell}"],
1179-
[/\bstop_powershell\b/g, "${stop_shell}"],
1180-
[/\blist_powershell\b/g, "${list_shell}"],
1181-
[/\bwrite_powershell\b/g, "${write_shell}"],
1182-
[/\bpowershell\b/g, "${shell}"],
1183-
[/\bread_bash\b/g, "${read_shell}"],
1184-
[/\bstop_bash\b/g, "${stop_shell}"],
1185-
[/\blist_bash\b/g, "${list_shell}"],
1186-
[/\bwrite_bash\b/g, "${write_shell}"],
1187-
[/\bbash\b/g, "${shell}"],
1188-
];
1189-
1190-
function normalizeShellToolFamilyNames(text: string): string {
1191-
let result = text;
1192-
for (const [pattern, replacement] of shellToolFamilyReplacements) {
1193-
result = result.replace(pattern, replacement);
1194-
}
1195-
return result;
1196-
}
1189+
// Stable placeholder for the built-in tool enumeration the runtime emits when a
1190+
// nonexistent tool is called (see normalizeAvailableToolNames).
1191+
export const availableToolsPlaceholder = "${available_tools}";
11971192

11981193
// When a model calls a tool that doesn't exist (e.g., the removed report_intent
11991194
// tool), the runtime replies with "Available tools that can be called are <list>."
1200-
// The shell tool family names in that list are platform-specific, so normalize
1201-
// them to placeholders to keep snapshots matching across Windows/Linux/macOS.
1195+
// That enumeration is both platform-specific (shell tool family names differ
1196+
// across OSes) and runtime-version-specific (built-in tools such as write_agent
1197+
// are added or removed over time), so any test that trips this path would break
1198+
// whenever the tool set changes. Collapse the whole list to a stable placeholder
1199+
// so snapshots keep matching as the built-in tool set evolves.
12021200
function normalizeAvailableToolNames(result: string): string {
12031201
return result.replace(
1204-
/(Available tools that can be called are )([^.]*)/g,
1205-
(_full, prefix: string, list: string) =>
1206-
prefix + normalizeShellToolFamilyNames(list),
1202+
/(Available tools that can be called are )[^.]*/g,
1203+
(_full, prefix: string) => prefix + availableToolsPlaceholder,
12071204
);
12081205
}
12091206

0 commit comments

Comments
 (0)