diff --git a/devlog/_plan/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md b/devlog/_plan/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md new file mode 100644 index 00000000000..ab8d9ba4d0e --- /dev/null +++ b/devlog/_plan/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md @@ -0,0 +1,107 @@ +# #5008 — what the upstream actually validates + +Evidence gathered for the intermittent first-send 400 on `google-antigravity/gemini-3.8-flash`: +"Please ensure that function call turn comes immediately after a user turn or after a function +response turn." Sources are public and primary where one exists. Nothing here is a fix, and one +finding contradicts an assumption the investigation started from. + +## Where the error comes from + +It is a Google service-layer `400 INVALID_ARGUMENT`, not a client-library check. The public Go, +Python and Node clients carry the content types and serializers but not this string or its +validator. + +- Direct Gemini API, same string: + +- Vertex/gRPC, `io.grpc.StatusRuntimeException: INVALID_ARGUMENT` then the same string: + +- The converse validator captured from `generativelanguage.googleapis.com/...:generateContent`: + "Please ensure that function response turn comes immediately after a function call turn." + +- The exact string captured from `daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent`: + + +For ordinary `generateContent` the validated object is the submitted `contents[]`, which Google +describes as holding "the conversation history and the latest request" +(). + +## The ordering the upstream considers valid + +The canonical loop is `user` then `model` carrying `functionCall` then `user` carrying every +matching `functionResponse`. + +- A `user` turn carrying `functionResponse` parts counts as a function-response turn; current + Google examples use `role: "user"` for it, where older ones used `role: "function"`. That churn + explains contradictory cookbook examples. + +- Parallel calls need exactly one response each, together in the immediately following turn: + "Return exactly one `FunctionResponse` for each `FunctionCall` received." + +- Calls and responses must not interleave. Google documents `FC1 + signature, FC2, FR1, FR2` as + valid and `FC1, FR1, FC2, FR2` as a 400. + +- **A model text-only turn immediately followed by a separate model `functionCall` turn is + invalid**: the call turn then follows a model turn, which is exactly what the error rejects. + Same-role adjacency is not forbidden by the protobuf schema, which only constrains role values, + but it is unsafe around function calling. + +## The finding that contradicts the starting assumption + +The issue was narrowed on the grounds that the reporter's A-F matrix all returned 200, so the +translator's adjacency handling was unlikely to be at fault. Case A is exactly the shape above — +`model(text)` then `model(functionCall)` — and the upstream contract says that shape is invalid. +A near-identical report exists against another Antigravity proxy: Gemini 3.6/3.7 Flash failed +after several tool rounds because a converted reasoning carrier became a model turn immediately +before another model `functionCall`, while clean tool ordering and plain chat worked +(). + +`messagesToGeminiFormat` can emit that pair today: it pushes one `model` turn per assistant +message, so a text-only assistant message followed by an assistant message carrying tool calls +serializes as two adjacent model turns. That is a sourced structural candidate, and it is the +first one this investigation has. It is not proof: a 200 on the reporter's isolated case A is a +real observation too, and the two together suggest the rule is enforced conditionally rather than +uniformly. Nothing here establishes which condition. + +Related long-session corruption mechanisms, all of them client-side rather than server-state: + +- orphaned `functionResponse` after a model turn carrying a call plus empty text was dropped: + +- a projected request that began with model tool turns after MCP calls and large masked outputs: + +- checkpoint/resume during pending tool execution producing incomplete call/response batches: + + +Gemini 3.5 Flash has separate intermittent function-call failures with large arguments, but those +surface as `MAX_TOKENS` or `MALFORMED_FUNCTION_CALL`, not this ordering error +(). + +## The session-state hypothesis is unproven + +Cloud Code Assist demonstrably receives both the full history and a session identifier: Gemini +CLI sends `contents: toContents(req.contents)` alongside `session_id: sessionId` and comments +"Use sessionId as trajectoryId" +(, +). +A captured Antigravity request carries `contents`, `sessionId` and `requestType: "agent"` together +(). + +What no public source establishes is that `sessionId` indexes an authoritative server-side +conversation ledger, or that Cloud Code Assist validates `contents[]` against one. Google +documents the opposite default for stateless operation — "you must pass the full history of the +conversation" — and exposes server-side continuation separately through the Interactions API's +`previous_interaction_id` (). +"trajectoryId" is equally compatible with telemetry, agent association or routing. + +## Verdict + +Public evidence explains the error as validation of the serialized tool-call history, and it +supplies several long-session corruption mechanisms on the client side. It does not explain an +intermittent first-send 400 when the exact final Cloud Code Assist payload is independently +valid. Server/client state divergence remains a hypothesis. The issue should stay open. + +## What was built on this evidence + +`src/adapters/google-wire-shape.ts` (PR #5079) projects the structure of the compiled request +without its contents, so the next occurrence can be described from the request that actually +failed. Its `call-turn-after-model-turn` class is the shape named above; the projection reports +it and does not act on it. No translator change was made. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a9e14f2c28c..f62726a4cbc 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -778,6 +778,7 @@ "google-vertex-stream.test.ts": "adapters/google", "google-vertex-thought-signature.test.ts": "adapters/google", "google-wire-compiler.test.ts": "adapters/google", + "google-wire-shape.test.ts": "adapters/google", "grok-attribution.test.ts": "providers/xai", "grok-config-inject.test.ts": "providers/xai", "grok-effort-inject.test.ts": "providers/xai", diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 9e3453aa590..d4ab5eb8728 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -629,7 +629,7 @@ export function antigravityUsesReplayCache(model: string): boolean { * neither the wire metadata nor the replay cache can supply a real one, this is the official * validator-bypass token. */ -const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator"; +export const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator"; /** * True when the model speaks the Gemini wire dialect that requires a thought signature on the diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index 8411c8262eb..a40703f8377 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -77,7 +77,7 @@ export function antigravitySessionId(parsed: OcxParsedRequest): string { // // Clients that send no thread header keep the text anchor and its instability; this is a scoped // repair, not a universal one. - const text = clientThreadAnchor(parsed) ?? firstUserText(parsed); + const text = codexThreadAnchor(parsed).preimage ?? firstUserText(parsed); if (!text) return `-${Math.floor(Math.random() * 9e18).toString()}`; const digest = createHash("sha256").update(text, "utf8").digest(); const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn; @@ -96,7 +96,10 @@ export function antigravitySessionId(parsed: OcxParsedRequest): string { * functionCall identity (name+args), so a shared id does not misattribute them. Instability, not * collision, is the failure mode this function exists to prevent. */ -function clientThreadAnchor(parsed: OcxParsedRequest): string | undefined { +function codexThreadAnchor(parsed: OcxParsedRequest): { + kind: "parent-and-own" | "parent-only" | "absent"; + preimage?: string; +} { // `_clientThreadId` carries `x-codex-parent-thread-id`, which every parallel child of one // parent presents identically, so anchoring on it alone collapsed concurrent children onto a // single upstream Cloud Code Assist session (#5033). @@ -113,9 +116,9 @@ function clientThreadAnchor(parsed: OcxParsedRequest): string | undefined { // survive both compaction and restart. const own = parsed._codexOwnThreadId?.trim(); const parent = parsed._clientThreadId?.trim(); - if (own && parent) return `codex-thread:${parent}\u0000${own}`; + if (own && parent) return { kind: "parent-and-own", preimage: `codex-thread:${parent}\u0000${own}` }; // Parent-only clients keep the anchor they already had. - if (parent) return `codex-thread:${parent}`; + if (parent) return { kind: "parent-only", preimage: `codex-thread:${parent}` }; // A parentless ROOT deliberately omits the parent header. `src/server/context-history.ts` says // so in as many words: root model requests use (session-id=root, thread-id=root) and do not // fabricate a parent key. It has no pair to key on, and #5054's claim that a root presents @@ -125,7 +128,28 @@ function clientThreadAnchor(parsed: OcxParsedRequest): string | undefined { // preference: durable Antigravity replay state is keyed by model plus session id, and moving a // root's anchor on upgrade strands every signature stored under the old session — the exact // instability this derivation exists to avoid, introduced while fixing sharing. - return undefined; + return { kind: "absent" }; +} + +/** + * Which class of anchor {@link antigravitySessionId} derives this request's session id from. + * + * The id itself identifies a live conversation and is a Google-visible wire value, so it is not + * something a user can paste into a public bug report. The class is: it distinguishes the four + * stability regimes above — a Codex parent/child pair, a parent-only client, the content-derived + * text anchor that compaction can move, and the random fallback that changes every turn — while + * carrying no identity at all. + * + * It reads the SAME decision the id derivation reads, so the two cannot disagree about which + * regime a request is in. What it does not do is change how the id itself is derived: nothing + * about a Google-visible wire value moves because a diagnostic wants to describe it. + */ +export type AntigravitySessionAnchor = "parent-and-own" | "parent-only" | "text" | "none"; + +export function antigravitySessionAnchor(parsed: OcxParsedRequest): AntigravitySessionAnchor { + const anchor = codexThreadAnchor(parsed); + if (anchor.kind !== "absent") return anchor.kind; + return firstUserText(parsed) ? "text" : "none"; } /** A Gemini content part as it appears in an Antigravity request body. */ diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 03cb6de7a34..7b97c96f971 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -208,10 +208,25 @@ function functionDeclarations(root: JsonObject): JsonObject[] { }); } +/** + * Upstream INVALID_ARGUMENT text blaming a tool or function declaration schema. + * + * Shared with the diagnostic error classifier so the repair path and the description of why a + * request was rejected cannot drift into disagreeing about the same payload. + */ +export function isGoogleToolSchemaErrorText(errorPayload: string): boolean { + return /(?:input[_ ]schema|json schema|function[_ ]declarations?|x-mcp-header)/i.test(errorPayload); +} + +/** Upstream INVALID_ARGUMENT text blaming the thinking configuration. */ +export function isGoogleThinkingConfigErrorText(errorPayload: string): boolean { + return /thinking[_ ]?(?:config|level)/i.test(errorPayload); +} + /** Build a changed request for one known-safe replay of an INVALID_ARGUMENT response. */ export function repairGoogleInvalidRequestBody(body: string, errorPayload: string): string | undefined { - const schemaError = /(?:input[_ ]schema|json schema|function[_ ]declarations?|x-mcp-header)/i.test(errorPayload); - const thinkingError = /thinking[_ ]?(?:config|level)/i.test(errorPayload); + const schemaError = isGoogleToolSchemaErrorText(errorPayload); + const thinkingError = isGoogleThinkingConfigErrorText(errorPayload); if (!schemaError && !thinkingError) return undefined; let parsed: unknown; try { diff --git a/src/adapters/google-wire-shape.ts b/src/adapters/google-wire-shape.ts new file mode 100644 index 00000000000..47dfcbabce2 --- /dev/null +++ b/src/adapters/google-wire-shape.ts @@ -0,0 +1,461 @@ +import { MAX_DEBUG_LINE_BYTES } from "../lib/debug-log-buffer"; +import { THOUGHT_SIGNATURE_BYPASS } from "./google-antigravity-replay"; +import type { AntigravitySessionAnchor } from "./google-antigravity-wire"; +import { isGoogleThinkingConfigErrorText, isGoogleToolSchemaErrorText } from "./google-wire-compiler"; + +/** + * Content-free structural projection of a compiled Google-family wire request (#5008). + * + * Why this exists. The report behind #5008 is an intermittent HTTP 400 on the FIRST upstream + * send of a long, tool-heavy Antigravity session: "Please ensure that function call turn comes + * immediately after a user turn or after a function response turn." The reporter's own static + * matrix (A-F on the issue) exercised every adjacency hazard the translator can produce and all + * of them returned 200, so the failing wire cannot be reconstructed from the repro shapes. What + * is missing is a description of the request that actually failed, and the only description a + * user can attach to a public bug report is one that carries no conversation in it. + * + * This module is that description. It reads the request object AFTER compileGoogleWireBody and + * after Antigravity replay/signature adjustment — the exact object the envelope carries — and + * returns counts, positions, kinds and booleans. + * + * What it never retains, by construction rather than by redaction: prompt or system text, tool + * arguments, tool results, tool or function names, original or wire tool-call ids, thought + * signature text or any hash of it, inline file bytes, project or account identifiers, the + * request id, the Cloud Code Assist session id, the Codex thread or session ids, and the first + * user message. Tool-call identity survives only as a request-internal ordinal assigned in + * first-appearance order, which is enough to show pairing structure and is meaningless outside + * the single request that produced it. + * + * Totals are computed over the whole request; per-turn detail is cut at + * GOOGLE_WIRE_SHAPE_TURN_CEILING and the summary is marked truncated. A 440-message session + * therefore still reports its true turn and call counts. + * + * This is a projection, not a validator. It does not decide whether a request is acceptable to + * the upstream, and nothing in the request path consults its output. + * + * What it costs. With provider debug off the projection never runs: the call site passes a + * builder to debugProviderDiagnosticLazy, which returns before invoking it. With provider debug + * ON it runs synchronously on the dispatch path, before the request is sent, and it walks every + * turn, every part and every call id, then serializes, redacts and writes one line. The cost is + * linear in history length, which is largest for exactly the long sessions this exists to + * describe. That is the price of observing the real outbound body rather than a reconstruction, + * and an operator who turns provider debug on for a 440-message session is paying it on every + * turn. + */ + +/** Per-turn detail beyond this many turns is dropped; totals are unaffected. */ +export const GOOGLE_WIRE_SHAPE_TURN_CEILING = 64; + +/** Per-turn ordinal lists are cut here; the turn's own counts are unaffected. */ +export const GOOGLE_WIRE_SHAPE_ORDINAL_CEILING = 16; + +/** Longest error text inspected by classifyGoogleWireUpstreamError. */ +const UPSTREAM_ERROR_SCAN_LIMIT = 2048; + +/** + * Serialized ceiling for one summary, held at half the debug buffer's per-line cap so the event + * prefix and any redaction copy still fit under it. + * + * The item ceilings above are not sufficient on their own: 64 retained turns carrying 16 ordinals + * each serialize past the buffer's limit, and the buffer truncates at a byte boundary. That + * leaves a consumer with JSON it cannot parse whose retained prefix still says truncated is + * false — a cut that hides itself. Trimming here instead keeps the line parseable and keeps the + * flag honest. + */ +export const GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES = Math.floor(MAX_DEBUG_LINE_BYTES / 2); + +export type GoogleWireTurnRole = "user" | "model" | "other"; + +export type GoogleWirePartKind = + | "text" + | "thought" + | "functionCall" + | "functionResponse" + | "inlineData" + | "other"; + +/** + * Ordering classes transcribed from the upstream error text, not inferred from it. + * + * The message names one relationship — a function-call turn must follow a user turn or a + * function-response turn — so call-turn-opens-request and call-turn-after-model-turn are the two + * ways a well-formed request can contradict it, call-turn-after-unknown-turn covers a predecessor + * that is neither, and response-turn-without-call-turn is its converse. + * request-ends-with-model-turn is a different upstream 400 that messagesToGeminiFormat already + * guards with its "(continue)" nudge; it is projected so a defeated guard is visible rather than + * silent. + * + * A flag is not a verdict. The reporter's case A produces call-turn-after-model-turn and + * returned 200, which is itself worth being able to observe in a real failing request. + */ +export type GoogleWireOrderingViolation = + | "call-turn-opens-request" + | "call-turn-after-model-turn" + | "call-turn-after-unknown-turn" + | "response-turn-without-call-turn" + | "request-ends-with-model-turn"; + +/** Bounded classes for an upstream rejection. The error text itself is never retained. */ +export type GoogleWireUpstreamErrorClass = + | "turn-adjacency" + | "thought-signature" + | "tool-schema" + | "thinking-config" + | "other"; + +export interface GoogleWireTurnShape { + /** Position in contents. */ + index: number; + role: GoogleWireTurnRole; + /** Number of parts in this turn. */ + parts: number; + /** Distinct part kinds, in first-appearance order. */ + kinds: GoogleWirePartKind[]; + /** Request-internal ordinals of the function calls in this turn. */ + calls: number[]; + /** Request-internal ordinals of the function responses in this turn. */ + responses: number[]; + /** Function calls carrying any thought signature. */ + signedCalls: number; + /** Of those, the ones carrying only the validator-bypass sentinel. */ + sentinelCalls: number; + violation?: GoogleWireOrderingViolation; +} + +/** Facts the caller knows and the wire body does not carry. */ +export interface GoogleWireShapeFacts { + /** Which anchor class produced the Antigravity session id. Never the id itself. */ + sessionAnchor?: AntigravitySessionAnchor; + /** + * Function calls the translator signed from client history or the durable replay store, + * counted before the Antigravity session cache runs. + */ + historySignedCalls?: number; + /** A durable replay scope was attached to this request. Its completeness is NOT asserted. */ + replayScopeBound?: boolean; + /** The real physical send this body belongs to, 1 for the first send. */ + sendOrdinal?: number; + /** Bounded class of the upstream rejection, when this body was rejected. */ + errorClass?: GoogleWireUpstreamErrorClass; +} + +export type GoogleWireShapeSummary = { + version: 1; + /** Per-turn detail was cut by a ceiling. Totals below remain exact. */ + truncated: boolean; + turns: number; + roles: Record; + functionCalls: number; + functionResponses: number; + /** Distinct tool-call ids seen anywhere in the request. */ + distinctCallIds: number; + /** Function calls that carried no id at all. */ + callsWithoutId: number; + /** Function responses that carried no id at all. */ + responsesWithoutId: number; + /** Call ordinals no response answers. */ + unansweredCalls: number; + /** Response ordinals no call introduced. */ + unmatchedResponses: number; + toolDeclarations: number; + hasSystemInstruction: boolean; + hasSessionId: boolean; + sessionAnchor: AntigravitySessionAnchor | "unknown"; + signature: { + /** At least one function call reaches the wire with a signature. */ + present: boolean; + /** Every signed call carries only the validator-bypass sentinel. */ + sentinelOnly: boolean; + signedCalls: number; + sentinelCalls: number; + /** Signed at translation time, from client history or the durable replay store. */ + historySignedCalls: number; + /** + * The remainder the Antigravity session cache supplied, clamped at zero. The cache never + * overwrites an existing signature, so the three counts partition the signed calls. + */ + sessionCacheSignedCalls: number; + /** + * A durable replay scope was attached. Whether it was COMPLETE enough to key a lookup is + * decided inside lookupReplayThoughtSignature and is deliberately not claimed here. + */ + replayScopeBound: boolean; + }; + orderingViolations: number; + firstOrderingViolation: { index: number; kind: GoogleWireOrderingViolation } | null; + /** Present only when the caller supplied send facts. */ + send: { ordinal: number; errorClass: GoogleWireUpstreamErrorClass | null } | null; + turnShapes: GoogleWireTurnShape[]; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function turnRole(content: Record): GoogleWireTurnRole { + const role = content.role; + return role === "user" || role === "model" ? role : "other"; +} + +function partKind(part: Record): GoogleWirePartKind { + if (part.thought === true) return "thought"; + if (isRecord(part.functionCall)) return "functionCall"; + if (isRecord(part.functionResponse)) return "functionResponse"; + if (isRecord(part.inline_data) || isRecord(part.inlineData)) return "inlineData"; + if (typeof part.text === "string") return "text"; + return "other"; +} + +/** + * A function call or response id, read only to be replaced by an ordinal. The value never + * leaves this function. + */ +function callIdOf(call: Record): string | undefined { + const id = call.id; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + +function signatureOf(part: Record): string | undefined { + const camel = part.thoughtSignature; + if (typeof camel === "string" && camel.length > 0) return camel; + const snake = part.thought_signature; + if (typeof snake === "string" && snake.length > 0) return snake; + return undefined; +} + +/** Request-internal renumbering: first appearance wins, and the mapping dies with the call. */ +function ordinalAllocator(): (rawId: string) => number { + const ordinals = new Map(); + return rawId => { + const existing = ordinals.get(rawId); + if (existing !== undefined) return existing; + const next = ordinals.size + 1; + ordinals.set(rawId, next); + return next; + }; +} + +function countToolDeclarations(tools: unknown): number { + if (!Array.isArray(tools)) return 0; + let total = 0; + for (const tool of tools) { + if (!isRecord(tool) || !Array.isArray(tool.functionDeclarations)) continue; + total += tool.functionDeclarations.length; + } + return total; +} + +interface TurnFacts { + role: GoogleWireTurnRole; + hasFunctionCall: boolean; + hasFunctionResponse: boolean; +} + +/** + * The first ordering class this turn contradicts, judged against the turn before it. + * + * "Function response turn" is read as a user turn carrying at least one functionResponse, which + * is the shape messagesToGeminiFormat emits for a tool-result batch. A plain user turn satisfies + * the rule on its own, so a call turn after either one is unflagged. + */ +function violationFor(turn: TurnFacts, previous: TurnFacts | undefined, isLast: boolean): GoogleWireOrderingViolation | undefined { + if (turn.role === "model" && turn.hasFunctionCall) { + if (!previous) return "call-turn-opens-request"; + if (previous.role === "model") return "call-turn-after-model-turn"; + if (previous.role !== "user") return "call-turn-after-unknown-turn"; + } + if (turn.role === "user" && turn.hasFunctionResponse) { + if (!previous || previous.role !== "model" || !previous.hasFunctionCall) { + return "response-turn-without-call-turn"; + } + } + if (isLast && turn.role === "model") return "request-ends-with-model-turn"; + return undefined; +} + +function serializedBytes(summary: GoogleWireShapeSummary): number { + return new TextEncoder().encode(JSON.stringify(summary)).length; +} + +/** + * Drop per-turn detail from the tail until the summary serializes under its byte budget. + * + * Trimming from the tail rather than the head is deliberate: the opening turns of a request are + * where a first-send ordering violation lives, and they are what a reader needs most. + */ +function fitToSerializedBudget(summary: GoogleWireShapeSummary): GoogleWireShapeSummary { + if (serializedBytes(summary) <= GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES) return summary; + let kept = summary.turnShapes.length; + let fitted: GoogleWireShapeSummary = { ...summary, truncated: true, turnShapes: summary.turnShapes }; + while (kept > 0 && serializedBytes(fitted) > GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES) { + kept -= 1; + fitted = { ...summary, truncated: true, turnShapes: summary.turnShapes.slice(0, kept) }; + } + return fitted; +} + +/** + * Project the structure of a compiled Google wire request body. + * + * Pure and non-mutating: every field is read, nothing is written back, and the returned object + * shares no reference with the input. + */ +export function summarizeGoogleWireShape( + body: unknown, + facts: GoogleWireShapeFacts = {}, +): GoogleWireShapeSummary { + const root = isRecord(body) ? body : {}; + const contents: unknown[] = Array.isArray(root.contents) ? root.contents : []; + const ordinalFor = ordinalAllocator(); + const historySignedCalls = Math.max(0, facts.historySignedCalls ?? 0); + + const roles: Record = { user: 0, model: 0, other: 0 }; + const callOrdinals = new Set(); + const responseOrdinals = new Set(); + const turnShapes: GoogleWireTurnShape[] = []; + + let functionCalls = 0; + let functionResponses = 0; + let callsWithoutId = 0; + let responsesWithoutId = 0; + let signedCalls = 0; + let sentinelCalls = 0; + let orderingViolations = 0; + let truncated = false; + let firstOrderingViolation: GoogleWireShapeSummary["firstOrderingViolation"] = null; + let previous: TurnFacts | undefined; + + for (let index = 0; index < contents.length; index++) { + const rawContent: unknown = contents[index]; + const content = isRecord(rawContent) ? rawContent : {}; + const role = turnRole(content); + roles[role] += 1; + const parts: unknown[] = Array.isArray(content.parts) ? content.parts : []; + const kinds: GoogleWirePartKind[] = []; + const calls: number[] = []; + const responses: number[] = []; + let turnSignedCalls = 0; + let turnSentinelCalls = 0; + + for (const rawPart of parts) { + const part = isRecord(rawPart) ? rawPart : {}; + const kind = partKind(part); + if (!kinds.includes(kind)) kinds.push(kind); + if (kind === "functionCall") { + functionCalls += 1; + const id = callIdOf(part.functionCall as Record); + if (id === undefined) { + callsWithoutId += 1; + } else { + const ordinal = ordinalFor(id); + callOrdinals.add(ordinal); + if (calls.length < GOOGLE_WIRE_SHAPE_ORDINAL_CEILING) calls.push(ordinal); + else truncated = true; + } + const signature = signatureOf(part); + if (signature !== undefined) { + turnSignedCalls += 1; + if (signature === THOUGHT_SIGNATURE_BYPASS) turnSentinelCalls += 1; + } + } else if (kind === "functionResponse") { + functionResponses += 1; + const id = callIdOf(part.functionResponse as Record); + if (id === undefined) { + responsesWithoutId += 1; + } else { + const ordinal = ordinalFor(id); + responseOrdinals.add(ordinal); + if (responses.length < GOOGLE_WIRE_SHAPE_ORDINAL_CEILING) responses.push(ordinal); + else truncated = true; + } + } + } + + signedCalls += turnSignedCalls; + sentinelCalls += turnSentinelCalls; + const current: TurnFacts = { + role, + hasFunctionCall: kinds.includes("functionCall"), + hasFunctionResponse: kinds.includes("functionResponse"), + }; + const violation = violationFor(current, previous, index === contents.length - 1); + if (violation) { + orderingViolations += 1; + if (!firstOrderingViolation) firstOrderingViolation = { index, kind: violation }; + } + if (turnShapes.length < GOOGLE_WIRE_SHAPE_TURN_CEILING) { + turnShapes.push({ + index, + role, + parts: parts.length, + kinds, + calls, + responses, + signedCalls: turnSignedCalls, + sentinelCalls: turnSentinelCalls, + ...(violation ? { violation } : {}), + }); + } else { + truncated = true; + } + previous = current; + } + + let unansweredCalls = 0; + for (const ordinal of callOrdinals) if (!responseOrdinals.has(ordinal)) unansweredCalls += 1; + let unmatchedResponses = 0; + for (const ordinal of responseOrdinals) if (!callOrdinals.has(ordinal)) unmatchedResponses += 1; + + return fitToSerializedBudget({ + version: 1, + truncated, + turns: contents.length, + roles, + functionCalls, + functionResponses, + distinctCallIds: new Set([...callOrdinals, ...responseOrdinals]).size, + callsWithoutId, + responsesWithoutId, + unansweredCalls, + unmatchedResponses, + toolDeclarations: countToolDeclarations(root.tools), + hasSystemInstruction: isRecord(root.systemInstruction), + hasSessionId: typeof root.sessionId === "string" && root.sessionId.length > 0, + sessionAnchor: facts.sessionAnchor ?? "unknown", + signature: { + present: signedCalls > 0, + sentinelOnly: signedCalls > 0 && signedCalls === sentinelCalls, + signedCalls, + sentinelCalls, + historySignedCalls, + sessionCacheSignedCalls: Math.max(0, signedCalls - sentinelCalls - historySignedCalls), + replayScopeBound: facts.replayScopeBound === true, + }, + orderingViolations, + firstOrderingViolation, + send: facts.sendOrdinal === undefined + ? null + : { ordinal: facts.sendOrdinal, errorClass: facts.errorClass ?? null }, + turnShapes, + }); +} + +/** + * Classify an upstream rejection into one of a fixed set of classes. + * + * Bounded in both directions: at most UPSTREAM_ERROR_SCAN_LIMIT characters are inspected, and + * only the class is returned, so no upstream text reaches a caller through this function. + */ +export function classifyGoogleWireUpstreamError(message: unknown): GoogleWireUpstreamErrorClass { + if (typeof message !== "string" || message.length === 0) return "other"; + const scanned = message.slice(0, UPSTREAM_ERROR_SCAN_LIMIT); + if (/function\s+call\s+turn|function\s+response\s+turn|model\s+turn\s+are\s+not\s+supported|assistant\s+message\s+prefill/i.test(scanned)) { + return "turn-adjacency"; + } + if (/thought[_\s-]?signature|TYPE_BYTES/i.test(scanned)) return "thought-signature"; + // Shared with the wire compiler's repair path so a payload cannot be repaired as one class + // and described as another. + if (isGoogleToolSchemaErrorText(scanned)) return "tool-schema"; + if (isGoogleThinkingConfigErrorText(scanned)) return "thinking-config"; + return "other"; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 685cd2177cd..449870c0b3e 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,5 +1,5 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; -import { debugDroppedFrame } from "../lib/debug"; +import { debugDroppedFrame, debugProviderDiagnosticLazy } from "../lib/debug"; import { createToolCallIdAllocator } from "./tool-call-id"; import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; import type { @@ -20,7 +20,8 @@ import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; -import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; +import { ANTIGRAVITY_REQUEST_UA, antigravitySessionAnchor, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; +import { summarizeGoogleWireShape } from "./google-wire-shape"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { @@ -989,6 +990,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Vertex and AI Studio share one decision. A second check here would append a // duplicate nudge whenever signature sanitization reshapes the tail afterwards. } + // Opt-in structural description of the request that is about to leave (#5008). + // + // Passed as a BUILDER, not a value: the lazy form gates before invoking it, so a session + // with provider debug off never pays the walk, and it evaluates the projection inside its + // own try/catch, so a throw in here cannot turn a built request into a rejected one. The + // projection only reads `request`, so the bytes below are the same either way. + debugProviderDiagnosticLazy("google", "antigravity-wire-shape", () => summarizeGoogleWireShape(request, { + sessionAnchor: antigravitySessionAnchor(parsed), + // Signed at translation time, from client history or the durable store. The + // Antigravity session cache signs afterwards, inside applyAntigravityReplay, and the + // projection attributes that remainder to the cache rather than to this count. + historySignedCalls: replayedCallIds.length, + replayScopeBound: parsed._reasoningReplayScope !== undefined, + })); const envelope = { model: wireModelId, // The envelope's `userAgent` field is a protocol constant ("antigravity"), distinct from diff --git a/src/lib/debug-log-buffer.ts b/src/lib/debug-log-buffer.ts index 17e0fc84bb0..df355ec2a93 100644 --- a/src/lib/debug-log-buffer.ts +++ b/src/lib/debug-log-buffer.ts @@ -9,7 +9,12 @@ export interface DebugLogEntry { const MAX_LINES = 2_000; const MAX_DEBUG_SUBSCRIBERS = 64; -const MAX_DEBUG_LINE_BYTES = 16 * 1024; +/** + * Per-line retention cap. Exported because a producer of a long structured line has to budget + * BELOW it: this buffer truncates at a byte boundary, so a JSON line cut here stops being + * parseable while its retained prefix still reads as complete. + */ +export const MAX_DEBUG_LINE_BYTES = 16 * 1024; const buffer: DebugLogEntry[] = []; interface DebugSubscriberRegistration { lease: AdmissionLease } const listeners = new Map<(entry: DebugLogEntry) => void, DebugSubscriberRegistration>(); diff --git a/src/lib/debug.ts b/src/lib/debug.ts index 5d4b54b050f..938e5737ea7 100644 --- a/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -29,3 +29,26 @@ export function debugProviderDiagnostic(adapter: string, event: string, details: /* diagnostics must never affect request handling */ } } + +/** + * The same diagnostic for details that are expensive to compute or read live request state. + * + * The eager form takes an already-built object, so the build runs in ARGUMENT position: outside + * this module's gate, and outside its try/catch. For a projection that walks a whole request + * that is wrong twice over — it pays the walk while debug is off, and a throw inside it reaches + * the caller, which for an adapter means a built request becomes a rejected one and a send that + * would have happened does not. Pass the builder instead: it runs only when debug is on, and a + * throw inside it is swallowed exactly like a throw in the emit. + */ +export function debugProviderDiagnosticLazy( + adapter: string, + event: string, + details: () => Record, +): void { + if (!isDebugEnabled()) return; + try { + emitDebugLine(`[ocx:${adapter}:${event}] ${JSON.stringify(redactSecrets(details()))}`); + } catch { + /* diagnostics must never affect request handling */ + } +} diff --git a/structure/providers/google.md b/structure/providers/google.md index 21d22fc15c6..3da16fe08a3 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -72,3 +72,40 @@ a claim about what the upstream can do); an image-capable model, whose `response configuration contradicts JSON-constrained text; and a `json_schema` format carrying no schema, which would otherwise downgrade to bare JSON mode. An image-capable model with no structured-output request keeps its existing `responseModalities` behavior. + +## Google wire-shape projection + +`src/adapters/google-wire-shape.ts` describes a compiled Google request without carrying any of +it. `summarizeGoogleWireShape` reads the body after `compileGoogleWireBody` and after Antigravity +replay and signature adjustment, which is the object the envelope sends, and returns per-role turn +counts, function call and response counts and their pairing, the position and class of the first +ordering violation, signature presence and sentinel-only signing, the session anchor class, and a +bounded upstream error class. Tool-call identity survives only as a request-internal ordinal in +first-appearance order. + +What it must never retain is the point of the module: prompt or system text, tool arguments and +results, tool and function names, original or wire call ids, signature text or any hash of it, +inline file bytes, project and account identifiers, the request id, the Cloud Code Assist session +id, Codex thread and session ids, and the first user message. Totals stay exact for the whole +request while per-turn detail stops at a fixed ceiling and sets `truncated`, so a long agentic +session still reports its real counts. + +It is a projection, not a validator. Nothing in the request path consults its output. +`antigravitySessionAnchor` in `google-antigravity-wire.ts` is the matching content-free read of +the session boundary: it reports which of the four anchor classes produced the session id without +reporting the id, and it reads the same decision the id derivation reads, so the two cannot +disagree about which regime a request is in. + +The adapter passes a builder to `debugProviderDiagnosticLazy`, never a built object. That gates +before invoking it, so a request with provider debug off never pays the walk, and it evaluates +the projection inside the logger's own try/catch, so a throw in a diagnostic cannot turn a built +request into a rejected one. With provider debug ON the projection runs synchronously on the +dispatch path before the request is sent, and its cost is linear in history length — largest for +exactly the long sessions it exists to describe. Observing the real outbound body rather than a +reconstruction is what that buys. + +Two ceilings bound the output, and both are needed. The item ceilings cap retained turns and the +per-turn ordinal lists; the serialized ceiling, held at half `MAX_DEBUG_LINE_BYTES`, then trims +turn detail from the tail until the summary fits. Without the second, a worst case inside the +first serializes past the debug buffer's per-line cap, and the buffer truncates at a byte +boundary: the consumer gets unparseable JSON whose retained prefix still reads `truncated: false`. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b63b0f23a25..3e94190d1ca 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -26,7 +26,7 @@ surface is listed here so a maintainer can find the owner without grepping: | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. `google-wire-shape.ts` sits outside that path entirely: it only reads the compiled body, and only when provider debug is on. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. An image's ladder position is pinned to its own identity (content hash + media type) rather than recomputed from recency each request (#4532); appending a newer image therefore cannot demote and re-encode older images and bust Anthropic's prompt prefix cache. Unseen images still take the age-tier pyramid's first position, the total byte budget still binds, and a 413 `tierBias` retry still applies. Recorded positions only move down the ladder, so the store is monotonic. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | diff --git a/tests/adapters/google/google-wire-shape.test.ts b/tests/adapters/google/google-wire-shape.test.ts new file mode 100644 index 00000000000..82ac7eca358 --- /dev/null +++ b/tests/adapters/google/google-wire-shape.test.ts @@ -0,0 +1,622 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../../../src/adapters/google"; +import { antigravitySessionAnchor, antigravitySessionId } from "../../../src/adapters/google-antigravity-wire"; +import { + classifyGoogleWireUpstreamError, + GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES, + GOOGLE_WIRE_SHAPE_TURN_CEILING, + summarizeGoogleWireShape, +} from "../../../src/adapters/google-wire-shape"; +import { debugProviderDiagnosticLazy } from "../../../src/lib/debug"; +import { getDebugLogEntries, MAX_DEBUG_LINE_BYTES, resetDebugLogBufferForTests } from "../../../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests, setDebugSettings } from "../../../src/lib/debug-settings"; +import { createRequestExecutionBudget, type RequestExecutionBudgetPolicy } from "../../../src/lib/request-execution-budget"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { + callId, + compactedToolSession, + compiledWireBody, + longToolSession, + modelCallTurn, + modelTextTurn, + THOUGHT_SIGNATURE_SENTINEL, + toolResultTurn, + toolRoundTrip, + unidentifiedCallTurn, + userTurn, + WIRE_MARKERS, + type WireTurn, +} from "../../fixtures/google-wire-shape-cases"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const provider = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + googleMode: "cloud-code-assist", + project: "proj-123", + apiKey: "ya29.token", +} as OcxProviderConfig; + +/** Exactly `sends` physical sends allowed, with no reserve and no alternate target. */ +function budgetOf(sends: number) { + const policy: RequestExecutionBudgetPolicy = { + maxTotalModelSends: sends, + baseSendAllowance: sends, + finalRecoveryAllowance: 0, + maxAlternateTargetSends: 0, + maxTargetTransitions: 0, + }; + return createRequestExecutionBudget(policy, "lr-google-wire-shape-test"); +} + +function parsedToolSession(rounds: number, threads: { parent?: string; own?: string } = {}): OcxParsedRequest { + const messages: unknown[] = [{ role: "user", content: "opening turn" }]; + for (let round = 1; round <= rounds; round++) { + messages.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call-${round}`, name: "search", arguments: { q: "x" } }], + }); + messages.push({ role: "toolResult", toolCallId: `call-${round}`, toolName: "search", content: "ok" }); + } + messages.push({ role: "user", content: "next turn" }); + return { + modelId: "gemini-3-pro", + stream: false, + context: { messages, systemPrompt: [], tools: [] }, + options: {}, + ...(threads.parent === undefined ? {} : { _clientThreadId: threads.parent }), + ...(threads.own === undefined ? {} : { _codexOwnThreadId: threads.own }), + } as unknown as OcxParsedRequest; +} + +function parsedWithThreads(threads: { parent?: string; own?: string }, firstText = "opening turn"): OcxParsedRequest { + return { + modelId: "gemini-3-pro", + stream: false, + context: { messages: [{ role: "user", content: firstText }], systemPrompt: [], tools: [] }, + options: {}, + ...(threads.parent === undefined ? {} : { _clientThreadId: threads.parent }), + ...(threads.own === undefined ? {} : { _codexOwnThreadId: threads.own }), + } as unknown as OcxParsedRequest; +} + +/** Every marker the fixtures plant, so one assertion can prove none of them survives. */ +const ALL_MARKERS = [...Object.values(WIRE_MARKERS), callId(1), callId(2), callId(3)]; + +afterEach(() => { + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); +}); + +// The projection is a diagnostic. If it can move a byte, a send, or a signature, it is worse +// than the missing information it exists to supply — so this runs before anything about shape. +describe("google wire shape projection is inert", () => { + test("summarizing does not mutate the body it reads", () => { + const body = compiledWireBody(longToolSession(5, { signature: WIRE_MARKERS.signature }), { + toolDeclarations: 3, + }); + const before = JSON.stringify(body); + summarizeGoogleWireShape(body, { sessionAnchor: "parent-and-own", sendOrdinal: 1 }); + expect(JSON.stringify(body)).toBe(before); + }); + + test("provider debug changes neither the compiled request nor the headers", async () => { + const adapter = createGoogleAdapter(provider); + const request = parsedToolSession(4, { parent: "tp-9f31", own: "tc-4a02" }); + const realError = console.error; + console.error = () => {}; + try { + setDebugSettings({ debug: false }); + const off = await adapter.buildRequest(request); + setDebugSettings({ debug: true }); + const on = await adapter.buildRequest(request); + const offEnvelope = JSON.parse(off.body) as Record; + const onEnvelope = JSON.parse(on.body) as Record; + expect(JSON.stringify(onEnvelope.request)).toBe(JSON.stringify(offEnvelope.request)); + expect(on.url).toBe(off.url); + expect(on.method).toBe(off.method); + expect(JSON.stringify(on.headers)).toBe(JSON.stringify(off.headers)); + // The envelope requestId is a fresh uuid per build and is the only difference allowed. + expect(onEnvelope.requestId).not.toBe(offEnvelope.requestId); + } finally { + console.error = realError; + } + }); + + // buildRequest never calls fetch, so counting fetches around it proves nothing about the + // diagnostic. The send count worth pinning is the one the budget and the physical-send + // observer see on the real dispatch path, with the request the diagnostic just described. + test("provider debug changes neither the physical send count nor the bytes dispatched", async () => { + const adapter = createGoogleAdapter(provider); + const parsedRequest = parsedToolSession(4, { parent: "tp-9f31", own: "tc-4a02" }); + const realError = console.error; + console.error = () => {}; + try { + const run = async (debug: boolean) => { + setDebugSettings({ debug }); + const built = await adapter.buildRequest(parsedRequest); + const sends: number[] = []; + const dispatched: string[] = []; + const executor = (async (_input: unknown, init?: { body?: unknown }) => { + dispatched.push(typeof init?.body === "string" ? init.body : ""); + return new Response(JSON.stringify({ response: { candidates: [] } }), { + status: 200, headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof globalThis.fetch; + const response = await adapter.fetchResponse!(built, { + executor, + sendBudget: budgetOf(3), + onPhysicalSend: send => { sends.push(send.ordinal); }, + timeoutMs: 5_000, + }); + expect(response.status).toBe(200); + const envelope = JSON.parse(dispatched[0]!) as Record; + const requestId = envelope.requestId; + // The envelope request id is a fresh uuid per build and has nothing to do with debug; + // normalizing it lets the rest of the dispatched body be compared byte for byte. + envelope.requestId = ""; + return { sends, requestId, body: JSON.stringify(envelope) }; + }; + const off = await run(false); + const on = await run(true); + // Exactly one physical send either way, and the budget saw the same single dispatch. + expect(off.sends).toEqual([1]); + expect(on.sends).toEqual(off.sends); + // And every byte that actually left is identical apart from that uuid — not merely the + // bytes that were built. + expect(on.body).toBe(off.body); + expect(on.requestId).not.toBe(off.requestId); + } finally { + console.error = realError; + } + }); + + // Proven by execution, not asserted: the exact expression google.ts hands to the lazy logger, + // given a body that throws when the projection reads it. + test("a projection that throws is swallowed and emits nothing", () => { + const hostile = { + get contents(): never { throw new Error("projection boom"); }, + }; + setDebugSettings({ debug: true }); + resetDebugLogBufferForTests(); + expect(() => debugProviderDiagnosticLazy( + "google", "antigravity-wire-shape", () => summarizeGoogleWireShape(hostile), + )).not.toThrow(); + expect(getDebugLogEntries()).toEqual([]); + }); + + test("the builder is never invoked while provider debug is off", () => { + let built = 0; + setDebugSettings({ debug: false }); + debugProviderDiagnosticLazy("google", "antigravity-wire-shape", () => { + built += 1; + return {}; + }); + expect(built).toBe(0); + }); + + test("the emitted diagnostic carries no prompt, argument, result, id or signature", async () => { + const adapter = createGoogleAdapter(provider); + const realError = console.error; + console.error = () => {}; + try { + setDebugSettings({ debug: true }); + resetDebugLogBufferForTests(); + await adapter.buildRequest(parsedToolSession(3, { parent: "tp-9f31", own: "tc-4a02" })); + const line = getDebugLogEntries().map(entry => entry.line).find(l => l.includes("antigravity-wire-shape")); + expect(line).toBeDefined(); + const emitted = line as string; + for (const secret of ["opening turn", "next turn", "search", "call-1", "call-2", "proj-123", "ya29.token", THOUGHT_SIGNATURE_SENTINEL, "tp-9f31", "tc-4a02"]) { + expect(emitted).not.toContain(secret); + } + expect(emitted).toContain("\"sessionAnchor\":\"parent-and-own\""); + } finally { + console.error = realError; + } + }); + + test("provider debug off emits nothing at all", async () => { + const adapter = createGoogleAdapter(provider); + setDebugSettings({ debug: false }); + resetDebugLogBufferForTests(); + await adapter.buildRequest(parsedToolSession(3, { parent: "tp-9f31", own: "tc-4a02" })); + expect(getDebugLogEntries()).toEqual([]); + }); +}); + +describe("google wire shape projection describes structure", () => { + test("a well-formed tool round trip projects its roles, counts and pairing", () => { + const summary = summarizeGoogleWireShape( + compiledWireBody([userTurn(), ...toolRoundTrip(1), userTurn()], { toolDeclarations: 37 }), + { sessionAnchor: "parent-and-own" }, + ); + expect(summary.turns).toBe(4); + expect(summary.roles).toEqual({ user: 3, model: 1, other: 0 }); + expect(summary.functionCalls).toBe(1); + expect(summary.functionResponses).toBe(1); + expect(summary.distinctCallIds).toBe(1); + expect(summary.unansweredCalls).toBe(0); + expect(summary.unmatchedResponses).toBe(0); + expect(summary.toolDeclarations).toBe(37); + expect(summary.hasSystemInstruction).toBe(true); + expect(summary.hasSessionId).toBe(true); + expect(summary.orderingViolations).toBe(0); + expect(summary.firstOrderingViolation).toBeNull(); + expect(summary.truncated).toBe(false); + expect(summary.turnShapes[1]).toEqual({ + index: 1, + role: "model", + parts: 1, + kinds: ["functionCall"], + calls: [1], + responses: [], + signedCalls: 0, + sentinelCalls: 0, + }); + expect(summary.turnShapes[2]?.responses).toEqual([1]); + }); + + test("call ids become request-internal ordinals, so unrelated ids project identically", () => { + const first = compiledWireBody([ + userTurn("first conversation"), + modelCallTurn(["alpha-7f3c", "beta-1"]), + toolResultTurn(["beta-1", "alpha-7f3c"]), + ]); + const second = compiledWireBody([ + userTurn("an entirely different conversation"), + modelCallTurn(["toolu_01QqRz", "fc_9928"]), + toolResultTurn(["fc_9928", "toolu_01QqRz"]), + ]); + expect(summarizeGoogleWireShape(first)).toEqual(summarizeGoogleWireShape(second)); + const summary = summarizeGoogleWireShape(first); + expect(summary.turnShapes[1]?.calls).toEqual([1, 2]); + // Responses keep their own order, and the ordinals show the answers were transposed. + expect(summary.turnShapes[2]?.responses).toEqual([2, 1]); + expect(JSON.stringify(summary)).not.toContain("alpha-7f3c"); + }); + + test("a call turn that opens the request is flagged with its position", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([ + modelCallTurn([callId(1)]), + toolResultTurn([callId(1)]), + ])); + expect(summary.firstOrderingViolation).toEqual({ index: 0, kind: "call-turn-opens-request" }); + expect(summary.orderingViolations).toBe(1); + }); + + // The reporter's case A. It is flagged because the upstream error names this relationship, + // and it returned 200 upstream — which is why this projection reports rather than judges. + test("a call turn after a model text turn is flagged as such", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([ + userTurn(), + modelTextTurn(), + modelCallTurn([callId(1)]), + toolResultTurn([callId(1)]), + ])); + expect(summary.firstOrderingViolation).toEqual({ index: 2, kind: "call-turn-after-model-turn" }); + expect(summary.roles).toEqual({ user: 2, model: 2, other: 0 }); + }); + + test("a response turn with no call turn before it is flagged", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([ + userTurn(), + toolResultTurn([callId(1)]), + ])); + expect(summary.firstOrderingViolation).toEqual({ index: 1, kind: "response-turn-without-call-turn" }); + expect(summary.unmatchedResponses).toBe(1); + expect(summary.unansweredCalls).toBe(0); + }); + + test("a model tail is flagged, which is what the (continue) nudge exists to prevent", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([userTurn(), modelTextTurn()])); + expect(summary.firstOrderingViolation).toEqual({ index: 1, kind: "request-ends-with-model-turn" }); + }); + + test("a call turn after an unrecognized role is not blamed on a model turn", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([ + userTurn(), + { role: "function", parts: [{ text: WIRE_MARKERS.prompt }] }, + modelCallTurn([callId(1)]), + toolResultTurn([callId(1)]), + ])); + expect(summary.roles).toEqual({ user: 2, model: 1, other: 1 }); + expect(summary.firstOrderingViolation).toEqual({ index: 2, kind: "call-turn-after-unknown-turn" }); + }); + + // The exhaustive leak oracle: every marker the fixtures plant, checked against one projection. + test("no marker the fixture plants survives into a summary", () => { + const summary = summarizeGoogleWireShape( + compiledWireBody([ + userTurn(), + ...toolRoundTrip(1, { signature: WIRE_MARKERS.signature }), + ...toolRoundTrip(2), + modelCallTurn([callId(3)]), + toolResultTurn([callId(3)]), + ], { toolDeclarations: 4 }), + { sessionAnchor: "parent-and-own", historySignedCalls: 1, replayScopeBound: true, sendOrdinal: 1, errorClass: "turn-adjacency" }, + ); + const serialized = JSON.stringify(summary); + for (const marker of ALL_MARKERS) expect(serialized).not.toContain(marker); + // The projection is not vacuous: it did describe the request it refused to quote. + expect(summary.functionCalls).toBe(3); + expect(summary.toolDeclarations).toBe(4); + expect(summary.hasSessionId).toBe(true); + expect(summary.signature.present).toBe(true); + }); + + test("an unanswered call and an id-less call are counted separately", () => { + const summary = summarizeGoogleWireShape(compiledWireBody([ + userTurn(), + modelCallTurn([callId(1), callId(2)]), + toolResultTurn([callId(1)]), + unidentifiedCallTurn(), + userTurn(), + ])); + expect(summary.functionCalls).toBe(3); + expect(summary.callsWithoutId).toBe(1); + expect(summary.unansweredCalls).toBe(1); + expect(summary.distinctCallIds).toBe(2); + }); + + test("a long session keeps exact totals and truncates only its per-turn detail", () => { + const rounds = 60; + const summary = summarizeGoogleWireShape(compiledWireBody(longToolSession(rounds))); + expect(summary.turns).toBe(2 + rounds * 2); + expect(summary.functionCalls).toBe(rounds); + expect(summary.functionResponses).toBe(rounds); + expect(summary.distinctCallIds).toBe(rounds); + expect(summary.unansweredCalls).toBe(0); + expect(summary.orderingViolations).toBe(0); + expect(summary.truncated).toBe(true); + // Two ceilings bind, and the tighter one wins: the turn ceiling caps the retained detail, + // then the serialized budget trims further if the kept turns still do not fit a debug line. + expect(summary.turnShapes.length).toBeLessThanOrEqual(GOOGLE_WIRE_SHAPE_TURN_CEILING); + expect(summary.turnShapes.length).toBeGreaterThan(0); + expect(new TextEncoder().encode(JSON.stringify(summary)).length) + .toBeLessThanOrEqual(GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES); + // The retained detail is still the head of the request, where a first-send violation lives. + expect(summary.turnShapes[0]?.index).toBe(0); + }); + + test("a parallel batch wider than the ordinal ceiling truncates the list, not the count", () => { + const ids = Array.from({ length: 20 }, (_unused, i) => callId(i + 1)); + const summary = summarizeGoogleWireShape(compiledWireBody([ + userTurn(), + modelCallTurn(ids), + toolResultTurn(ids), + ])); + expect(summary.functionCalls).toBe(20); + expect(summary.distinctCallIds).toBe(20); + expect(summary.turnShapes[1]?.calls).toHaveLength(16); + expect(summary.truncated).toBe(true); + }); + + // The item ceilings alone are not enough: 64 retained turns carrying 16 ordinals each serialize + // past the debug buffer's per-line cap, and the buffer cuts at a byte boundary. This drives the + // worst case through the REAL buffer and parses what comes back out of it. + test("the worst case inside the item ceilings still parses out of the real debug buffer", () => { + const contents: WireTurn[] = [userTurn()]; + for (let round = 0; round < 32; round++) { + const ids = Array.from({ length: 16 }, (_unused, i) => callId(round * 16 + i + 1)); + contents.push(modelCallTurn(ids, { signature: WIRE_MARKERS.signature }), toolResultTurn(ids)); + } + const summary = summarizeGoogleWireShape(compiledWireBody(contents, { toolDeclarations: 37 }), { + sessionAnchor: "parent-and-own", historySignedCalls: 32, replayScopeBound: true, + }); + expect(summary.turns).toBe(65); + expect(summary.functionCalls).toBe(512); + expect(summary.truncated).toBe(true); + // The size budget bit before the turn ceiling did, which is the whole point. + expect(summary.turnShapes.length).toBeLessThan(GOOGLE_WIRE_SHAPE_TURN_CEILING); + expect(new TextEncoder().encode(JSON.stringify(summary)).length) + .toBeLessThanOrEqual(GOOGLE_WIRE_SHAPE_MAX_SERIALIZED_BYTES); + + setDebugSettings({ debug: true }); + resetDebugLogBufferForTests(); + const realError = console.error; + console.error = () => {}; + try { + debugProviderDiagnosticLazy("google", "antigravity-wire-shape", () => summary); + } finally { + console.error = realError; + } + const entries = getDebugLogEntries(); + expect(entries).toHaveLength(1); + const line = entries[0]!.line; + expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(MAX_DEBUG_LINE_BYTES); + // Parseable, and still honest about having been cut. A buffer-truncated line would fail the + // parse, and its retained prefix would still have read truncated:false. + const roundTripped = JSON.parse(line.slice(line.indexOf("] ") + 2)) as + { truncated: boolean; turns: number; functionCalls: number; turnShapes: unknown[] }; + expect(roundTripped.truncated).toBe(true); + expect(roundTripped.turns).toBe(65); + expect(roundTripped.functionCalls).toBe(512); + expect(roundTripped.turnShapes.length).toBe(summary.turnShapes.length); + }); + + test("compaction shortens the request without changing what the projection reports about a turn", () => { + const full = summarizeGoogleWireShape(compiledWireBody(longToolSession(20))); + const compacted = summarizeGoogleWireShape(compiledWireBody(compactedToolSession(20, 4))); + expect(full.functionCalls).toBe(20); + expect(compacted.functionCalls).toBe(4); + expect(compacted.orderingViolations).toBe(0); + expect(compacted.turnShapes[1]).toEqual({ ...full.turnShapes[1]!, index: 1 }); + }); + + test("signature presence, sentinel-only signing and the two signing sources stay separable", () => { + const unsigned = summarizeGoogleWireShape(compiledWireBody([userTurn(), ...toolRoundTrip(1)])); + expect(unsigned.signature).toEqual({ + present: false, + sentinelOnly: false, + signedCalls: 0, + sentinelCalls: 0, + historySignedCalls: 0, + sessionCacheSignedCalls: 0, + replayScopeBound: false, + }); + + const signed = summarizeGoogleWireShape( + compiledWireBody([userTurn(), ...toolRoundTrip(1, { signature: WIRE_MARKERS.signature })]), + { historySignedCalls: 1, replayScopeBound: true }, + ); + expect(signed.signature).toEqual({ + present: true, + sentinelOnly: false, + signedCalls: 1, + sentinelCalls: 0, + historySignedCalls: 1, + sessionCacheSignedCalls: 0, + replayScopeBound: true, + }); + expect(JSON.stringify(signed)).not.toContain(WIRE_MARKERS.signature); + + // A signature the translator did not supply is attributed to the Antigravity session cache. + const fromCache = summarizeGoogleWireShape( + compiledWireBody([userTurn(), ...toolRoundTrip(1, { signature: WIRE_MARKERS.signature })]), + { historySignedCalls: 0, replayScopeBound: true }, + ); + expect(fromCache.signature.historySignedCalls).toBe(0); + expect(fromCache.signature.sessionCacheSignedCalls).toBe(1); + + // Sentinel-only is the shape a lookup miss reaches the wire as: signed, but by us. + const sentinel = summarizeGoogleWireShape( + compiledWireBody([userTurn(), ...toolRoundTrip(1, { signature: THOUGHT_SIGNATURE_SENTINEL })]), + { historySignedCalls: 0, replayScopeBound: true }, + ); + expect(sentinel.signature.present).toBe(true); + expect(sentinel.signature.sentinelOnly).toBe(true); + expect(sentinel.signature.sessionCacheSignedCalls).toBe(0); + expect(sentinel.signature.replayScopeBound).toBe(true); + }); + + test("send facts are carried only when the caller has them", () => { + const body = compiledWireBody([userTurn(), ...toolRoundTrip(1)]); + expect(summarizeGoogleWireShape(body).send).toBeNull(); + expect(summarizeGoogleWireShape(body, { sendOrdinal: 1, errorClass: "turn-adjacency" }).send) + .toEqual({ ordinal: 1, errorClass: "turn-adjacency" }); + expect(summarizeGoogleWireShape(body, { sendOrdinal: 3 }).send).toEqual({ ordinal: 3, errorClass: null }); + expect(summarizeGoogleWireShape(body).sessionAnchor).toBe("unknown"); + }); + + test("a body that is not a Google request degrades to an empty description", () => { + for (const input of [undefined, null, 42, "text", [], {}, { contents: "not-an-array" }]) { + const summary = summarizeGoogleWireShape(input); + expect(summary.turns).toBe(0); + expect(summary.turnShapes).toEqual([]); + expect(summary.truncated).toBe(false); + expect(summary.hasSessionId).toBe(false); + } + }); +}); + +describe("google wire upstream error classification", () => { + test("the reported 400 classifies as a turn-adjacency rejection", () => { + expect(classifyGoogleWireUpstreamError( + "Antigravity invalid request: Please ensure that function call turn comes immediately after a user turn or after a function response turn.", + )).toBe("turn-adjacency"); + }); + + test("the neighbouring Google 400 classes stay distinct", () => { + expect(classifyGoogleWireUpstreamError("Requests ending with a model turn are not supported")).toBe("turn-adjacency"); + expect(classifyGoogleWireUpstreamError("Invalid thought_signature: TYPE_BYTES decoding failed")).toBe("thought-signature"); + expect(classifyGoogleWireUpstreamError("tools[2].custom.input_schema is invalid")).toBe("tool-schema"); + expect(classifyGoogleWireUpstreamError("Unsupported thinkingLevel for this model")).toBe("thinking-config"); + expect(classifyGoogleWireUpstreamError("quota exceeded")).toBe("other"); + }); + + test("classification is bounded and never returns upstream text", () => { + expect(classifyGoogleWireUpstreamError(undefined)).toBe("other"); + expect(classifyGoogleWireUpstreamError("")).toBe("other"); + // Past the scan limit the marker is not read, so a huge payload cannot be walked in full. + expect(classifyGoogleWireUpstreamError(`${"x".repeat(4096)} function call turn`)).toBe("other"); + }); +}); + +// The session boundary is the other half of #5008: the reporter's hypothesis is that the +// upstream session diverged from what the proxy replays. The anchor class says which stability +// regime a request is in without naming the conversation. +describe("antigravity session anchor classification", () => { + test("the four anchor classes are distinguished", () => { + expect(antigravitySessionAnchor(parsedWithThreads({ parent: "p1", own: "c1" }))).toBe("parent-and-own"); + expect(antigravitySessionAnchor(parsedWithThreads({ parent: "p1" }))).toBe("parent-only"); + expect(antigravitySessionAnchor(parsedWithThreads({}))).toBe("text"); + expect(antigravitySessionAnchor(parsedWithThreads({}, ""))).toBe("none"); + }); + + test("a direct HTTP client with no Codex headers lands on the text anchor", () => { + const direct = parsedToolSession(2); + expect(antigravitySessionAnchor(direct)).toBe("text"); + }); + + test("an own-thread anchor keeps its class and its id when the first message is compacted", () => { + const before = parsedWithThreads({ parent: "p1", own: "c1" }, "the original first user message"); + const after = parsedWithThreads({ parent: "p1", own: "c1" }, "a compaction summary that replaced it"); + expect(antigravitySessionAnchor(before)).toBe(antigravitySessionAnchor(after)); + expect(antigravitySessionId(before)).toBe(antigravitySessionId(after)); + }); + + test("the text anchor keeps its class while its id moves under compaction", () => { + const before = parsedWithThreads({}, "the original first user message"); + const after = parsedWithThreads({}, "a compaction summary that replaced it"); + expect(antigravitySessionAnchor(before)).toBe("text"); + expect(antigravitySessionAnchor(after)).toBe("text"); + // The instability the anchor class exists to make visible. + expect(antigravitySessionId(before)).not.toBe(antigravitySessionId(after)); + }); + + test("the same own id under two parents stays one class and two sessions", () => { + const underP1 = parsedWithThreads({ parent: "p1", own: "shared-child" }); + const underP2 = parsedWithThreads({ parent: "p2", own: "shared-child" }); + expect(antigravitySessionAnchor(underP1)).toBe("parent-and-own"); + expect(antigravitySessionAnchor(underP2)).toBe("parent-and-own"); + expect(antigravitySessionId(underP1)).not.toBe(antigravitySessionId(underP2)); + }); + + test("two overlapping children of one parent are one class and two sessions", () => { + const childA = parsedWithThreads({ parent: "p1", own: "c1" }); + const childB = parsedWithThreads({ parent: "p1", own: "c2" }); + expect(antigravitySessionAnchor(childA)).toBe(antigravitySessionAnchor(childB)); + expect(antigravitySessionId(childA)).not.toBe(antigravitySessionId(childB)); + }); + + test("a restarted proxy re-derives the same class and the same id", () => { + // Both values come from Codex ids and message text alone; nothing process-random enters, + // which is what lets durable replay state survive a restart. + for (const threads of [{ parent: "p1", own: "c1" }, { parent: "p1" }, {}]) { + const first = parsedWithThreads(threads); + const second = parsedWithThreads(threads); + expect(antigravitySessionAnchor(first)).toBe(antigravitySessionAnchor(second)); + expect(antigravitySessionId(first)).toBe(antigravitySessionId(second)); + } + }); + + test("the anchorless request is the one whose id does not survive a second look", () => { + const first = parsedWithThreads({}, ""); + expect(antigravitySessionAnchor(first)).toBe("none"); + expect(antigravitySessionId(first)).not.toBe(antigravitySessionId(parsedWithThreads({}, ""))); + }); + + test("the adapter reports the anchor class it actually used", async () => { + const adapter = createGoogleAdapter(provider); + const realError = console.error; + console.error = () => {}; + try { + setDebugSettings({ debug: true }); + for (const [threads, expected] of [ + [{ parent: "p1", own: "c1" }, "parent-and-own"], + [{ parent: "p1" }, "parent-only"], + [{}, "text"], + ] as const) { + resetDebugLogBufferForTests(); + await adapter.buildRequest(parsedToolSession(1, threads)); + const line = getDebugLogEntries().map(entry => entry.line).find(l => l.includes("antigravity-wire-shape")); + expect(line).toContain(`"sessionAnchor":"${expected}"`); + } + } finally { + console.error = realError; + } + }); +}); diff --git a/tests/fixtures/google-wire-shape-cases.ts b/tests/fixtures/google-wire-shape-cases.ts new file mode 100644 index 00000000000..50177efee23 --- /dev/null +++ b/tests/fixtures/google-wire-shape-cases.ts @@ -0,0 +1,113 @@ +/** + * Synthetic compiled Google wire bodies for the #5008 structural projection. + * + * Builders rather than recorded traffic: a captured Antigravity request is exactly the artifact + * this projection exists so that nobody has to handle. Every string below is a marker, and the + * tests assert that none of them survives into a summary. + */ + +export const WIRE_MARKERS = { + prompt: "MARKER-PROMPT-TEXT", + systemText: "MARKER-SYSTEM-INSTRUCTION", + toolName: "MARKER_TOOL_NAME", + toolArgument: "MARKER-TOOL-ARGUMENT", + toolResult: "MARKER-TOOL-RESULT", + signature: "MARKERSIGNATUREBLOB0123456789", + sessionId: "-1234567890123456789", +} as const; + +/** Mirrors THOUGHT_SIGNATURE_BYPASS in src/adapters/google-antigravity-replay.ts. */ +export const THOUGHT_SIGNATURE_SENTINEL = "skip_thought_signature_validator"; + +export type WirePart = Record; +export type WireTurn = { role: string; parts: WirePart[] }; +export type WireBody = Record; + +export function callId(ordinal: number): string { + return `MARKER-CALL-ID-${ordinal}`; +} + +export function userTurn(text: string = WIRE_MARKERS.prompt): WireTurn { + return { role: "user", parts: [{ text }] }; +} + +export function modelTextTurn(text: string = WIRE_MARKERS.prompt): WireTurn { + return { role: "model", parts: [{ text }] }; +} + +export function modelCallTurn(ids: readonly string[], options: { signature?: string } = {}): WireTurn { + return { + role: "model", + parts: ids.map(id => ({ + functionCall: { name: WIRE_MARKERS.toolName, args: { query: WIRE_MARKERS.toolArgument }, id }, + ...(options.signature === undefined ? {} : { thoughtSignature: options.signature }), + })), + }; +} + +/** A function call the translator could not give a usable id, as an id-less wire part. */ +export function unidentifiedCallTurn(): WireTurn { + return { + role: "model", + parts: [{ functionCall: { name: WIRE_MARKERS.toolName, args: { query: WIRE_MARKERS.toolArgument } } }], + }; +} + +export function toolResultTurn(ids: readonly string[]): WireTurn { + return { + role: "user", + parts: ids.map(id => ({ + functionResponse: { name: WIRE_MARKERS.toolName, response: { result: WIRE_MARKERS.toolResult }, id }, + })), + }; +} + +export function compiledWireBody( + contents: readonly WireTurn[], + options: { toolDeclarations?: number; systemInstruction?: boolean; sessionId?: string } = {}, +): WireBody { + const body: WireBody = { contents: [...contents] }; + if (options.systemInstruction !== false) { + body.systemInstruction = { parts: [{ text: WIRE_MARKERS.systemText }] }; + } + const declarations = options.toolDeclarations ?? 0; + if (declarations > 0) { + body.tools = [{ + functionDeclarations: Array.from({ length: declarations }, (_unused, i) => ({ + name: `${WIRE_MARKERS.toolName}_${i}`, + description: WIRE_MARKERS.toolArgument, + parameters: { type: "object", properties: {} }, + })), + }]; + } + body.sessionId = options.sessionId ?? WIRE_MARKERS.sessionId; + return body; +} + +/** + * The shape a well-formed tool round trip reaches the wire as: user, then the model call turn, + * then the adjacent function-response turn. + */ +export function toolRoundTrip(round: number, options: { signature?: string } = {}): WireTurn[] { + const id = callId(round); + return [modelCallTurn([id], options), toolResultTurn([id])]; +} + +/** + * A long tool-heavy session of the kind #5008 reports: one opening user turn followed by + * `rounds` adjacent call/response pairs. Deterministic, so a projection of it is stable. + */ +export function longToolSession(rounds: number, options: { signature?: string } = {}): WireTurn[] { + const contents: WireTurn[] = [userTurn()]; + for (let round = 1; round <= rounds; round++) contents.push(...toolRoundTrip(round, options)); + contents.push(userTurn()); + return contents; +} + +/** Compaction keeps the tail and replaces the head; the structure survives, the text does not. */ +export function compactedToolSession(rounds: number, keptRounds: number): WireTurn[] { + const contents: WireTurn[] = [userTurn("MARKER-COMPACTION-SUMMARY")]; + for (let round = rounds - keptRounds + 1; round <= rounds; round++) contents.push(...toolRoundTrip(round)); + contents.push(userTurn()); + return contents; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1d4d47ceaef..b0f0809d6c5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -606,6 +606,7 @@ "google-vertex-stream.test.ts": "adapters/google", "google-vertex-thought-signature.test.ts": "adapters/google", "google-wire-compiler.test.ts": "adapters/google", + "google-wire-shape.test.ts": "adapters/google", "grok-attribution.test.ts": "providers/xai", "grok-config-inject.test.ts": "providers/xai", "grok-effort-inject.test.ts": "providers/xai", diff --git a/tests/lib/debug.test.ts b/tests/lib/debug.test.ts index b80f7db41f5..2a9b8153cb5 100644 --- a/tests/lib/debug.test.ts +++ b/tests/lib/debug.test.ts @@ -3,7 +3,7 @@ import { appendDebugLogLine, debugBufferMetrics, getDebugLogEntries, resetDebugL import { ResourceAdmissionError, RETAINED_TRUNCATION_MARKER, retainedUtf8Bytes, truncateRetainedUtf8 } from "../../src/lib/admission"; import { getInjectionDebugLogEntries, injectionDebugLog, resetInjectionDebugLogBufferForTests } from "../../src/lib/injection-debug-log"; import { markActivity, activityBreadcrumb } from "../../src/lib/sidecar-tracker"; -import { debugDroppedFrame, debugProviderDiagnostic } from "../../src/lib/debug"; +import { debugDroppedFrame, debugProviderDiagnostic, debugProviderDiagnosticLazy } from "../../src/lib/debug"; import { resetDebugSettingsForTests, setDebugSettings } from "../../src/lib/debug-settings"; describe("retained UTF-8 sizing", () => { @@ -142,6 +142,53 @@ describe("debug frame logging", () => { } }); + // The eager form builds its details in argument position, so an expensive or fallible + // projection runs outside both the gate and the try/catch. The lazy form is what a caller on + // the request path has to use, and these are the two properties it exists for. + test("debugProviderDiagnosticLazy never invokes its builder while disabled", () => { + delete process.env.OCX_DEBUG; + delete process.env.OCX_DEBUG_FRAMES; + let built = 0; + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + debugProviderDiagnosticLazy("google", "wire-shape", () => { built += 1; return { turns: 1 }; }); + expect(built).toBe(0); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + test("debugProviderDiagnosticLazy swallows a builder that throws", () => { + delete process.env.OCX_DEBUG; + setDebugSettings({ debug: true }); + resetDebugLogBufferForTests(); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(() => debugProviderDiagnosticLazy("google", "wire-shape", () => { + throw new Error("projection boom"); + })).not.toThrow(); + // Nothing reached the console or the ring: a failed diagnostic is a silent one. + expect(error).not.toHaveBeenCalled(); + expect(getDebugLogEntries()).toEqual([]); + } finally { + error.mockRestore(); + } + }); + + test("debugProviderDiagnosticLazy emits the built details when enabled", () => { + delete process.env.OCX_DEBUG; + setDebugSettings({ debug: true }); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + debugProviderDiagnosticLazy("google", "wire-shape", () => ({ turns: 7 })); + expect(error).toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0]?.[0] ?? "")).toContain("[ocx:google:wire-shape] {\"turns\":7}"); + } finally { + error.mockRestore(); + } + }); + test("debugProviderDiagnostic emits when enabled via runtime settings API", () => { delete process.env.OCX_DEBUG; setDebugSettings({ debug: true });