Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/plugin/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ---
Expand All @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions src/plugin/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand Down