mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user