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/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 a660936eabd..54cba077dc9 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -39,8 +39,12 @@ import { import { resolveStallTimeoutSec } from "../stall-timeout"; import { createCitationMarkerFilter, + createMemoryCitationEscaper, + escapeMemoryCitationMentions, + passthroughMemoryCitationEscaper, stripCitationMarkers, type CitationMarkerFilter, + type MemoryCitationEscaper, } from "../responses/citation-markers"; import { declaresCodeModeExec, normalizeDeclaredToolName } from "../types"; import { appendSafeWebSearchSource, safeWebSearchSources } from "../web-search/sources"; @@ -132,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 @@ -167,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); @@ -381,6 +393,9 @@ export function bridgeToResponsesSSE( outputIndex: number; 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; @@ -570,13 +585,20 @@ 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 ? escapeVisibleTags(heldTail) : ""; + currentMsg.heldBytes = replaceRetainedString(currentMsg.heldBytes, "", "live_transient"); + 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)); + 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. @@ -982,13 +1004,15 @@ 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: "" } }); - 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 = 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 }); } emit("response.output_item.done", { output_index: outputIndex, item }); @@ -1026,6 +1050,8 @@ export function bridgeToResponsesSSE( currentMsg = { itemId, outputIndex, text: emptyChunks(), citationFilter: createCitationMarkerFilter(), + memoryCitationEscaper: escapeTags ? createMemoryCitationEscaper() : passthroughMemoryCitationEscaper, + heldBytes: 0, ...(event.phase ? { phase: event.phase } : {}), }; } @@ -1038,10 +1064,19 @@ 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 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) : ""; + 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, - content_index: 0, delta: visible, + content_index: 0, delta: emitted, }); } break; 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