Files
openclaw/extensions/github-copilot/replay-policy.ts
Xiaoyu b4de5fdd3a 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.
2026-08-25 22:59:39 -07:00

73 lines
2.6 KiB
TypeScript

// Github Copilot plugin module implements replay policy behavior.
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]";
// 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 {
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(
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 isCopilotAnthropicTransport(ctx.modelApi)
? stripCopilotAssistantThinkingMessages(ctx.messages)
: ctx.messages;
}