fix(copilot): prevent invalid Claude tool IDs from causing retries (#117789)

* fix(copilot): normalize Claude tool IDs before requests

* fix(copilot): preserve typed payload and stream contracts

* fix(copilot): preserve unique paired Anthropic tool IDs
This commit is contained in:
Peter Steinberger
2026-08-01 22:59:12 -07:00
committed by GitHub
parent fc0371bbe7
commit de6bd7d1bc
2 changed files with 529 additions and 5 deletions
+417 -1
View File
@@ -1,5 +1,8 @@
// Github Copilot tests cover stream plugin behavior.
import type { Context } from "openclaw/plugin-sdk/llm";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple, type Context, type Model } from "openclaw/plugin-sdk/llm";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { describe, expect, it, vi } from "vitest";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
@@ -39,6 +42,386 @@ function buildExpectedCopilotHeaders(
}
describe("wrapCopilotAnthropicStream", () => {
it("normalizes Copilot Claude wire tool IDs without mutating the persisted transcript", () => {
const model = {
provider: "github-copilot",
api: "anthropic-messages",
id: "claude-sonnet-4.6",
} as never;
const sourceIds = [
"toolu_native_123",
"already-valid_id-456",
"pipe|value",
"dot.value",
"colon:value",
"slash/value",
"space value",
"functions.read:0",
`toolu_${"x".repeat(80)}`,
];
const messages = [
{ role: "user", content: "Use each tool" },
{
role: "assistant",
provider: "github-copilot",
api: "anthropic-messages",
model: "claude-sonnet-4.6",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "signature" },
...sourceIds.map((id) => ({ type: "toolCall", id, name: "read", arguments: {} })),
],
},
...sourceIds.map((id) => ({
role: "toolResult",
toolCallId: id,
toolName: "read",
content: [{ type: "text", text: `result for ${id}` }],
})),
] as Context["messages"];
const persistedTranscript = structuredClone(messages);
let observedPayload: { messages: Array<{ role: string; content: unknown }> } | undefined;
const baseStreamFn = vi.fn<StreamFn>((streamModel, context, options) => {
const payload = {
messages: context.messages.map((message) => {
if (message.role === "toolResult") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: message.toolCallId,
content: message.content,
},
],
};
}
if (message.role !== "assistant") {
return { role: message.role, content: message.content };
}
return {
role: "assistant",
content: message.content.map((block) =>
block.type === "toolCall"
? { type: "tool_use", id: block.id, name: block.name, input: block.arguments }
: block,
),
};
}),
};
options?.onPayload?.(payload, streamModel);
observedPayload = payload;
return { async *[Symbol.asyncIterator]() {} } as never;
});
void requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn))(model, { messages }, {});
const outbound = observedPayload?.messages ?? [];
const assistant = outbound.find((message) => message.role === "assistant");
const assistantBlocks = Array.isArray(assistant?.content) ? assistant.content : [];
const outboundIds = assistantBlocks
.filter((block): block is { type: "tool_use"; id: string } => block.type === "tool_use")
.map((block) => block.id);
const resultIds = outbound.flatMap((message) =>
Array.isArray(message.content)
? message.content
.filter(
(block): block is { type: "tool_result"; tool_use_id: string } =>
block.type === "tool_result",
)
.map((block) => block.tool_use_id)
: [],
);
expect(outboundIds).toEqual([
"toolu_native_123",
"already-valid_id-456",
"pipe_value",
"dot_value",
"colon_value",
"slash_value",
"space_value",
"functions_read_0",
`toolu_${"x".repeat(58)}`,
]);
expect(resultIds).toEqual(outboundIds);
expect(assistantBlocks.some((block) => block.type === "thinking")).toBe(false);
expect(messages).toEqual(persistedTranscript);
});
it("uniquely pairs colliding wire IDs, preserves valid IDs, and remains idempotent", () => {
const longPrefix = "x".repeat(64);
const sourceIds = [
"a.b",
"a:b",
"a_b",
"a_b_2",
"a.b",
"native_id",
"native_id",
"native_id_2",
`${longPrefix}first`,
`${longPrefix}second`,
longPrefix,
];
const expectedIds = [
"a_b_3",
"a_b_4",
"a_b",
"a_b_2",
"a_b_5",
"native_id",
"native_id_3",
"native_id_2",
`${"x".repeat(62)}_2`,
`${"x".repeat(62)}_3`,
longPrefix,
];
const toolUseBlocks = sourceIds.map((id) => ({
type: "tool_use",
id,
name: "read",
input: {},
}));
const toolResultBlocks = [...sourceIds, "a.b"].map((tool_use_id) => ({
type: "tool_result",
tool_use_id,
content: "done",
}));
const payload = {
messages: [
{
role: "assistant",
content: toolUseBlocks,
},
{
role: "user",
content: toolResultBlocks,
},
],
};
const baseStreamFn = vi.fn<StreamFn>((model, _context, options) => {
options?.onPayload?.(payload, model);
const patchedOnce = structuredClone(payload);
options?.onPayload?.(payload, model);
expect(payload).toEqual(patchedOnce);
return { async *[Symbol.asyncIterator]() {} } as never;
});
void requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn))(
{ provider: "github-copilot", api: "anthropic-messages", id: "claude-sonnet-4.6" } as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
const toolUseIds = toolUseBlocks.map((block) => block.id);
const toolResultIds = toolResultBlocks.map((block) => block.tool_use_id);
expect(toolUseIds).toEqual(expectedIds);
expect(toolResultIds).toEqual([...expectedIds, "a_b_5"]);
expect(new Set(toolUseIds).size).toBe(expectedIds.length);
expect(toolUseIds.every((id) => /^[a-zA-Z0-9_-]{1,64}$/.test(id))).toBe(true);
});
it.each(["sync", "async", "sync in-place", "async in-place"] as const)(
"normalizes Copilot Claude payloads returned by a %s caller hook",
async (hookType) => {
let returnedPayload: unknown;
const baseStreamFn = vi.fn(async (model, _context, options) => {
const initialPayload = { messages: [{ role: "user", content: "initial request" }] };
const replacement = await options?.onPayload?.(initialPayload, model);
returnedPayload = replacement ?? initialPayload;
return { async *[Symbol.asyncIterator]() {} } as never;
});
const replacement = {
messages: [
{ role: "system", content: "replacement system prompt" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private" },
{ type: "tool_use", id: "functions.read:0", name: "read", input: {} },
{ type: "tool_use", id: "a.b", name: "read", input: {} },
{ type: "tool_use", id: "a:b", name: "read", input: {} },
{ type: "tool_use", id: "a_b", name: "read", input: {} },
{ type: "tool_use", id: "a.b", name: "read", input: {} },
],
},
{
role: "user",
content: ["functions.read:0", "a.b", "a:b", "a_b", "a.b"].map((tool_use_id) => ({
type: "tool_result",
tool_use_id,
content: "done",
})),
},
],
};
await requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn))(
{ provider: "github-copilot", api: "anthropic-messages", id: "claude-sonnet-4.6" } as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{
onPayload: (payload) => {
const inPlace = hookType.endsWith("in-place");
if (inPlace && payload && typeof payload === "object") {
Object.assign(payload, replacement);
}
const result = inPlace ? undefined : replacement;
return hookType.startsWith("async") ? Promise.resolve(result) : result;
},
},
);
expect(returnedPayload).toEqual({
messages: [
{
role: "system",
content: [
{
type: "text",
text: "replacement system prompt",
cache_control: { type: "ephemeral" },
},
],
},
{
role: "assistant",
content: ["functions_read_0", "a_b_2", "a_b_3", "a_b", "a_b_4"].map((id) => ({
type: "tool_use",
id,
name: "read",
input: {},
})),
},
{
role: "user",
content: ["functions_read_0", "a_b_2", "a_b_3", "a_b", "a_b_4"].map((tool_use_id) => ({
type: "tool_result",
tool_use_id,
content: "done",
})),
},
],
});
},
);
it("sends uniquely paired IDs through the actual Anthropic SDK and loopback HTTP", async () => {
const requests: Array<{
path: string;
messages: Array<{ role: string; content: Array<Record<string, unknown>> | string }>;
}> = [];
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
const payload = JSON.parse(body) as { messages: (typeof requests)[number]["messages"] };
requests.push({ path: request.url ?? "", messages: payload.messages });
response.writeHead(200, { "content-type": "text/event-stream" });
response.write(
"event: message_start\ndata: " +
JSON.stringify({
type: "message_start",
message: { id: "msg_loopback", usage: { input_tokens: 1, output_tokens: 0 } },
}) +
"\n\nevent: message_stop\ndata: " +
JSON.stringify({ type: "message_stop" }) +
"\n\n",
);
response.end();
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address() as AddressInfo;
const longPrefix = "x".repeat(64);
const sourceIds = ["a.b", "a:b", "a_b", `${longPrefix}first`, `${longPrefix}second`];
const model = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "github-copilot",
api: "anthropic-messages",
baseUrl: `http://127.0.0.1:${address.port}`,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 4_096,
} satisfies Model<"anthropic-messages">;
const context = {
messages: [
{ role: "user", content: "Use each tool", timestamp: 1 },
{
role: "assistant",
provider: "github-copilot",
api: "anthropic-messages",
model: model.id,
content: sourceIds.map((id) => ({
type: "toolCall" as const,
id,
name: "read",
arguments: {},
})),
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
...sourceIds.map((toolCallId, index) => ({
role: "toolResult" as const,
toolCallId,
toolName: "read",
content: [{ type: "text" as const, text: `result ${index}` }],
isError: false,
timestamp: index + 3,
})),
],
} satisfies Context;
try {
const stream = await requireStreamFn(wrapCopilotAnthropicStream(streamSimple))(
model,
context,
{ apiKey: "copilot-token", maxRetries: 0 },
);
const result = await stream.result();
expect(result.stopReason).toBe("stop");
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
expect(requests).toHaveLength(1);
expect(requests[0]?.path).toBe("/v1/messages");
const wireBlocks = requests[0]?.messages.flatMap((message) =>
Array.isArray(message.content) ? message.content : [],
);
const toolUseIds = wireBlocks
?.filter((block) => block.type === "tool_use")
.map((block) => block.id);
const toolResultIds = wireBlocks
?.filter((block) => block.type === "tool_result")
.map((block) => block.tool_use_id);
const expectedIds = ["a_b_2", "a_b_3", "a_b", longPrefix, `${"x".repeat(62)}_2`];
expect(toolUseIds).toEqual(expectedIds);
expect(toolResultIds).toEqual(expectedIds);
expect(new Set(toolUseIds).size).toBe(expectedIds.length);
});
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
@@ -173,6 +556,39 @@ describe("wrapCopilotAnthropicStream", () => {
expect(baseStreamFn.mock.calls).toEqual([[model, context, options]]);
});
it.each([
{ provider: "anthropic", id: "claude-sonnet-4-6", toolId: "toolu_native_123" },
{ provider: "kimi", id: "k2p5", toolId: "functions.read:0" },
])("does not patch unrelated $provider Anthropic streams", (model) => {
const payload = {
messages: [
{ role: "assistant", content: [{ type: "tool_use", id: model.toolId }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: model.toolId }] },
],
};
const baseStreamFn = vi.fn((streamModel, _context, streamOptions) => {
streamOptions?.onPayload?.(payload, streamModel);
return { async *[Symbol.asyncIterator]() {} } as never;
});
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
const streamModel = {
provider: model.provider,
id: model.id,
api: "anthropic-messages",
} as never;
const context = { messages: [{ role: "user", content: "hi" }] } as never;
const options = { headers: { Existing: "1" }, onPayload: vi.fn() };
void wrapped(streamModel, context, options as never);
expect(baseStreamFn.mock.calls).toEqual([[streamModel, context, options]]);
expect(payload.messages[0]?.content[0]).toEqual({ type: "tool_use", id: model.toolId });
expect(payload.messages[1]?.content[0]).toEqual({
type: "tool_result",
tool_use_id: model.toolId,
});
});
it("adds Copilot headers, sanitizes reasoning replay, and rewrites message IDs before payload send", () => {
const reasoningId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64");
const overlongReasoningId = `5PX6gLHXT5wE+Y2tPmUV4gn+${"B".repeat(384)}`;
+112 -4
View File
@@ -57,14 +57,18 @@ function buildCopilotDynamicHeaders(params: {
};
}
function patchOnPayloadResult(result: unknown): unknown {
function patchOnPayloadResult(
result: unknown,
patchPayload: (payload: unknown) => unknown = sanitizeCopilotReplayResponsePayload,
fallbackPayload?: unknown,
): unknown {
if (result && typeof result === "object" && "then" in result) {
return Promise.resolve(result).then((next) => {
sanitizeCopilotReplayResponsePayload(next);
patchPayload(next === undefined ? fallbackPayload : next);
return next;
});
}
sanitizeCopilotReplayResponsePayload(result);
patchPayload(result === undefined ? fallbackPayload : result);
return result;
}
@@ -81,9 +85,102 @@ function buildCopilotRequestHeaders(
};
}
type CopilotAnthropicToolBlock = {
record: Record<string, unknown>;
idKey: "id" | "tool_use_id";
rawId: string;
};
function normalizeCopilotAnthropicToolIds(messages: unknown[]): void {
const blocks: CopilotAnthropicToolBlock[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") {
continue;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
continue;
}
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const record = block as Record<string, unknown>;
const idKey =
record.type === "tool_use" ? "id" : record.type === "tool_result" ? "tool_use_id" : null;
const rawId = idKey ? record[idKey] : undefined;
if (idKey && typeof rawId === "string") {
blocks.push({ record, idKey, rawId });
}
}
}
// Reserve valid IDs globally so an earlier invalid call cannot steal the ID
// of a later native call; replaying this payload patch must also be stable.
const validId = /^[a-zA-Z0-9_-]{1,64}$/;
const reserved = new Set(
blocks
.filter((block) => block.idKey === "id" && validId.test(block.rawId))
.map((block) => block.rawId),
);
const used = new Set(reserved);
const claimedValid = new Set<string>();
const pendingByRawId = new Map<string, string[]>();
const lastResolvedByRawId = new Map<string, string>();
const allocate = (rawId: string): string => {
if (validId.test(rawId) && !claimedValid.has(rawId)) {
claimedValid.add(rawId);
return rawId;
}
const base = rawId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool";
if (!used.has(base)) {
used.add(base);
return base;
}
for (let occurrence = 2; ; occurrence += 1) {
const suffix = `_${occurrence}`;
const candidate = `${base.slice(0, 64 - suffix.length)}${suffix}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
}
};
for (const block of blocks) {
if (block.idKey === "id") {
const wireId = allocate(block.rawId);
const pending = pendingByRawId.get(block.rawId);
if (pending) {
pending.push(wireId);
} else {
pendingByRawId.set(block.rawId, [wireId]);
}
block.record.id = wireId;
continue;
}
// Upstream projection can collapse distinct raw calls to the same string;
// consume occurrences in order so each result answers its own tool call.
const pending = pendingByRawId.get(block.rawId);
const wireId =
pending?.shift() ?? lastResolvedByRawId.get(block.rawId) ?? allocate(block.rawId);
if (pending?.length === 0) {
pendingByRawId.delete(block.rawId);
}
lastResolvedByRawId.set(block.rawId, wireId);
block.record.tool_use_id = wireId;
}
}
function patchCopilotAnthropicPayload(payload: Record<string, unknown>): void {
if (Array.isArray(payload.messages)) {
payload.messages = stripCopilotAssistantThinkingMessages(payload.messages);
const messages = stripCopilotAssistantThinkingMessages(payload.messages);
payload.messages = messages;
normalizeCopilotAnthropicToolIds(messages);
}
applyAnthropicEphemeralCacheControlMarkers(payload);
}
@@ -100,6 +197,7 @@ export function wrapCopilotAnthropicStream(
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return streamWithPayloadPatch(
underlying,
model,
@@ -107,6 +205,16 @@ export function wrapCopilotAnthropicStream(
{
...options,
headers: buildCopilotRequestHeaders(context, options?.headers),
onPayload: (payload, payloadModel) =>
patchOnPayloadResult(
originalOnPayload?.(payload, payloadModel),
(replacement) => {
if (replacement && typeof replacement === "object") {
patchCopilotAnthropicPayload(replacement as Record<string, unknown>);
}
},
payload,
),
},
patchCopilotAnthropicPayload,
);