fix(copilot): apply Anthropic replay policy to Copilot Claude (#125015)

Copilot routes Claude over api.*.githubcopilot.com/v1/messages, a real
Anthropic Messages endpoint, but its replay policy returned only
`{ dropThinkingBlocks: true }`. resolveTranscriptPolicy skips the core
transport-family fallback entirely once a provider registers
buildReplayPolicy, so validateAnthropicTurns stayed false and core never
stripped a trailing assistant prefill turn. The next request after
auto-compaction was rejected with 400 "This model does not support
assistant message prefill".

Dispatch on ctx.modelApi and reuse buildStrictAnthropicReplayPolicy for
the Anthropic transport. dropThinkingBlocks stays unconditional so the
#81520 fix holds, and tool-call ids stay owned by wrapCopilotAnthropicStream
so the persisted transcript is not rewritten.
This commit is contained in:
Xiaoyu
2026-08-25 22:59:39 -07:00
committed by GitHub
parent f94eb160fb
commit b4de5fdd3a
5 changed files with 145 additions and 15 deletions
+9 -1
View File
@@ -345,12 +345,19 @@ describe("github-copilot plugin", () => {
},
];
expect(provider.buildReplayPolicy?.({ modelId: "claude-haiku-4.5" } as never)).toEqual({
expect(
provider.buildReplayPolicy?.({
modelId: "claude-haiku-4.5",
modelApi: "anthropic-messages",
} as never),
).toMatchObject({
dropThinkingBlocks: true,
validateAnthropicTurns: true,
});
expect(
provider.sanitizeReplayHistory?.({
modelId: "claude-haiku-4.5",
modelApi: "anthropic-messages",
messages,
} as never),
).toEqual([
@@ -362,6 +369,7 @@ describe("github-copilot plugin", () => {
expect(
provider.sanitizeReplayHistory?.({
modelId: "gpt-5.4",
modelApi: "openai-responses",
messages,
} as never),
).toBe(messages);
+1 -1
View File
@@ -702,7 +702,7 @@ export default definePluginEntry({
refreshOAuth: async (credential) => refreshGithubCopilotOAuth(credential),
buildAuthDoctorHint: buildGithubCopilotAuthDoctorHint,
wrapStreamFn: wrapCopilotProviderStream,
buildReplayPolicy: ({ modelId }) => buildGithubCopilotReplayPolicy(modelId),
buildReplayPolicy: buildGithubCopilotReplayPolicy,
sanitizeReplayHistory: sanitizeGithubCopilotReplayHistory,
resolveThinkingProfile: ({ modelId, compat }) => {
const extendedLevels = resolveCopilotExtendedThinkingLevels(modelId, compat);
@@ -0,0 +1,100 @@
// Github Copilot tests cover replay policy transport dispatch.
import type { ProviderReplayPolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it } from "vitest";
import {
buildGithubCopilotReplayPolicy,
sanitizeGithubCopilotReplayHistory,
} from "./replay-policy.js";
function buildPolicy(modelApi: ProviderReplayPolicyContext["modelApi"], modelId: string) {
return buildGithubCopilotReplayPolicy({ provider: "github-copilot", modelApi, modelId });
}
describe("buildGithubCopilotReplayPolicy", () => {
it("applies Anthropic turn validation to Claude models", () => {
// Copilot Claude hits a real Anthropic Messages endpoint, which rejects a
// transcript ending on an assistant turn. Core only strips that trailing
// prefill turn when validateAnthropicTurns is set.
expect(buildPolicy("anthropic-messages", "claude-opus-5")).toMatchObject({
validateAnthropicTurns: true,
sanitizeMode: "full",
repairToolUseResultPairing: true,
preserveSignatures: true,
allowSyntheticToolResults: true,
});
});
it("drops replayed thinking for thinking-preserving Claude ids", () => {
for (const modelId of [
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-opus-4.8",
"claude-sonnet-4.6",
"claude-haiku-4.5",
]) {
expect(buildPolicy("anthropic-messages", modelId)).toMatchObject({
dropThinkingBlocks: true,
});
}
});
it("leaves transcript tool ids to the Copilot stream wrapper", () => {
const policy = buildPolicy("anthropic-messages", "claude-opus-5");
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");
});
it("claims no policy for OpenAI-compatible transports", () => {
expect(buildPolicy("openai-responses", "gpt-5.4")).toBeUndefined();
expect(buildPolicy("openai-completions", "gemini-3.1-pro-preview")).toBeUndefined();
});
});
describe("sanitizeGithubCopilotReplayHistory", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private", thinkingSignature: "sig" },
{ type: "redacted_thinking", data: "opaque" },
{ type: "text", text: "visible" },
],
},
];
it("strips replayed thinking on the Anthropic transport", () => {
expect(
sanitizeGithubCopilotReplayHistory({
provider: "github-copilot",
modelApi: "anthropic-messages",
modelId: "claude-opus-5",
messages,
} as never),
).toEqual([{ role: "assistant", content: [{ type: "text", text: "visible" }] }]);
});
it("replaces a thinking-only assistant turn with a placeholder", () => {
expect(
sanitizeGithubCopilotReplayHistory({
provider: "github-copilot",
modelApi: "anthropic-messages",
modelId: "claude-opus-5",
messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private" }] }],
} as never),
).toEqual([
{ role: "assistant", content: [{ type: "text", text: "[assistant reasoning omitted]" }] },
]);
});
it("passes history through on OpenAI-compatible transports", () => {
expect(
sanitizeGithubCopilotReplayHistory({
provider: "github-copilot",
modelApi: "openai-responses",
modelId: "gpt-5.4",
messages,
} as never),
).toBe(messages);
});
});
+28 -11
View File
@@ -1,11 +1,18 @@
// 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";
import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderSanitizeReplayHistoryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildStrictAnthropicReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared";
const OMITTED_COPILOT_REASONING_TEXT = "[assistant reasoning omitted]";
function isCopilotClaudeModel(modelId?: string | null): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("claude");
// Copilot routes Claude over the real Anthropic Messages transport, so transport
// identity - not the model id - owns replay behavior. The wire patch in
// stream.ts gates on the same signal; keep the two in sync.
function isCopilotAnthropicTransport(modelApi?: string | null): boolean {
return modelApi === "anthropic-messages";
}
function isThinkingBlock(value: unknown): boolean {
@@ -40,16 +47,26 @@ export function stripCopilotAssistantThinkingMessages<T>(messages: T[]): T[] {
return touched ? sanitized : messages;
}
export function buildGithubCopilotReplayPolicy(modelId?: string) {
return isCopilotClaudeModel(modelId)
? {
dropThinkingBlocks: true,
}
: {};
export function buildGithubCopilotReplayPolicy(
ctx: ProviderReplayPolicyContext,
): ProviderReplayPolicy | undefined {
if (!isCopilotAnthropicTransport(ctx.modelApi)) {
return undefined;
}
return buildStrictAnthropicReplayPolicy({
// Unconditional: Copilot strips replayed thinking for every Claude model, so
// it never owns signed-thinking replay. The shared by-model helper would
// re-enable it for thinking-preserving Claude ids.
dropThinkingBlocks: true,
// wrapCopilotAnthropicStream rewrites tool ids on the wire and deliberately
// leaves the persisted transcript untouched. Core-side rewriting would
// mutate that transcript instead.
sanitizeToolCallIds: false,
});
}
export function sanitizeGithubCopilotReplayHistory(ctx: ProviderSanitizeReplayHistoryContext) {
return isCopilotClaudeModel(ctx.modelId)
return isCopilotAnthropicTransport(ctx.modelApi)
? stripCopilotAssistantThinkingMessages(ctx.messages)
: ctx.messages;
}