Skip to content

Commit d3c3bd6

Browse files
committed
fix(pi): preserve signed history across model turns
1 parent b27a24e commit d3c3bd6

4 files changed

Lines changed: 516 additions & 47 deletions

File tree

packages/pi/src/convert.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,113 @@ describe("buildGeminiRequest", () => {
9898
})
9999
})
100100

101+
it("replays same-model thinking and signed text", () => {
102+
const request = buildGeminiRequest(
103+
ctx({
104+
messages: [
105+
{
106+
role: "assistant",
107+
content: [
108+
{ type: "thinking", thinking: "reasoning" },
109+
{ type: "text", text: "answer", textSignature: "SIG123" },
110+
],
111+
api: "google-generative-ai",
112+
provider: "google-antigravity",
113+
model: "antigravity-claude-opus-4-6-thinking",
114+
usage: {
115+
input: 0,
116+
output: 0,
117+
cacheRead: 0,
118+
cacheWrite: 0,
119+
totalTokens: 0,
120+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
121+
},
122+
stopReason: "stop",
123+
timestamp: 0,
124+
},
125+
],
126+
}),
127+
{
128+
provider: "google-antigravity",
129+
model: "antigravity-claude-opus-4-6-thinking",
130+
},
131+
)
132+
133+
expect(request.contents).toEqual([
134+
{
135+
role: "model",
136+
parts: [
137+
{ text: "reasoning", thought: true },
138+
{ text: "answer", thoughtSignature: "SIG123" },
139+
],
140+
},
141+
])
142+
})
143+
144+
it("strips foreign thinking and signatures and uses model-role tool results", () => {
145+
const request = buildGeminiRequest(
146+
ctx({
147+
messages: [
148+
{
149+
role: "assistant",
150+
content: [
151+
{ type: "thinking", thinking: "claude reasoning" },
152+
{ type: "text", text: "before tool", textSignature: "TEXT_SIG" },
153+
{
154+
type: "toolCall",
155+
id: "c1",
156+
name: "read",
157+
arguments: { path: "a.ts" },
158+
thoughtSignature: "TOOL_SIG",
159+
},
160+
],
161+
api: "google-generative-ai",
162+
provider: "google-antigravity",
163+
model: "antigravity-claude-opus-4-6-thinking",
164+
usage: {
165+
input: 0,
166+
output: 0,
167+
cacheRead: 0,
168+
cacheWrite: 0,
169+
totalTokens: 0,
170+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
171+
},
172+
stopReason: "toolUse",
173+
timestamp: 0,
174+
},
175+
{
176+
role: "toolResult",
177+
toolCallId: "c1",
178+
toolName: "read",
179+
content: [{ type: "text", text: "file A" }],
180+
isError: false,
181+
timestamp: 1,
182+
},
183+
],
184+
}),
185+
{
186+
provider: "google-antigravity",
187+
model: "antigravity-gemini-3.6-flash",
188+
},
189+
)
190+
191+
expect(request.contents).toEqual([
192+
{
193+
role: "model",
194+
parts: [
195+
{ text: "before tool" },
196+
{ functionCall: { name: "read", args: { path: "a.ts" }, id: "c1" } },
197+
],
198+
},
199+
{
200+
role: "model",
201+
parts: [
202+
{ functionResponse: { name: "read", response: { output: "file A" }, id: "c1" } },
203+
],
204+
},
205+
])
206+
})
207+
101208
it("groups consecutive tool results into a single user turn", () => {
102209
const request = buildGeminiRequest(
103210
ctx({

packages/pi/src/convert.ts

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { toGeminiSchema } from "@cortexkit/antigravity-auth-core"
22
import type {
3+
AssistantMessage,
34
Context,
45
ImageContent,
56
Message,
@@ -12,7 +13,7 @@ import type {
1213

1314
/** Gemini `contents` part shapes. */
1415
type GeminiPart =
15-
| { text: string }
16+
| { text: string; thought?: boolean; thoughtSignature?: string }
1617
| { inlineData: { mimeType: string; data: string } }
1718
| {
1819
functionCall: { name: string; args: Record<string, unknown>; id: string }
@@ -55,26 +56,39 @@ function convertUserParts(content: Array<TextContent | ImageContent>): GeminiPar
5556
return parts
5657
}
5758

58-
function convertAssistantParts(content: Array<TextContent | ThinkingContent | ToolCall>): GeminiPart[] {
59+
function convertAssistantParts(
60+
message: AssistantMessage,
61+
preserveSignedHistory: boolean,
62+
): GeminiPart[] {
5963
const parts: GeminiPart[] = []
60-
for (const block of content) {
61-
if (block.type === "text" && block.text.trim()) {
62-
parts.push({ text: sanitize(block.text) })
64+
for (const block of message.content) {
65+
if (block.type === "thinking") {
66+
if (preserveSignedHistory && block.thinking) {
67+
parts.push({
68+
text: sanitize(block.thinking),
69+
thought: true,
70+
...(block.thinkingSignature ? { thoughtSignature: block.thinkingSignature } : {}),
71+
})
72+
}
73+
} else if (block.type === "text" && block.text.trim()) {
74+
parts.push({
75+
text: sanitize(block.text),
76+
...(preserveSignedHistory && block.textSignature
77+
? { thoughtSignature: block.textSignature }
78+
: {}),
79+
})
6380
} else if (block.type === "toolCall") {
64-
// Antigravity requires the prior functionCall to echo its
65-
// thoughtSignature on replay (400 INVALID_ARGUMENT otherwise).
6681
parts.push({
6782
functionCall: {
6883
name: block.name,
6984
args: (block.arguments ?? {}) as Record<string, unknown>,
7085
id: block.id,
7186
},
72-
...(block.thoughtSignature ? { thoughtSignature: block.thoughtSignature } : {}),
87+
...(preserveSignedHistory && block.thoughtSignature
88+
? { thoughtSignature: block.thoughtSignature }
89+
: {}),
7390
})
7491
}
75-
// Thinking blocks are intentionally not replayed: OpenCode/pi history does
76-
// not carry replayable signed Antigravity thinking, and unsigned thinking
77-
// is rejected. The model regenerates thinking each turn.
7892
}
7993
return parts
8094
}
@@ -90,8 +104,35 @@ function toolResultResponse(message: ToolResultMessage): Record<string, unknown>
90104
return { output: text }
91105
}
92106

93-
function convertMessages(messages: Message[]): GeminiContent[] {
107+
export interface BuildGeminiRequestOptions {
108+
provider?: string
109+
model?: string
110+
}
111+
112+
function isSameTargetModel(
113+
message: AssistantMessage,
114+
options: BuildGeminiRequestOptions | undefined,
115+
): boolean {
116+
if (!options?.provider || !options.model) return true
117+
return message.provider === options.provider && message.model === options.model
118+
}
119+
120+
function convertMessages(
121+
messages: Message[],
122+
options?: BuildGeminiRequestOptions,
123+
): GeminiContent[] {
94124
const contents: GeminiContent[] = []
125+
const callMatchesTarget = new Map<string, boolean>()
126+
127+
for (const message of messages) {
128+
if (message?.role !== "assistant") continue
129+
const matchesTarget = isSameTargetModel(message, options)
130+
for (const block of message.content) {
131+
if (block.type === "toolCall") {
132+
callMatchesTarget.set(block.id, matchesTarget)
133+
}
134+
}
135+
}
95136

96137
for (const message of messages) {
97138
if (!message) continue
@@ -108,12 +149,13 @@ function convertMessages(messages: Message[]): GeminiContent[] {
108149
}
109150

110151
if (message.role === "assistant") {
111-
const parts = convertAssistantParts(message.content)
152+
const parts = convertAssistantParts(message, isSameTargetModel(message, options))
112153
if (parts.length) contents.push({ role: "model", parts })
113154
continue
114155
}
115156

116157
if (message.role === "toolResult") {
158+
const role = callMatchesTarget.get(message.toolCallId) === false ? "model" : "user"
117159
const part: GeminiPart = {
118160
functionResponse: {
119161
name: message.toolName,
@@ -123,10 +165,10 @@ function convertMessages(messages: Message[]): GeminiContent[] {
123165
}
124166
// Gemini groups consecutive function responses into one user turn.
125167
const last = contents[contents.length - 1]
126-
if (last && last.role === "user" && last.parts.every((p) => "functionResponse" in p)) {
168+
if (last && last.role === role && last.parts.every((p) => "functionResponse" in p)) {
127169
last.parts.push(part)
128170
} else {
129-
contents.push({ role: "user", parts: [part] })
171+
contents.push({ role, parts: [part] })
130172
}
131173
}
132174
}
@@ -154,9 +196,12 @@ function convertTools(tools: Tool[] | undefined): GeminiTool[] | undefined {
154196
* Convert a pi `Context` into a Gemini `generateContent` request body
155197
* (the inner `request` object of the Antigravity envelope).
156198
*/
157-
export function buildGeminiRequest(context: Context): GeminiRequest {
199+
export function buildGeminiRequest(
200+
context: Context,
201+
options?: BuildGeminiRequestOptions,
202+
): GeminiRequest {
158203
const request: GeminiRequest = {
159-
contents: convertMessages(context.messages),
204+
contents: convertMessages(context.messages, options),
160205
}
161206

162207
const tools = convertTools(context.tools)

0 commit comments

Comments
 (0)