diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 50bd65699d7..b4eb39b789e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -169,6 +169,12 @@ API, the adapter removes item-level `extra_content` from the outgoing HTTP reque create. Native `/responses/compact` applies the same projection. The original history keeps the Google thought signature for later Google turns. Other Responses gateways retain their extensions. +OpenAI-operated destinations receive replayed `url_citation` annotations with missing or invalid +titles and indices repaired. This applies to ordinary HTTP/WebSocket requests and native +`/responses/compact`. Custom gateways keep the original annotations even when +`supportsNativeCompactEndpoint: true` enables native compaction. Citation repair leaves the +client's stored history unchanged. + The adapter preserves the incoming client's `User-Agent` as a fallback in both auth modes because some Responses-compatible providers use the Codex client fingerprint for compatibility behavior. An explicitly configured provider `User-Agent` remains authoritative regardless of header casing; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 57c7ccf0e6b..e8abc6aab58 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -40,7 +40,7 @@ import { emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; +import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; @@ -315,7 +315,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairLegacyDottedToolCallNames(outBody); // Shared HTTP/WS outbound projection, including concrete combo/fallback destinations. if (isOpenAiOperatedResponsesDestination(provider)) { - outBody = stripProviderInputItemMetadata(outBody); + outBody = repairUrlCitationAnnotations(stripProviderInputItemMetadata(outBody)); } if (!isCanonicalOpenAiForwardProvider(provider)) { if (provider.preserveResponsesMessageMetadata !== true) { diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index 6fb0f591854..2b5ab93d25c 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -24,7 +24,7 @@ export function stripProviderInputItemMetadata(body: unknown): unknown { input ??= [...body.input]; input[index] = next; } - return repairUrlCitationAnnotations(input ? { ...body, input } : body); + return input ? { ...body, input } : body; } function isUrlCitationIndex(value: unknown): value is number { diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 28cb3fbc718..f9d687e1e09 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -25,7 +25,7 @@ import { parseRequest } from "../../responses/parser"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { applyReasoningRetention, appendRetentionNotice, type ReasoningRetentionResult } from "../../responses/reasoning-retention"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; -import { stripProviderInputItemMetadata } from "../../adapters/openai-responses/request-strips"; +import { repairUrlCitationAnnotations, stripProviderInputItemMetadata } from "../../adapters/openai-responses/request-strips"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair"; import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router"; @@ -949,7 +949,7 @@ export async function handleResponsesCompact( // #5095: the compact endpoint forwards `raw` directly, so it needs the same legacy dotted // call-name repair the adapter applies. A damaged item here refuses the compaction itself, // which is the request a long task depends on to keep going. - // This branch is OpenAI-only and bypasses the adapter's Google replay projection (#7111). + // Direct compact bypasses the adapter's Google replay projection (#7111). const compactBody = repairLegacyDottedToolCallNames( stripProviderInputItemMetadata(sanitizeReasoningInputContent(compactBodyRaw, { preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(compactProvider) @@ -1160,8 +1160,9 @@ export async function handleResponsesCompact( if (!isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.preserveResponsesMessageMetadata !== true) { replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody; } + // A queued key selection can refresh the destination; project only for this send. return isOpenAiOperatedResponsesDestination(sendProvider) - ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); + ? repairUrlCitationAnnotations(replayBody) : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody)); }; const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, diff --git a/structure/INDEX.md b/structure/INDEX.md index 58f8b82b5e8..82e2b7e737c 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -51,6 +51,7 @@ The wire surfaces a client actually talks to. | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | One-shot exact signature-binding recovery before output, and successful-retry memo boundaries. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | | [`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | Optional native reasoning ciphertext forwarding on model/account switches, configuration and management controls. | +| [`transports/responses-citations.md`](transports/responses-citations.md) | Destination-gated citation repair for ordinary Responses and every native compact send. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | | [`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md) | Combo preflight output commitment, turn lease lifetime, and bounded hop warnings. | @@ -179,7 +180,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/adapters/openai-chat.ts` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-chat/` | [`providers/mimo-bare-calls.md`](providers/mimo-bare-calls.md) | | `src/adapters/openai-responses/opengateway-input.ts` | [`providers/opengateway.md`](providers/opengateway.md) | -| `src/adapters/openai-responses/passthrough.ts` | [`providers/opengateway.md`](providers/opengateway.md) | +| `src/adapters/openai-responses/passthrough.ts` | [`transports/responses-citations.md`](transports/responses-citations.md)
[`providers/opengateway.md`](providers/opengateway.md) | +| `src/adapters/openai-responses/request-strips.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) | | `src/adapters/workbuddy-schema.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/adapters/workbuddy.ts` | [`providers/workbuddy.md`](providers/workbuddy.md) | | `src/advisor/` | [`advisor.md`](advisor.md) | @@ -299,6 +301,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/responses/advisor-plan-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/advisor-slot.ts` | [`advisor.md`](advisor.md) | | `src/server/responses/anthropic-thinking-recovery.ts` | [`transports/anthropic-thinking-recovery.md`](transports/anthropic-thinking-recovery.md) | +| `src/server/responses/compact.ts` | [`transports/responses-citations.md`](transports/responses-citations.md) | | `src/server/responses/opengateway-completion-guard.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-completion.ts` | [`providers/opengateway.md`](providers/opengateway.md) | | `src/server/responses/passthrough-dispatch.ts` | [`providers/opengateway.md`](providers/opengateway.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 5066e40883f..558c8e8b36e 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -309,6 +309,17 @@ "gui/" ] }, + { + "path": "transports/responses-citations.md", + "tier": 3, + "title": "Replayed URL Citations", + "scope": "Destination-gated citation repair for ordinary Responses and every native compact send.", + "documents": [ + "src/adapters/openai-responses/request-strips.ts", + "src/adapters/openai-responses/passthrough.ts", + "src/server/responses/compact.ts" + ] + }, { "path": "transports/responses-wire-shapes.md", "tier": 3, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7dee8a53da0..8dae56c743c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -445,7 +445,7 @@ picker, so the catalog and the wire agree; a rung the upstream then refuses is r error rather than replayed without the effort, because the operator asked for it. The flag is part of the compatibility behavior record for the same reason as above. -The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. Ordinary requests and native compact apply the [destination-specific citation projection](transports/responses-citations.md) independently of provider metadata stripping. ## Hosted-search continuation binding > Decision record: [ADR-6930](decisions/ADR-6930-hosted-search-continuation-binding.md) > Decision record: [ADR-6931](decisions/ADR-6931-lite-hosted-search-opt-in.md) diff --git a/structure/transports/responses-citations.md b/structure/transports/responses-citations.md new file mode 100644 index 00000000000..1e7f465a312 --- /dev/null +++ b/structure/transports/responses-citations.md @@ -0,0 +1,19 @@ +# Replayed URL Citations + +OpenAI-operated Responses destinations repair replayed `url_citation` annotations to the required +string title and non-negative integer indices through `repairUrlCitationAnnotations` in +`src/adapters/openai-responses/request-strips.ts`. Provider metadata stripping remains an +independent projection. The ordinary HTTP/WS adapter in +`src/adapters/openai-responses/passthrough.ts` applies citation repair only after destination +classification. + +The direct native `/responses/compact` path in `src/server/responses/compact.ts` applies the same +gate in its final body builder for every physical send, including a provider destination refreshed +after key admission. It keeps the shared history free of citation repair so an OpenAI-to-gateway +refresh preserves the gateway's original annotations and the reverse refresh still repairs OpenAI +history. Opting a third-party gateway into native compaction declares protocol support, not +permission to rewrite its citations. `tests/responses/compact-key-selection.test.ts` covers exact +gateway preservation and both refresh directions; +`tests/responses/responses-native-item-extensions-compact.test.ts` covers official API and ChatGPT +compact repair, while `tests/responses/responses-url-citation-title-repair.test.ts` covers ordinary +HTTP/WS projection, helper independence and input immutability. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 95081c94e2a..6275ba66101 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. Every custom native v1 send carries only the gateway transport's headers, and Codex pool recovery (401 refresh replay, quota and transient alternates) and pool outcome recording skip gateway sends, so recovery cannot put a stored ChatGPT credential on a gateway wire (`tests/responses/native-compaction-gateway-credentials.test.ts`). Readable reasoning retention also bypasses native v2-capable gateways; the capability predicate controls local retention as well as dispatch. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. Key-auth compact revalidates the serving key after pacing, rebuilding custom gateway paths, transport headers and replay sanitization against that physical selection; `tests/responses/compact-key-selection.test.ts` covers initial gateway dispatch and queued reselection. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. Every custom native v1 send carries only the gateway transport's headers, and Codex pool recovery (401 refresh replay, quota and transient alternates) and pool outcome recording skip gateway sends, so recovery cannot put a stored ChatGPT credential on a gateway wire (`tests/responses/native-compaction-gateway-credentials.test.ts`). Readable reasoning retention also bypasses native v2-capable gateways; the capability predicate controls local retention as well as dispatch. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. Key-auth compact revalidates the serving key after pacing, rebuilding custom gateway paths, transport headers, replay sanitization and [citation projection](responses-citations.md) against that physical selection; `tests/responses/compact-key-selection.test.ts` covers initial gateway dispatch and queued reselection. Canonical native v1's 404 fallback also bypasses local readable retention: its native v2 turn receives the original reasoning and creates no archive (`tests/responses/reasoning-retention-routing.test.ts`). A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:`/`ocx2:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no known serving-identity mismatch and the destination is known to decode native blobs — the canonical ChatGPT forward surface, the official OpenAI API, or a provider with the explicit diff --git a/tests/responses/compact-key-selection.test.ts b/tests/responses/compact-key-selection.test.ts index 289e0689872..277647b5c30 100644 --- a/tests/responses/compact-key-selection.test.ts +++ b/tests/responses/compact-key-selection.test.ts @@ -65,10 +65,10 @@ async function queue(config: OcxConfig, id: string) { await Bun.sleep(5); } } -function start(config: OcxConfig, abort: AbortController) { +function start(config: OcxConfig, abort: AbortController, input: unknown[] = [{ role: "user", content: "fixture question" }]) { const logCtx: RequestLogContext = { model: "", provider: "" }; const req = new Request("http://localhost/v1/responses/compact", { method: "POST", headers: { "content-type": "application/json" }, - signal: AbortSignal.any([abort.signal, AbortSignal.timeout(5_000)]), body: JSON.stringify({ model: `${config.defaultProvider}/test`, input: [{ role: "user", content: "fixture question" }], stream: false }) }); + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(5_000)]), body: JSON.stringify({ model: `${config.defaultProvider}/test`, input, stream: false }) }); const pending = handleResponsesCompact(req, config, logCtx).then(async response => ({ response, text: await response.text(), logCtx })); void pending.catch(() => {}); return pending; @@ -308,9 +308,15 @@ for (const reselect of [false, true]) test(`gateway compact preserves custom tra provider.supportsNativeCompactEndpoint = true; provider.headers = { "x-gateway-static": "initial" }; saveConfig(config); + const annotations = [ + { type: "url_citation", url: "https://gateway.example/source", start_index: "3", end_index: -1, gateway: { source: "fixture" } }, + { type: "url_citation", url: "https://gateway.example/untitled", title: null, start_index: 0, end_index: 1 }, + { type: "file_citation", file_id: "file_fixture", index: 0 }, + ]; + const input = [{ role: "user", content: "fixture question" }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "fixture answer", annotations }] }]; const held = reselect ? await waitForProviderRequestSlot(config.defaultProvider, provider) : undefined; const abort = new AbortController(); - const pending = start(config, abort); + const pending = start(config, abort, input); try { if (reselect) { await queue(config, "k1"); @@ -327,7 +333,45 @@ for (const reselect of [false, true]) test(`gateway compact preserves custom tra expect(seen[0]!.url).toBe(`https://gateway.example/v1/${reselect ? "refreshed-responses" : "custom-responses"}/compact`); expect(seen[0]!.headers.get("x-gateway-static")).toBe(reselect ? "replacement" : "initial"); expect(seen[0]!.headers.get("chatgpt-account-id")).toBeNull(); - expect(seen[0]!.body).toMatchObject({ model: "test", input: [{ role: "user", content: "fixture question" }] }); + expect(seen[0]!.body).toMatchObject({ model: "test" }); + expect((seen[0]!.body as { input: unknown }).input).toEqual(input); noLeaks(config); } finally { abort.abort(); held?.release(); await pending.catch(() => undefined); } }); + +for (const toOpenAi of [false, true]) test(`compact citation projection follows the destination refreshed after admission (OpenAI=${toOpenAi})`, async () => { + const config = fixture(); + const provider = config.providers[NAME]!; + config.providers = { "gateway-key-fixture": provider }; + config.defaultProvider = "gateway-key-fixture"; + const officialBase = "https://api.openai.com/v1"; + const gatewayBase = "https://gateway.example/v1"; + provider.baseUrl = toOpenAi ? gatewayBase : officialBase; + provider.supportsNativeCompactEndpoint = true; + saveConfig(config); + const citation = { type: "url_citation", url: "https://gateway.example/source", start_index: "3", end_index: -1, gateway: { source: "fixture" } }; + const user = { role: "user", content: "fixture question" }; + const assistant = { type: "message", role: "assistant", content: [{ type: "output_text", text: "fixture answer", annotations: [citation] }] }; + const input = [user, assistant]; + const before = structuredClone(input); + const held = await waitForProviderRequestSlot(config.defaultProvider, provider); + const abort = new AbortController(); + const pending = start(config, abort, input); + try { + await queue(config, "k1"); + expect(seen).toEqual([]); + provider.baseUrl = toOpenAi ? officialBase : gatewayBase; + held.release(); + const result = await pending; + expect(result.response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.url).toBe(`${provider.baseUrl}/responses/compact`); + expect(seen[0]!.key).toBe(`Bearer ${KEYS[0]}`); + const expectedCitation = toOpenAi ? { ...citation, title: citation.url, start_index: 3, end_index: 0 } : citation; + expect((seen[0]!.body as { input: unknown }).input).toEqual([ + user, { ...assistant, content: [{ ...assistant.content[0]!, annotations: [expectedCitation] }] }, + ]); + expect(input).toEqual(before); + noLeaks(config); + } finally { abort.abort(); held.release(); await pending.catch(() => undefined); } +}); diff --git a/tests/responses/responses-native-item-extensions-compact.test.ts b/tests/responses/responses-native-item-extensions-compact.test.ts index 2d32a987635..55def6f784a 100644 --- a/tests/responses/responses-native-item-extensions-compact.test.ts +++ b/tests/responses/responses-native-item-extensions-compact.test.ts @@ -44,7 +44,7 @@ describe("native compact item extensions (#7111)", () => { ]; for (const [name, provider] of providers) { - test(`strips Google replay metadata on the direct ${name} compact send`, async () => { + test(`strips Google replay metadata and repairs citations on the direct ${name} compact send`, async () => { const calls: Array<{ url: string; body: Record }> = []; const mockFetch = (async (input, init) => { const req = new Request(input, init); @@ -58,12 +58,17 @@ describe("native compact item extensions (#7111)", () => { }; const call = { type: "function_call", call_id: "call_probe", name: "probe", arguments: '{"value":1}' }; const output = { type: "function_call_output", call_id: "call_probe", output: '{"extra_content":"tool data"}' }; + const citation = { type: "url_citation", url: "https://source.example/reference", start_index: "3", end_index: -1, extra: "keep" }; + const assistant = { type: "message", role: "assistant", content: [ + { type: "output_text", text: "Fixture answer.", annotations: [citation] }, + ] }; const body = { model: `${name}/gpt-6-sol`, input: [ { role: "user", content: "Compact the completed tool call." }, { ...call, extra_content: { google: { thought_signature: "synthetic-signature-7111" } } }, output, + assistant, ], }; const before = structuredClone(body); @@ -76,7 +81,11 @@ describe("native compact item extensions (#7111)", () => { await response.text(); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe(`${provider.baseUrl}/responses/compact`); - expect(calls[0]!.body.input).toEqual([body.input[0], call, output]); + expect(calls[0]!.body.input).toEqual([body.input[0], call, output, { + ...assistant, content: [{ ...assistant.content[0], annotations: [ + { ...citation, title: citation.url, start_index: 3, end_index: 0 }, + ] }], + }]); expect(body).toEqual(before); }); } diff --git a/tests/responses/responses-url-citation-title-repair.test.ts b/tests/responses/responses-url-citation-title-repair.test.ts index aba11a1b83b..94fea6873ed 100644 --- a/tests/responses/responses-url-citation-title-repair.test.ts +++ b/tests/responses/responses-url-citation-title-repair.test.ts @@ -64,9 +64,24 @@ describe("url_citation annotation repair on outbound history", () => { expect(repairUrlCitationAnnotations(null)).toBeNull(); }); - test("stripProviderInputItemMetadata (the OpenAI-operated outbound projection) applies it", () => { - const body = { input: [{ role: "user", content: "hi" }, assistantWith([{ type: "url_citation", url: "https://a.example", start_index: 0, end_index: 0 }])] }; - expect(annotationsOf(stripProviderInputItemMetadata(body))[0]!.title).toBe("https://a.example"); + test("metadata stripping and citation repair remain independent projections", () => { + const citation = { type: "url_citation", url: "https://a.example", start_index: "3", end_index: -1, extra: "keep" }; + const message = { ...assistantWith([citation]), extra_content: { google: { thought_signature: "fixture-signature" } } }; + const body = { input: [{ role: "user", content: "hi" }, message] }; + const before = structuredClone(body); + + const stripped = stripProviderInputItemMetadata(body) as { input: Record[] }; + expect(stripped.input[1]).not.toHaveProperty("extra_content"); + expect(stripped.input[1]!.content).toBe(message.content); + expect(annotationsOf(stripped)).toBe(message.content[0]!.annotations); + expect(annotationsOf(stripped)).toEqual([citation]); + + const repaired = repairUrlCitationAnnotations(body) as { input: Record[] }; + expect(repaired.input[1]!.extra_content).toBe(message.extra_content); + expect(annotationsOf(repaired)).toEqual([ + { ...citation, title: citation.url, start_index: 3, end_index: 0 }, + ]); + expect(body).toEqual(before); }); const native: OcxProviderConfig[] = [