diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index 25e467ba114e..33dac38a3a5a 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -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((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((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> | 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((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((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>; @@ -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)}`; diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 68a3c923eff1..2cca85c2f539 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -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; + 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; + 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(); + const pendingByRawId = new Map(); + const lastResolvedByRawId = new Map(); + + 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): 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); + } + }, + payload, + ), }, patchCopilotAnthropicPayload, );