mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Gio Della-Libera
parent
399f5bc993
commit
aef670cf0c
@@ -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"]>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user