diff --git a/CHANGELOG.md b/CHANGELOG.md index f066d3d623f..8fdb6f01201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only. +- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci. - Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc. - Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc. - Cron/MCP: retire bundled MCP runtimes through one shared cleanup path for isolated cron run ends, persistent cron session rollover, and direct cron `deleteAfterRun` fallback cleanup. Fixes #69145, #68623, and #68827. diff --git a/extensions/github-copilot/connection-bound-ids.test.ts b/extensions/github-copilot/connection-bound-ids.test.ts new file mode 100644 index 00000000000..3051b2eb187 --- /dev/null +++ b/extensions/github-copilot/connection-bound-ids.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + rewriteCopilotConnectionBoundResponseIds, + rewriteCopilotResponsePayloadConnectionBoundIds, +} from "./connection-bound-ids.js"; + +describe("github-copilot connection-bound response IDs", () => { + it("rewrites opaque response item IDs deterministically", () => { + const originalId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64"); + const first = [{ id: originalId, type: "reasoning" }]; + const second = [{ id: originalId, type: "reasoning" }]; + + expect(rewriteCopilotConnectionBoundResponseIds(first)).toBe(true); + expect(rewriteCopilotConnectionBoundResponseIds(second)).toBe(true); + expect(first[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/); + expect(first[0]?.id).toBe(second[0]?.id); + }); + + it("uses response item type prefixes and preserves local IDs", () => { + const functionCallId = Buffer.from(`function-call-${"y".repeat(20)}`).toString("base64"); + const messageId = Buffer.from(`message-${"z".repeat(24)}`).toString("base64"); + const input = [ + { id: "rs_existing", type: "reasoning" }, + { id: "msg_existing", type: "message" }, + { id: "fc_existing", type: "function_call" }, + { id: functionCallId, type: "function_call" }, + { id: messageId, type: "message" }, + ]; + + expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(true); + expect(input[0]?.id).toBe("rs_existing"); + expect(input[1]?.id).toBe("msg_existing"); + expect(input[2]?.id).toBe("fc_existing"); + expect(input[3]?.id).toMatch(/^fc_[a-f0-9]{16}$/); + expect(input[4]?.id).toMatch(/^msg_[a-f0-9]{16}$/); + }); + + it("patches response payload input arrays only", () => { + const messageId = Buffer.from(`message-${"m".repeat(24)}`).toString("base64"); + const payload = { input: [{ id: messageId, type: "message" }] }; + + expect(rewriteCopilotResponsePayloadConnectionBoundIds(payload)).toBe(true); + expect(payload.input[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/); + expect(rewriteCopilotResponsePayloadConnectionBoundIds(undefined)).toBe(false); + expect(rewriteCopilotResponsePayloadConnectionBoundIds({ input: "text" })).toBe(false); + }); +}); diff --git a/extensions/github-copilot/connection-bound-ids.ts b/extensions/github-copilot/connection-bound-ids.ts new file mode 100644 index 00000000000..f066473dd99 --- /dev/null +++ b/extensions/github-copilot/connection-bound-ids.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; + +// Copilot's OpenAI-compatible `/responses` endpoint can emit replay item IDs +// that encode upstream connection state. Those IDs are rejected after the +// connection changes, so normalize them at the provider boundary before send. + +function looksLikeConnectionBoundId(id: string): boolean { + if (id.length < 24) { + return false; + } + if (/^(?:rs|msg|fc)_[A-Za-z0-9_-]+$/.test(id)) { + return false; + } + if (!/^[A-Za-z0-9+/_-]+=*$/.test(id)) { + return false; + } + return Buffer.from(id, "base64").length >= 16; +} + +function deriveReplacementId(type: string | undefined, originalId: string): string { + const prefix = type === "reasoning" ? "rs" : type === "function_call" ? "fc" : "msg"; + const hex = createHash("sha256").update(originalId).digest("hex").slice(0, 16); + return `${prefix}_${hex}`; +} + +type InputItem = Record & { id?: unknown; type?: unknown }; + +export function rewriteCopilotConnectionBoundResponseIds(input: unknown): boolean { + if (!Array.isArray(input)) { + return false; + } + let rewrote = false; + for (const item of input as InputItem[]) { + const id = item.id; + if (typeof id !== "string" || id.length === 0) { + continue; + } + if (looksLikeConnectionBoundId(id)) { + item.id = deriveReplacementId(typeof item.type === "string" ? item.type : undefined, id); + rewrote = true; + } + } + return rewrote; +} + +export function rewriteCopilotResponsePayloadConnectionBoundIds(payload: unknown): boolean { + if (!payload || typeof payload !== "object") { + return false; + } + return rewriteCopilotConnectionBoundResponseIds((payload as { input?: unknown }).input); +} diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts index 120e7b003b9..b0b345fb084 100644 --- a/extensions/github-copilot/stream.test.ts +++ b/extensions/github-copilot/stream.test.ts @@ -1,6 +1,10 @@ import { buildCopilotDynamicHeaders } from "openclaw/plugin-sdk/provider-stream-shared"; import { describe, expect, it, vi } from "vitest"; -import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js"; +import { + wrapCopilotAnthropicStream, + wrapCopilotOpenAIResponsesStream, + wrapCopilotProviderStream, +} from "./stream.js"; describe("wrapCopilotAnthropicStream", () => { it("adds Copilot headers and Anthropic cache markers for Claude payloads", async () => { @@ -89,6 +93,62 @@ describe("wrapCopilotAnthropicStream", () => { expect(baseStreamFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), options); }); + it("rewrites Copilot Responses connection-bound IDs before payload send", () => { + const connectionBoundId = Buffer.from(`reasoning-${"x".repeat(24)}`).toString("base64"); + const payloads: Array<{ input: Array> }> = []; + const baseStreamFn = vi.fn((_model, _context, options) => { + const payload = { input: [{ id: connectionBoundId, type: "reasoning" }] }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return { + async *[Symbol.asyncIterator]() {}, + } as never; + }); + + const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn); + + void wrapped( + { + provider: "github-copilot", + api: "openai-responses", + id: "gpt-5.4", + } as never, + { messages: [{ role: "user", content: "hi" }] } as never, + {}, + ); + + expect(payloads[0]?.input[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/); + }); + + it("rewrites Copilot Responses IDs returned by an existing payload hook", async () => { + const connectionBoundId = Buffer.from(`message-${"y".repeat(24)}`).toString("base64"); + let returnedPayload: unknown; + const baseStreamFn = vi.fn(async (_model, _context, options) => { + returnedPayload = await options?.onPayload?.({ input: [] }, _model); + return { + async *[Symbol.asyncIterator]() {}, + } as never; + }); + + const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn); + + await wrapped( + { + provider: "github-copilot", + api: "openai-responses", + id: "gpt-5.4", + } as never, + { messages: [{ role: "user", content: "hi" }] } as never, + { + onPayload: () => ({ input: [{ id: connectionBoundId, type: "message" }] }), + } as never, + ); + + expect((returnedPayload as { input: Array> }).input[0]?.id).toMatch( + /^msg_[a-f0-9]{16}$/, + ); + }); + it("adapts provider stream context without changing wrapper behavior", () => { const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never); diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts index 99da262b728..b2dc00a54df 100644 --- a/extensions/github-copilot/stream.ts +++ b/extensions/github-copilot/stream.ts @@ -7,8 +7,21 @@ import { hasCopilotVisionInput, streamWithPayloadPatch, } from "openclaw/plugin-sdk/provider-stream-shared"; +import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js"; type _StreamContext = Parameters[1]; +type StreamOptions = Parameters[2]; + +function patchOnPayloadResult(result: unknown): unknown { + if (result && typeof result === "object" && "then" in result) { + return Promise.resolve(result).then((next) => { + rewriteCopilotResponsePayloadConnectionBoundIds(next); + return next; + }); + } + rewriteCopilotResponsePayloadConnectionBoundIds(result); + return result; +} export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; @@ -36,6 +49,25 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): }; } -export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn { - return wrapCopilotAnthropicStream(ctx.streamFn); +export function wrapCopilotOpenAIResponsesStream(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if (model.provider !== "github-copilot" || model.api !== "openai-responses") { + return underlying(model, context, options); + } + + const originalOnPayload = options?.onPayload; + const wrappedOptions: StreamOptions = { + ...options, + onPayload: (payload, payloadModel) => { + rewriteCopilotResponsePayloadConnectionBoundIds(payload); + return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel)); + }, + }; + return underlying(model, context, wrappedOptions); + }; +} + +export function wrapCopilotProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn { + return wrapCopilotOpenAIResponsesStream(wrapCopilotAnthropicStream(ctx.streamFn)); }