mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 03:04:28 +00:00
fix(ai): use skip_thought_signature_validator for unsigned Gemini 3 tool calls
Replace text fallback with the official sentinel value so unsigned function calls retain structured context in multi-turn conversations. closes #1829
This commit is contained in:
parent
cfbb15876a
commit
a0d839ce84
4 changed files with 148 additions and 50 deletions
|
|
@ -2,21 +2,26 @@ import { describe, expect, it } from "vitest";
|
|||
import { convertMessages } from "../src/providers/google-shared.js";
|
||||
import type { Context, Model } from "../src/types.js";
|
||||
|
||||
describe("google-shared convertMessages", () => {
|
||||
it("converts unsigned tool calls to text for Gemini 3", () => {
|
||||
const model: Model<"google-generative-ai"> = {
|
||||
id: "gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro Preview",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
|
||||
|
||||
function makeGemini3Model(id = "gemini-3-pro-preview"): Model<"google-generative-ai"> {
|
||||
return {
|
||||
id,
|
||||
name: "Gemini 3 Pro Preview",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () => {
|
||||
it("uses skip_thought_signature_validator for unsigned tool calls on Gemini 3", () => {
|
||||
const model = makeGemini3Model();
|
||||
const now = Date.now();
|
||||
const context: Context = {
|
||||
messages: [
|
||||
|
|
@ -51,22 +56,117 @@ describe("google-shared convertMessages", () => {
|
|||
|
||||
const contents = convertMessages(model, context);
|
||||
|
||||
let toolTurn: (typeof contents)[number] | undefined;
|
||||
for (let i = contents.length - 1; i >= 0; i -= 1) {
|
||||
if (contents[i]?.role === "model") {
|
||||
toolTurn = contents[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
expect(modelTurn).toBeTruthy();
|
||||
|
||||
expect(toolTurn).toBeTruthy();
|
||||
expect(toolTurn?.parts?.some((p) => p.functionCall !== undefined)).toBe(false);
|
||||
// Should be a structured functionCall, NOT text fallback
|
||||
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined);
|
||||
expect(fcPart).toBeTruthy();
|
||||
expect(fcPart?.functionCall?.name).toBe("bash");
|
||||
expect(fcPart?.functionCall?.args).toEqual({ command: "ls -la" });
|
||||
expect(fcPart?.thoughtSignature).toBe(SKIP_THOUGHT_SIGNATURE);
|
||||
|
||||
const text = toolTurn?.parts?.map((p) => p.text ?? "").join("\n");
|
||||
// Should contain historical context note to prevent mimicry
|
||||
expect(text).toContain("Historical context");
|
||||
expect(text).toContain("bash");
|
||||
expect(text).toContain("ls -la");
|
||||
expect(text).toContain("Do not mimic this format");
|
||||
// No text fallback should exist
|
||||
const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? [];
|
||||
const historicalText = textParts.filter((p) => p.text?.includes("Historical context"));
|
||||
expect(historicalText).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves valid thoughtSignature when present (same provider/model)", () => {
|
||||
const model = makeGemini3Model();
|
||||
const now = Date.now();
|
||||
// Valid base64 signature (16 bytes = 24 chars base64)
|
||||
const validSig = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{ role: "user", content: "Hi", timestamp: now },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "bash",
|
||||
arguments: { command: "echo hi" },
|
||||
thoughtSignature: validSig,
|
||||
},
|
||||
],
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined);
|
||||
|
||||
expect(fcPart).toBeTruthy();
|
||||
expect(fcPart?.thoughtSignature).toBe(validSig);
|
||||
});
|
||||
|
||||
it("does not add sentinel for non-Gemini-3 models", () => {
|
||||
const model: Model<"google-generative-ai"> = {
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const now = Date.now();
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{ role: "user", content: "Hi", timestamp: now },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "bash",
|
||||
arguments: { command: "ls" },
|
||||
// No thoughtSignature
|
||||
},
|
||||
],
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-antigravity",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contents = convertMessages(model, context);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined);
|
||||
|
||||
expect(fcPart).toBeTruthy();
|
||||
// No sentinel, no thoughtSignature at all
|
||||
expect(fcPart?.thoughtSignature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue