fix(agents): normalize Copilot replay tool IDs

Normalize GitHub Copilot Responses replay tool-call IDs before dispatch so resumed sessions with historical overlong item IDs no longer fail Copilot schema validation.

Closes #82749.
This commit is contained in:
Galin Iliev
2026-05-16 18:22:10 -07:00
committed by GitHub
parent e50927b6c9
commit 4537b89da6
4 changed files with 91 additions and 3 deletions
+1
View File
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
- Mac app: let menu gateway/session error text wrap across a few lines and stop rebuilding dynamic Context/Gateway menu rows while the menu is open, reducing flicker.
- Mac app: make device pairing approval sheets friendlier, with concise Mac/device copy, shortened identifiers, friendly scope labels, and Approve as the primary action.
- Providers/Qwen: honor session thinking level for `qwen-chat-template` payloads so `/think off` disables nested llama.cpp chat-template thinking controls. Fixes #82768. Thanks @bfox55.
@@ -2298,6 +2298,85 @@ describe("openai transport stream", () => {
});
});
it("normalizes overlong Copilot Responses replay tool ids before dispatch", () => {
const longToolItemId = "iVec" + "A".repeat(360);
const longToolCallId = `call_ug6lFGKwZDjHfzW8H0PDQRwN|${longToolItemId}`;
const params = buildOpenAIResponsesParams(
{
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-responses",
provider: "github-copilot",
baseUrl: "https://api.githubcopilot.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-responses">,
{
systemPrompt: "system",
messages: [
{ role: "user", content: "read the queue", timestamp: 0 },
{
role: "assistant",
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5.5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 1,
content: [
{
type: "toolCall",
id: longToolCallId,
name: "exec",
arguments: { command: "gh pr list --limit 1" },
},
],
},
{
role: "toolResult",
toolCallId: longToolCallId,
toolName: "exec",
content: [{ type: "text", text: "[]" }],
isError: false,
timestamp: 2,
},
{ role: "user", content: "continue", timestamp: 3 },
],
tools: [],
} as never,
{ sessionId: "session-123" },
) as {
input?: Array<{ type?: string; id?: string; call_id?: string }>;
};
const functionCall = params.input?.find((item) => item.type === "function_call");
const functionOutput = params.input?.find((item) => item.type === "function_call_output");
expect(functionCall).toBeDefined();
expect(functionOutput).toBeDefined();
expect(functionCall?.id).toMatch(/^fc_/);
expect(functionCall?.id?.length).toBeLessThanOrEqual(64);
expect(functionCall?.call_id).toBe("call_ug6lFGKwZDjHfzW8H0PDQRwN");
expect(functionOutput?.call_id).toBe(functionCall?.call_id);
for (const item of params.input ?? []) {
if (item.id !== undefined) {
expect(item.id.length).toBeLessThanOrEqual(64);
}
if (item.call_id !== undefined) {
expect(item.call_id.length).toBeLessThanOrEqual(64);
}
}
});
it("adds minimal user input for Codex responses when only the system prompt is present", () => {
const params = buildOpenAIResponsesParams(
{
+3 -1
View File
@@ -767,6 +767,7 @@ function convertResponsesMessages(
const messages: ResponseInput = [];
const shouldReplayReasoningItems = options?.replayReasoningItems ?? true;
const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true;
const shouldNormalizeSameModelToolCallIds = model.provider === "github-copilot";
const normalizeIdPart = (part: string) => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
@@ -802,6 +803,7 @@ function convertResponsesMessages(
context.messages,
model,
normalizeToolCallId,
{ normalizeSameModelToolCallIds: shouldNormalizeSameModelToolCallIds },
);
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
if (includeSystemPrompt && context.systemPrompt) {
@@ -1692,7 +1694,7 @@ export function buildOpenAIResponsesParams(
const messages = convertResponsesMessages(
model,
context,
new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]),
new Set(["openai", "openai-codex", "opencode", "azure-openai-responses", "github-copilot"]),
{
includeSystemPrompt: !isCodexResponses,
supportsDeveloperRole,
+8 -2
View File
@@ -45,7 +45,10 @@ export function transformTransportMessages(
targetModel: Model<Api>,
source: { provider: string; api: Api; model: string },
) => string,
options?: { preserveCrossModelToolCallThoughtSignature?: boolean },
options?: {
normalizeSameModelToolCallIds?: boolean;
preserveCrossModelToolCallThoughtSignature?: boolean;
},
): Context["messages"] {
const allowSyntheticToolResults = defaultAllowSyntheticToolResults(model.api);
const syntheticToolResultText = CODEX_STYLE_ABORTED_OUTPUT_APIS.has(model.api)
@@ -103,7 +106,10 @@ export function transformTransportMessages(
normalizedToolCall = { ...normalizedToolCall };
delete normalizedToolCall.thoughtSignature;
}
if (!isSameModel && normalizeToolCallId) {
if (
(!isSameModel || options?.normalizeSameModelToolCallIds === true) &&
normalizeToolCallId
) {
const normalizedId = normalizeToolCallId(block.id, model, msg);
if (normalizedId !== block.id) {
toolCallIdMap.set(block.id, normalizedId);