diff --git a/extensions/ai-providers/antigravity/google-conversion.ts b/extensions/ai-providers/antigravity/google-conversion.ts index 1c1165e3..74561f9e 100644 --- a/extensions/ai-providers/antigravity/google-conversion.ts +++ b/extensions/ai-providers/antigravity/google-conversion.ts @@ -140,6 +140,10 @@ function transformMessages( ? { ...message, toolCallId: normalizedId } : message; } + // Only assistant turns are rewritten. Any other role must pass through + // untouched: a transcript system message (Pi 0.86+) reaching this branch would + // be iterated as if it held assistant content and silently emptied. + if (message.role !== "assistant") return message; const isSameModel = message.provider === model.provider && @@ -346,6 +350,11 @@ export function convertMessages( continue; } + // Everything that is not a conversation turn is not representable on the wire. + // Skipping it is what keeps a stray role from becoming a nameless + // `functionResponse`, which Cloud Code Assist rejects outright. + if (message.role !== "toolResult") continue; + const textResult = message.content .filter((part) => part.type === "text") .map((part) => part.text) @@ -361,6 +370,14 @@ export function convertMessages( : hasImages ? "(see attached image)" : ""; + // A result with no tool name carries no identity to report back; emitting it + // as `functionResponse` would fail the whole request. Keep its text instead. + if (!message.toolName) { + if (responseValue.length > 0) { + contents.push({ role: "user", parts: [{ text: responseValue }] }); + } + continue; + } const imageParts: GooglePart[] = imageContent.map((image) => ({ inlineData: { mimeType: image.mimeType, data: image.data }, })); diff --git a/extensions/ai-providers/antigravity/provider.ts b/extensions/ai-providers/antigravity/provider.ts index 6733f268..0d545843 100644 --- a/extensions/ai-providers/antigravity/provider.ts +++ b/extensions/ai-providers/antigravity/provider.ts @@ -23,9 +23,11 @@ import type { Context, Model, SimpleStreamOptions, + Tool, ToolCall, } from "@earendil-works/pi-ai/compat"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat"; +import { resolveTranscript } from "../transcript.ts"; import { emptyUsage } from "../usage.ts"; import { decodeApiKey } from "./credentials.ts"; import { @@ -186,13 +188,6 @@ function isClaudeRoute(modelId: string): boolean { return modelId.toLowerCase().includes("claude"); } -function normalizeSystemPrompts( - systemPrompt: Context["systemPrompt"], -): string[] { - if (!systemPrompt) return []; - return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt]; -} - /** Deterministic conversation id: hash of the first user text, like the client. */ function deriveSessionId(context: Context): string { for (const message of context.messages) { @@ -271,11 +266,10 @@ function buildToolConfig( /** Convert pi tools to CCA functionDeclarations with sanitized schemas. */ function buildTools( - context: Context, + tools: Tool[] | undefined, toolChoice: AntigravityStreamOptions["toolChoice"], ): Record[] | undefined { if (toolChoice === "none") return undefined; - const tools = context.tools; if (!tools || tools.length === 0) return undefined; const converted = convertTools([...tools], true) as | { functionDeclarations: Record[] }[] @@ -297,10 +291,18 @@ export function buildRequestBody( projectId: string, state?: AntigravitySessionState, ): Record { - const contents = convertMessages(model, context); + // Pi 0.86+ folds the system prompt and tool declarations into transcript system + // messages; Pi <= 0.85.1 still passes them as Context fields. Resolve both shapes + // before converting, so the conversation never carries a system message and the + // prompt/tools are never silently dropped. + const transcript = resolveTranscript(context); + const contents = convertMessages(model, { + ...context, + messages: transcript.messages, + }); const request: Record = { contents }; - const systemPrompts = normalizeSystemPrompts(context.systemPrompt); + const systemPrompts = transcript.systemPrompts; if (systemPrompts.length > 0) { request.systemInstruction = { role: "user", @@ -308,7 +310,7 @@ export function buildRequestBody( }; } - const tools = buildTools(context, options?.toolChoice); + const tools = buildTools(transcript.tools, options?.toolChoice); if (tools) request.tools = tools; const toolConfig = buildToolConfig( model, diff --git a/extensions/ai-providers/cursor/provider.ts b/extensions/ai-providers/cursor/provider.ts index 653cf176..7a049ef8 100644 --- a/extensions/ai-providers/cursor/provider.ts +++ b/extensions/ai-providers/cursor/provider.ts @@ -13,6 +13,7 @@ import type { ToolCall, } from "@earendil-works/pi-ai/compat"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat"; +import { applyTranscript } from "../transcript.ts"; import { emptyUsage } from "../usage.ts"; import { ConnectFrameReader } from "./connect-frame-reader.ts"; import { @@ -548,9 +549,10 @@ function resolveWireModel(model: Model): { /** Build the protobuf Run request and retain blobs for the same Connect stream. */ export async function buildCursorRequest( model: Model, - context: Context, + rawContext: Context, options?: SimpleStreamOptions, ): Promise { + const context = applyTranscript(rawContext); const store: CursorBlobStore = new Map(); const activeIndex = context.messages.at(-1)?.role === "user" @@ -687,9 +689,13 @@ function errorFromEndStream(data: Uint8Array): Error | undefined { /** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */ export function streamCursor( model: Model, - context: Context, + rawContext: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream { + // Pi 0.86+ folds the system prompt and tools into transcript system messages; + // resolving here keeps every downstream read of `context.systemPrompt` / + // `context.tools` / `context.messages` correct on both Pi input shapes. + const context = applyTranscript(rawContext); const stream = createAssistantMessageEventStream(); (async () => { const output: AssistantMessage = { diff --git a/extensions/ai-providers/transcript.ts b/extensions/ai-providers/transcript.ts new file mode 100644 index 00000000..f02782f8 --- /dev/null +++ b/extensions/ai-providers/transcript.ts @@ -0,0 +1,133 @@ +/** + * Transcript resolution shared by the opt-in AI providers. + * + * Pi 0.86.0 changed the input handed to a custom provider's stream from `Context` + * to a normalized `TranscriptContext`: `systemPrompt` and `tools` are folded into + * a leading transcript system message, and later system messages carry prompt and + * tool deltas. Pi <= 0.85.1 still passes those two fields directly. + * + * Both shapes have to work from one implementation. OpenPI's published peer range + * (`>=0.85.1`) admits 0.86+, while the locked development baseline is still + * 0.85.1, so a provider that only reads `context.systemPrompt` / `context.tools` + * silently degrades to an empty system prompt and zero tool declarations on 0.86+ + * — and, for converters that treat unknown roles as tool results, to an invalid + * `functionResponse` as well. + * + * The replay below mirrors Pi's `getCurrentSystemMessage` / `getCurrentTools` + * behavior without importing them: those helpers do not exist before Pi 0.86. + */ + +import type { Context, Message, Tool } from "@earendil-works/pi-ai/compat"; + +/** Transcript system message as emitted by Pi 0.86+ (`TranscriptContext`). */ +export interface TranscriptSystemMessage { + role: "system"; + content?: string | { type: "text"; text: string }[]; + sections?: Record; + toolsAdded?: Tool[]; + toolsRemoved?: { name: string }[]; + timestamp?: number; +} + +type TranscriptMessage = Message | TranscriptSystemMessage; + +export interface ResolvedTranscript { + /** + * Which input shape produced this result. `context` means Pi <= 0.85.1 passed + * `systemPrompt` / `tools` as separate fields and nothing had to be replayed. + */ + source: "context" | "transcript"; + /** System prompts in wire order; empty when the transcript declares none. */ + systemPrompts: string[]; + /** Tool declarations in effect at the end of the transcript. */ + tools: Tool[] | undefined; + /** Conversation messages, with every transcript system message removed. */ + messages: Message[]; +} + +function isSystemMessage( + message: TranscriptMessage, +): message is TranscriptSystemMessage { + return message.role === "system"; +} + +function textFromContent(content: TranscriptSystemMessage["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function normalizeSystemPrompts( + systemPrompt: Context["systemPrompt"], +): string[] { + if (!systemPrompt) return []; + return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt]; +} + +/** + * Replay a transcript into the system prompt and tool set currently in effect. + * + * Later system messages patch earlier ones: `sections` are replaced by name (a + * `null` value removes the section), and tool deltas are applied in order. + */ +export function resolveTranscript(context: Context): ResolvedTranscript { + const messages = context.messages as unknown as TranscriptMessage[]; + const systemMessages = messages.filter(isSystemMessage); + if (systemMessages.length === 0) { + return { + source: "context", + systemPrompts: normalizeSystemPrompts(context.systemPrompt), + tools: context.tools, + messages: context.messages, + }; + } + + const promptParts: string[] = []; + const sections = new Map(); + const tools = new Map(); + for (const message of systemMessages) { + const text = textFromContent(message.content); + if (text.length > 0) promptParts.push(text); + for (const [name, value] of Object.entries(message.sections ?? {})) { + if (value === null) sections.delete(name); + else sections.set(name, value); + } + for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name); + for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool); + } + for (const value of sections.values()) { + if (value.length > 0) promptParts.push(value); + } + + return { + source: "transcript", + systemPrompts: promptParts.length > 0 ? [promptParts.join("\n\n")] : [], + tools: tools.size > 0 ? [...tools.values()] : undefined, + messages: messages.filter( + (message): message is Message => !isSystemMessage(message), + ), + }; +} + +/** + * Adapt either input shape to the `Context` a provider already understands. + * + * Returns the original context untouched on Pi <= 0.85.1, so existing behavior is + * preserved exactly; on 0.86+ it re-materializes the folded fields. + */ +export function applyTranscript(context: Context): Context { + const resolved = resolveTranscript(context); + if (resolved.source === "context") return context; + return { + ...context, + systemPrompt: + resolved.systemPrompts.length > 0 + ? resolved.systemPrompts.join("\n\n") + : undefined, + tools: resolved.tools, + messages: resolved.messages, + }; +} diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index 9611097c..fc538823 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -36,6 +36,7 @@ import { streamAntigravity, } from "../../../extensions/ai-providers/antigravity/provider.ts"; import { collapseAntigravityModels } from "../../../extensions/ai-providers/antigravity/routing.ts"; +import { resolveTranscript } from "../../../extensions/ai-providers/transcript.ts"; const GEMINI_MODEL: Model = { id: "gemini-3.1-pro", @@ -758,6 +759,163 @@ test("buildRequestBody honors disabled and forced tool choices", () => { ); }); +// --- transcript input shape (Pi 0.86+) ------------------------------------- + +const TRANSCRIPT_TOOLS = [ + { + name: "read", + description: "Read file contents", + parameters: { type: "object", properties: {} }, + }, + { + name: "bash", + description: "Execute bash commands", + parameters: { type: "object", properties: {} }, + }, +]; + +/** + * Pi 0.86+ hands a custom provider a TranscriptContext: `systemPrompt` and `tools` + * are folded into a leading system message, and later system messages carry + * prompt/tool deltas. `content` is empty in that leading message. + */ +function transcriptContext(extraSystem: Record[] = []) { + return { + messages: [ + { + role: "system", + content: "", + sections: { + preamble: "You are an expert coding assistant.", + rules: "- be concise", + }, + toolsAdded: TRANSCRIPT_TOOLS, + timestamp: 0, + }, + ...extraSystem, + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: 1, + }, + ], + } as unknown as Context; +} + +test("resolveTranscript replays the system prompt and tools from a Pi 0.86+ transcript", () => { + const resolved = resolveTranscript(transcriptContext()); + assert.equal(resolved.source, "transcript"); + assert.deepEqual(resolved.systemPrompts, [ + "You are an expert coding assistant.\n\n- be concise", + ]); + assert.deepEqual( + resolved.tools?.map((tool) => tool.name), + ["read", "bash"], + ); + assert.deepEqual( + resolved.messages.map((message) => message.role), + ["user"], + ); +}); + +test("resolveTranscript applies tool removals and section patches in order", () => { + const resolved = resolveTranscript( + transcriptContext([ + { + role: "system", + content: "Second instruction.", + sections: { rules: null, docs: "docs section" }, + toolsAdded: [ + { + name: "edit", + description: "Edit files", + parameters: { type: "object", properties: {} }, + }, + ], + toolsRemoved: [{ name: "read" }], + timestamp: 2, + }, + ]), + ); + // Matches Pi's own getCurrentSystemPrompt(): all content first, then the + // sections still in effect, in first-insertion order. + assert.deepEqual(resolved.systemPrompts, [ + "Second instruction.\n\nYou are an expert coding assistant.\n\ndocs section", + ]); + assert.deepEqual( + resolved.tools?.map((tool) => tool.name), + ["bash", "edit"], + ); +}); + +test("resolveTranscript leaves the Pi <= 0.85.1 Context shape untouched", () => { + const resolved = resolveTranscript(SIMPLE_CONTEXT); + assert.equal(resolved.source, "context"); + assert.deepEqual(resolved.systemPrompts, ["You are helpful."]); + assert.equal(resolved.tools, undefined); + assert.equal(resolved.messages, SIMPLE_CONTEXT.messages); +}); + +test("buildRequestBody reads the system prompt and tools from a Pi 0.86+ transcript", () => { + const body = buildRequestBody( + GEMINI_MODEL, + transcriptContext(), + undefined, + "p", + ) as { + request: { + contents: { role: string; parts: { text?: string }[] }[]; + systemInstruction: { role: string; parts: { text: string }[] }; + tools: { functionDeclarations: { name: string }[] }[]; + }; + }; + assert.equal( + body.request.systemInstruction.parts[0]!.text, + "You are an expert coding assistant.\n\n- be concise", + ); + assert.deepEqual( + body.request.tools[0]!.functionDeclarations.map( + (declaration) => declaration.name, + ), + ["read", "bash"], + ); + assert.deepEqual(body.request.contents, [ + { role: "user", parts: [{ text: "hello" }] }, + ]); +}); + +test("convertMessages never turns a transcript system message into a functionResponse", () => { + // The regression: a leading system message with empty content used to reach the + // tool-result branch and produce `{ functionResponse: { response: ... } }` with no + // name, which Cloud Code Assist rejects as contents[0].parts[0]. + const contents = convertMessages(GEMINI_MODEL, transcriptContext()); + assert.deepEqual(contents, [{ role: "user", parts: [{ text: "hello" }] }]); + assert.equal( + contents.some((content) => + content.parts.some((part) => part.functionResponse !== undefined), + ), + false, + ); +}); + +test("convertMessages keeps a nameless tool result out of the functionResponse path", () => { + const contents = convertMessages(GEMINI_MODEL, { + messages: [ + { + role: "toolResult", + toolCallId: "call-1", + toolName: "", + content: [{ type: "text", text: "tool output" }], + isError: false, + timestamp: 0, + }, + ], + } as unknown as Context); + assert.deepEqual(contents, [ + { role: "user", parts: [{ text: "tool output" }] }, + ]); +}); + test("discovery collapses wire variants and validates advertised capabilities", async () => { globalThis.fetch = (async () => new Response( diff --git a/tests/extensions/ai-providers/cursor.test.ts b/tests/extensions/ai-providers/cursor.test.ts index 5513494e..ae83bc6a 100644 --- a/tests/extensions/ai-providers/cursor.test.ts +++ b/tests/extensions/ai-providers/cursor.test.ts @@ -268,6 +268,41 @@ test("Cursor request encodes image content in the selected image protobuf", asyn assert.equal(selectedImage.mimeType, "image/png"); }); +test("Cursor reads the system prompt and tools from a Pi 0.86+ transcript", async () => { + // Pi 0.86+ folds Context.systemPrompt / Context.tools into transcript system + // messages. The same conversation must encode to the same root prompt either way; + // before the fix the folded shape silently fell back to the generic prompt and + // advertised no tools. + const lookup = { + name: "lookup", + description: "Look up a public page", + parameters: { type: "object", properties: {} }, + }; + const user = { role: "user", content: "hello", timestamp: 1 }; + const fromTranscript = await buildCursorRequest(MODEL, { + messages: [ + { + role: "system", + content: "", + sections: { preamble: "Follow the system rule." }, + toolsAdded: [lookup], + timestamp: 0, + }, + user, + ], + } as unknown as Context); + const fromContext = await buildCursorRequest(MODEL, { + systemPrompt: "Follow the system rule.", + tools: [lookup], + messages: [user], + } as unknown as Context); + assert.ok(fromTranscript.conversationState.rootPromptMessagesJson.length > 0); + assert.deepEqual( + [...fromTranscript.conversationState.rootPromptMessagesJson], + [...fromContext.conversationState.rootPromptMessagesJson], + ); +}); + test("Cursor pins bare Composer 2.5 to the Standard lane", async () => { const standard = await buildCursorRequest( { ...MODEL, id: "composer-2.5" },