diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index e0b148927d2..114376d265b 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -244,7 +244,9 @@ Remote and generic API clients retain the provider's hosted response format. Partial previews and URL-only results are not rendered by this compatibility layer. Artifacts use the existing retention limit, so save images you want to keep before older files are pruned. An invalid image or a failed local write produces a visible -failure message instead of a broken image link. +failure message instead of a broken image link. Display batches reject duplicate image +identities, oversized routing metadata, and output that exceeds the display byte limit; +these failures return HTTP 502 before streaming or a failed terminal event after it starts. ### Built-in image generation (`image_gen`) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 646700d05c6..d285790bfd2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -24,6 +24,7 @@ "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", + "responses-hosted-image-limits.test.ts": "responses", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", diff --git a/src/server/responses-hosted-image-display.ts b/src/server/responses-hosted-image-display.ts index 66ea6ed6a26..f40bcb915a2 100644 --- a/src/server/responses-hosted-image-display.ts +++ b/src/server/responses-hosted-image-display.ts @@ -2,6 +2,8 @@ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { join, posix } from "node:path"; import { getConfigDir } from "../config"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; +import { TRANSLATOR_MAX_TURN_BYTES } from "../lib/translator-budget"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { chargeImageBudget, createImageBudget, decodeValidatedImageBase64, @@ -12,6 +14,16 @@ import { sseDataPayload, replaceSseDataPayload, type SseBlockRewrite } from "./s type Row = Record; const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v); const originators = new Set(["codex_cli_rs", "Codex Desktop", "codex_app", "codex_work_desktop"]); +const MAX_DISPLAY_ITEMS = 128; +const MAX_ITEM_METADATA_BYTES = 64 * 1024; +const MAX_RETAINED_METADATA_BYTES = 1024 * 1024; +const displayKey = (item: Row, index: number) => typeof item.id === "string" ? "id:" + item.id : "index:" + index; + +function displayBytes(value: unknown, limit: number): number { + try { return jsonUtf8Bytes(value, limit); } + catch { throw new RangeError("hosted image result exceeds local display limits"); } +} + const generatedName = /^img-codex-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.(png|jpg|webp|gif)$/; /** One spelling per local path: file URL or plain, either separator, dot segments, folded case. */ @@ -71,13 +83,20 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text const states = new Map(); let extraSequence = 0; let wroteArtifact = false; + let retainedMetadataBytes = 0; function stateFor(item: Row, index: number) { - const key = typeof item.id === "string" ? item.id : "index:" + index; + // Validate every occurrence, including repeats whose state is already complete. + const metadataBytes = displayBytes({ id: item.id, + metadata: item.internal_chat_message_metadata_passthrough }, MAX_ITEM_METADATA_BYTES); + const key = displayKey(item, index); let state = states.get(key); if (state) return state; - // Bound metadata independently of the existing 100 MiB decoded-image budget. - if (states.size >= 128) throw new RangeError("hosted image result count exceeds local display limit"); + if (states.size >= MAX_DISPLAY_ITEMS) throw new RangeError("hosted image result count exceeds local display limit"); + if (metadataBytes > MAX_RETAINED_METADATA_BYTES - retainedMetadataBytes) { + throw new RangeError("hosted image result exceeds local display limits"); + } + retainedMetadataBytes += metadataBytes; const id = "msg_ocx_img_" + createHash("sha256").update(key).digest("hex").slice(0, 32); state = { added: false, done: false, message: { // Persist image results outside the client's collapsible progress messages. @@ -147,15 +166,51 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text return events; } - function snapshot(response: Row, terminal: boolean, events?: Row[]): Row { + function projectedMessage(message: Row): Row { + if (message.status !== "in_progress") return message; + // Longest possible local filename and JSON text size, without decoding or writing an image. + const pathText = "![Generated image](<" + join(getArtifactsDir(), "img-codex-" + "0".repeat(36) + ".webp") + ">)"; + const fallback = "The completed image result has no supported image data for local display."; + const text = displayBytes(pathText, TRANSLATOR_MAX_TURN_BYTES) > displayBytes(fallback, TRANSLATOR_MAX_TURN_BYTES) + ? pathText : fallback; + return { ...message, status: "incomplete", content: [{ type: "output_text", text, annotations: [] }] }; + } + + function lifecycleBytes(message: Row): number { + // At most two item copies and four text copies, plus fixed event fields, ids and numeric indices. + return 2 * displayBytes(message, TRANSLATOR_MAX_TURN_BYTES) + + 4 * displayBytes(message.content[0].text, TRANSLATOR_MAX_TURN_BYTES) + 2048; + } + + function snapshot(response: Row, terminal: boolean, events?: Row[], envelope?: Row): Row { if (!Array.isArray(response.output)) return response; - // Reject oversized snapshots before saving artifacts that cannot be delivered. - const keys = new Set(states.keys()); + // Preflight the complete snapshot before any artifact writes or expanded serialization. + const keys = new Set(); + const ids = new Set(); for (const [index, item] of response.output.entries()) { if (!object(item) || item.type !== "image_generation_call") continue; - keys.add(typeof item.id === "string" ? item.id : "index:" + index); - if (keys.size > 128) throw new RangeError("hosted image result count exceeds local display limit"); + const key = displayKey(item, index); + if (keys.has(key)) throw new RangeError("duplicate hosted image identity in local display snapshot"); + keys.add(key); + if (typeof item.id === "string") ids.add(item.id); + stateFor(item, index); + } + if (keys.size === 0) return response; + const seen = new Set(); + for (const item of response.output) { + if (!object(item) || !ids.has(item.id)) continue; + if (seen.has(item.id)) throw new RangeError("conflicting hosted image identity in local display snapshot"); + seen.add(item.id); } + let extraBytes = 32; // Sequence-number growth on a rewritten SSE envelope. + const preview = { ...response, output: response.output.map((item: unknown, index: number) => { + if (!object(item) || item.type !== "image_generation_call") return item; + const state = stateFor(item, index); + const message = terminal ? projectedMessage(state.message) : state.message; + if (events && terminal && !state.done) extraBytes += lifecycleBytes(message); + return message; + }) }; + displayBytes(envelope ? { ...envelope, response: preview } : preview, TRANSLATOR_MAX_TURN_BYTES - extraBytes); let changed = false; const output = response.output.map((item: unknown, index: number) => { if (!object(item) || item.type !== "image_generation_call") return item; @@ -182,9 +237,14 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text && (hasIndex || typeof event.item.id === "string")) { if (event.type === "response.output_item.added") { const state = stateFor(event.item, index); + if (!state.added) displayBytes({ ...event, item: state.message }, TRANSLATOR_MAX_TURN_BYTES - 32); result = state.added ? [] : [{ ...event, item: state.message }]; state.added = true; } else if (event.type === "response.output_item.done") { + const state = stateFor(event.item, index); + if (!state.done && lifecycleBytes(projectedMessage(state.message)) > TRANSLATOR_MAX_TURN_BYTES) { + throw new RangeError("hosted image result exceeds local display limits"); + } result = doneEvents(event.item, index); } } else if (typeof event.type === "string" && event.type.startsWith("response.image_generation_call.")) { @@ -193,7 +253,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text } else if (object(event.response)) { const terminal = ["response.completed", "response.incomplete", "response.failed"].includes(event.type); const injected: Row[] = []; - const response = snapshot(event.response, terminal, injected); + const response = snapshot(event.response, terminal, injected, event); if (response !== event.response) result = [...injected, { ...event, response }]; } const sequence = Number.isSafeInteger(event.sequence_number) ? event.sequence_number + extraSequence : undefined; @@ -216,6 +276,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text }; rewrite.dispose = () => { states.clear(); + retainedMetadataBytes = 0; if (wroteArtifact) { wroteArtifact = false; pruneArtifacts(); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 89b9c9e406f..f2d170bfd0f 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -1329,7 +1329,7 @@ export async function deliverPassthroughResponse( clientJson = imageDisplay.json(clientJson); } catch (error) { if (error instanceof RangeError) { - return formatErrorResponse(502, "upstream_error", "hosted image result count exceeds local display limit"); + return formatErrorResponse(502, "upstream_error", "hosted image result exceeds local display limits"); } throw error; } finally { imageDisplay.dispose?.(); } diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 6c5d041770d..588209c22a9 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -48,9 +48,16 @@ HTTP references here are for upstream context, not a claim of desktop HTTP rende Image data must pass the shared base64, format and byte-budget checks in `src/images/artifacts.ts`. Files use random names, exclusive creation and mode 0600; the artifact directory uses mode 0700 on creation. Per-response display state is capped -at 128 image items, with the existing 50 MiB per-image and 100 MiB aggregate decoded -limits. Exceeding the item cap returns HTTP 502 before streaming or a failed SSE -terminal after streaming has started. Retention runs after the batch. Failed or invalid results produce a bounded +at 128 image items. Each retained identity/metadata envelope is limited to 64 KiB of +serialized UTF-8 JSON, with a 1 MiB aggregate allowance independent of image decoding. +Metadata is preserved unchanged within those bounds, and repeated lifecycle inputs are +validated even when display state already exists. Snapshot image identities are unique, +including against non-image items; explicit ids and fallback indices use separate namespaces. +Projected JSON and SSE lifecycle output is preflighted against the 32 MiB transport limit +before artifact writes or serialization, including metadata reused from earlier events. +The existing 50 MiB per-image and 100 MiB aggregate decoded limits still apply. +Refused display results return HTTP 502 before streaming or a failed SSE terminal after +streaming has started. Retention runs after the batch. Failed or invalid results produce a bounded display message without reflecting payloads or filesystem errors. URL results and partial-image previews are not downloaded or rendered by this projection. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 160b25b1426..7826c483b6f 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -106,6 +106,11 @@ These optimizations do not add request queues, retry policies, or RSS-based admi Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. +Hosted Responses image display uses the allocation-free JSON byte counter to admit retained +metadata and projected output before saving artifacts or serializing expanded JSON/SSE. +The [Images display contract](../data-planes/images.md#hosted-responses-image-display) owns its +item/metadata limits and lifecycle behavior; raw continuation replay stays upstream-shaped. + ## Response-log inspection `src/lib/redact.ts` scans XML identifying attributes over disjoint tag spans rather than diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f25fa0b2e99..39b40866d05 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -21,6 +21,7 @@ "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", + "responses-hosted-image-limits.test.ts": "responses", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "link-relay-bound-transport.test.ts": "clients", diff --git a/tests/responses/responses-hosted-image-delivery.test.ts b/tests/responses/responses-hosted-image-delivery.test.ts index e47f6816a4b..374af93cd4a 100644 --- a/tests/responses/responses-hosted-image-delivery.test.ts +++ b/tests/responses/responses-hosted-image-delivery.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; import { deliverPassthroughResponse, setRelayPlatformForTests } from "../../src/server/responses/passthrough-delivery"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { createTempHome, type TempHome } from "../helpers/temp-home"; @@ -21,19 +21,26 @@ const nl = String.fromCharCode(10); test('full client history replay removes generated artifact paths before upstream dispatch', async () => { const release = acquireOwnedSpendHome(); - const originalFetch = globalThis.fetch; const rewrite = createHostedImageDisplayRewrite(); const requests: any[] = []; try { const message = JSON.parse(rewrite.json(JSON.stringify({ output: [item] }))).output[0]; const displayedText = message.content[0].text; const path = displayedText.match(/<([^>]+)>/)[1]; - globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + // A provider-owned executor also intercepts discovery before DNS/pinned transport. + const fixtureFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url === 'https://fixture.test/v1/models' && init?.method === 'GET') { + return Response.json({ object: 'list', data: [{ id: 'fixture-model' }] }); + } + expect(url).toBe('https://fixture.test/v1/responses'); + expect(init?.method).toBe('POST'); requests.push(JSON.parse(String(init?.body))); return Response.json({ id: 'resp_replay_fixture', status: 'completed', output: [] }); }) as typeof fetch; const config = { port: 0, defaultProvider: 'fixture', providers: { fixture: { adapter: 'openai-responses', baseUrl: 'https://fixture.test/v1', authMode: 'key', apiKey: 'fixture-key', + fetch: fixtureFetch, upstreamWebsocket: false, } } } as OcxConfig; for (const content of [message.content, displayedText]) { // Clients may omit the synthetic item id when serializing their full history. @@ -53,23 +60,30 @@ test('full client history replay removes generated artifact paths before upstrea } expect(message.content[0].text).toBe(displayedText); expect(readFileSync(path)).toEqual(Buffer.from(item.result, 'base64')); - } finally { globalThis.fetch = originalFetch; rewrite.dispose?.(); release(); } + } finally { rewrite.dispose?.(); release(); } }); test('remote compaction removes generated artifact paths before the upstream request', async () => { const release = acquireOwnedSpendHome(); - const originalFetch = globalThis.fetch; const rewrite = createHostedImageDisplayRewrite(); const requests: Array<{ url: string; body: string }> = []; try { const displayedText = JSON.parse(rewrite.json(JSON.stringify({ output: [item] }))).output[0].content[0].text; const path = displayedText.match(/<([^>]+)>/)[1]; - globalThis.fetch = (async (url: unknown, init?: RequestInit) => { - requests.push({ url: String(url), body: String(init?.body) }); + // Keep every allowed endpoint synthetic; unexpected calls fail without a transport fallback. + const fixtureFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url === 'https://api.openai.com/v1/models' && init?.method === 'GET') { + return Response.json({ object: 'list', data: [{ id: 'gpt-5.6-luna' }] }); + } + expect(url).toBe('https://api.openai.com/v1/responses/compact'); + expect(init?.method).toBe('POST'); + requests.push({ url, body: String(init?.body) }); return Response.json({ output: [{ type: 'compaction', encrypted_content: 'native-summary' }] }); }) as typeof fetch; const config = { ...getDefaultConfig(), defaultProvider: 'openai-apikey', providers: { 'openai-apikey': { adapter: 'openai-responses', baseUrl: 'https://api.openai.com/v1', authMode: 'key', apiKey: 'fixture-key', + fetch: fixtureFetch, upstreamWebsocket: false, } } } as OcxConfig; const result = await handleResponsesCompact(new Request('http://localhost/v1/responses/compact', { method: 'POST', headers: { 'content-type': 'application/json', originator: 'Codex Desktop' }, @@ -86,7 +100,7 @@ test('remote compaction removes generated artifact paths before the upstream req expect(requests[0]!.body).not.toContain(JSON.stringify(path).slice(1, -1)); expect(requests[0]!.body).toContain(artifactHttpUrl(path)); expect(requests[0]!.body).toContain('Keep the task state.'); - } finally { globalThis.fetch = originalFetch; rewrite.dispose?.(); release(); } + } finally { rewrite.dispose?.(); release(); } }); const cases: Array<{ platform: 'darwin' | 'linux' | 'win32'; format: string; client: string; cache: string }> = []; @@ -97,7 +111,9 @@ for (const platform of ['darwin', 'linux', 'win32'] as const) { for (const cache of ['plaintext', 'envelope']) cases.push({ platform, format: 'sse', client: 'local', cache }); } for (const platform of ['darwin', 'linux', 'win32'] as const) { - for (const format of ['sse', 'json', 'json-to-sse']) cases.push({ platform, format, client: 'local', cache: 'overflow' }); + for (const format of ['sse', 'json', 'json-to-sse']) { + for (const cache of ['overflow', 'duplicate', 'metadata']) cases.push({ platform, format, client: 'local', cache }); + } } for (const { platform, format, client, cache } of cases) { const local = client === 'local'; @@ -108,8 +124,12 @@ for (const { platform, format, client, cache } of cases) { const remembered: any[] = []; let resolveRemembered!: () => void; const didRemember = new Promise(resolve => { resolveRemembered = resolve; }); + const refused = ['overflow', 'duplicate', 'metadata'].includes(cache); const currentResponse = cache === 'overflow' ? { ...response, output: Array.from({ length: 129 }, (_, i) => ({ ...item, id: 'ig_limit_' + i, status: 'failed', result: null })) } + : cache === 'duplicate' ? { ...response, output: [item, { ...item, status: 'failed', result: null }] } + : cache === 'metadata' ? { ...response, output: [item, { ...item, id: 'ig_large', status: 'failed', result: null, + internal_chat_message_metadata_passthrough: { value: 'x'.repeat(256 * 1024) } }] } : response; const payload = format !== 'sse' ? JSON.stringify(currentResponse) : [ { type: 'response.created', response: { ...response, status: 'in_progress', output: [] } }, @@ -118,7 +138,7 @@ for (const { platform, format, client, cache } of cases) { { type: 'response.output_item.added', output_index: 1, item: { ...finalMessage, status: 'in_progress', content: [] } }, { type: 'response.output_item.done', output_index: 1, item: finalMessage }, { type: 'response.completed', response: currentResponse }, - ].map(e => 'data: ' + JSON.stringify(e) + nl + nl).join('') + 'data: [DONE]' + nl + nl; + ].filter(e => !refused || e.type === 'response.created' || e.type === 'response.completed').map(e => 'data: ' + JSON.stringify(e) + nl + nl).join('') + 'data: [DONE]' + nl + nl; const req = new Request('http://127.0.0.1:10100/v1/responses', { headers: client !== 'generic' ? { originator: 'Codex Desktop' } : {}, }); @@ -146,21 +166,24 @@ for (const { platform, format, client, cache } of cases) { commitReasoningReplayServingRoute: () => {}, recordTerminalOutcomes: false } as any, native as any, ); - if (cache === 'overflow' && format !== 'sse') { + if (refused && format !== 'sse') { expect(result.status).toBe(502); const body = await result.text(); - expect(body).toContain('hosted image result count exceeds local display limit'); + expect(body).toContain('hosted image result exceeds local display limits'); expect(body).not.toContain(home.root); + expect(existsSync(home.path('artifacts'))).toBe(false); + expect(remembered[0]?.output).toEqual(currentResponse.output); return; } expect(result.status).toBe(200); expect(result.headers.get('content-type')?.includes('text/event-stream')).toBe(format !== 'json'); const body = await result.text(); - if (cache === 'overflow') { + if (refused) { const terminal = body.split(nl).filter(l => l.startsWith('data: {')) .map(l => JSON.parse(l.slice(6))).find(e => e.type === 'response.failed'); expect(terminal).toBeDefined(); expect(body).not.toContain(item.result); + expect(existsSync(home.path('artifacts'))).toBe(false); return; } const completed = format === 'json' ? JSON.parse(body) : body.split(nl).filter(l => l.startsWith('data: {')) diff --git a/tests/responses/responses-hosted-image-limits.test.ts b/tests/responses/responses-hosted-image-limits.test.ts new file mode 100644 index 00000000000..e05cbe2bdb7 --- /dev/null +++ b/tests/responses/responses-hosted-image-limits.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { existsSync, readdirSync } from 'node:fs'; +import { createTempHome, type TempHome } from '../helpers/temp-home'; +import { item } from '../fixtures/hosted-image-display'; +import { createHostedImageDisplayRewrite as factory } from '../../src/server/responses-hosted-image-display'; +import { TRANSLATOR_MAX_TURN_BYTES } from '../../src/lib/translator-budget'; + +let home: TempHome; +beforeEach(() => { home = createTempHome('hosted-image-limits-'); }); +afterEach(() => { home.remove(); }); +const block = (event: unknown) => 'data: ' + JSON.stringify(event); +const failed = { ...item, status: 'failed', result: null }; + +for (const format of ['json', 'terminal', 'snapshot'] as const) { + for (const conflict of ['duplicate', 'non-image'] as const) { + test(`${format} refuses ${conflict} snapshot identities before saving artifacts`, () => { + const rewrite = factory(); + const output = [item, conflict === 'duplicate' ? { ...item, status: 'failed', result: null } + : { id: item.id, type: 'message', role: 'assistant', content: [] }]; + try { + expect(() => format === 'json' ? rewrite.json(JSON.stringify({ output })) + : rewrite(block({ type: format === 'terminal' ? 'response.completed' : 'response.in_progress', response: { output } }))) + .toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } + }); + } +} + +test('the reported oversized repeated failed-item snapshot is refused', () => { + const rewrite = factory(); + const output = Array.from({ length: 256 }, (_, index) => ({ ...failed, + ...(index === 0 ? { internal_chat_message_metadata_passthrough: { value: 'x'.repeat(256 * 1024) } } : {}), + })); + try { + expect(() => rewrite.json(JSON.stringify({ output }))).toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } +}); + +test('failed lifecycle reuse charges metadata once per distinct identity', () => { + const rewrite = factory(); + const metadata = { value: 'x'.repeat(60 * 1024) }; + const image = (index: number) => ({ ...failed, id: 'retained_' + index, + internal_chat_message_metadata_passthrough: metadata }); + try { + const first = image(0); + const emitted = [ + { type: 'response.output_item.added', output_index: 0, item: { ...first, status: 'in_progress' } }, + { type: 'response.output_item.done', output_index: 0, item: first }, + { type: 'response.completed', response: { output: [first] } }, + ].flatMap(event => rewrite(block(event))).map(value => JSON.parse(value.split('data: ')[1]!)); + const done = emitted.find(event => event.type === 'response.output_item.done').item; + expect(done.internal_chat_message_metadata_passthrough).toEqual(metadata); + expect(emitted.at(-1).response.output[0]).toEqual(done); + expect(emitted.filter(event => event.type === 'response.output_item.done')).toHaveLength(1); + // Sixteen unique items retain about 960 KiB. Recharging lifecycle repeats would overflow. + expect(() => { + for (let index = 1; index < 16; index++) { + rewrite(block({ type: 'response.output_item.done', output_index: index, item: image(index) })); + } + }).not.toThrow(); + // Seventeen still fit (including identity/envelope bytes); eighteen exceed the 1 MiB cap. + expect(() => { rewrite(block({ type: 'response.output_item.done', output_index: 16, item: image(16) })); }).not.toThrow(); + expect(() => { rewrite(block({ type: 'response.output_item.done', output_index: 17, item: image(17) })); }) + .toThrow('hosted image result exceeds local display limits'); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } +}); + +for (const format of ['json', 'added', 'done', 'terminal'] as const) { + test(`${format} refuses oversized failed-item metadata without artifacts`, () => { + const rewrite = factory(); + const large = { ...failed, internal_chat_message_metadata_passthrough: { value: 'x'.repeat(256 * 1024) } }; + try { + expect(() => format === 'json' ? rewrite.json(JSON.stringify({ output: [item, large] })) + : rewrite(block(format === 'terminal' ? { type: 'response.failed', response: { output: [item, large] } } + : { type: 'response.output_item.' + format, output_index: 0, item: large }))).toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } + }); +} + +test('cached items still validate incoming metadata before early lifecycle returns', () => { + const rewrite = factory(); + try { + rewrite(block({ type: 'response.output_item.done', output_index: 0, item: failed })); + const large = { ...failed, internal_chat_message_metadata_passthrough: '\u0000'.repeat(12 * 1024) }; + expect(() => rewrite(block({ type: 'response.output_item.done', output_index: 0, item: large }))).toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } +}); + +test('retained identity and aggregate metadata are bounded before snapshot artifacts', () => { + for (const output of [ + [item, { ...failed, id: 'x'.repeat(128 * 1024) }], + Array.from({ length: 18 }, (_, index) => ({ ...item, id: 'image_' + index, + internal_chat_message_metadata_passthrough: { value: 'x'.repeat(60 * 1024) } })), + ]) { + const rewrite = factory(); + try { + expect(() => rewrite.json(JSON.stringify({ output }))).toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } + } +}); + +for (const format of ['json', 'terminal'] as const) { + test(`${format} preflights cached metadata expansion before saving artifacts`, () => { + const rewrite = factory(); + try { + rewrite(block({ type: 'response.output_item.added', output_index: 0, + item: { ...item, status: 'in_progress', result: null, + internal_chat_message_metadata_passthrough: { value: 'x'.repeat(60 * 1024) } } })); + const response = { output: [item], padding: 'x'.repeat(TRANSLATOR_MAX_TURN_BYTES - 1024) }; + expect(() => format === 'json' ? rewrite.json(JSON.stringify(response)) + : rewrite(block({ type: 'response.completed', response }))).toThrow(); + expect(existsSync(home.path('artifacts'))).toBe(false); + } finally { rewrite.dispose?.(); } + }); +} + +test('ordinary metadata survives added/done/terminal reuse exactly once', () => { + const rewrite = factory(); + const metadata = { synthetic: true, routing: { lane: 'image', labels: ['日本語', 'a\nb'] } }; + const image = { ...item, internal_chat_message_metadata_passthrough: metadata }; + try { + const emitted = [ + { type: 'response.output_item.added', output_index: 0, item: { ...image, status: 'in_progress', result: null } }, + { type: 'response.output_item.done', output_index: 0, item: image }, + { type: 'response.completed', response: { output: [image] } }, + ].flatMap(event => rewrite(block(event))).map(value => JSON.parse(value.split('data: ')[1]!)); + const added = emitted.find(event => event.type === 'response.output_item.added').item; + const done = emitted.find(event => event.type === 'response.output_item.done').item; + expect(added.id).toBe(done.id); + expect(done.internal_chat_message_metadata_passthrough).toEqual(metadata); + expect(emitted.at(-1).response.output[0]).toEqual(done); + expect(emitted.filter(event => event.type === 'response.output_item.done')).toHaveLength(1); + expect(readdirSync(home.path('artifacts'))).toHaveLength(1); + } finally { rewrite.dispose?.(); } +}); + +test('literal upstream ids cannot alias the fallback index namespace', () => { + const rewrite = factory(); + try { + const result = JSON.parse(rewrite.json(JSON.stringify({ output: [ + { ...item, id: undefined }, { ...failed, id: 'index:0' }, + ] }))); + expect(result.output[0].id).not.toBe(result.output[1].id); + expect(result.output[0].status).toBe('completed'); + expect(result.output[1].status).toBe('incomplete'); + expect(readdirSync(home.path('artifacts'))).toHaveLength(1); + } finally { rewrite.dispose?.(); } +});