Skip to content

Commit 2a7f9f3

Browse files
committed
fix: avoid Copilot replay item ID collisions
1 parent f2a0b3d commit 2a7f9f3

2 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2377,6 +2377,87 @@ describe("openai transport stream", () => {
23772377
}
23782378
});
23792379

2380+
it("keeps distinct overlong Copilot Responses replay item ids distinct", () => {
2381+
const sharedToolItemPrefix = "iVec" + "A".repeat(160);
2382+
const firstToolCallId = `call_first|${sharedToolItemPrefix}Aa`;
2383+
const secondToolCallId = `call_second|${sharedToolItemPrefix}BB`;
2384+
const params = buildOpenAIResponsesParams(
2385+
{
2386+
id: "gpt-5.5",
2387+
name: "GPT-5.5",
2388+
api: "openai-responses",
2389+
provider: "github-copilot",
2390+
baseUrl: "https://api.githubcopilot.com",
2391+
reasoning: true,
2392+
input: ["text"],
2393+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2394+
contextWindow: 200000,
2395+
maxTokens: 8192,
2396+
} satisfies Model<"openai-responses">,
2397+
{
2398+
systemPrompt: "system",
2399+
messages: [
2400+
{
2401+
role: "assistant",
2402+
api: "openai-responses",
2403+
provider: "github-copilot",
2404+
model: "gpt-5.5",
2405+
usage: {
2406+
input: 0,
2407+
output: 0,
2408+
cacheRead: 0,
2409+
cacheWrite: 0,
2410+
totalTokens: 0,
2411+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2412+
},
2413+
stopReason: "toolUse",
2414+
timestamp: 1,
2415+
content: [
2416+
{ type: "toolCall", id: firstToolCallId, name: "read", arguments: { path: "a" } },
2417+
{ type: "toolCall", id: secondToolCallId, name: "read", arguments: { path: "b" } },
2418+
],
2419+
},
2420+
{
2421+
role: "toolResult",
2422+
toolCallId: firstToolCallId,
2423+
toolName: "read",
2424+
content: [{ type: "text", text: "a" }],
2425+
isError: false,
2426+
timestamp: 2,
2427+
},
2428+
{
2429+
role: "toolResult",
2430+
toolCallId: secondToolCallId,
2431+
toolName: "read",
2432+
content: [{ type: "text", text: "b" }],
2433+
isError: false,
2434+
timestamp: 3,
2435+
},
2436+
{ role: "user", content: "continue", timestamp: 4 },
2437+
],
2438+
tools: [],
2439+
} as never,
2440+
{ sessionId: "session-123" },
2441+
) as {
2442+
input?: Array<{ type?: string; id?: string; call_id?: string }>;
2443+
};
2444+
2445+
const functionCalls = params.input?.filter((item) => item.type === "function_call") ?? [];
2446+
const functionOutputs =
2447+
params.input?.filter((item) => item.type === "function_call_output") ?? [];
2448+
expect(functionCalls).toHaveLength(2);
2449+
expect(functionOutputs).toHaveLength(2);
2450+
expect(functionCalls.map((item) => item.id)).toEqual([
2451+
expect.stringMatching(/^fc_/),
2452+
expect.stringMatching(/^fc_/),
2453+
]);
2454+
expect(new Set(functionCalls.map((item) => item.id)).size).toBe(2);
2455+
for (const item of functionCalls) {
2456+
expect(item.id?.length).toBeLessThanOrEqual(64);
2457+
}
2458+
expect(functionOutputs.map((item) => item.call_id)).toEqual(["call_first", "call_second"]);
2459+
});
2460+
23802461
it("adds minimal user input for Codex responses when only the system prompt is present", () => {
23812462
const params = buildOpenAIResponsesParams(
23822463
{

src/agents/openai-transport-stream.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { randomUUID } from "node:crypto";
1+
import { createHash, randomUUID } from "node:crypto";
22
import type { StreamFn } from "@earendil-works/pi-agent-core";
33
import {
44
calculateCost,
@@ -721,11 +721,7 @@ export function resolveAzureOpenAIApiVersion(env = process.env): string {
721721
}
722722

723723
function shortHash(value: string): string {
724-
let hash = 0;
725-
for (let i = 0; i < value.length; i += 1) {
726-
hash = (hash * 31 + value.charCodeAt(i)) | 0;
727-
}
728-
return Math.abs(hash).toString(36);
724+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
729725
}
730726

731727
function encodeTextSignatureV1(id: string, phase?: "commentary" | "final_answer"): string {
@@ -768,15 +764,21 @@ function convertResponsesMessages(
768764
const shouldReplayReasoningItems = options?.replayReasoningItems ?? true;
769765
const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true;
770766
const shouldNormalizeSameModelToolCallIds = model.provider === "github-copilot";
767+
const sanitizeIdPart = (part: string) => part.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+$/, "");
771768
const normalizeIdPart = (part: string) => {
772-
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
769+
const sanitized = sanitizeIdPart(part);
773770
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
774771
return normalized.replace(/_+$/, "");
775772
};
776773
const buildForeignResponsesItemId = (itemId: string) => {
777774
const normalized = `fc_${shortHash(itemId)}`;
778775
return normalized.length > 64 ? normalized.slice(0, 64) : normalized;
779776
};
777+
const buildSameProviderCopilotResponsesItemId = (itemId: string) => {
778+
const sanitized = sanitizeIdPart(itemId);
779+
const candidate = sanitized.startsWith("fc_") ? sanitized : `fc_${sanitized}`;
780+
return candidate.length > 64 ? buildForeignResponsesItemId(itemId) : candidate;
781+
};
780782
const normalizeToolCallId = (
781783
id: string,
782784
_targetModel: Model<Api>,
@@ -793,7 +795,9 @@ function convertResponsesMessages(
793795
const isForeignToolCall = source.provider !== model.provider || source.api !== model.api;
794796
let normalizedItemId = isForeignToolCall
795797
? buildForeignResponsesItemId(itemId)
796-
: normalizeIdPart(itemId);
798+
: model.provider === "github-copilot"
799+
? buildSameProviderCopilotResponsesItemId(itemId)
800+
: normalizeIdPart(itemId);
797801
if (!normalizedItemId.startsWith("fc_")) {
798802
normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`);
799803
}

0 commit comments

Comments
 (0)