From b4de5fdd3a4fee35bef37cc69669c96af548b25f Mon Sep 17 00:00:00 2001 From: Xiaoyu <85524621+xiaoyu-work@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:59:39 -0700 Subject: [PATCH] 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. --- extensions/github-copilot/index.test.ts | 10 +- extensions/github-copilot/index.ts | 2 +- .../github-copilot/replay-policy.test.ts | 100 ++++++++++++++++++ extensions/github-copilot/replay-policy.ts | 39 +++++-- src/agents/transcript-policy.test.ts | 9 +- 5 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 extensions/github-copilot/replay-policy.test.ts diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 975745caa570..90c7fab7bcc2 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -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); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 6ba43fcfb68e..313596863cfd 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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); diff --git a/extensions/github-copilot/replay-policy.test.ts b/extensions/github-copilot/replay-policy.test.ts new file mode 100644 index 000000000000..cec1cd7a68d5 --- /dev/null +++ b/extensions/github-copilot/replay-policy.test.ts @@ -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); + }); +}); diff --git a/extensions/github-copilot/replay-policy.ts b/extensions/github-copilot/replay-policy.ts index 8147d8c6ff49..62f0ec2a9f1f 100644 --- a/extensions/github-copilot/replay-policy.ts +++ b/extensions/github-copilot/replay-policy.ts @@ -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(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; } diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index c23d31fd183f..d1b666ae5ed3 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -123,11 +123,16 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { allowSyntheticToolResults: true, }; case "github-copilot": - return modelId.includes("claude") + return context?.modelApi === "anthropic-messages" ? { + sanitizeMode: "full", + preserveSignatures: true, + repairToolUseResultPairing: true, + validateAnthropicTurns: true, + allowSyntheticToolResults: true, dropThinkingBlocks: true, } - : {}; + : undefined; case "mistral": return { sanitizeToolCallIds: true,