Skip to content

Commit f83d703

Browse files
stephentoubCopilot
andcommitted
Normalize report_intent tool-list drift in replay proxy
The 1.0.72 CLI now appends the full built-in tool enumeration to the "Tool 'report_intent' does not exist." error, while existing snapshots recorded it truncated. Strip the whole "Available tools that can be called are <list>." sentence on both the request and stored sides so list-present, list-absent, and differing list contents are all equivalent, fixing subagent_hooks (all SDKs) and should_abort_a_session (Go/Python/Rust/.NET) without editing any snapshots. Also collapse the new trailing multi-turn advice on the task tool's background-agent start result to a stable placeholder. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e94bab38-c22e-4a38-8d8b-c82c1991a33c
1 parent 1bb29f5 commit f83d703

2 files changed

Lines changed: 236 additions & 22 deletions

File tree

test/harness/replayingCapiProxy.test.ts

Lines changed: 203 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ 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 () => {
512+
test("strips the available-tools list so its contents don't matter", async () => {
513513
const requestBody = JSON.stringify({
514514
messages: [
515515
{ role: "user", content: "Help me" },
@@ -543,10 +543,52 @@ Always include PINEAPPLE_COCONUT_42.
543543
const toolMessage = result.conversations[0].messages.find(
544544
(m) => m.role === "tool",
545545
);
546-
// The whole enumeration collapses so snapshots stay stable as the built-in
547-
// tool set evolves (e.g. write_agent being added).
546+
// The whole "Available tools..." sentence is stripped so snapshots stay
547+
// stable whether or not the runtime emits the list and as the built-in tool
548+
// set evolves (e.g. write_agent being added).
548549
expect(toolMessage?.content).toBe(
549-
"Tool 'report_intent' does not exist. Available tools that can be called are ${available_tools}.",
550+
"Tool 'report_intent' does not exist.",
551+
);
552+
});
553+
554+
test("collapses background-agent start advice to a stable placeholder", async () => {
555+
const requestBody = JSON.stringify({
556+
messages: [
557+
{ role: "user", content: "Help me" },
558+
{
559+
role: "assistant",
560+
tool_calls: [
561+
{
562+
id: "tc1",
563+
type: "function",
564+
function: { name: "task", arguments: "{}" },
565+
},
566+
],
567+
},
568+
{
569+
role: "tool",
570+
tool_call_id: "tc1",
571+
content:
572+
"Agent started in background with agent_id: read-file. You'll be notified when it completes. Tell the user you're waiting and end your response, or continue unrelated work until notified. The agent supports multi-turn conversations — use write_agent to send follow-up messages.",
573+
},
574+
],
575+
});
576+
const responseBody = JSON.stringify({
577+
choices: [{ message: { role: "assistant", content: "Done" } }],
578+
});
579+
580+
const outputPath = await createProxy([
581+
{ url: "/chat/completions", requestBody, responseBody },
582+
]);
583+
584+
const result = await readYamlOutput(outputPath);
585+
const toolMessage = result.conversations[0].messages.find(
586+
(m) => m.role === "tool",
587+
);
588+
// The volatile advice tail collapses so snapshots stay stable as the wording
589+
// evolves across runtime versions, while the agent_id is preserved.
590+
expect(toolMessage?.content).toBe(
591+
"Agent started in background with agent_id: read-file. ${background_agent_advice}",
550592
);
551593
});
552594

@@ -947,6 +989,163 @@ Always include PINEAPPLE_COCONUT_42.
947989
}
948990
});
949991

992+
test("matches truncated report_intent results against a runtime that emits the tool list", async () => {
993+
const cachePath = path.join(tempDir, "cache.yaml");
994+
// Older snapshots truncated the report_intent error to just the first
995+
// sentence, with no "Available tools..." enumeration recorded on disk.
996+
const cacheContent = yaml.stringify({
997+
models: ["test-model"],
998+
conversations: [
999+
{
1000+
messages: [
1001+
{ role: "system", content: "${system}" },
1002+
{ role: "user", content: "Report intent" },
1003+
{
1004+
role: "assistant",
1005+
tool_calls: [
1006+
{
1007+
id: "toolcall_0",
1008+
type: "function",
1009+
function: { name: "report_intent", arguments: "{}" },
1010+
},
1011+
],
1012+
},
1013+
{
1014+
role: "tool",
1015+
tool_call_id: "toolcall_0",
1016+
content: "Tool 'report_intent' does not exist.",
1017+
},
1018+
{ role: "assistant", content: "Done" },
1019+
],
1020+
},
1021+
],
1022+
} satisfies NormalizedData);
1023+
await writeFile(cachePath, cacheContent);
1024+
1025+
const proxy = new ReplayingCapiProxy(
1026+
"http://localhost:9999",
1027+
cachePath,
1028+
workDir,
1029+
);
1030+
const proxyUrl = await proxy.start();
1031+
1032+
try {
1033+
const response = await makeRequest(proxyUrl, "/chat/completions", {
1034+
body: {
1035+
model: "test-model",
1036+
messages: [
1037+
{ role: "system", content: "System prompt" },
1038+
{ role: "user", content: "Report intent" },
1039+
{
1040+
role: "assistant",
1041+
tool_calls: [
1042+
{
1043+
id: "runtime-call-id",
1044+
type: "function",
1045+
function: { name: "report_intent", arguments: "{}" },
1046+
},
1047+
],
1048+
},
1049+
{
1050+
role: "tool",
1051+
tool_call_id: "runtime-call-id",
1052+
// Newer runtime appends the full built-in tool enumeration.
1053+
content:
1054+
"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.",
1055+
},
1056+
],
1057+
},
1058+
});
1059+
1060+
expect(response.status).toBe(200);
1061+
expect(
1062+
(JSON.parse(response.body) as ChatCompletion).choices[0].message
1063+
.content,
1064+
).toBe("Done");
1065+
} finally {
1066+
await proxy.stop();
1067+
}
1068+
});
1069+
1070+
test("matches background-agent start results after the advice wording changes", async () => {
1071+
const cachePath = path.join(tempDir, "cache.yaml");
1072+
// Legacy snapshot recorded before the runtime appended multi-turn advice
1073+
// to the background-agent start message.
1074+
const cacheContent = yaml.stringify({
1075+
models: ["test-model"],
1076+
conversations: [
1077+
{
1078+
messages: [
1079+
{ role: "system", content: "${system}" },
1080+
{ role: "user", content: "Spawn an agent" },
1081+
{
1082+
role: "assistant",
1083+
tool_calls: [
1084+
{
1085+
id: "toolcall_0",
1086+
type: "function",
1087+
function: { name: "task", arguments: "{}" },
1088+
},
1089+
],
1090+
},
1091+
{
1092+
role: "tool",
1093+
tool_call_id: "toolcall_0",
1094+
content:
1095+
"Agent started in background with agent_id: read-file. You'll be notified when it completes. Tell the user you're waiting and end your response, or continue unrelated work until notified.",
1096+
},
1097+
{ role: "assistant", content: "Done" },
1098+
],
1099+
},
1100+
],
1101+
} satisfies NormalizedData);
1102+
await writeFile(cachePath, cacheContent);
1103+
1104+
const proxy = new ReplayingCapiProxy(
1105+
"http://localhost:9999",
1106+
cachePath,
1107+
workDir,
1108+
);
1109+
const proxyUrl = await proxy.start();
1110+
1111+
try {
1112+
const response = await makeRequest(proxyUrl, "/chat/completions", {
1113+
body: {
1114+
model: "test-model",
1115+
messages: [
1116+
{ role: "system", content: "System prompt" },
1117+
{ role: "user", content: "Spawn an agent" },
1118+
{
1119+
role: "assistant",
1120+
tool_calls: [
1121+
{
1122+
id: "runtime-call-id",
1123+
type: "function",
1124+
function: { name: "task", arguments: "{}" },
1125+
},
1126+
],
1127+
},
1128+
{
1129+
role: "tool",
1130+
tool_call_id: "runtime-call-id",
1131+
// Newer runtime appends multi-turn advice referencing write_agent.
1132+
content:
1133+
"Agent started in background with agent_id: read-file. You'll be notified when it completes. Tell the user you're waiting and end your response, or continue unrelated work until notified. The agent supports multi-turn conversations — use write_agent to send follow-up messages.",
1134+
},
1135+
],
1136+
},
1137+
});
1138+
1139+
expect(response.status).toBe(200);
1140+
expect(
1141+
(JSON.parse(response.body) as ChatCompletion).choices[0].message
1142+
.content,
1143+
).toBe("Done");
1144+
} finally {
1145+
await proxy.stop();
1146+
}
1147+
});
1148+
9501149
test("expands workdir placeholder in cached response", async () => {
9511150
const cachePath = path.join(tempDir, "cache.yaml");
9521151
const cacheContent = yaml.stringify({

test/harness/replayingCapiProxy.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export class ReplayingCapiProxy extends CapturingHttpProxy {
127127
{ toolName: "${shell}", normalizer: normalizeShellExitMarkers },
128128
{ toolName: "*", normalizer: normalizeGhAuthMessages },
129129
{ toolName: "*", normalizer: normalizeAvailableToolNames },
130+
{ toolName: "task", normalizer: normalizeBackgroundAgentAdvice },
130131
{ toolName: "read_agent", normalizer: normalizeReadAgentTimings },
131132
];
132133

@@ -1299,17 +1300,17 @@ function coalesceMessages(
12991300
return result;
13001301
}
13011302

1302-
// Re-normalizes the built-in tool enumeration in stored tool results at load
1303-
// time. Snapshots recorded before normalizeAvailableToolNames collapsed the
1304-
// whole list (or recorded against an older tool set) still contain the literal
1305-
// enumeration on disk; the result normalizers only run against live requests,
1306-
// so without this the stored side would keep the stale list and never match a
1307-
// request whose tool set has since changed.
1303+
// Re-normalizes stored tool results at load time. Snapshots recorded against an
1304+
// older runtime still contain the literal built-in tool enumeration (or the
1305+
// background-agent advice wording) on disk; the result normalizers only run
1306+
// against live requests, so without this the stored side would keep the stale
1307+
// text and never match a request whose runtime output has since changed.
13081308
function normalizeStoredToolMessages(conversations: NormalizedConversation[]) {
13091309
for (const conversation of conversations) {
13101310
for (const message of conversation.messages) {
13111311
if (message.role === "tool" && typeof message.content === "string") {
13121312
message.content = normalizeAvailableToolNames(message.content);
1313+
message.content = normalizeBackgroundAgentAdvice(message.content);
13131314
}
13141315
}
13151316
}
@@ -1405,21 +1406,35 @@ function normalizeReadAgentTimings(result: string): string {
14051406
.replace(/\bduration: \d+(?:\.\d+)?s\b/g, "duration: 0s");
14061407
}
14071408

1408-
// Stable placeholder for the built-in tool enumeration the runtime emits when a
1409-
// nonexistent tool is called (see normalizeAvailableToolNames).
1410-
export const availableToolsPlaceholder = "${available_tools}";
1411-
14121409
// When a model calls a tool that doesn't exist (e.g., the removed report_intent
1413-
// tool), the runtime replies with "Available tools that can be called are <list>."
1414-
// That enumeration is both platform-specific (shell tool family names differ
1415-
// across OSes) and runtime-version-specific (built-in tools such as write_agent
1416-
// are added or removed over time), so any test that trips this path would break
1417-
// whenever the tool set changes. Collapse the whole list to a stable placeholder
1418-
// so snapshots keep matching as the built-in tool set evolves.
1410+
// tool), the runtime replies with "Tool '<name>' does not exist. Available tools
1411+
// that can be called are <list>." Some runtime versions omit that second sentence
1412+
// entirely, and when present the enumeration is both platform-specific (shell tool
1413+
// family names differ across OSes) and runtime-version-specific (built-in tools
1414+
// such as write_agent are added or removed over time). Strip the whole "Available
1415+
// tools..." sentence so its presence, absence, and exact contents are all
1416+
// equivalent and snapshots keep matching as the built-in tool set evolves.
14191417
function normalizeAvailableToolNames(result: string): string {
14201418
return result.replace(
1421-
/(Available tools that can be called are )[^.]*/g,
1422-
(_full, prefix: string) => prefix + availableToolsPlaceholder,
1419+
/\s*Available tools that can be called are [^.]*\./g,
1420+
"",
1421+
);
1422+
}
1423+
1424+
// Stable placeholder for the advice text the runtime appends after starting a
1425+
// background agent (see normalizeBackgroundAgentAdvice).
1426+
export const backgroundAgentAdvicePlaceholder = "${background_agent_advice}";
1427+
1428+
// When the task tool starts an agent in the background, the runtime replies with
1429+
// "Agent started in background with agent_id: <id>." followed by advice text
1430+
// ("You'll be notified when it completes... The agent supports multi-turn
1431+
// conversations — use write_agent...") that changes across runtime versions.
1432+
// Collapse that trailing advice to a stable placeholder so snapshots keep
1433+
// matching as the wording evolves, while preserving the meaningful agent_id.
1434+
function normalizeBackgroundAgentAdvice(result: string): string {
1435+
return result.replace(
1436+
/(Agent started in background with agent_id: \S+\.)[\s\S]*/,
1437+
(_full, prefix: string) => `${prefix} ${backgroundAgentAdvicePlaceholder}`,
14231438
);
14241439
}
14251440

0 commit comments

Comments
 (0)