From b284a3b7e37d65d8495afd5cfacd1563a0eb490c Mon Sep 17 00:00:00 2001 From: Petr Ivanov Date: Tue, 20 Oct 2026 21:13:17 +0000 Subject: [PATCH] fix: synthesize assistant function_call before orphaned function results GigaChat requires every 'function' result in the history to be immediately preceded by an 'assistant' message carrying a function_call. opencode can send orphaned tool results in long sessions (after compaction, or when the assistant produced text between tool calls), which the API rejects with 422 ('every function result must have an assistant function call in history'). translateMessages now tracks whether the last appended message is an assistant with a function_call and synthesizes a matching assistant message when one is missing. --- src/plugin/messages.ts | 44 +++++++++++++++++++++++++++++++++- src/plugin/translation.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/plugin/messages.ts b/src/plugin/messages.ts index 68431e6..1a2e3c8 100644 --- a/src/plugin/messages.ts +++ b/src/plugin/messages.ts @@ -56,6 +56,30 @@ export async function translateMessages( }); } + // GigaChat requires every "function" result in the history to be immediately + // preceded by an "assistant" message with a function_call. opencode can send + // orphaned tool results in long sessions (e.g. after compaction or when the + // assistant produced text between tool calls), which the API rejects with 422 + // ("every function result must have an assistant function call in history"). + // Track whether the last appended message is an assistant carrying a + // function_call, and synthesize a matching assistant message when needed. + let lastGigaIsFunctionCallAssistant = false; + function pushFunctionResultPaired(gigaMsg: any, resolvedName: string | undefined) { + if (!lastGigaIsFunctionCallAssistant) { + const syntheticName = resolvedName || "tool_call"; + gigaMessages.push({ + role: "assistant", + content: null, + function_call: { + name: syntheticName, + arguments: {}, + }, + }); + } + gigaMessages.push(gigaMsg); + lastGigaIsFunctionCallAssistant = false; + } + // 5. Translate and append non-system messages. // // Pre-pass: build a map of tool_call_id -> function_name from assistant messages. @@ -168,11 +192,13 @@ export async function translateMessages( msg.tool_calls[0].function?.arguments, ), }; + lastGigaIsFunctionCallAssistant = true; } else if (msg.function_call) { gigaMsg.function_call = { name: getToolAlias(msg.function_call.name), arguments: parseArgumentsToObject(msg.function_call.arguments), }; + lastGigaIsFunctionCallAssistant = true; } // --- functions_state_id passthrough --- @@ -196,7 +222,23 @@ export async function translateMessages( gigaMsg.name = getToolAlias(msg.name); } - gigaMessages.push(gigaMsg); + // --- Append, guaranteeing assistant/function pairing --- + if (msg.role === "tool" || msg.role === "function") { + pushFunctionResultPaired( + gigaMsg, + gigaMsg.name || + (msg.tool_call_id + ? getToolAlias(toolCallIdToName.get(msg.tool_call_id)) + : undefined), + ); + } else { + gigaMessages.push(gigaMsg); + if (msg.role === "assistant") { + lastGigaIsFunctionCallAssistant = !!gigaMsg.function_call; + } else { + lastGigaIsFunctionCallAssistant = false; + } + } } return gigaMessages; diff --git a/src/plugin/translation.test.ts b/src/plugin/translation.test.ts index 44d6fa4..4915f26 100644 --- a/src/plugin/translation.test.ts +++ b/src/plugin/translation.test.ts @@ -238,6 +238,41 @@ describe("GigaChat Request Translator", () => { expect(gigaRequest.messages[3].content).toBe('{"temp": 20}'); }); + it("synthesizes an assistant function_call before an orphaned function result", async () => { + // opencode can send a tool result without a preceding assistant function_call + // in long sessions (e.g. after compaction). GigaChat rejects this with 422 + // ("every function result must have an assistant function call in history"), + // so the plugin must synthesize a matching assistant message. + const openAiRequest = { + messages: [ + { role: "user", content: "hi" }, + { + role: "tool", + tool_call_id: "call_orphan", + name: "bash", + content: '{"output":"ok"}', + }, + ], + }; + + const gigaRequest = await translateOpenAiToGigaChat( + openAiRequest, + "mock-token", + true, + "mock-ca", + ); + + expect(gigaRequest.messages).toHaveLength(3); + // user -> synthesized assistant -> function + expect(gigaRequest.messages[0].role).toBe("user"); + expect(gigaRequest.messages[1].role).toBe("assistant"); + expect(gigaRequest.messages[1].function_call).toBeDefined(); + expect(gigaRequest.messages[1].function_call.name).toBe( + getToolAlias("bash"), + ); + expect(gigaRequest.messages[2].role).toBe("function"); + }); + it("should carry over stop sequences and map object tool_choice", async () => { const openAiRequest = { messages: [{ role: "user", content: "Test stop and tool choice" }],