From 43403a2e495d7e9e250a8b8a027eb3ad27c7d3b9 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Tue, 27 Oct 2026 01:30:34 -0400 Subject: [PATCH 1/6] fix(antigravity): surface Argon thought text and stop a quoted tag from truncating answers Gemini 4 Argon answers through CCA with thought parts only when the request names its tier in thinkingConfig. The Argon wire ids already carry the tier as a suffix (gemini-4-argon-high), so the adapter sent includeThoughts alone; live CCA then returns 200 with thousands of thoughtsTokenCount and zero thought parts, and the plan arrives as ordinary visible text. Measured 2026-10-27: the same request with thinkingLevel repeated alongside includeThoughts returns the thought stream, while gemini-3.8-flash does not need it and keeps its one-field shape. Separately, the Codex client treats a literal as markup and drops everything between the first opening tag and the last closing tag. A model that quotes the tag in prose loses the rest of its answer, which reads as a turn truncated at a backtick. The bridge now escapes mentions while leaving a real citation block (one whose body starts with ) byte for byte, in both the streaming and buffered paths. --- src/bridge/sse.ts | 25 ++- src/protocols/encoders/adapter-events.ts | 9 +- src/providers/antigravity-models.ts | 48 ++++- src/responses/citation-markers.ts | 193 +++++++++++++++++- .../google/gemini-4-argon-antigravity.test.ts | 40 ++-- tests/responses/citation-markers.test.ts | 44 ++++ 6 files changed, 333 insertions(+), 26 deletions(-) diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 99e4463f29b..7c8118f3526 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -38,8 +38,11 @@ import { import { resolveStallTimeoutSec } from "../stall-timeout"; import { createCitationMarkerFilter, + createMemoryCitationEscaper, + escapeMemoryCitationMentions, stripCitationMarkers, type CitationMarkerFilter, + type MemoryCitationEscaper, } from "../responses/citation-markers"; import { declaresCodeModeExec, normalizeDeclaredToolName } from "../types"; import { appendSafeWebSearchSource, safeWebSearchSources } from "../web-search/sources"; @@ -378,6 +381,7 @@ export function bridgeToResponsesSSE( outputIndex: number; text: StringChunks; citationFilter: CitationMarkerFilter; + memoryCitationEscaper: MemoryCitationEscaper; phase?: OcxMessagePhase; } | null = null; let currentReasoning: { itemId: string; outputIndex: number; text: StringChunks } | null = null; @@ -510,10 +514,16 @@ export function bridgeToResponsesSSE( // accumulated text: closeCurrentMessage re-sends it in output_text.done and // output_item.done, so filtering only the deltas would leave the markers in both. const trailing = currentMsg.citationFilter.flush(); - if (trailing) { + const escapedTrailing = trailing ? currentMsg.memoryCitationEscaper.push(trailing) : ""; + // A held tail is a partial tag whose whole-message context is now known: resolve it once + // against the accumulated text so a real block's trailing close tag survives. + const heldTail = currentMsg.memoryCitationEscaper.pending(); + const resolvedTail = heldTail ? escapeMemoryCitationMentions(heldTail) : ""; + const finalDelta = escapedTrailing + resolvedTail; + if (finalDelta) { emit("response.output_text.delta", { item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, - content_index: 0, delta: trailing, + content_index: 0, delta: finalDelta, }); } const messageText = stripCitationMarkers(joinChunks(currentMsg.text)); @@ -950,6 +960,7 @@ export function bridgeToResponsesSSE( currentMsg = { itemId, outputIndex, text: emptyChunks(), citationFilter: createCitationMarkerFilter(), + memoryCitationEscaper: createMemoryCitationEscaper(), ...(event.phase ? { phase: event.phase } : {}), }; } @@ -962,10 +973,16 @@ export function bridgeToResponsesSSE( // unterminated tail and releases it at close (#3150). The accumulator above // keeps the raw text; it is stripped once in closeCurrentMessage. const visible = currentMsg.citationFilter.push(event.text); - if (visible) { + // Escape a literal memory-citation tag: the Codex client hides everything between + // the first opening tag and the last closing one, so one quoted mention in prose + // swallows the rest of the answer and reads as a truncated turn. This runs per + // delta, so it uses the streaming escaper: a tag arrives split across deltas, and a + // whole-string match inside a single delta would miss it. + const emitted = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; + if (emitted) { emit("response.output_text.delta", { item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, - content_index: 0, delta: visible, + content_index: 0, delta: emitted, }); } break; diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 716e45879da..aef44e9175d 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -34,7 +34,7 @@ import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; import { redactSecretString } from "../../lib/redact"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import { createCitationMarkerFilter, type CitationMarkerFilter } from "../../responses/citation-markers"; +import { createCitationMarkerFilter, escapeMemoryCitationMentions, type CitationMarkerFilter } from "../../responses/citation-markers"; import { isTruncatedStopReason, truncationReasonFor } from "../../responses/truncated-stop-reason"; import { safeWebSearchSources } from "../../web-search/sources"; import { resolveStallTimeoutSec } from "../../stall-timeout"; @@ -358,7 +358,7 @@ export function encodeAdapterEventStream( if (!currentMsg) return; // A citation span can straddle a delta boundary; the filter releases its held tail here. const trailing = currentMsg.citationFilter.flush(); - if (trailing) writer.text(trailing); + if (trailing) writer.text(escapeMemoryCitationMentions(trailing)); writer.messageDone(); currentMsg = null; }; @@ -603,7 +603,10 @@ export function encodeAdapterEventStream( currentMsg = { citationFilter: createCitationMarkerFilter(), ...(event.phase ? { phase: event.phase } : {}) }; } const visible = currentMsg.citationFilter.push(event.text); - if (visible) writer.text(visible); + // A literal memory-citation tag in prose makes the Codex client hide everything + // between the first opening tag and the last closing one, which reads as a truncated + // answer. Escape it here, while it streams. + if (visible) writer.text(escapeMemoryCitationMentions(visible)); break; } case "thinking_delta": { diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 73ea76c5967..b3ad02ecabf 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -243,6 +243,19 @@ const ANTIGRAVITY_EFFORT_WIRE_MAP: Record> = { */ const ANTIGRAVITY_SUFFIX_TIER_MODELS = new Set([GEMINI_ARGON, "gemini-3.8-flash", CLAUDE_OPUS_55, CLAUDE_SONNET_55]); +/** + * Suffix-tier models that withhold `thought: true` text unless the request ALSO names its tier + * in `thinkingConfig.thinkingLevel`. + * + * Measured against live CCA (2026-10-27): `gemini-4-argon-high` with + * `{includeThoughts: true}` alone answers 200 with `thoughtsTokenCount` in the thousands and + * zero thought parts — the whole plan arrives as ordinary visible text. Sending + * `{thinkingLevel: "high", includeThoughts: true}` on the same wire id returns the thought + * parts. `gemini-3.8-flash-high` does not behave this way: its include-only request still + * returns thoughts, so it stays out of this set and keeps the one-field shape. + */ +const ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS = new Set([GEMINI_ARGON]); + function completeDiscoveredEffortWireModelIds( pickerId: string, available: ReadonlyMap>, @@ -747,7 +760,18 @@ export function resolveAntigravityEffortWireModel( baseUrl?: string, ): { wireModelId: string; thinkingLevel?: string } { const discoveredEffortWireModelId = discoveredAntigravityEffortWireModelId(modelId, effort, baseUrl); - if (discoveredEffortWireModelId) return { wireModelId: discoveredEffortWireModelId }; + if (discoveredEffortWireModelId) { + // Discovery returns the tier's wire id, never a level; Argon still needs the level on the + // wire to expose thought text, so derive it from the requested effort (or its default tier). + const discoveredLevel = ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) + ? (effort ? resolveAntigravityThinkingLevel(effort) : undefined) + ?? ANTIGRAVITY_DEFAULT_EFFORT[modelId] + : undefined; + return { + wireModelId: discoveredEffortWireModelId, + ...(discoveredLevel ? { thinkingLevel: discoveredLevel } : {}), + }; + } // A collapsed picker row reports ONE representative wire id (whichever tier CCA // listed first), so live discovery cannot describe a ladder — it can only name a @@ -786,7 +810,12 @@ export function resolveAntigravityEffortWireModel( // Rule 1: suffix/compat alias — suffix IS the effort. if (isAntigravitySuffixModelId(modelId)) { - return { wireModelId: resolveAntigravityWireModelId(modelId, baseUrl) }; + const wireModelId = resolveAntigravityWireModelId(modelId, baseUrl); + // Argon is the measured exception to "the suffix IS the effort": CCA withholds its thought + // text unless the tier is also named in thinkingConfig. Only a `-low/-medium/-high` suffix + // carries a level, so a bare Argon id keeps its historical shape. + const argonSuffix = /^gemini-4-argon-(low|medium|high)$/.exec(wireModelId); + return argonSuffix ? { wireModelId, thinkingLevel: argonSuffix[1]! } : { wireModelId }; } // Rule 1b: single-wire-id Gemini model whose tiers ride on thinkingLevel. Without @@ -811,11 +840,20 @@ export function resolveAntigravityEffortWireModel( : effort; if (requested && requested in effortMap) { const wireModelId = effortMap[requested]!; - // The suffix already names the tier; see ANTIGRAVITY_SUFFIX_TIER_MODELS. - return suffixTiered ? { wireModelId } : { wireModelId, thinkingLevel: requested }; + // The suffix already names the tier; see ANTIGRAVITY_SUFFIX_TIER_MODELS. Argon is the + // exception: it withholds thought text unless the level is repeated on the wire, and the + // level sent is this same suffix tier, so the two fields cannot disagree. + return suffixTiered && !ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) + ? { wireModelId } + : { wireModelId, thinkingLevel: requested }; } const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!; - return { wireModelId: effortMap[defaultEffort]! }; + return { + wireModelId: effortMap[defaultEffort]!, + ...(ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) + ? { thinkingLevel: defaultEffort } + : {}), + }; } // Rule 4: Claude models — effort via thinkingConfig only (no suffix variants). diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index d83036ba95f..0cdf7ab168f 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -62,6 +62,195 @@ export interface CitationMarkerFilter { flush(): string; } +/** + * The memory-citation tags the Codex client renders specially: it keeps the LAST + * `` block and drops everything else, so a literal mention of the tag in prose + * swallows the rest of the answer between the first opening tag and the last closing one. A + * model that quotes the tag ("output the `...` block at the end") therefore appears to truncate + * at the quote. Matches the open tag, the close tag, and a backticked open tag, since a fenced or + * inline-code copy is what shows up inside an otherwise ordinary sentence. + */ +const MEMORY_CITATION_OPEN = ""; +const MEMORY_CITATION_CLOSE = ""; +const MEMORY_CITATION_TAG = /<\/?(oai-mem-citation)>/g; + +/** + * Body shape of a REAL memory-citation block: `` plus ``. + * + * This is what separates a block the client should render from a mention of the tag name inside + * ordinary prose. Escaping both would break the real feature: the client would print the tags + * instead of the citation list, and the turn's memory references would silently disappear. + */ +const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>[\s\S]*/; + +/** First element of a real block's body, used to decide from a short lookahead. */ +const MEMORY_CITATION_BODY_OPEN = ""; + +/** + * Whether the text right after an opening tag still could begin a real block. + * + * A real block puts `` (usually on the next line) immediately after the open + * tag. Ordinary prose that quotes the tag puts a backtick, punctuation, or words there instead, + * so one short lookahead separates the two without buffering the whole block. + */ +function bodyCouldBeReal(afterOpen: string): boolean { + const trimmed = afterOpen.replace(/^\s+/, ""); + return MEMORY_CITATION_BODY_OPEN.startsWith(trimmed) || trimmed.startsWith(MEMORY_CITATION_BODY_OPEN); +} + +function isMemoryCitationBlock(body: string): boolean { + return MEMORY_CITATION_BODY.test(body); +} + +/** + * Whether an opening tag begins a real block: the entries list has to follow it directly. + * + * `isMemoryCitationBlock` over a whole body cannot answer this, because prose before a real block + * also spans a `……` stretch when the search is + * anchored at the mention rather than at the block. The adjacency check is what makes the mention + * provably a mention. + */ +function opensRealBlock(text: string, openAt: number): number | undefined { + const afterOpen = openAt + MEMORY_CITATION_OPEN.length; + const bodyStart = afterOpen + (text.slice(afterOpen).match(/^\s*/)?.[0].length ?? 0); + if (!text.startsWith(MEMORY_CITATION_BODY_OPEN, bodyStart)) return undefined; + const close = text.indexOf(MEMORY_CITATION_CLOSE, bodyStart); + if (close === -1) return undefined; + return isMemoryCitationBlock(text.slice(afterOpen, close)) ? close + MEMORY_CITATION_CLOSE.length : undefined; +} + +/** + * Neutralize a literal memory-citation tag so the client renders it as text. + * + * The client parses the tag as markup, not as markdown, so wrapping it in backticks does not + * protect it. Escaping the leading `<` as `<` keeps the same visible characters in every + * renderer while leaving the payload itself recognizable. + */ +export function escapeMemoryCitationMentions(text: string): string { + if (!text.includes("oai-mem-citation")) return text; + // Claim real blocks FIRST, so a mention earlier in the prose cannot pair with a real block's + // closing tag and leave the block half-escaped. Everything outside a claimed block is a + // mention and gets escaped, tags included. Matching runs over the ORIGINAL string as one pass + // (appendReplacement), because `String.replace` would re-scan the text we just escaped. + const blocks: Array<{ start: number; end: number }> = []; + let searchFrom = 0; + for (;;) { + const open = text.indexOf(MEMORY_CITATION_OPEN, searchFrom); + if (open === -1) break; + const end = opensRealBlock(text, open); + if (end !== undefined) { + blocks.push({ start: open, end }); + searchFrom = end; + } else { + searchFrom = open + MEMORY_CITATION_OPEN.length; + } + } + const escapedMentions = (segment: string): string => { + let out = ""; + let i = 0; + for (;;) { + const open = segment.indexOf(MEMORY_CITATION_OPEN, i); + const close = segment.indexOf(MEMORY_CITATION_CLOSE, i); + const next = open === -1 ? close : close === -1 ? open : Math.min(open, close); + if (next === -1) { + out += segment.slice(i); + return out; + } + out += segment.slice(i, next); + const tag = open !== -1 && open === next ? MEMORY_CITATION_OPEN : MEMORY_CITATION_CLOSE; + out += `<${tag.slice(1)}`; + i = next + tag.length; + } + }; + if (blocks.length === 0) return escapedMentions(text); + let out = ""; + let cursor = 0; + for (const block of blocks) { + out += escapedMentions(text.slice(cursor, block.start)); + out += text.slice(block.start, block.end); + cursor = block.end; + } + out += escapedMentions(text.slice(cursor)); + return out; +} + +export interface MemoryCitationEscaper { + /** Feed one streaming delta; returns the portion safe to emit now. */ + push(delta: string): string; + /** Text still withheld at the end of the stream; resolve it with `escapeMemoryCitationMentions`. */ + pending(): string; +} + +/** + * Streaming form of `escapeMemoryCitationMentions`. + * + * A tag can straddle delta boundaries, so a per-delta regex would miss the mention that matters + * most: the model writing the tag name one token at a time. The escaper buffers until it can tell + * a mention from a real block. A real block is emitted verbatim, tags included, as soon as its + * `` body starts — the client keeps it, which is the whole point of the block. + * Everything else is a mention and gets `<`. + */ +export function createMemoryCitationEscaper(): MemoryCitationEscaper { + /** Unresolved tail: either a partial tag, or a tag whose follower has not arrived yet. */ + let held = ""; + return { + push(delta: string): string { + if (!held && !delta.includes("<")) return delta; + const input = held + delta; + held = ""; + let out = ""; + let i = 0; + while (i < input.length) { + const next = input.indexOf("<", i); + if (next === -1) { + out += input.slice(i); + break; + } + out += input.slice(i, next); + const tail = input.slice(next); + const isOpen = MEMORY_CITATION_OPEN.startsWith(tail) || tail.startsWith(MEMORY_CITATION_OPEN); + const isClose = MEMORY_CITATION_CLOSE.startsWith(tail) || tail.startsWith(MEMORY_CITATION_CLOSE); + if (!isOpen && !isClose) { + out += "<"; + i = next + 1; + continue; + } + // A complete close tag is never part of a block we would keep here: real blocks are + // emitted whole from their opening tag, so this one belongs to prose. + if (tail.startsWith(MEMORY_CITATION_CLOSE)) { + out += "</oai-mem-citation>"; + i = next + MEMORY_CITATION_CLOSE.length; + continue; + } + if (!tail.startsWith(MEMORY_CITATION_OPEN)) { + // The open tag itself is still arriving. + held = tail; + return out; + } + const afterOpen = tail.slice(MEMORY_CITATION_OPEN.length); + const trimmed = afterOpen.replace(/^\s+/, ""); + if (trimmed.length === 0) { + // Whitespace-only follow-up could still be the start of a real block. + held = tail; + return out; + } + if (MEMORY_CITATION_BODY_OPEN.startsWith(trimmed) || trimmed.startsWith(MEMORY_CITATION_BODY_OPEN)) { + // A real block: emit its opening tag and stream the body through untouched. + out += MEMORY_CITATION_OPEN; + i = next + MEMORY_CITATION_OPEN.length; + continue; + } + out += "<oai-mem-citation>"; + i = next + MEMORY_CITATION_OPEN.length; + } + return out; + }, + pending(): string { + return held; + }, + }; +} + /** * Remove every complete citation span from a whole string. * @@ -71,9 +260,9 @@ export interface CitationMarkerFilter { * stray SEPARATOR or END, and every non-citation directive. */ export function stripCitationMarkers(text: string): string { - if (!text.includes(CITATION_MARKER_START)) return text; + if (!text.includes(CITATION_MARKER_START)) return escapeMemoryCitationMentions(text); const filter = createCitationMarkerFilter(); - return filter.push(text) + filter.flush(); + return escapeMemoryCitationMentions(filter.push(text) + filter.flush()); } /** diff --git a/tests/adapters/google/gemini-4-argon-antigravity.test.ts b/tests/adapters/google/gemini-4-argon-antigravity.test.ts index 5e784b412c4..50494935421 100644 --- a/tests/adapters/google/gemini-4-argon-antigravity.test.ts +++ b/tests/adapters/google/gemini-4-argon-antigravity.test.ts @@ -78,18 +78,33 @@ describe("Gemini 4 Argon on Antigravity", () => { expect(ANTIGRAVITY_MODEL_INPUT_MODALITIES["gemini-4-argon"]).toEqual(["text", "image"]); }); - test("each effort resolves to its verified suffix wire id with no thinkingLevel", () => { - for (const [effort, wireModelId] of [ - ["low", "gemini-4-argon-low"], - ["medium", "gemini-4-argon-medium"], - ["high", "gemini-4-argon-high"], - ["xhigh", "gemini-4-argon-high"], - ["max", "gemini-4-argon-high"], + test("each effort resolves to its verified suffix wire id plus the same tier as thinkingLevel", () => { + // Argon is the measured exception to "the suffix IS the effort": with only + // includeThoughts on the wire, CCA answers 200 with thousands of thoughtsTokenCount and no + // thought parts, so the plan shows up as ordinary text. Repeating the suffix tier as + // thinkingLevel is what makes the thought text arrive (live CCA, 2026-10-27). + for (const [effort, wireModelId, thinkingLevel] of [ + ["low", "gemini-4-argon-low", "low"], + ["medium", "gemini-4-argon-medium", "medium"], + ["high", "gemini-4-argon-high", "high"], + ["xhigh", "gemini-4-argon-high", "high"], + ["max", "gemini-4-argon-high", "high"], ] as const) { - expect(resolveAntigravityEffortWireModel("gemini-4-argon", effort)).toEqual({ wireModelId }); + expect(resolveAntigravityEffortWireModel("gemini-4-argon", effort)).toEqual({ wireModelId, thinkingLevel }); } // Google's documented default thinking level. - expect(resolveAntigravityEffortWireModel("gemini-4-argon")).toEqual({ wireModelId: "gemini-4-argon-medium" }); + expect(resolveAntigravityEffortWireModel("gemini-4-argon")) + .toEqual({ wireModelId: "gemini-4-argon-medium", thinkingLevel: "medium" }); + }); + + test("a suffix Argon id keeps its own tier as the level; other suffix families stay level-free", () => { + expect(resolveAntigravityEffortWireModel("gemini-4-argon-high")) + .toEqual({ wireModelId: "gemini-4-argon-high", thinkingLevel: "high" }); + // Bare Argon names no tier, so there is no level to state twice. + expect(resolveAntigravityEffortWireModel("gemini-4-argon")).toMatchObject({ wireModelId: "gemini-4-argon-medium" }); + // 3.8 Flash returns thought text with includeThoughts alone; it must not gain a second field. + expect(resolveAntigravityEffortWireModel("gemini-3.8-flash", "high")) + .toEqual({ wireModelId: "gemini-3.8-flash-high" }); }); test("usage collapses every Argon wire id onto the picker id", () => { @@ -143,12 +158,13 @@ describe("Antigravity User-Agent selection", () => { }); test("an Argon inference request carries the CLI UA and the suffix wire id", async () => { - for (const [effort, wire] of [["low", "gemini-4-argon-low"], ["high", "gemini-4-argon-high"], [undefined, "gemini-4-argon-medium"]] as const) { + for (const [effort, wire, level] of [["low", "gemini-4-argon-low", "low"], ["high", "gemini-4-argon-high", "high"], [undefined, "gemini-4-argon-medium", "medium"]] as const) { const { req, env } = await built("gemini-4-argon", effort); expect(env.model).toBe(wire); expect(req.headers["User-Agent"]).toBe(CLI_UA); - // The suffix already names the tier; a thinkingLevel beside it would state it twice. - expect(env.request.generationConfig?.thinkingConfig?.thinkingLevel).toBeUndefined(); + // The suffix names the tier, and Argon additionally needs it named in thinkingConfig or + // CCA withholds the thought parts. Both spellings are the same tier, so they cannot conflict. + expect(env.request.generationConfig?.thinkingConfig?.thinkingLevel).toBe(level); } }); diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index 618a3a22e7f..cb6d95ebcfc 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -4,6 +4,8 @@ import { CITATION_MARKER_SEPARATOR, CITATION_MARKER_START, createCitationMarkerFilter, + createMemoryCitationEscaper, + escapeMemoryCitationMentions, hasCitationMarker, stripCitationMarkers, } from "../../src/responses/citation-markers"; @@ -218,4 +220,46 @@ describe("non-citation directive spans pass through (#6039)", () => { expect(filter.push(`ualize${P}{"path"`)).toBe(`ualize${P}{"path"`); expect(filter.flush()).toBe(""); }); + + // The Codex client treats a literal as markup: it keeps the LAST block and + // drops everything between the first opening tag and the last closing one. A model that quotes + // the tag name in prose therefore loses the rest of its answer, which is the "truncated at a + // backtick" report. Escaping the leading `<` keeps the characters visible without matching. + test("a literal memory-citation tag in prose is escaped, not left to the client", () => { + expect(escapeMemoryCitationMentions("put the `` block last")) + .toBe("put the `<oai-mem-citation>` block last"); + expect(escapeMemoryCitationMentions("")) + .toBe("</oai-mem-citation>"); + // Text that merely shares a prefix is untouched. + expect(escapeMemoryCitationMentions(" and

tags")) + .toBe(" and

tags"); + }); + + test("a real memory-citation block keeps its tags so the client can still render it", () => { + const block = "\n\nMEMORY.md:1-2|note=[x]\n\n" + + "\n0123\n\n"; + // A mention before the block is escaped, the block itself survives byte for byte. + expect(escapeMemoryCitationMentions(`put the \`\` block last.\n\n${block}`)) + .toBe(`put the \`<oai-mem-citation>\` block last.\n\n${block}`); + expect(escapeMemoryCitationMentions(block)).toBe(block); + }); + + test("the streaming escaper escapes a tag split across deltas", () => { + const escaper = createMemoryCitationEscaper(); + const chunks = ["answer: ", "<", "oai", "-mem-cit", "ation", ">", " rest"]; + const out = chunks.map(chunk => escaper.push(chunk)).join("") + escaper.pending(); + expect(out).toBe("answer: <oai-mem-citation> rest"); + }); + + test("the streaming escaper releases a tail that stops looking like a tag", () => { + const escaper = createMemoryCitationEscaper(); + // " Date: Tue, 3 Nov 2026 03:01:52 -0500 Subject: [PATCH 2/6] fix(antigravity): hold streamed citation blocks until closed, share escaper state in the adapter encoder, keep the lone Argon level --- src/protocols/encoders/adapter-events.ts | 25 ++++-- src/providers/antigravity-models.ts | 4 +- src/responses/citation-markers.ts | 61 ++++++++------ .../antigravity-discovered-families.test.ts | 7 ++ tests/responses/citation-markers.test.ts | 82 +++++++++++++++++++ 5 files changed, 149 insertions(+), 30 deletions(-) diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index aef44e9175d..74728ba6622 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -34,7 +34,13 @@ import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; import { redactSecretString } from "../../lib/redact"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import { createCitationMarkerFilter, escapeMemoryCitationMentions, type CitationMarkerFilter } from "../../responses/citation-markers"; +import { + createCitationMarkerFilter, + createMemoryCitationEscaper, + escapeMemoryCitationMentions, + type CitationMarkerFilter, + type MemoryCitationEscaper, +} from "../../responses/citation-markers"; import { isTruncatedStopReason, truncationReasonFor } from "../../responses/truncated-stop-reason"; import { safeWebSearchSources } from "../../web-search/sources"; import { resolveStallTimeoutSec } from "../../stall-timeout"; @@ -332,7 +338,7 @@ export function encodeAdapterEventStream( }; // Item state, ported from bridgeToResponsesSSE. - let currentMsg: { phase?: OcxMessagePhase; citationFilter: CitationMarkerFilter } | null = null; + let currentMsg: { phase?: OcxMessagePhase; citationFilter: CitationMarkerFilter; memoryCitationEscaper: MemoryCitationEscaper } | null = null; let currentReasoning: { itemId: string } | null = null; let currentRawReasoning: { itemId: string } | null = null; let pendingSignature: string | undefined; @@ -358,7 +364,11 @@ export function encodeAdapterEventStream( if (!currentMsg) return; // A citation span can straddle a delta boundary; the filter releases its held tail here. const trailing = currentMsg.citationFilter.flush(); - if (trailing) writer.text(escapeMemoryCitationMentions(trailing)); + // A tag or candidate block still held by the escaper is resolved once, whole, so a real + // block's closing tag is neither split nor re-escaped. + const tail = (trailing ? currentMsg.memoryCitationEscaper.push(trailing) : "") + + escapeMemoryCitationMentions(currentMsg.memoryCitationEscaper.pending()); + if (tail) writer.text(tail); writer.messageDone(); currentMsg = null; }; @@ -600,13 +610,18 @@ export function encodeAdapterEventStream( // Only an explicit phase change starts a new message. if (currentMsg && event.phase !== undefined && currentMsg.phase !== event.phase) closeCurrentMessage(); if (!currentMsg) { - currentMsg = { citationFilter: createCitationMarkerFilter(), ...(event.phase ? { phase: event.phase } : {}) }; + currentMsg = { + citationFilter: createCitationMarkerFilter(), + memoryCitationEscaper: createMemoryCitationEscaper(), + ...(event.phase ? { phase: event.phase } : {}), + }; } const visible = currentMsg.citationFilter.push(event.text); // A literal memory-citation tag in prose makes the Codex client hide everything // between the first opening tag and the last closing one, which reads as a truncated // answer. Escape it here, while it streams. - if (visible) writer.text(escapeMemoryCitationMentions(visible)); + const escaped = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; + if (escaped) writer.text(escaped); break; } case "thinking_delta": { diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index b3ad02ecabf..25f81ea30c5 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -787,11 +787,13 @@ export function resolveAntigravityEffortWireModel( : discoveredAntigravityWireModelId(modelId, baseUrl); if (discoveredWireModelId && (discoveredWireModelId !== modelId || isAntigravitySuffixModelId(modelId))) { const defaultLevel = ANTIGRAVITY_THINKING_LEVEL_MODELS[modelId]; + // A lone discovered Argon tier keeps its suffix id; the level it names is what exposes thought text. + const argonSuffix = /^gemini-4-argon-(low|medium|high)$/.exec(discoveredWireModelId); return { wireModelId: discoveredWireModelId, ...(defaultLevel ? { thinkingLevel: effort ? resolveAntigravityThinkingLevel(effort) ?? defaultLevel : defaultLevel } - : {}), + : argonSuffix ? { thinkingLevel: argonSuffix[1]! } : {}), }; } diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index 0cdf7ab168f..766396aad15 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -86,6 +86,9 @@ const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>[\s\S /** First element of a real block's body, used to decide from a short lookahead. */ const MEMORY_CITATION_BODY_OPEN = ""; +/** Longest candidate block the streaming escaper holds while waiting for its closing tag. */ +const MAX_MEMORY_CITATION_HOLD = 65_536; + /** * Whether the text right after an opening tag still could begin a real block. * @@ -191,8 +194,14 @@ export interface MemoryCitationEscaper { * Everything else is a mention and gets `<`. */ export function createMemoryCitationEscaper(): MemoryCitationEscaper { - /** Unresolved tail: either a partial tag, or a tag whose follower has not arrived yet. */ + /** + * Unresolved tail. It starts at a `<` and is either a partial tag, or an opening tag whose block + * has not closed yet. A candidate block is held WHOLE until its closing tag arrives, because only + * the complete body (entries, then rollout ids) proves it is a real block rather than a mention. + */ let held = ""; + const escapeOpen = "<" + MEMORY_CITATION_OPEN.slice(1); + const escapeClose = "<" + MEMORY_CITATION_CLOSE.slice(1); return { push(delta: string): string { if (!held && !delta.includes("<")) return delta; @@ -208,39 +217,43 @@ export function createMemoryCitationEscaper(): MemoryCitationEscaper { } out += input.slice(i, next); const tail = input.slice(next); - const isOpen = MEMORY_CITATION_OPEN.startsWith(tail) || tail.startsWith(MEMORY_CITATION_OPEN); - const isClose = MEMORY_CITATION_CLOSE.startsWith(tail) || tail.startsWith(MEMORY_CITATION_CLOSE); - if (!isOpen && !isClose) { - out += "<"; - i = next + 1; - continue; - } - // A complete close tag is never part of a block we would keep here: real blocks are - // emitted whole from their opening tag, so this one belongs to prose. if (tail.startsWith(MEMORY_CITATION_CLOSE)) { - out += "</oai-mem-citation>"; + // A close tag outside a claimed block is prose. + out += escapeClose; i = next + MEMORY_CITATION_CLOSE.length; continue; } if (!tail.startsWith(MEMORY_CITATION_OPEN)) { - // The open tag itself is still arriving. - held = tail; - return out; + if (MEMORY_CITATION_OPEN.startsWith(tail) || MEMORY_CITATION_CLOSE.startsWith(tail)) { + // A tag is still arriving. + held = tail; + return out; + } + out += "<"; + i = next + 1; + continue; } const afterOpen = tail.slice(MEMORY_CITATION_OPEN.length); - const trimmed = afterOpen.replace(/^\s+/, ""); - if (trimmed.length === 0) { - // Whitespace-only follow-up could still be the start of a real block. - held = tail; - return out; - } - if (MEMORY_CITATION_BODY_OPEN.startsWith(trimmed) || trimmed.startsWith(MEMORY_CITATION_BODY_OPEN)) { - // A real block: emit its opening tag and stream the body through untouched. - out += MEMORY_CITATION_OPEN; + if (!bodyCouldBeReal(afterOpen)) { + out += escapeOpen; i = next + MEMORY_CITATION_OPEN.length; continue; } - out += "<oai-mem-citation>"; + const end = opensRealBlock(tail, 0); + if (end !== undefined) { + // A complete real block is released verbatim, both tags included, so the delta stream + // equals what `escapeMemoryCitationMentions` keeps in the final text. + out += tail.slice(0, end); + i = next + end; + continue; + } + if (!tail.includes(MEMORY_CITATION_CLOSE, MEMORY_CITATION_OPEN.length) && tail.length <= MAX_MEMORY_CITATION_HOLD) { + // The block may still close; hold it whole. + held = tail; + return out; + } + // Closed without a valid body, or too large to keep holding: a mention. + out += escapeOpen; i = next + MEMORY_CITATION_OPEN.length; } return out; diff --git a/tests/adapters/google/antigravity-discovered-families.test.ts b/tests/adapters/google/antigravity-discovered-families.test.ts index e4b02c719b9..732d28f98e1 100644 --- a/tests/adapters/google/antigravity-discovered-families.test.ts +++ b/tests/adapters/google/antigravity-discovered-families.test.ts @@ -98,3 +98,10 @@ test("an evidenced overlapping base takes routing precedence over a parent tier expect(resolveAntigravityEffortWireModel("foo-low", "high", baseUrl)).toEqual({ wireModelId: "foo-low-high" }); expect(resolveAntigravityEffortWireModel("foo", "low", baseUrl)).toEqual({ wireModelId: "foo-low" }); }); + +test("a lone discovered Argon tier keeps its level so thought text still streams", () => { + const rows = parseAntigravityAvailableModels(payload("gemini-4-argon", ["high"]))!; + registerAntigravityDiscoveredWireModels(baseUrl, rows); + expect(resolveAntigravityEffortWireModel("gemini-4-argon-high", undefined, baseUrl)) + .toEqual({ wireModelId: "gemini-4-argon-high", thinkingLevel: "high" }); +}); diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index cb6d95ebcfc..9f46b21fce2 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -9,6 +9,10 @@ import { hasCitationMarker, stripCitationMarkers, } from "../../src/responses/citation-markers"; +import { encodeChatCompletionSse } from "../../src/protocols/encoders/chat"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; /** * #3150: the ChatGPT backend delimits inline citations with private-use characters @@ -262,4 +266,82 @@ describe("non-citation directive spans pass through (#6039)", () => { expect(held.push("tail \n0123\n\n"; + + /** Feed a string to the escaper in the given pieces and resolve what it still holds. */ + const streamed = (pieces: string[]): string => { + const escaper = createMemoryCitationEscaper(); + return pieces.map(piece => escaper.push(piece)).join("") + escapeMemoryCitationMentions(escaper.pending()); + }; + + test("a real block split one character at a time keeps both tags, equal to the final text", () => { + const text = `see this\n\n${REAL_BLOCK}`; + expect(streamed([...text])).toBe(text); + expect(streamed([...text])).toBe(escapeMemoryCitationMentions(text)); + }); + + test("an incomplete or invalid block is a mention and is escaped like the final text", () => { + for (const text of [ + "x e y", + "x never closes", + "quoted prose more", + ]) { + expect(streamed([...text])).toBe(escapeMemoryCitationMentions(text)); + } + }); + + test("delta/done parity holds for every two-way split of a mixed text", () => { + const text = `a \`\` b ${REAL_BLOCK} c d`; + const expected = escapeMemoryCitationMentions(text); + for (let cut = 0; cut <= text.length; cut++) { + expect(streamed([text.slice(0, cut), text.slice(cut)])).toBe(expected); + } + }); + + async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; + } + const textEvents = (pieces: string[]): AdapterEvent[] => [ + ...pieces.map(text => ({ type: "text_delta", text }) as AdapterEvent), + { type: "done" } as AdapterEvent, + ]; + + test("the Chat encoder escapes a mention split across deltas and keeps a split real block", async () => { + const chatText = async (pieces: string[]) => { + const raw = await new Response(encodeChatCompletionSse(replay(textEvents(pieces)), { + model: "client-model", translatorBudget: createTestTranslatorBudget(), + })).text(); + return raw.split("\n").filter(line => line.startsWith("data: {")).map(line => { + const content = (JSON.parse(line.slice(6)) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content; + return content ?? ""; + }).join(""); + }; + expect(await chatText(["quoted prose"])).toBe("quoted <oai-mem-citation> prose"); + const real = `lead\n${REAL_BLOCK}`; + expect(await chatText([...real])).toBe(real); + }); + + test("the Responses bridge streams the same text it reports as done", async () => { + const run = async (pieces: string[]) => { + const raw = await new Response(bridgeToResponsesSSE( + replay(textEvents(pieces)), "internal/model", undefined, undefined, undefined, undefined, 2_000, + { translatorBudget: createTestTranslatorBudget(), enforceDeclaredToolNames: false }, + )).text(); + const frames = raw.split("\n\n").map(block => block.split("\n").find(line => line.startsWith("data: "))).filter(line => line && line !== "data: [DONE]") + .map(line => JSON.parse(line!.slice(6)) as { type?: string; delta?: string; text?: string }); + return { + deltas: frames.filter(f => f.type === "response.output_text.delta").map(f => f.delta ?? "").join(""), + done: frames.find(f => f.type === "response.output_text.done")?.text ?? "", + }; + }; + const real = `lead\n${REAL_BLOCK}`; + const block = await run([...real]); + expect(block.deltas).toBe(real); + expect(block.done).toBe(real); + const mention = await run(["quoted prose"]); + expect(mention.deltas).toBe("quoted <oai-mem-citation> prose"); + expect(mention.done).toBe(mention.deltas); + }); }); From 2d4f07b2043aebf071b644ac8b07a3a6e85a0c63 Mon Sep 17 00:00:00 2001 From: "Andrew (agent)" Date: Tue, 3 Nov 2026 11:00:33 -0500 Subject: [PATCH 3/6] refactor(antigravity): move the Argon tier change to its own PR; charge held citation bytes to the translator budget --- src/bridge/sse.ts | 6 +++ src/protocols/encoders/adapter-events.ts | 17 +++++- src/providers/antigravity-models.ts | 52 +++---------------- src/responses/citation-markers.ts | 7 ++- structure/transports/responses-wire-shapes.md | 2 +- .../antigravity-discovered-families.test.ts | 7 --- .../google/gemini-4-argon-antigravity.test.ts | 40 +++++--------- tests/responses/citation-markers.test.ts | 31 +++++++++++ 8 files changed, 77 insertions(+), 85 deletions(-) diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 7c8118f3526..f76cee7557d 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -382,6 +382,8 @@ export function bridgeToResponsesSSE( text: StringChunks; citationFilter: CitationMarkerFilter; memoryCitationEscaper: MemoryCitationEscaper; + /** Bytes the escaper currently holds, charged to the translator budget. */ + heldBytes: number; phase?: OcxMessagePhase; } | null = null; let currentReasoning: { itemId: string; outputIndex: number; text: StringChunks } | null = null; @@ -519,6 +521,7 @@ export function bridgeToResponsesSSE( // against the accumulated text so a real block's trailing close tag survives. const heldTail = currentMsg.memoryCitationEscaper.pending(); const resolvedTail = heldTail ? escapeMemoryCitationMentions(heldTail) : ""; + currentMsg.heldBytes = replaceRetainedString(currentMsg.heldBytes, "", "live_transient"); const finalDelta = escapedTrailing + resolvedTail; if (finalDelta) { emit("response.output_text.delta", { @@ -961,6 +964,7 @@ export function bridgeToResponsesSSE( itemId, outputIndex, text: emptyChunks(), citationFilter: createCitationMarkerFilter(), memoryCitationEscaper: createMemoryCitationEscaper(), + heldBytes: 0, ...(event.phase ? { phase: event.phase } : {}), }; } @@ -979,6 +983,8 @@ export function bridgeToResponsesSSE( // delta, so it uses the streaming escaper: a tag arrives split across deltas, and a // whole-string match inside a single delta would miss it. const emitted = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; + currentMsg.heldBytes = replaceRetainedString( + currentMsg.heldBytes, currentMsg.memoryCitationEscaper.pending(), "live_transient"); if (emitted) { emit("response.output_text.delta", { item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 74728ba6622..ba5c97f3d58 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -338,7 +338,19 @@ export function encodeAdapterEventStream( }; // Item state, ported from bridgeToResponsesSSE. - let currentMsg: { phase?: OcxMessagePhase; citationFilter: CitationMarkerFilter; memoryCitationEscaper: MemoryCitationEscaper } | null = null; + let currentMsg: { + phase?: OcxMessagePhase; + citationFilter: CitationMarkerFilter; + memoryCitationEscaper: MemoryCitationEscaper; + /** Bytes the escaper currently holds, charged to the translator budget. */ + heldBytes: number; + } | null = null; + const syncHeldCitationBytes = (msg: NonNullable, held: string) => { + const next = Buffer.byteLength(held); + if (next > msg.heldBytes) budget.chargeRetained(next - msg.heldBytes, { kind: "live_transient" }); + else if (next < msg.heldBytes) budget.releaseRetained(msg.heldBytes - next, { kind: "live_transient" }); + msg.heldBytes = next; + }; let currentReasoning: { itemId: string } | null = null; let currentRawReasoning: { itemId: string } | null = null; let pendingSignature: string | undefined; @@ -368,6 +380,7 @@ export function encodeAdapterEventStream( // block's closing tag is neither split nor re-escaped. const tail = (trailing ? currentMsg.memoryCitationEscaper.push(trailing) : "") + escapeMemoryCitationMentions(currentMsg.memoryCitationEscaper.pending()); + syncHeldCitationBytes(currentMsg, ""); if (tail) writer.text(tail); writer.messageDone(); currentMsg = null; @@ -613,6 +626,7 @@ export function encodeAdapterEventStream( currentMsg = { citationFilter: createCitationMarkerFilter(), memoryCitationEscaper: createMemoryCitationEscaper(), + heldBytes: 0, ...(event.phase ? { phase: event.phase } : {}), }; } @@ -621,6 +635,7 @@ export function encodeAdapterEventStream( // between the first opening tag and the last closing one, which reads as a truncated // answer. Escape it here, while it streams. const escaped = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; + syncHeldCitationBytes(currentMsg, currentMsg.memoryCitationEscaper.pending()); if (escaped) writer.text(escaped); break; } diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 25f81ea30c5..73ea76c5967 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -243,19 +243,6 @@ const ANTIGRAVITY_EFFORT_WIRE_MAP: Record> = { */ const ANTIGRAVITY_SUFFIX_TIER_MODELS = new Set([GEMINI_ARGON, "gemini-3.8-flash", CLAUDE_OPUS_55, CLAUDE_SONNET_55]); -/** - * Suffix-tier models that withhold `thought: true` text unless the request ALSO names its tier - * in `thinkingConfig.thinkingLevel`. - * - * Measured against live CCA (2026-10-27): `gemini-4-argon-high` with - * `{includeThoughts: true}` alone answers 200 with `thoughtsTokenCount` in the thousands and - * zero thought parts — the whole plan arrives as ordinary visible text. Sending - * `{thinkingLevel: "high", includeThoughts: true}` on the same wire id returns the thought - * parts. `gemini-3.8-flash-high` does not behave this way: its include-only request still - * returns thoughts, so it stays out of this set and keeps the one-field shape. - */ -const ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS = new Set([GEMINI_ARGON]); - function completeDiscoveredEffortWireModelIds( pickerId: string, available: ReadonlyMap>, @@ -760,18 +747,7 @@ export function resolveAntigravityEffortWireModel( baseUrl?: string, ): { wireModelId: string; thinkingLevel?: string } { const discoveredEffortWireModelId = discoveredAntigravityEffortWireModelId(modelId, effort, baseUrl); - if (discoveredEffortWireModelId) { - // Discovery returns the tier's wire id, never a level; Argon still needs the level on the - // wire to expose thought text, so derive it from the requested effort (or its default tier). - const discoveredLevel = ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) - ? (effort ? resolveAntigravityThinkingLevel(effort) : undefined) - ?? ANTIGRAVITY_DEFAULT_EFFORT[modelId] - : undefined; - return { - wireModelId: discoveredEffortWireModelId, - ...(discoveredLevel ? { thinkingLevel: discoveredLevel } : {}), - }; - } + if (discoveredEffortWireModelId) return { wireModelId: discoveredEffortWireModelId }; // A collapsed picker row reports ONE representative wire id (whichever tier CCA // listed first), so live discovery cannot describe a ladder — it can only name a @@ -787,13 +763,11 @@ export function resolveAntigravityEffortWireModel( : discoveredAntigravityWireModelId(modelId, baseUrl); if (discoveredWireModelId && (discoveredWireModelId !== modelId || isAntigravitySuffixModelId(modelId))) { const defaultLevel = ANTIGRAVITY_THINKING_LEVEL_MODELS[modelId]; - // A lone discovered Argon tier keeps its suffix id; the level it names is what exposes thought text. - const argonSuffix = /^gemini-4-argon-(low|medium|high)$/.exec(discoveredWireModelId); return { wireModelId: discoveredWireModelId, ...(defaultLevel ? { thinkingLevel: effort ? resolveAntigravityThinkingLevel(effort) ?? defaultLevel : defaultLevel } - : argonSuffix ? { thinkingLevel: argonSuffix[1]! } : {}), + : {}), }; } @@ -812,12 +786,7 @@ export function resolveAntigravityEffortWireModel( // Rule 1: suffix/compat alias — suffix IS the effort. if (isAntigravitySuffixModelId(modelId)) { - const wireModelId = resolveAntigravityWireModelId(modelId, baseUrl); - // Argon is the measured exception to "the suffix IS the effort": CCA withholds its thought - // text unless the tier is also named in thinkingConfig. Only a `-low/-medium/-high` suffix - // carries a level, so a bare Argon id keeps its historical shape. - const argonSuffix = /^gemini-4-argon-(low|medium|high)$/.exec(wireModelId); - return argonSuffix ? { wireModelId, thinkingLevel: argonSuffix[1]! } : { wireModelId }; + return { wireModelId: resolveAntigravityWireModelId(modelId, baseUrl) }; } // Rule 1b: single-wire-id Gemini model whose tiers ride on thinkingLevel. Without @@ -842,20 +811,11 @@ export function resolveAntigravityEffortWireModel( : effort; if (requested && requested in effortMap) { const wireModelId = effortMap[requested]!; - // The suffix already names the tier; see ANTIGRAVITY_SUFFIX_TIER_MODELS. Argon is the - // exception: it withholds thought text unless the level is repeated on the wire, and the - // level sent is this same suffix tier, so the two fields cannot disagree. - return suffixTiered && !ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) - ? { wireModelId } - : { wireModelId, thinkingLevel: requested }; + // The suffix already names the tier; see ANTIGRAVITY_SUFFIX_TIER_MODELS. + return suffixTiered ? { wireModelId } : { wireModelId, thinkingLevel: requested }; } const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!; - return { - wireModelId: effortMap[defaultEffort]!, - ...(ANTIGRAVITY_THOUGHT_LEVEL_REQUIRED_SUFFIX_MODELS.has(modelId) - ? { thinkingLevel: defaultEffort } - : {}), - }; + return { wireModelId: effortMap[defaultEffort]! }; } // Rule 4: Claude models — effort via thinkingConfig only (no suffix variants). diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index 766396aad15..9822a087db0 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -86,8 +86,11 @@ const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>[\s\S /** First element of a real block's body, used to decide from a short lookahead. */ const MEMORY_CITATION_BODY_OPEN = ""; -/** Longest candidate block the streaming escaper holds while waiting for its closing tag. */ -const MAX_MEMORY_CITATION_HOLD = 65_536; +/** + * Longest candidate block the streaming escaper holds while waiting for its closing tag. Callers + * charge the held bytes to their translator budget (`live_transient`) as the hold grows. + */ +const MAX_MEMORY_CITATION_HOLD = 16_384; /** * Whether the text right after an opening tag still could begin a real block. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index eced4076992..0908c78d16a 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -595,6 +595,6 @@ such span in the parsed context — system prompt, string content and text parts that ASCII directive, following the app's own payload rules, and `parseRequest` applies it to the context it returns. `_rawBody` is not touched, so native passthrough stays byte-identical and stored `previous_response_id` history keeps the original text. The citation filter in -`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). +`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag (the client hides everything between the first opening and last closing tag) and holds a candidate block WHOLE until its closing tag, releasing it verbatim only when the body validates, so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16 KiB candidate is escaped. The Responses bridge and the adapter encoder each keep one escaper per message and charge its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). `tests/responses/visualization-directives.test.ts` pins the payload rules, the linear-time scan and the parser and Anthropic request paths. diff --git a/tests/adapters/google/antigravity-discovered-families.test.ts b/tests/adapters/google/antigravity-discovered-families.test.ts index 732d28f98e1..e4b02c719b9 100644 --- a/tests/adapters/google/antigravity-discovered-families.test.ts +++ b/tests/adapters/google/antigravity-discovered-families.test.ts @@ -98,10 +98,3 @@ test("an evidenced overlapping base takes routing precedence over a parent tier expect(resolveAntigravityEffortWireModel("foo-low", "high", baseUrl)).toEqual({ wireModelId: "foo-low-high" }); expect(resolveAntigravityEffortWireModel("foo", "low", baseUrl)).toEqual({ wireModelId: "foo-low" }); }); - -test("a lone discovered Argon tier keeps its level so thought text still streams", () => { - const rows = parseAntigravityAvailableModels(payload("gemini-4-argon", ["high"]))!; - registerAntigravityDiscoveredWireModels(baseUrl, rows); - expect(resolveAntigravityEffortWireModel("gemini-4-argon-high", undefined, baseUrl)) - .toEqual({ wireModelId: "gemini-4-argon-high", thinkingLevel: "high" }); -}); diff --git a/tests/adapters/google/gemini-4-argon-antigravity.test.ts b/tests/adapters/google/gemini-4-argon-antigravity.test.ts index 50494935421..5e784b412c4 100644 --- a/tests/adapters/google/gemini-4-argon-antigravity.test.ts +++ b/tests/adapters/google/gemini-4-argon-antigravity.test.ts @@ -78,33 +78,18 @@ describe("Gemini 4 Argon on Antigravity", () => { expect(ANTIGRAVITY_MODEL_INPUT_MODALITIES["gemini-4-argon"]).toEqual(["text", "image"]); }); - test("each effort resolves to its verified suffix wire id plus the same tier as thinkingLevel", () => { - // Argon is the measured exception to "the suffix IS the effort": with only - // includeThoughts on the wire, CCA answers 200 with thousands of thoughtsTokenCount and no - // thought parts, so the plan shows up as ordinary text. Repeating the suffix tier as - // thinkingLevel is what makes the thought text arrive (live CCA, 2026-10-27). - for (const [effort, wireModelId, thinkingLevel] of [ - ["low", "gemini-4-argon-low", "low"], - ["medium", "gemini-4-argon-medium", "medium"], - ["high", "gemini-4-argon-high", "high"], - ["xhigh", "gemini-4-argon-high", "high"], - ["max", "gemini-4-argon-high", "high"], + test("each effort resolves to its verified suffix wire id with no thinkingLevel", () => { + for (const [effort, wireModelId] of [ + ["low", "gemini-4-argon-low"], + ["medium", "gemini-4-argon-medium"], + ["high", "gemini-4-argon-high"], + ["xhigh", "gemini-4-argon-high"], + ["max", "gemini-4-argon-high"], ] as const) { - expect(resolveAntigravityEffortWireModel("gemini-4-argon", effort)).toEqual({ wireModelId, thinkingLevel }); + expect(resolveAntigravityEffortWireModel("gemini-4-argon", effort)).toEqual({ wireModelId }); } // Google's documented default thinking level. - expect(resolveAntigravityEffortWireModel("gemini-4-argon")) - .toEqual({ wireModelId: "gemini-4-argon-medium", thinkingLevel: "medium" }); - }); - - test("a suffix Argon id keeps its own tier as the level; other suffix families stay level-free", () => { - expect(resolveAntigravityEffortWireModel("gemini-4-argon-high")) - .toEqual({ wireModelId: "gemini-4-argon-high", thinkingLevel: "high" }); - // Bare Argon names no tier, so there is no level to state twice. - expect(resolveAntigravityEffortWireModel("gemini-4-argon")).toMatchObject({ wireModelId: "gemini-4-argon-medium" }); - // 3.8 Flash returns thought text with includeThoughts alone; it must not gain a second field. - expect(resolveAntigravityEffortWireModel("gemini-3.8-flash", "high")) - .toEqual({ wireModelId: "gemini-3.8-flash-high" }); + expect(resolveAntigravityEffortWireModel("gemini-4-argon")).toEqual({ wireModelId: "gemini-4-argon-medium" }); }); test("usage collapses every Argon wire id onto the picker id", () => { @@ -158,13 +143,12 @@ describe("Antigravity User-Agent selection", () => { }); test("an Argon inference request carries the CLI UA and the suffix wire id", async () => { - for (const [effort, wire, level] of [["low", "gemini-4-argon-low", "low"], ["high", "gemini-4-argon-high", "high"], [undefined, "gemini-4-argon-medium", "medium"]] as const) { + for (const [effort, wire] of [["low", "gemini-4-argon-low"], ["high", "gemini-4-argon-high"], [undefined, "gemini-4-argon-medium"]] as const) { const { req, env } = await built("gemini-4-argon", effort); expect(env.model).toBe(wire); expect(req.headers["User-Agent"]).toBe(CLI_UA); - // The suffix names the tier, and Argon additionally needs it named in thinkingConfig or - // CCA withholds the thought parts. Both spellings are the same tier, so they cannot conflict. - expect(env.request.generationConfig?.thinkingConfig?.thinkingLevel).toBe(level); + // The suffix already names the tier; a thinkingLevel beside it would state it twice. + expect(env.request.generationConfig?.thinkingConfig?.thinkingLevel).toBeUndefined(); } }); diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index 9f46b21fce2..ab4f0bfa7e6 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -344,4 +344,35 @@ describe("non-citation directive spans pass through (#6039)", () => { expect(mention.deltas).toBe("quoted <oai-mem-citation> prose"); expect(mention.done).toBe(mention.deltas); }); + + test("a held candidate block is charged to the translator budget and released at close", async () => { + const body = "MEMORY.md:1-2|note=[x]\n".repeat(150); + const candidate = `\n\n${body}`; + const pieces: string[] = []; + for (let at = 0; at < candidate.length; at += 500) pieces.push(candidate.slice(at, at + 500)); + const peak = async (text: string[]) => { + const budget = createTestTranslatorBudget(); + await new Response(bridgeToResponsesSSE( + replay(textEvents(text)), "internal/model", undefined, undefined, undefined, undefined, 2_000, + { translatorBudget: budget, enforceDeclaredToolNames: false }, + )).text(); + return budget.snapshot(); + }; + const held = await peak(pieces); + // Same bytes and newlines, but not a tag, so nothing is held. + const free = await peak(pieces.map(piece => piece.replace("", ""))); + // The never-closed candidate is held whole, so the bridge's high-water mark carries it ... + expect(held.highWaterBytes).toBeGreaterThanOrEqual(free.highWaterBytes + Buffer.byteLength(candidate) / 2); + // ... and everything is released once the message closes. + // (The escaped `<` adds a few bytes to the retained text, nothing more.) + expect(Math.abs(held.currentBytes - free.currentBytes)).toBeLessThanOrEqual(16); + }); + + test("an oversized candidate stops being held and is escaped like the final text", () => { + const huge = `\n\n${"y".repeat(40_000)}`; + const escaper = createMemoryCitationEscaper(); + const out = [...huge.match(/[\s\S]{1,4000}/g)!].map(piece => escaper.push(piece)).join(""); + expect(Buffer.byteLength(escaper.pending())).toBeLessThanOrEqual(16_384 + 4_000); + expect(out + escapeMemoryCitationMentions(escaper.pending())).toBe(escapeMemoryCitationMentions(huge)); + }); }); From a21626617b44813c12f45e9a9fc77a881d33a65f Mon Sep 17 00:00:00 2001 From: "Andrew (agent)" Date: Tue, 3 Nov 2026 11:46:04 -0500 Subject: [PATCH 4/6] fix(bridge): one size rule for streamed and final citation blocks; escape signed progress text's visible copy --- src/bridge/sse.ts | 6 ++-- src/protocols/encoders/adapter-events.ts | 2 +- src/responses/anthropic-progress.ts | 12 +++++-- src/responses/citation-markers.ts | 3 ++ structure/transports/responses-wire-shapes.md | 2 +- tests/responses/citation-markers.test.ts | 34 +++++++++++++++++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index df417f247e9..c6398c9d939 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -928,8 +928,10 @@ export function bridgeToResponsesSSE( if (item.type === "message") { const part = item.content![0]; emit("response.content_part.added", { item_id: item.id, output_index: outputIndex, content_index: 0, part: { ...part, text: "" } }); - emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: event.text }); - emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: event.text }); + // The visible copy is escaped; the carrier envelope above keeps the raw text. + const visibleText = escapeMemoryCitationMentions(event.text); + emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: visibleText }); + emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: visibleText }); emit("response.content_part.done", { item_id: item.id, output_index: outputIndex, content_index: 0, part }); } emit("response.output_item.done", { output_index: outputIndex, item }); diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 9ff71f6f2eb..e0fc07123fa 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -614,7 +614,7 @@ export function encodeAdapterEventStream( flushHiddenReasoningEnvelope(); if (currentToolCall) closeCurrentToolCall(); writer.progressThinking(event.text, event.anthropicThinkingSignature); - writer.text(event.text); + writer.text(escapeMemoryCitationMentions(event.text)); writer.messageDone(); break; } diff --git a/src/responses/anthropic-progress.ts b/src/responses/anthropic-progress.ts index b6874af01e9..2f76a97918e 100644 --- a/src/responses/anthropic-progress.ts +++ b/src/responses/anthropic-progress.ts @@ -1,14 +1,19 @@ +import { escapeMemoryCitationMentions } from "./citation-markers"; import { encodeReasoningEnvelope, decodeReasoningEnvelope, type ReasoningEnvelope } from "./reasoning-envelope"; import type { TranslatorBudget } from "../lib/translator-budget"; -/** A visible update is a presentation copy of one original signed thinking block. */ +/** + * A visible update is a presentation copy of one original signed thinking block. The copy is + * escaped for a quoted memory-citation tag; the envelope keeps the raw text so the signed payload + * is unchanged. + */ export function anthropicProgressItems(text: string, signature: string, budget?: TranslatorBudget) { const id = `msg_${crypto.randomUUID().replaceAll("-", "")}`; return [ { type: "reasoning", id: `rs_${crypto.randomUUID().replaceAll("-", "")}`, summary: [], encrypted_content: encodeReasoningEnvelope({ sig: signature, txt: text, upd: id }, budget) }, { type: "message", id, role: "assistant", status: "completed", phase: "commentary", - content: [{ type: "output_text", text, annotations: [] }] }, + content: [{ type: "output_text", text: escapeMemoryCitationMentions(text), annotations: [] }] }, ]; } @@ -22,7 +27,8 @@ export function isAnthropicProgressMirror(item: unknown, envelope: ReasoningEnve if (msg.role !== "assistant" || msg.phase !== "commentary" || (msg.id !== undefined && msg.id !== envelope.upd)) return false; if (!Array.isArray(msg.content) || msg.content.length !== 1) return false; const part = msg.content[0] as { type?: unknown; text?: unknown } | undefined; - return part?.type === "output_text" && part.text === envelope.txt; + return part?.type === "output_text" + && (part.text === envelope.txt || part.text === escapeMemoryCitationMentions(envelope.txt)); } export function anthropicProgressEnvelope(item: unknown, budget?: TranslatorBudget): ReasoningEnvelope | null { diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index 9822a087db0..b7530201344 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -122,6 +122,9 @@ function opensRealBlock(text: string, openAt: number): number | undefined { if (!text.startsWith(MEMORY_CITATION_BODY_OPEN, bodyStart)) return undefined; const close = text.indexOf(MEMORY_CITATION_CLOSE, bodyStart); if (close === -1) return undefined; + // The streaming escaper holds at most MAX_MEMORY_CITATION_HOLD of a candidate, so a block larger + // than that is a mention in BOTH forms; one size rule keeps deltas and `output_text.done` equal. + if (close + MEMORY_CITATION_CLOSE.length - openAt > MAX_MEMORY_CITATION_HOLD) return undefined; return isMemoryCitationBlock(text.slice(afterOpen, close)) ? close + MEMORY_CITATION_CLOSE.length : undefined; } diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 3ec9df82a42..d6095ef032a 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -595,6 +595,6 @@ such span in the parsed context — system prompt, string content and text parts that ASCII directive, following the app's own payload rules, and `parseRequest` applies it to the context it returns. `_rawBody` is not touched, so native passthrough stays byte-identical and stored `previous_response_id` history keeps the original text. The citation filter in -`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag (the client hides everything between the first opening and last closing tag) and holds a candidate block WHOLE until its closing tag, releasing it verbatim only when the body validates, so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16 KiB candidate is escaped. The Responses bridge and the adapter encoder each keep one escaper per message and charge its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). +`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag (the client hides everything between the first opening and last closing tag) and holds a candidate block WHOLE until its closing tag, releasing it verbatim only when the body validates, so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16 KiB candidate is escaped (the same size rule applies to the whole-string form, so deltas and the final text agree). The visible copy of a signed progress update is escaped too, while its signed envelope keeps the raw text and the mirror check accepts either form. The Responses bridge and the adapter encoder each keep one escaper per message and charge its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). `tests/responses/visualization-directives.test.ts` pins the payload rules, the linear-time scan and the parser and Anthropic request paths. diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index ab4f0bfa7e6..483657a98d0 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -13,6 +13,8 @@ import { encodeChatCompletionSse } from "../../src/protocols/encoders/chat"; import { bridgeToResponsesSSE } from "../../src/bridge"; import type { AdapterEvent } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { isAnthropicProgressMirror } from "../../src/responses/anthropic-progress"; +import { decodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; /** * #3150: the ChatGPT backend delimits inline citations with private-use characters @@ -375,4 +377,36 @@ describe("non-citation directive spans pass through (#6039)", () => { expect(Buffer.byteLength(escaper.pending())).toBeLessThanOrEqual(16_384 + 4_000); expect(out + escapeMemoryCitationMentions(escaper.pending())).toBe(escapeMemoryCitationMentions(huge)); }); + + test("a block above the hold limit is a mention in the streamed and the final text alike", () => { + const big = `\n\n${"MEMORY.md:1-2|note=[x]\n".repeat(900)}\n` + + "\n0123\n\n"; + expect(big.length).toBeGreaterThan(16_384); + const finalText = escapeMemoryCitationMentions(big); + expect(finalText.startsWith("<oai-mem-citation>")).toBe(true); + expect(streamed(big.match(/[\s\S]{1,1000}/g)!)).toBe(finalText); + }); + + test("signed progress text escapes only its visible copy, and the mirror check still matches", async () => { + const raw = "quoted prose"; + const sse = await new Response(bridgeToResponsesSSE( + replay([ + { type: "text_delta", text: raw, anthropicThinkingSignature: "SyntheticProgressSignature123456==" } as AdapterEvent, + { type: "done" } as AdapterEvent, + ]), "internal/model", undefined, undefined, undefined, undefined, 2_000, + { translatorBudget: createTestTranslatorBudget(), enforceDeclaredToolNames: false }, + )).text(); + const frames = sse.split("\n\n").map(block => block.split("\n").find(line => line.startsWith("data: "))) + .filter(line => line && line !== "data: [DONE]").map(line => JSON.parse(line!.slice(6)) as { + type?: string; text?: string; delta?: string; item?: { type?: string; encrypted_content?: string; content?: unknown[] }; + }); + const escaped = "quoted <oai-mem-citation> prose"; + expect(frames.find(f => f.type === "response.output_text.delta")?.delta).toBe(escaped); + expect(frames.find(f => f.type === "response.output_text.done")?.text).toBe(escaped); + const items = frames.filter(f => f.type === "response.output_item.done").map(f => f.item!); + const carrier = items.find(item => item.type === "reasoning")!; + const envelope = decodeReasoningEnvelope(carrier.encrypted_content!); + expect(envelope?.txt).toBe(raw); + expect(isAnthropicProgressMirror(items.find(item => item.type === "message"), envelope)).toBe(true); + }); }); From 19921bf5a935684992052cc8b44ca89333ebf482 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 18:44:49 +0900 Subject: [PATCH 5/6] fix(bridge): escape quoted memory-citation tags on the Responses wire only Maintainer follow-up to the review on #8044: - Chat Completions and Messages clients never parse , so the adapter-events encoder changes are reverted and both Responses bridges take escapeMemoryCitationTags (default true); Responses delivery and run-turn paths pass false for chat/anthropic inbound wires. stripCitationMarkers no longer escapes on its own. - A real block needs only a complete list; the client's parse_memory_citation accepts a block without or , so requiring one regressed dev. - Comments and contracts state the client hides to the FIRST closing tag (or EOF), that the hold limit is UTF-16 units, and the code-span trade-off; the progress mirror contract names the escaped form. - Tests: Chat/Messages paths keep raw text (stream and buffered), buffered Responses escapes, an entries-only block stays real, and the progress item's visible text is asserted directly. Co-authored-by: andrew05060414 <59988150+andrew05060414@users.noreply.github.com> --- src/bridge/response-json.ts | 13 ++++- src/bridge/sse.ts | 26 +++++++--- src/protocols/encoders/adapter-events.ts | 45 +++-------------- src/responses/anthropic-progress.ts | 4 +- src/responses/citation-markers.ts | 45 +++++++++++------ src/server/responses/adapter-delivery.ts | 2 + src/server/responses/run-turn-execution.ts | 2 + structure/providers/anthropic-progress.md | 4 +- structure/transports/responses-wire-shapes.md | 2 +- tests/responses/citation-markers.test.ts | 49 +++++++++++++++++-- 10 files changed, 121 insertions(+), 71 deletions(-) diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 935c018aadf..58e1c23f3eb 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -31,6 +31,7 @@ import { } from "../responses/thought-signature-replay"; import { createCitationMarkerFilter, + escapeMemoryCitationMentions, stripCitationMarkers, type CitationMarkerFilter, } from "../responses/citation-markers"; @@ -99,6 +100,12 @@ function buildResponseJSONWithBudget( bareCustomToolNames?: ReadonlySet; /** See `bridgeToResponsesSSE`: enforcement is separate from normalization (#4735). */ enforceDeclaredToolNames?: boolean; + /** + * Escape a quoted `` tag in visible text (default true). The Codex client + * parses the tag and hides the rest of the answer; Chat Completions and Messages deliveries, + * whose clients never parse it, pass false so their text stays byte-for-byte. + */ + escapeMemoryCitationTags?: boolean; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; freeformToolNames?: Set; @@ -256,7 +263,9 @@ function buildResponseJSONWithBudget( // ChatGPT-backend citation markers arrive as literal private-use characters that the // Codex TUI prints verbatim (#3150). Strip them here rather than at the accumulator so // the retained byte accounting above still describes what the upstream actually sent. - const text = stripCitationMarkers(currentTextStr); + // A quoted memory-citation tag is escaped for the Codex client only (citation-markers.ts). + const stripped = stripCitationMarkers(currentTextStr); + const text = options?.escapeMemoryCitationTags === false ? stripped : escapeMemoryCitationMentions(stripped); const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0); const annotations = pendingWebSources.map(s => ({ type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0, @@ -406,7 +415,7 @@ function buildResponseJSONWithBudget( flushSummaryReasoning(); flushRawReasoning(); flushToolCall(); - for (const item of anthropicProgressItems(e.text, e.anthropicThinkingSignature, budget)) pushOutput(item as OutputItem); + for (const item of anthropicProgressItems(e.text, e.anthropicThinkingSignature, budget, options?.escapeMemoryCitationTags !== false)) pushOutput(item as OutputItem); break; } // Only flush on an explicit phase change. A later delta that omits `phase` must keep diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index e2047ee0e3d..54cba077dc9 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -41,6 +41,7 @@ import { createCitationMarkerFilter, createMemoryCitationEscaper, escapeMemoryCitationMentions, + passthroughMemoryCitationEscaper, stripCitationMarkers, type CitationMarkerFilter, type MemoryCitationEscaper, @@ -135,6 +136,12 @@ export function bridgeToResponsesSSE( * would reach the client under the invented name instead of the normalized one. */ enforceDeclaredToolNames?: boolean; + /** + * Escape a quoted `` tag in visible text (default true). The Codex client + * parses the tag and hides the rest of the answer; Chat Completions and Messages deliveries, + * whose clients never parse it, pass false so their text stays byte-for-byte. + */ + escapeMemoryCitationTags?: boolean; /** * Deliver a non-retryable upstream refusal as `response.failed` with a terminal-refusal code * instead of `response.incomplete` (see `./terminal-refusal`). Set only for the Responses @@ -170,6 +177,8 @@ export function bridgeToResponsesSSE( }; }, ): ReadableStream { + const escapeTags = options?.escapeMemoryCitationTags !== false; + const escapeVisibleTags = (text: string): string => escapeTags ? escapeMemoryCitationMentions(text) : text; const proxyIncompleteContext = options?.proxyErrorContext ?? {}; const replayCacheScope = options?.replayCacheScope; const maxOutputCompletion = createMaxOutputCompletion(options); @@ -580,7 +589,7 @@ export function bridgeToResponsesSSE( // A held tail is a partial tag whose whole-message context is now known: resolve it once // against the accumulated text so a real block's trailing close tag survives. const heldTail = currentMsg.memoryCitationEscaper.pending(); - const resolvedTail = heldTail ? escapeMemoryCitationMentions(heldTail) : ""; + const resolvedTail = heldTail ? escapeVisibleTags(heldTail) : ""; currentMsg.heldBytes = replaceRetainedString(currentMsg.heldBytes, "", "live_transient"); const finalDelta = escapedTrailing + resolvedTail; if (finalDelta) { @@ -589,7 +598,7 @@ export function bridgeToResponsesSSE( content_index: 0, delta: finalDelta, }); } - const messageText = stripCitationMarkers(joinChunks(currentMsg.text)); + const messageText = escapeVisibleTags(stripCitationMarkers(joinChunks(currentMsg.text))); // Chat Completions has no message-phase field. Keep its live item provisional, then // classify it only when the next adapter event proves whether this text led into more // work or completed the turn. Explicit adapter phases always outrank this inference. @@ -995,13 +1004,13 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); flushHiddenReasoningEnvelope(); if (currentToolCall) closeCurrentToolCall(); - for (const item of anthropicProgressItems(event.text, event.anthropicThinkingSignature, budget)) { + for (const item of anthropicProgressItems(event.text, event.anthropicThinkingSignature, budget, escapeTags)) { emit("response.output_item.added", { output_index: outputIndex, item: { ...item, ...(item.type === "message" ? { status: "in_progress", content: [] } : {}) } }); if (item.type === "message") { const part = item.content![0]; emit("response.content_part.added", { item_id: item.id, output_index: outputIndex, content_index: 0, part: { ...part, text: "" } }); // The visible copy is escaped; the carrier envelope above keeps the raw text. - const visibleText = escapeMemoryCitationMentions(event.text); + const visibleText = escapeVisibleTags(event.text); emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: visibleText }); emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: visibleText }); emit("response.content_part.done", { item_id: item.id, output_index: outputIndex, content_index: 0, part }); @@ -1041,7 +1050,7 @@ export function bridgeToResponsesSSE( currentMsg = { itemId, outputIndex, text: emptyChunks(), citationFilter: createCitationMarkerFilter(), - memoryCitationEscaper: createMemoryCitationEscaper(), + memoryCitationEscaper: escapeTags ? createMemoryCitationEscaper() : passthroughMemoryCitationEscaper, heldBytes: 0, ...(event.phase ? { phase: event.phase } : {}), }; @@ -1055,9 +1064,10 @@ export function bridgeToResponsesSSE( // unterminated tail and releases it at close (#3150). The accumulator above // keeps the raw text; it is stripped once in closeCurrentMessage. const visible = currentMsg.citationFilter.push(event.text); - // Escape a literal memory-citation tag: the Codex client hides everything between - // the first opening tag and the last closing one, so one quoted mention in prose - // swallows the rest of the answer and reads as a truncated turn. This runs per + // Escape a literal memory-citation tag: the Codex client hides everything from an + // opening tag to the first closing one (or to the end when none follows), so one + // quoted mention in prose swallows the rest of the answer and reads as a truncated + // turn. This runs per // delta, so it uses the streaming escaper: a tag arrives split across deltas, and a // whole-string match inside a single delta would miss it. const emitted = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 5e366b08527..786275d7ed8 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -35,13 +35,7 @@ import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; import { redactSecretString } from "../../lib/redact"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; -import { - createCitationMarkerFilter, - createMemoryCitationEscaper, - escapeMemoryCitationMentions, - type CitationMarkerFilter, - type MemoryCitationEscaper, -} from "../../responses/citation-markers"; +import { createCitationMarkerFilter, type CitationMarkerFilter } from "../../responses/citation-markers"; import { isTruncatedStopReason, truncationReasonFor } from "../../responses/truncated-stop-reason"; import { safeWebSearchSources } from "../../web-search/sources"; import { resolveStallTimeoutSec } from "../../stall-timeout"; @@ -353,19 +347,7 @@ export function encodeAdapterEventStream( }; // Item state, ported from bridgeToResponsesSSE. - let currentMsg: { - phase?: OcxMessagePhase; - citationFilter: CitationMarkerFilter; - memoryCitationEscaper: MemoryCitationEscaper; - /** Bytes the escaper currently holds, charged to the translator budget. */ - heldBytes: number; - } | null = null; - const syncHeldCitationBytes = (msg: NonNullable, held: string) => { - const next = Buffer.byteLength(held); - if (next > msg.heldBytes) budget.chargeRetained(next - msg.heldBytes, { kind: "live_transient" }); - else if (next < msg.heldBytes) budget.releaseRetained(msg.heldBytes - next, { kind: "live_transient" }); - msg.heldBytes = next; - }; + let currentMsg: { phase?: OcxMessagePhase; citationFilter: CitationMarkerFilter } | null = null; let currentReasoning: { itemId: string } | null = null; let currentRawReasoning: { itemId: string } | null = null; let pendingSignature: string | undefined; @@ -391,12 +373,7 @@ export function encodeAdapterEventStream( if (!currentMsg) return; // A citation span can straddle a delta boundary; the filter releases its held tail here. const trailing = currentMsg.citationFilter.flush(); - // A tag or candidate block still held by the escaper is resolved once, whole, so a real - // block's closing tag is neither split nor re-escaped. - const tail = (trailing ? currentMsg.memoryCitationEscaper.push(trailing) : "") - + escapeMemoryCitationMentions(currentMsg.memoryCitationEscaper.pending()); - syncHeldCitationBytes(currentMsg, ""); - if (tail) writer.text(tail); + if (trailing) writer.text(trailing); writer.messageDone(); currentMsg = null; }; @@ -631,7 +608,7 @@ export function encodeAdapterEventStream( flushHiddenReasoningEnvelope(); if (currentToolCall) closeCurrentToolCall(); writer.progressThinking(event.text, event.anthropicThinkingSignature); - writer.text(escapeMemoryCitationMentions(event.text)); + writer.text(event.text); writer.messageDone(); break; } @@ -642,20 +619,10 @@ export function encodeAdapterEventStream( // Only an explicit phase change starts a new message. if (currentMsg && event.phase !== undefined && currentMsg.phase !== event.phase) closeCurrentMessage(); if (!currentMsg) { - currentMsg = { - citationFilter: createCitationMarkerFilter(), - memoryCitationEscaper: createMemoryCitationEscaper(), - heldBytes: 0, - ...(event.phase ? { phase: event.phase } : {}), - }; + currentMsg = { citationFilter: createCitationMarkerFilter(), ...(event.phase ? { phase: event.phase } : {}) }; } const visible = currentMsg.citationFilter.push(event.text); - // A literal memory-citation tag in prose makes the Codex client hide everything - // between the first opening tag and the last closing one, which reads as a truncated - // answer. Escape it here, while it streams. - const escaped = visible ? currentMsg.memoryCitationEscaper.push(visible) : ""; - syncHeldCitationBytes(currentMsg, currentMsg.memoryCitationEscaper.pending()); - if (escaped) writer.text(escaped); + if (visible) writer.text(visible); break; } case "thinking_delta": { diff --git a/src/responses/anthropic-progress.ts b/src/responses/anthropic-progress.ts index 2f76a97918e..f53d763bb49 100644 --- a/src/responses/anthropic-progress.ts +++ b/src/responses/anthropic-progress.ts @@ -7,13 +7,13 @@ import type { TranslatorBudget } from "../lib/translator-budget"; * escaped for a quoted memory-citation tag; the envelope keeps the raw text so the signed payload * is unchanged. */ -export function anthropicProgressItems(text: string, signature: string, budget?: TranslatorBudget) { +export function anthropicProgressItems(text: string, signature: string, budget?: TranslatorBudget, escapeVisible = true) { const id = `msg_${crypto.randomUUID().replaceAll("-", "")}`; return [ { type: "reasoning", id: `rs_${crypto.randomUUID().replaceAll("-", "")}`, summary: [], encrypted_content: encodeReasoningEnvelope({ sig: signature, txt: text, upd: id }, budget) }, { type: "message", id, role: "assistant", status: "completed", phase: "commentary", - content: [{ type: "output_text", text: escapeMemoryCitationMentions(text), annotations: [] }] }, + content: [{ type: "output_text", text: escapeVisible ? escapeMemoryCitationMentions(text) : text, annotations: [] }] }, ]; } diff --git a/src/responses/citation-markers.ts b/src/responses/citation-markers.ts index b7530201344..6059b41db37 100644 --- a/src/responses/citation-markers.ts +++ b/src/responses/citation-markers.ts @@ -63,32 +63,36 @@ export interface CitationMarkerFilter { } /** - * The memory-citation tags the Codex client renders specially: it keeps the LAST - * `` block and drops everything else, so a literal mention of the tag in prose - * swallows the rest of the answer between the first opening tag and the last closing one. A - * model that quotes the tag ("output the `...` block at the end") therefore appears to truncate - * at the quote. Matches the open tag, the close tag, and a backticked open tag, since a fenced or - * inline-code copy is what shows up inside an otherwise ordinary sentence. + * The memory-citation tags the Codex client parses as hidden markup (openai/codex + * `codex-rs/utils/stream-parser/src/citation.rs`): matching is literal and non-nested, the text + * from an opening tag to the FIRST closing tag is hidden, and an opening tag that never closes is + * auto-closed at the end of the stream, hiding everything after it. A model that quotes the tag + * ("output the `...` block at the end") therefore appears to truncate at the quote. Backticks do + * not protect it, because the client parses before any markdown rendering. */ const MEMORY_CITATION_OPEN = ""; const MEMORY_CITATION_CLOSE = ""; const MEMORY_CITATION_TAG = /<\/?(oai-mem-citation)>/g; /** - * Body shape of a REAL memory-citation block: `` plus ``. + * Body shape of a REAL memory-citation block: a complete `` list. The ids block + * that usually follows (``, or ``) is optional, matching the client's + * `parse_memory_citation` (`codex-rs/memories/read/src/citations.rs`), which accepts a block + * without it. * * This is what separates a block the client should render from a mention of the tag name inside * ordinary prose. Escaping both would break the real feature: the client would print the tags * instead of the citation list, and the turn's memory references would silently disappear. */ -const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>[\s\S]*/; +const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>/; /** First element of a real block's body, used to decide from a short lookahead. */ const MEMORY_CITATION_BODY_OPEN = ""; /** - * Longest candidate block the streaming escaper holds while waiting for its closing tag. Callers - * charge the held bytes to their translator budget (`live_transient`) as the hold grows. + * Longest candidate block, in UTF-16 code units (JavaScript string length), that the streaming + * escaper holds while waiting for its closing tag; the whole-string form applies the same limit. + * Callers charge the held bytes to their translator budget (`live_transient`) as the hold grows. */ const MAX_MEMORY_CITATION_HOLD = 16_384; @@ -131,9 +135,14 @@ function opensRealBlock(text: string, openAt: number): number | undefined { /** * Neutralize a literal memory-citation tag so the client renders it as text. * - * The client parses the tag as markup, not as markdown, so wrapping it in backticks does not - * protect it. Escaping the leading `<` as `<` keeps the same visible characters in every - * renderer while leaving the payload itself recognizable. + * The client parses the tag as markup before markdown, so wrapping it in backticks does not + * protect it. The leading `<` becomes `<`. In ordinary markdown text that renders as `<` again; + * inside an inline code span or a fenced block CommonMark does not decode entities, so the reader + * sees `<oai-mem-citation>` literally. That visible difference is the trade-off for keeping the + * rest of the answer: the alternative is the client hiding everything after the quote. + * + * Only the Responses wire needs this: Chat Completions and Messages clients never parse the tag, + * so the bridges apply it behind `escapeMemoryCitationTags`. */ export function escapeMemoryCitationMentions(text: string): string { if (!text.includes("oai-mem-citation")) return text; @@ -190,6 +199,12 @@ export interface MemoryCitationEscaper { pending(): string; } +/** Escaper for deliveries that must not rewrite text (Chat Completions, Messages). */ +export const passthroughMemoryCitationEscaper: MemoryCitationEscaper = { + push: (delta: string) => delta, + pending: () => "", +}; + /** * Streaming form of `escapeMemoryCitationMentions`. * @@ -279,9 +294,9 @@ export function createMemoryCitationEscaper(): MemoryCitationEscaper { * stray SEPARATOR or END, and every non-citation directive. */ export function stripCitationMarkers(text: string): string { - if (!text.includes(CITATION_MARKER_START)) return escapeMemoryCitationMentions(text); + if (!text.includes(CITATION_MARKER_START)) return text; const filter = createCitationMarkerFilter(); - return escapeMemoryCitationMentions(filter.push(text) + filter.flush()); + return filter.push(text) + filter.flush(); } /** diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 29d48312252..65cdf2ec11f 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -270,6 +270,7 @@ export async function deliverAdapterResponse( bareCustomToolNames, enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", terminalRefusalAsFailed: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", + escapeMemoryCitationTags: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), @@ -381,6 +382,7 @@ export async function deliverAdapterResponse( declaredToolNames, bareCustomToolNames, enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", + escapeMemoryCitationTags: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", toolParameterSchemas, freeformToolNames, toolSearchToolNames, diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 03c0db9d71c..ba44ef99d96 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -941,6 +941,7 @@ export async function executeResponsesRunTurn( enforceDeclaredToolNames, // Same wire split: only a Responses client (Codex) needs the refusal as a failure. terminalRefusalAsFailed: enforceDeclaredToolNames, + escapeMemoryCitationTags: enforceDeclaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}), @@ -1109,6 +1110,7 @@ export async function executeResponsesRunTurn( toolNsMap, declaredToolNames, enforceDeclaredToolNames, + escapeMemoryCitationTags: enforceDeclaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 21719d60fed..21369a45a9a 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -33,7 +33,9 @@ envelope stores the exact signed text and signature plus `upd`, the following me Compaction ignores signed progress events: only ordinary summary text enters the checkpoint, and neither bridge emits the commentary pair during compaction. `src/responses/parser.ts` removes only the adjacent commentary copy with matching ID (or no ID), -phase and exact text, leaving the original thinking block in source order. Edited copies remain. +phase and text, leaving the original thinking block in source order. The text matches when it equals the +signed text exactly or its memory-citation-escaped form (the visible copy on a Responses delivery escapes a +quoted `` tag; the envelope keeps the raw text). Other edited copies remain. `src/claude/outbound.ts` restores the signed thinking block and consumes the presentation copy on both JSON and SSE conversions; ordinary locally hidden envelope text remains undisclosed. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 4cb42f1b292..1a414bbc02f 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -595,6 +595,6 @@ such span in the parsed context — system prompt, string content and text parts that ASCII directive, following the app's own payload rules, and `parseRequest` applies it to the context it returns. `_rawBody` is not touched, so native passthrough stays byte-identical and stored `previous_response_id` history keeps the original text. The citation filter in -`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag (the client hides everything between the first opening and last closing tag) and holds a candidate block WHOLE until its closing tag, releasing it verbatim only when the body validates, so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16 KiB candidate is escaped (the same size rule applies to the whole-string form, so deltas and the final text agree). The visible copy of a signed progress update is escaped too, while its signed envelope keeps the raw text and the mirror check accepts either form. The Responses bridge and the adapter encoder each keep one escaper per message and charge its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). +`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag as `<` on Responses deliveries only: the Codex client hides the text from an opening tag to the first closing tag, or to the end of the stream when none follows. A candidate block is held WHOLE until its closing tag and released verbatim only when its body opens with a complete `` list (the ids block is optional, as in the client's parser), so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16,384-UTF-16-unit candidate is escaped, by the same rule in the whole-string form. The visible copy of a signed progress update is escaped too, while its signed envelope keeps the raw text and the mirror check accepts either form. Both bridges take `escapeMemoryCitationTags` (default true); Responses delivery and run-turn paths pass false for Chat Completions and Messages, whose clients never parse the tag, and the Chat/Messages encoders never escape. Inside a code span the escape stays visible as `<`, the accepted cost of keeping the rest of the answer. The Responses bridge keeps one escaper per message and charges its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). `tests/responses/visualization-directives.test.ts` pins the payload rules, the linear-time scan and the parser and Anthropic request paths. diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index 483657a98d0..85150df8cf3 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -11,6 +11,7 @@ import { } from "../../src/responses/citation-markers"; import { encodeChatCompletionSse } from "../../src/protocols/encoders/chat"; import { bridgeToResponsesSSE } from "../../src/bridge"; +import { buildResponseJSON } from "../../src/bridge/response-json"; import type { AdapterEvent } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; import { isAnthropicProgressMirror } from "../../src/responses/anthropic-progress"; @@ -31,6 +32,12 @@ const P = CITATION_MARKER_SEPARATOR; const E = CITATION_MARKER_END; const span = `${S}cite${P}turn1view0${P}turn1view1${E}`; +/** Text of the first assistant message in a buffered Responses body. */ +function messageText(body: Record): string { + const output = body.output as Array<{ type?: string; content?: Array<{ text?: string }> }>; + return output.find(item => item.type === "message")?.content?.[0]?.text ?? ""; +} + describe("citation marker stripping (#3150)", () => { test("a complete span is removed and the surrounding text survives", () => { expect(stripCitationMarkers(`The setting is supported. ${span} Next.`)) @@ -310,7 +317,7 @@ describe("non-citation directive spans pass through (#6039)", () => { { type: "done" } as AdapterEvent, ]; - test("the Chat encoder escapes a mention split across deltas and keeps a split real block", async () => { + test("the Chat encoder never rewrites the tag: Chat clients do not parse it", async () => { const chatText = async (pieces: string[]) => { const raw = await new Response(encodeChatCompletionSse(replay(textEvents(pieces)), { model: "client-model", translatorBudget: createTestTranslatorBudget(), @@ -320,11 +327,45 @@ describe("non-citation directive spans pass through (#6039)", () => { return content ?? ""; }).join(""); }; - expect(await chatText(["quoted prose"])).toBe("quoted <oai-mem-citation> prose"); + expect(await chatText(["quoted prose"])).toBe("quoted prose"); const real = `lead\n${REAL_BLOCK}`; expect(await chatText([...real])).toBe(real); }); + const bridgeText = async (pieces: string[], escapeMemoryCitationTags?: boolean) => { + const raw = await new Response(bridgeToResponsesSSE( + replay(textEvents(pieces)), "internal/model", undefined, undefined, undefined, undefined, 2_000, + { translatorBudget: createTestTranslatorBudget(), enforceDeclaredToolNames: false, + ...(escapeMemoryCitationTags === undefined ? {} : { escapeMemoryCitationTags }) }, + )).text(); + const frames = raw.split("\n\n").map(block => block.split("\n").find(line => line.startsWith("data: "))).filter(line => line && line !== "data: [DONE]") + .map(line => JSON.parse(line!.slice(6)) as { type?: string; delta?: string; text?: string }); + return { + deltas: frames.filter(f => f.type === "response.output_text.delta").map(f => f.delta ?? "").join(""), + done: frames.find(f => f.type === "response.output_text.done")?.text ?? "", + }; + }; + + test("Chat and Messages deliveries through the Responses bridge keep the raw tag", async () => { + const raw = await bridgeText(["quoted prose"], false); + expect(raw).toEqual({ deltas: "quoted prose", done: "quoted prose" }); + const json = buildResponseJSON(textEvents(["quoted prose"]), "internal/model", { escapeMemoryCitationTags: false }); + expect(messageText(json)).toBe("quoted prose"); + }); + + test("a buffered Responses body escapes the mention and keeps a real block", () => { + const json = buildResponseJSON(textEvents([`quoted prose\n${REAL_BLOCK}`]), "internal/model"); + expect(messageText(json)).toBe(`quoted <oai-mem-citation> prose\n${REAL_BLOCK}`); + }); + + test("a real block without an ids list still counts as real, as the client parses it", async () => { + const entriesOnly = "\n\nMEMORY.md:1-2|note=[x]\n\n"; + const text = `answer\n${entriesOnly}`; + expect(escapeMemoryCitationMentions(text)).toBe(text); + expect(streamed([...text])).toBe(text); + expect(await bridgeText([...text])).toEqual({ deltas: text, done: text }); + }); + test("the Responses bridge streams the same text it reports as done", async () => { const run = async (pieces: string[]) => { const raw = await new Response(bridgeToResponsesSSE( @@ -407,6 +448,8 @@ describe("non-citation directive spans pass through (#6039)", () => { const carrier = items.find(item => item.type === "reasoning")!; const envelope = decodeReasoningEnvelope(carrier.encrypted_content!); expect(envelope?.txt).toBe(raw); - expect(isAnthropicProgressMirror(items.find(item => item.type === "message"), envelope)).toBe(true); + const visible = items.find(item => item.type === "message") as { content?: Array<{ text?: string }> } | undefined; + expect(visible?.content?.[0]?.text).toBe(escaped); + expect(isAnthropicProgressMirror(visible, envelope)).toBe(true); }); }); From 4def08da7a88d0fa2354d6cf1c17edb93d4d49e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 19:03:02 +0900 Subject: [PATCH 6/6] fix(bridge): keep sidecar and fold text raw for Chat/Messages; linear candidate scans Review follow-up on #8044: - The web-search and image sidecar loops bridge every inbound wire; they now take escapeMemoryCitationTags and sidecar-execution passes false for chat and anthropic, so those clients receive the raw tag. The Chat/Messages completion fold also keeps the raw text its fingerprints compare. - A block carrying only or is real, matching the client's parse_memory_citation. - Candidate scans look at most one hold limit ahead and share the next closing-tag search; outside claimed blocks one regex pass escapes. A 3 MB input of unclosed candidates went from ~10 s to ~0.1 s. Co-authored-by: andrew05060414 <59988150+andrew05060414@users.noreply.github.com> --- scripts/test-layout/layout-adapters.json | 1 + src/images/loop.ts | 3 + src/responses/citation-markers.ts | 76 ++++++++++--------- .../inference/client-encoder-delivery.ts | 2 + src/server/responses/sidecar-execution.ts | 2 + src/web-search/loop.ts | 3 + structure/transports/responses-wire-shapes.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/responses/citation-markers.test.ts | 30 ++++++++ .../web-search-memory-citation.test.ts | 45 +++++++++++ 10 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 tests/web-search/web-search-memory-citation.test.ts diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 8a9364f4817..bc0de6973f1 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,6 +1,7 @@ { "anthropic-active-limit-routing.test.ts": "adapters/anthropic", "web-search-eof.test.ts": "web-search", + "web-search-memory-citation.test.ts": "web-search", "openai-chat-single-system.test.ts": "adapters/openai", "fix-search-frame-shapes.test.ts": "web-search", "fix-search-orphan-source.test.ts": "web-search", diff --git a/src/images/loop.ts b/src/images/loop.ts index 503602d6618..589a51eaff0 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -391,6 +391,8 @@ export interface ImageBridgeDeps { forceEmptyResponseId?: boolean; /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ terminalRefusalAsFailed?: boolean; + /** False for Chat/Messages inbound: their clients never parse ``, so text stays raw. */ + escapeMemoryCitationTags?: boolean; } /** @@ -1221,6 +1223,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise/g; +const MEMORY_CITATION_MENTION = /<(\/?oai-mem-citation>)/g; /** - * Body shape of a REAL memory-citation block: a complete `` list. The ids block - * that usually follows (``, or ``) is optional, matching the client's - * `parse_memory_citation` (`codex-rs/memories/read/src/citations.rs`), which accepts a block - * without it. + * Body shape of a REAL memory-citation block: it opens with a complete `` list, or + * with a complete ids list (`` or ``) when the entries are absent. That matches + * the client's `parse_memory_citation` (`codex-rs/memories/read/src/citations.rs`), which accepts a block + * carrying either list and rejects one carrying neither. * * This is what separates a block the client should render from a mention of the tag name inside * ordinary prose. Escaping both would break the real feature: the client would print the tags * instead of the citation list, and the turn's memory references would silently disappear. */ -const MEMORY_CITATION_BODY = /[\s\S]*<\/citation_entries>/; +const MEMORY_CITATION_BODY_ELEMENTS: ReadonlyArray = [ + ["", ""], + ["", ""], + ["", ""], +]; -/** First element of a real block's body, used to decide from a short lookahead. */ -const MEMORY_CITATION_BODY_OPEN = ""; +/** First index at or after `from` (below `limit`) that is not whitespace. */ +function skipWhitespace(text: string, from: number, limit: number): number { + let at = from; + while (at < limit && /\s/.test(text[at]!)) at++; + return at; +} /** * Longest candidate block, in UTF-16 code units (JavaScript string length), that the streaming @@ -105,11 +113,12 @@ const MAX_MEMORY_CITATION_HOLD = 16_384; */ function bodyCouldBeReal(afterOpen: string): boolean { const trimmed = afterOpen.replace(/^\s+/, ""); - return MEMORY_CITATION_BODY_OPEN.startsWith(trimmed) || trimmed.startsWith(MEMORY_CITATION_BODY_OPEN); + return MEMORY_CITATION_BODY_ELEMENTS.some(([open]) => open.startsWith(trimmed) || trimmed.startsWith(open)); } function isMemoryCitationBlock(body: string): boolean { - return MEMORY_CITATION_BODY.test(body); + const trimmed = body.replace(/^\s+/, ""); + return MEMORY_CITATION_BODY_ELEMENTS.some(([open, close]) => trimmed.startsWith(open) && trimmed.includes(close, open.length)); } /** @@ -120,11 +129,21 @@ function isMemoryCitationBlock(body: string): boolean { * anchored at the mention rather than at the block. The adjacency check is what makes the mention * provably a mention. */ -function opensRealBlock(text: string, openAt: number): number | undefined { +function opensRealBlock(text: string, openAt: number, closeCache?: { at: number }): number | undefined { const afterOpen = openAt + MEMORY_CITATION_OPEN.length; - const bodyStart = afterOpen + (text.slice(afterOpen).match(/^\s*/)?.[0].length ?? 0); - if (!text.startsWith(MEMORY_CITATION_BODY_OPEN, bodyStart)) return undefined; - const close = text.indexOf(MEMORY_CITATION_CLOSE, bodyStart); + const bodyStart = skipWhitespace(text, afterOpen, Math.min(text.length, openAt + MAX_MEMORY_CITATION_HOLD)); + if (!MEMORY_CITATION_BODY_ELEMENTS.some(([open]) => text.startsWith(open, bodyStart))) return undefined; + // Callers scanning one string for several opening tags share the next closing tag, so prose with + // many unclosed candidates costs one forward search instead of one per tag (-2: not searched yet). + let close: number; + if (closeCache) { + if (closeCache.at === -2 || (closeCache.at !== -1 && closeCache.at < bodyStart)) { + closeCache.at = text.indexOf(MEMORY_CITATION_CLOSE, bodyStart); + } + close = closeCache.at; + } else { + close = text.indexOf(MEMORY_CITATION_CLOSE, bodyStart); + } if (close === -1) return undefined; // The streaming escaper holds at most MAX_MEMORY_CITATION_HOLD of a candidate, so a block larger // than that is a mention in BOTH forms; one size rule keeps deltas and `output_text.done` equal. @@ -151,11 +170,12 @@ export function escapeMemoryCitationMentions(text: string): string { // mention and gets escaped, tags included. Matching runs over the ORIGINAL string as one pass // (appendReplacement), because `String.replace` would re-scan the text we just escaped. const blocks: Array<{ start: number; end: number }> = []; + const closeCache = { at: -2 }; let searchFrom = 0; for (;;) { const open = text.indexOf(MEMORY_CITATION_OPEN, searchFrom); if (open === -1) break; - const end = opensRealBlock(text, open); + const end = opensRealBlock(text, open, closeCache); if (end !== undefined) { blocks.push({ start: open, end }); searchFrom = end; @@ -163,23 +183,8 @@ export function escapeMemoryCitationMentions(text: string): string { searchFrom = open + MEMORY_CITATION_OPEN.length; } } - const escapedMentions = (segment: string): string => { - let out = ""; - let i = 0; - for (;;) { - const open = segment.indexOf(MEMORY_CITATION_OPEN, i); - const close = segment.indexOf(MEMORY_CITATION_CLOSE, i); - const next = open === -1 ? close : close === -1 ? open : Math.min(open, close); - if (next === -1) { - out += segment.slice(i); - return out; - } - out += segment.slice(i, next); - const tag = open !== -1 && open === next ? MEMORY_CITATION_OPEN : MEMORY_CITATION_CLOSE; - out += `<${tag.slice(1)}`; - i = next + tag.length; - } - }; + // Outside claimed blocks every tag is a mention; one linear regex pass escapes them all. + const escapedMentions = (segment: string): string => segment.replace(MEMORY_CITATION_MENTION, "<$1"); if (blocks.length === 0) return escapedMentions(text); let out = ""; let cursor = 0; @@ -237,7 +242,10 @@ export function createMemoryCitationEscaper(): MemoryCitationEscaper { break; } out += input.slice(i, next); - const tail = input.slice(next); + // Look no further than one hold limit ahead: a real block never exceeds it, and slicing the + // whole remainder for every tag would make one large delta of many tags quadratic. + const tail = input.slice(next, next + MAX_MEMORY_CITATION_HOLD + 1); + const remaining = input.length - next; if (tail.startsWith(MEMORY_CITATION_CLOSE)) { // A close tag outside a claimed block is prose. out += escapeClose; @@ -268,7 +276,7 @@ export function createMemoryCitationEscaper(): MemoryCitationEscaper { i = next + end; continue; } - if (!tail.includes(MEMORY_CITATION_CLOSE, MEMORY_CITATION_OPEN.length) && tail.length <= MAX_MEMORY_CITATION_HOLD) { + if (!tail.includes(MEMORY_CITATION_CLOSE, MEMORY_CITATION_OPEN.length) && remaining <= MAX_MEMORY_CITATION_HOLD) { // The block may still close; hold it whole. held = tail; return out; diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index f2e9c10a752..54399816bdb 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -171,6 +171,8 @@ export async function deliverClientEncodedResponse(input: ClientEncodedDelivery) recordBufferedDelivery: false, // The client never receives this body, and its Chat/Messages wire has no envelope field. omitHiddenReasoningEnvelope: true, + // Keep the client's raw text: completion fingerprints compare it with what the client resends. + escapeMemoryCitationTags: false, }); } catch { return undefined; diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 2a16f74ead1..e8a4d7d4e9f 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -621,6 +621,7 @@ export async function executeResponsesSidecars( onFirstOutput: noteSidecarOutput, ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), terminalRefusalAsFailed: inboundWire !== "chat" && inboundWire !== "anthropic", + escapeMemoryCitationTags: inboundWire !== "chat" && inboundWire !== "anthropic", onCompletedResponse: (response, providerState) => { if (!options.abortSignal?.aborted) thinkingRecovery.rememberSuccess(response); const served = transportState.replayOAuthCredentialSnapshot; @@ -734,6 +735,7 @@ export async function executeResponsesSidecars( maxSearches: wsPlan.maxSearches, forceEmptyResponseId: true, terminalRefusalAsFailed: inboundWire !== "chat" && inboundWire !== "anthropic", + escapeMemoryCitationTags: inboundWire !== "chat" && inboundWire !== "anthropic", abortSignal: options.abortSignal, onFirstOutput: noteSidecarOutput, onRequestBuilt: request => { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e993f8b8129..cd8f941e5bf 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -390,6 +390,8 @@ export interface WebSearchLoopDeps { forceEmptyResponseId?: boolean; /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ terminalRefusalAsFailed?: boolean; + /** False for Chat/Messages inbound: their clients never parse ``, so text stays raw. */ + escapeMemoryCitationTags?: boolean; abortSignal?: AbortSignal; recordSidecarOutcome?: SidecarOutcomeRecorder; beforeSidecarDispatch?: () => void; @@ -1337,6 +1339,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise` tag as `<` on Responses deliveries only: the Codex client hides the text from an opening tag to the first closing tag, or to the end of the stream when none follows. A candidate block is held WHOLE until its closing tag and released verbatim only when its body opens with a complete `` list (the ids block is optional, as in the client's parser), so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16,384-UTF-16-unit candidate is escaped, by the same rule in the whole-string form. The visible copy of a signed progress update is escaped too, while its signed envelope keeps the raw text and the mirror check accepts either form. Both bridges take `escapeMemoryCitationTags` (default true); Responses delivery and run-turn paths pass false for Chat Completions and Messages, whose clients never parse the tag, and the Chat/Messages encoders never escape. Inside a code span the escape stays visible as `<`, the accepted cost of keeping the rest of the answer. The Responses bridge keeps one escaper per message and charges its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). +`src/responses/citation-markers.ts` is separate and never removes these spans (#6040). Its memory-citation escaper neutralizes a quoted `` tag as `<` on Responses deliveries only: the Codex client hides the text from an opening tag to the first closing tag, or to the end of the stream when none follows. A candidate block is held WHOLE until its closing tag and released verbatim only when its body opens with a complete `` list, or with a complete ``/`` list when the entries are absent (the client's parser accepts either), so streamed deltas equal the final `output_text.done` text; an invalid, never-closed or over-16,384-UTF-16-unit candidate is escaped, by the same rule in the whole-string form. The visible copy of a signed progress update is escaped too, while its signed envelope keeps the raw text and the mirror check accepts either form. Both bridges take `escapeMemoryCitationTags` (default true); the delivery, run-turn and web-search/image sidecar paths pass false for Chat Completions and Messages, whose clients never parse the tag, and neither the Chat/Messages encoders nor their completion fold escape. Candidate scans look at most one hold limit ahead and share the next closing-tag search, so many unclosed candidates stay linear. Inside a code span the escape stays visible as `<`, the accepted cost of keeping the rest of the answer. The Responses bridge keeps one escaper per message and charges its held bytes to the translator budget (`live_transient`), released when the message closes (`tests/responses/citation-markers.test.ts`). `tests/responses/visualization-directives.test.ts` pins the payload rules, the linear-time scan and the parser and Anthropic request paths. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 396d96c173f..2ae09cd00a6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -33,7 +33,7 @@ "fix-jsonc-cr-comments.test.ts": "lib", "definite-overflow-verdict.test.ts": "lib", "fix-jsonc-empty-comma.test.ts": "lib", - "web-search-eof.test.ts": "web-search", + "web-search-eof.test.ts": "web-search", "web-search-memory-citation.test.ts": "web-search", "fix-search-frame-shapes.test.ts": "web-search", "fix-search-orphan-source.test.ts": "web-search", "fix-search-unicode-clamp.test.ts": "web-search", diff --git a/tests/responses/citation-markers.test.ts b/tests/responses/citation-markers.test.ts index 85150df8cf3..08bc40c06f9 100644 --- a/tests/responses/citation-markers.test.ts +++ b/tests/responses/citation-markers.test.ts @@ -358,6 +358,36 @@ describe("non-citation directive spans pass through (#6039)", () => { expect(messageText(json)).toBe(`quoted <oai-mem-citation> prose\n${REAL_BLOCK}`); }); + test("a block carrying only an ids list is real too, as the client accepts it", async () => { + for (const ids of ["rollout_ids", "thread_ids"]) { + const block = ` +<${ids}> +0123 + +`; + const text = `answer +${block}`; + expect(escapeMemoryCitationMentions(text)).toBe(text); + expect(streamed([...text])).toBe(text); + } + // Neither list: still a mention. + expect(escapeMemoryCitationMentions("x")) + .toBe("<oai-mem-citation>x</oai-mem-citation>"); + }); + + test("many unclosed candidates stay linear in both forms", () => { + const text = "x ".repeat(80_000) + ""; + const started = performance.now(); + const whole = escapeMemoryCitationMentions(text); + const escaper = createMemoryCitationEscaper(); + const pieces = text.match(/[\s\S]{1,200000}/g)!; + const stream = pieces.map(piece => escaper.push(piece)).join("") + escapeMemoryCitationMentions(escaper.pending()); + expect(stream).toBe(whole); + expect(whole.startsWith("<oai-mem-citation>x ")).toBe(true); + // The earlier quadratic scan took about 13 s on this 3 MB input; the bounded one takes well under a second. + expect(performance.now() - started).toBeLessThan(4_000); + }); + test("a real block without an ids list still counts as real, as the client parses it", async () => { const entriesOnly = "\n\nMEMORY.md:1-2|note=[x]\n\n"; const text = `answer\n${entriesOnly}`; diff --git a/tests/web-search/web-search-memory-citation.test.ts b/tests/web-search/web-search-memory-citation.test.ts new file mode 100644 index 00000000000..f936e7b4495 --- /dev/null +++ b/tests/web-search/web-search-memory-citation.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import type { AdapterEvent } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { parseRequest } from "../../src/responses/parser"; +import { runWithWebSearch } from "../../src/web-search/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * #8044: the web-search sidecar bridges every inbound wire through the Responses SSE writer. A + * quoted memory-citation tag is escaped for the Codex client, while a Chat Completions or Messages + * caller (escapeMemoryCitationTags: false from sidecar-execution) receives the raw text. + */ +async function answer(escapeMemoryCitationTags?: boolean): Promise { + const adapter: ProviderAdapter = { + name: "mock-answer", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => new Response("ok"), + async *parseStream() { + yield { type: "text_delta", text: "quoted prose" } satisfies AdapterEvent; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } } satisfies AdapterEvent; + }, + async parseResponse() { throw new Error("parseResponse must be unreachable"); }, + }; + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + backend: "exa", + exaApiKey: "synthetic-exa-key", + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers(), + settings: { model: "exa-fixture-model", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 1, + ...(escapeMemoryCitationTags === undefined ? {} : { escapeMemoryCitationTags }), + }); + const frames = (await response.text()).split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6)) as { type?: string; text?: string }); + return frames.find(frame => frame.type === "response.output_text.done")?.text ?? ""; +} + +test("the web-search sidecar escapes a quoted tag for Responses and keeps it raw for Chat/Messages", async () => { + expect(await answer()).toBe("quoted <oai-mem-citation> prose"); + expect(await answer(false)).toBe("quoted prose"); +});