fix(copilot): strip replayed thinking blocks

Remove replayed thinking and redacted-thinking blocks from GitHub Copilot Claude history and final Anthropic payloads while preserving visible content, tool turns, and non-empty assistant structure.

Fixes #81520
Supersedes #87060 and #81534

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
This commit is contained in:
Peter Steinberger
2026-06-13 19:14:16 -07:00
committed by GitHub
co-authored by Gio Della-Libera
parent 399f5bc993
commit aef670cf0c
5 changed files with 145 additions and 6 deletions
+35
View File
@@ -153,6 +153,41 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
}
describe("github-copilot plugin", () => {
it("owns Claude replay thinking cleanup", () => {
const provider = registerProviderWithPluginConfig({});
const messages = [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "sig" },
{ type: "redacted_thinking", data: "opaque" },
{ type: "text", text: "visible" },
],
},
];
expect(provider.buildReplayPolicy?.({ modelId: "claude-haiku-4.5" } as never)).toEqual({
dropThinkingBlocks: true,
});
expect(
provider.sanitizeReplayHistory?.({
modelId: "claude-haiku-4.5",
messages,
} as never),
).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "visible" }],
},
]);
expect(
provider.sanitizeReplayHistory?.({
modelId: "gpt-5.4",
messages,
} as never),
).toBe(messages);
});
it("registers embedding provider", () => {
const registerMemoryEmbeddingProviderMock =
vi.fn<OpenClawPluginApi["registerMemoryEmbeddingProvider"]>();
+5 -1
View File
@@ -29,7 +29,10 @@ import {
fetchCopilotModelCatalog,
resolveCopilotForwardCompatModel,
} from "./models.js";
import { buildGithubCopilotReplayPolicy } from "./replay-policy.js";
import {
buildGithubCopilotReplayPolicy,
sanitizeGithubCopilotReplayHistory,
} from "./replay-policy.js";
import { wrapCopilotProviderStream } from "./stream.js";
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
@@ -450,6 +453,7 @@ export default definePluginEntry({
resolveDynamicModel: (ctx) => resolveCopilotForwardCompatModel(ctx),
wrapStreamFn: wrapCopilotProviderStream,
buildReplayPolicy: ({ modelId }) => buildGithubCopilotReplayPolicy(modelId),
sanitizeReplayHistory: sanitizeGithubCopilotReplayHistory,
resolveThinkingProfile: ({ modelId, compat }) => {
const extendedLevels = resolveCopilotExtendedThinkingLevels(modelId, compat);
return {
+46 -1
View File
@@ -1,10 +1,55 @@
// Github Copilot plugin module implements replay policy behavior.
import type { ProviderSanitizeReplayHistoryContext } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const OMITTED_COPILOT_REASONING_TEXT = "[assistant reasoning omitted]";
function isCopilotClaudeModel(modelId?: string | null): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("claude");
}
function isThinkingBlock(value: unknown): boolean {
if (!value || typeof value !== "object") {
return false;
}
const type = (value as { type?: unknown }).type;
return type === "thinking" || type === "redacted_thinking";
}
export function stripCopilotAssistantThinkingMessages<T>(messages: T[]): T[] {
let touched = false;
const sanitized = messages.map((message) => {
if (!message || typeof message !== "object") {
return message;
}
const record = message as { role?: unknown; content?: unknown };
if (record.role !== "assistant" || !Array.isArray(record.content)) {
return message;
}
const content = record.content.filter((block) => !isThinkingBlock(block));
if (content.length === record.content.length) {
return message;
}
touched = true;
return {
...message,
content:
content.length > 0 ? content : [{ type: "text", text: OMITTED_COPILOT_REASONING_TEXT }],
};
});
return touched ? sanitized : messages;
}
export function buildGithubCopilotReplayPolicy(modelId?: string) {
return normalizeLowercaseStringOrEmpty(modelId).includes("claude")
return isCopilotClaudeModel(modelId)
? {
dropThinkingBlocks: true,
}
: {};
}
export function sanitizeGithubCopilotReplayHistory(ctx: ProviderSanitizeReplayHistoryContext) {
return isCopilotClaudeModel(ctx.modelId)
? stripCopilotAssistantThinkingMessages(ctx.messages)
: ctx.messages;
}
+50 -3
View File
@@ -29,7 +29,7 @@ function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string
}
describe("wrapCopilotAnthropicStream", () => {
it("adds Copilot headers and Anthropic cache markers for Claude payloads", () => {
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
}> = [];
@@ -39,7 +39,11 @@ describe("wrapCopilotAnthropicStream", () => {
{ role: "system", content: "system prompt" },
{
role: "assistant",
content: [{ type: "thinking", text: "draft", cache_control: { type: "ephemeral" } }],
content: [
{ type: "thinking", thinking: "draft", cache_control: { type: "ephemeral" } },
{ type: "redacted_thinking", data: "opaque" },
{ type: "text", text: "visible reply" },
],
},
],
};
@@ -98,11 +102,54 @@ describe("wrapCopilotAnthropicStream", () => {
},
{
role: "assistant",
content: [{ type: "thinking", text: "draft" }],
content: [{ type: "text", text: "visible reply" }],
},
]);
});
it("keeps a non-empty assistant turn when Copilot replay only contains thinking", () => {
const payloads: Array<{
messages: Array<Record<string, unknown>>;
}> = [];
const baseStreamFn = vi.fn((model, _context, options) => {
const payload = {
messages: [
{ role: "user", content: "use the tool result" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private" },
{ type: "redacted_thinking", data: "opaque" },
],
},
{ role: "user", content: [{ type: "tool_result", content: "done" }] },
],
};
options?.onPayload?.(payload, model);
payloads.push(payload);
return {
async *[Symbol.asyncIterator]() {},
} as never;
});
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
void wrapped(
{
provider: "github-copilot",
api: "anthropic-messages",
id: "claude-haiku-4.5",
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{},
);
expect(payloads[0]?.messages).toEqual([
{ role: "user", content: "use the tool result" },
{ role: "assistant", content: [{ type: "text", text: "[assistant reasoning omitted]" }] },
{ role: "user", content: [{ type: "tool_result", content: "done" }] },
]);
});
it("leaves non-Anthropic Copilot models untouched", () => {
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
const wrapped = requireStreamFn(wrapCopilotAnthropicStream(baseStreamFn));
+9 -1
View File
@@ -8,6 +8,7 @@ import {
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js";
import { stripCopilotAssistantThinkingMessages } from "./replay-policy.js";
type StreamOptions = Parameters<StreamFn>[2];
@@ -82,6 +83,13 @@ function buildCopilotRequestHeaders(
};
}
function patchCopilotAnthropicPayload(payload: Record<string, unknown>): void {
if (Array.isArray(payload.messages)) {
payload.messages = stripCopilotAssistantThinkingMessages(payload.messages);
}
applyAnthropicEphemeralCacheControlMarkers(payload);
}
export function wrapCopilotAnthropicStream(
baseStreamFn: StreamFn | undefined,
): StreamFn | undefined {
@@ -102,7 +110,7 @@ export function wrapCopilotAnthropicStream(
...options,
headers: buildCopilotRequestHeaders(context, options?.headers),
},
applyAnthropicEphemeralCacheControlMarkers,
patchCopilotAnthropicPayload,
);
};
}