Skip to content

Commit c00300b

Browse files
stephentoubCopilot
andcommitted
Normalize parallel tool result order
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8ebc51a commit c00300b

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

test/harness/replayingCapiProxy.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,108 @@ describe("ReplayingCapiProxy", () => {
730730
}
731731
});
732732

733+
test("matches parallel tool results regardless of arrival order", async () => {
734+
const cachePath = path.join(tempDir, "cache.yaml");
735+
const cacheContent = yaml.stringify({
736+
models: ["test-model"],
737+
conversations: [
738+
{
739+
messages: [
740+
{ role: "system", content: "${system}" },
741+
{ role: "user", content: "Lookup city and country" },
742+
{
743+
role: "assistant",
744+
tool_calls: [
745+
{
746+
id: "toolcall_0",
747+
type: "function",
748+
function: {
749+
name: "lookup_city",
750+
arguments: '{"city":"Paris"}',
751+
},
752+
},
753+
{
754+
id: "toolcall_1",
755+
type: "function",
756+
function: {
757+
name: "lookup_country",
758+
arguments: '{"country":"France"}',
759+
},
760+
},
761+
],
762+
},
763+
{
764+
role: "tool",
765+
tool_call_id: "toolcall_0",
766+
content: "CITY_PARIS",
767+
},
768+
{
769+
role: "tool",
770+
tool_call_id: "toolcall_1",
771+
content: "COUNTRY_FRANCE",
772+
},
773+
{ role: "assistant", content: "Paris is in France." },
774+
],
775+
},
776+
],
777+
} satisfies NormalizedData);
778+
await writeFile(cachePath, cacheContent);
779+
780+
const proxy = new ReplayingCapiProxy(
781+
"http://localhost:9999",
782+
cachePath,
783+
workDir,
784+
);
785+
const proxyUrl = await proxy.start();
786+
787+
try {
788+
const response = await makeRequest(proxyUrl, "/chat/completions", {
789+
body: {
790+
model: "test-model",
791+
messages: [
792+
{ role: "system", content: "Be helpful" },
793+
{ role: "user", content: "Lookup city and country" },
794+
{
795+
role: "assistant",
796+
tool_calls: [
797+
{
798+
id: "city-id",
799+
type: "function",
800+
function: {
801+
name: "lookup_city",
802+
arguments: '{"city":"Paris"}',
803+
},
804+
},
805+
{
806+
id: "country-id",
807+
type: "function",
808+
function: {
809+
name: "lookup_country",
810+
arguments: '{"country":"France"}',
811+
},
812+
},
813+
],
814+
},
815+
{
816+
role: "tool",
817+
tool_call_id: "country-id",
818+
content: "COUNTRY_FRANCE",
819+
},
820+
{ role: "tool", tool_call_id: "city-id", content: "CITY_PARIS" },
821+
],
822+
},
823+
});
824+
825+
expect(response.status).toBe(200);
826+
expect(
827+
(JSON.parse(response.body) as ChatCompletion).choices[0].message
828+
.content,
829+
).toBe("Paris is in France.");
830+
} finally {
831+
await proxy.stop();
832+
}
833+
});
834+
733835
test("returns streaming response when stream: true", async () => {
734836
const cachePath = path.join(tempDir, "cache.yaml");
735837
const cacheContent = yaml.stringify({

test/harness/replayingCapiProxy.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ async function transformHttpExchanges(
677677
);
678678

679679
normalizeToolCalls(dedupedExchanges, toolResultNormalizers);
680+
normalizeToolResultOrder(dedupedExchanges);
680681
normalizeFilenames(dedupedExchanges, workDir);
681682
return { models: Array.from(dedupedModels), conversations: dedupedExchanges };
682683
}
@@ -782,6 +783,51 @@ function normalizeToolCalls(
782783
}
783784
}
784785

786+
function normalizeToolResultOrder(conversations: NormalizedConversation[]) {
787+
for (const conv of conversations) {
788+
for (let start = 0; start < conv.messages.length; ) {
789+
if (conv.messages[start].role !== "tool") {
790+
start++;
791+
continue;
792+
}
793+
794+
let end = start + 1;
795+
while (end < conv.messages.length && conv.messages[end].role === "tool") {
796+
end++;
797+
}
798+
799+
conv.messages
800+
.slice(start, end)
801+
.sort(compareToolResultMessages)
802+
.forEach((message, index) => {
803+
conv.messages[start + index] = message;
804+
});
805+
start = end;
806+
}
807+
}
808+
}
809+
810+
function compareToolResultMessages(
811+
left: NormalizedMessage,
812+
right: NormalizedMessage,
813+
) {
814+
return compareToolCallIds(left.tool_call_id, right.tool_call_id);
815+
}
816+
817+
function compareToolCallIds(left?: string, right?: string) {
818+
const leftNumber = parseNormalizedToolCallId(left);
819+
const rightNumber = parseNormalizedToolCallId(right);
820+
if (leftNumber !== undefined && rightNumber !== undefined) {
821+
return leftNumber - rightNumber;
822+
}
823+
return (left ?? "").localeCompare(right ?? "");
824+
}
825+
826+
function parseNormalizedToolCallId(id?: string) {
827+
const match = id?.match(/^toolcall_(\d+)$/);
828+
return match ? Number(match[1]) : undefined;
829+
}
830+
785831
// As we capture LLM calls, we see:
786832
// - Request A, response AB
787833
// - Request ABC, response ABCD

0 commit comments

Comments
 (0)