diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddbd8d17674..89c35758f17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai
- Models/fallback: resolve bare fallback model provider ids before model switching, so configured fallback chains keep working when a fallback is named without an explicit provider prefix. Thanks @steipete.
- Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload. Thanks @steipete.
- Providers/DeepSeek: wire V4 thinking controls and OpenAI-compatible replay policy so follow-up turns preserve DeepSeek `reasoning_content`, while the None/off thinking path strips replayed reasoning fields. Fixes #70931. Thanks @lsdsjy.
+- Providers/GitHub Copilot: align Copilot request headers across Anthropic and Responses transports, including tool-result and image follow-up turns, without enabling unverified Responses continuation. Thanks @steipete.
- Codex harness: send verbose tool progress to chat channels for native app-server runs, matching the Pi harness `/verbose on` and `/verbose full` behavior. (#70966) Thanks @jalehman.
- Codex models: fetch paginated Codex app-server model catalogs, mark truncated `/codex models` output, and keep ChatGPT OAuth defaults on the `openai-codex/gpt-5.5` route instead of the OpenAI API-key route. Thanks @steipete.
- Codex status: report Codex CLI OAuth as `oauth (codex-cli)` for native `codex/*` sessions instead of showing unknown auth. Fixes #70688. Thanks @jb510.
diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md
index cb5c353ea63..33f95c3b57b 100644
--- a/docs/providers/github-copilot.md
+++ b/docs/providers/github-copilot.md
@@ -90,6 +90,13 @@ openclaw models auth login --provider github-copilot --method device --set-defau
selects the correct transport based on the model ref.
+
+ OpenClaw sends Copilot IDE-style request headers on Copilot transports,
+ including tool-result and image follow-up turns. It does not enable
+ provider-level Responses continuation for Copilot unless that behavior has
+ been verified against Copilot's API.
+
+
OpenClaw resolves Copilot auth from environment variables in the following
priority order:
diff --git a/extensions/github-copilot/stream.test.ts b/extensions/github-copilot/stream.test.ts
index b0b345fb084..14d870e9eaa 100644
--- a/extensions/github-copilot/stream.test.ts
+++ b/extensions/github-copilot/stream.test.ts
@@ -93,7 +93,7 @@ describe("wrapCopilotAnthropicStream", () => {
expect(baseStreamFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), options);
});
- it("rewrites Copilot Responses connection-bound IDs before payload send", () => {
+ it("adds Copilot headers and rewrites 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) => {
@@ -106,6 +106,19 @@ describe("wrapCopilotAnthropicStream", () => {
});
const wrapped = wrapCopilotOpenAIResponsesStream(baseStreamFn);
+ const messages = [
+ {
+ role: "toolResult",
+ content: [
+ { type: "text", text: "look" },
+ { type: "image", image: "data:image/png;base64,abc" },
+ ],
+ },
+ ] as Parameters[0]["messages"];
+ const expectedCopilotHeaders = buildCopilotDynamicHeaders({
+ messages,
+ hasImages: true,
+ });
void wrapped(
{
@@ -113,10 +126,16 @@ describe("wrapCopilotAnthropicStream", () => {
api: "openai-responses",
id: "gpt-5.4",
} as never,
- { messages: [{ role: "user", content: "hi" }] } as never,
- {},
+ { messages } as never,
+ { headers: { "X-Test": "1" } },
);
+ expect(baseStreamFn.mock.calls[0]?.[2]).toMatchObject({
+ headers: {
+ ...expectedCopilotHeaders,
+ "X-Test": "1",
+ },
+ });
expect(payloads[0]?.input[0]?.id).toMatch(/^rs_[a-f0-9]{16}$/);
});
diff --git a/extensions/github-copilot/stream.ts b/extensions/github-copilot/stream.ts
index b2dc00a54df..2da6502a692 100644
--- a/extensions/github-copilot/stream.ts
+++ b/extensions/github-copilot/stream.ts
@@ -59,6 +59,13 @@ export function wrapCopilotOpenAIResponsesStream(baseStreamFn: StreamFn | undefi
const originalOnPayload = options?.onPayload;
const wrappedOptions: StreamOptions = {
...options,
+ headers: {
+ ...buildCopilotDynamicHeaders({
+ messages: context.messages,
+ hasImages: hasCopilotVisionInput(context.messages),
+ }),
+ ...options?.headers,
+ },
onPayload: (payload, payloadModel) => {
rewriteCopilotResponsePayloadConnectionBoundIds(payload);
return patchOnPayloadResult(originalOnPayload?.(payload, payloadModel));
diff --git a/src/agents/copilot-dynamic-headers.ts b/src/agents/copilot-dynamic-headers.ts
index 647198b5f51..58a6151a663 100644
--- a/src/agents/copilot-dynamic-headers.ts
+++ b/src/agents/copilot-dynamic-headers.ts
@@ -2,20 +2,38 @@ import type { Context } from "@mariozechner/pi-ai";
export const COPILOT_EDITOR_VERSION = "vscode/1.96.2";
export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7";
+export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
function inferCopilotInitiator(messages: Context["messages"]): "agent" | "user" {
const last = messages[messages.length - 1];
- return last && last.role !== "user" ? "agent" : "user";
+ if (!last) {
+ return "user";
+ }
+ if (last.role === "user" && containsCopilotContentType(last.content, "tool_result")) {
+ return "agent";
+ }
+ return last.role === "user" ? "user" : "agent";
+}
+
+function containsCopilotContentType(value: unknown, type: string): boolean {
+ if (Array.isArray(value)) {
+ return value.some((item) => containsCopilotContentType(item, type));
+ }
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+ const entry = value as { type?: unknown; content?: unknown };
+ return entry.type === type || containsCopilotContentType(entry.content, type);
}
export function hasCopilotVisionInput(messages: Context["messages"]): boolean {
return messages.some((message) => {
if (message.role === "user" && Array.isArray(message.content)) {
- return message.content.some((item) => item.type === "image");
+ return message.content.some((item) => containsCopilotContentType(item, "image"));
}
if (message.role === "toolResult" && Array.isArray(message.content)) {
- return message.content.some((item) => item.type === "image");
+ return message.content.some((item) => containsCopilotContentType(item, "image"));
}
return false;
});
@@ -28,6 +46,7 @@ export function buildCopilotIdeHeaders(
): Record {
return {
"Editor-Version": COPILOT_EDITOR_VERSION,
+ "Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
"User-Agent": COPILOT_USER_AGENT,
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
};
@@ -39,8 +58,9 @@ export function buildCopilotDynamicHeaders(params: {
}): Record {
return {
...buildCopilotIdeHeaders(),
- "X-Initiator": inferCopilotInitiator(params.messages),
- "Openai-Intent": "conversation-edits",
+ "Copilot-Integration-Id": "vscode-chat",
+ "Openai-Organization": "github-copilot",
+ "x-initiator": inferCopilotInitiator(params.messages),
...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}),
};
}
diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts
index 10e05ade4f5..d7f83b27638 100644
--- a/src/plugin-sdk/provider-stream-shared.test.ts
+++ b/src/plugin-sdk/provider-stream-shared.test.ts
@@ -1,9 +1,11 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
+ buildCopilotDynamicHeaders,
createHtmlEntityToolCallArgumentDecodingWrapper,
defaultToolStreamExtraParams,
decodeHtmlEntitiesInObject,
+ hasCopilotVisionInput,
} from "./provider-stream-shared.js";
type FakeWrappedStream = {
@@ -61,6 +63,71 @@ describe("defaultToolStreamExtraParams", () => {
});
});
+describe("buildCopilotDynamicHeaders", () => {
+ it("matches Copilot IDE-style request headers without the legacy Openai-Intent", () => {
+ expect(
+ buildCopilotDynamicHeaders({
+ messages: [{ role: "user", content: "hi", timestamp: 1 }],
+ hasImages: false,
+ }),
+ ).toMatchObject({
+ "Copilot-Integration-Id": "vscode-chat",
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
+ "Openai-Organization": "github-copilot",
+ "x-initiator": "user",
+ });
+ expect(
+ buildCopilotDynamicHeaders({
+ messages: [{ role: "user", content: "hi", timestamp: 1 }],
+ hasImages: false,
+ }),
+ ).not.toHaveProperty("Openai-Intent");
+ });
+
+ it("marks tool-result follow-up turns as agent initiated and vision-capable", () => {
+ expect(
+ buildCopilotDynamicHeaders({
+ messages: [
+ { role: "user", content: "hi", timestamp: 1 },
+ {
+ role: "toolResult",
+ content: [{ type: "image", data: "abc", mimeType: "image/png" }],
+ timestamp: 2,
+ toolCallId: "call_1",
+ toolName: "view_image",
+ isError: false,
+ },
+ ],
+ hasImages: true,
+ }),
+ ).toMatchObject({
+ "Copilot-Vision-Request": "true",
+ "x-initiator": "agent",
+ });
+ });
+
+ it("detects nested tool-result image blocks in user-shaped provider payloads", () => {
+ const messages = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ content: [{ type: "image", source: { data: "abc", media_type: "image/png" } }],
+ },
+ ],
+ timestamp: 1,
+ },
+ ] as unknown as Parameters[0]["messages"];
+
+ expect(hasCopilotVisionInput(messages)).toBe(true);
+ expect(buildCopilotDynamicHeaders({ messages, hasImages: true })).toMatchObject({
+ "Copilot-Vision-Request": "true",
+ "x-initiator": "agent",
+ });
+ });
+});
+
describe("createHtmlEntityToolCallArgumentDecodingWrapper", () => {
it("decodes tool call arguments in final and streaming messages", async () => {
const resultMessage = {