From 45348aba8b527cf3c7c65bf88b7718c0e8c58c11 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 21 Oct 2026 19:07:08 +0000 Subject: [PATCH] fix(helpers): preserve Responses cache breakpoint TTLs --- src/helpers/browser.ts | 34 ++-- src/helpers/responses-tool-runner.ts | 15 +- tests/helpers/browser-capabilities.test.ts | 41 +++++ tests/helpers/browser-runner.test.ts | 189 ++++++++++++--------- 4 files changed, 184 insertions(+), 95 deletions(-) diff --git a/src/helpers/browser.ts b/src/helpers/browser.ts index 0a2cbb5e6e..d4e49a80b7 100644 --- a/src/helpers/browser.ts +++ b/src/helpers/browser.ts @@ -573,14 +573,21 @@ function readText(value: unknown, limit: number, allowEmpty: boolean): string { return value; } -function validImageBreakpoint(value: unknown): boolean { - return ( - value === undefined || - value === null || - (isObj(value) && - configuredOption(value, 'mode') === 'explicit' && - Object.keys(value).every((key) => key === 'mode')) - ); +function snapshotImageBreakpoint( + value: unknown, +): { mode: 'explicit'; ttl?: '30m' | '12h' } | null | undefined { + if (value === undefined || value === null) { + return value; + } + if (!isObj(value) || Object.keys(value).some((key) => key !== 'mode' && key !== 'ttl')) { + throw new TypeError('Invalid browser image breakpoint.'); + } + const mode = configuredOption(value, 'mode'); + const ttl = configuredOption(value, 'ttl'); + if (mode !== 'explicit' || (ttl !== undefined && ttl !== '30m' && ttl !== '12h')) { + throw new TypeError('Invalid browser image breakpoint.'); + } + return { mode, ...(ttl === undefined ? {} : { ttl }) }; } function validImageSource(value: unknown): value is string | null | undefined { @@ -613,14 +620,15 @@ function snapshotImage(value: unknown, member: 'screenshot' | 'zoom'): ResponseI const detail = configuredOption(value, 'detail'); const image_url = configuredOption(value, 'image_url'); const file_id = configuredOption(value, 'file_id'); - const prompt_cache_breakpoint = configuredOption(value, 'prompt_cache_breakpoint'); + const prompt_cache_breakpoint = snapshotImageBreakpoint( + configuredOption(value, 'prompt_cache_breakpoint'), + ); if ( type !== 'input_image' || !validImageDetail(detail) || !validImageSource(image_url) || !validImageSource(file_id) || - (typeof image_url !== 'string' && typeof file_id !== 'string') || - !validImageBreakpoint(prompt_cache_breakpoint) + (typeof image_url !== 'string' && typeof file_id !== 'string') ) { throw new TypeError('Invalid browser image.'); } @@ -629,9 +637,7 @@ function snapshotImage(value: unknown, member: 'screenshot' | 'zoom'): ResponseI ...(detail === undefined ? {} : { detail }), ...(image_url === undefined ? {} : { image_url }), ...(file_id === undefined ? {} : { file_id }), - ...(prompt_cache_breakpoint === undefined - ? {} - : { prompt_cache_breakpoint: prompt_cache_breakpoint === null ? null : { mode: 'explicit' } }), + ...(prompt_cache_breakpoint === undefined ? {} : { prompt_cache_breakpoint }), }; } catch { throw new OpenAIError( diff --git a/src/helpers/responses-tool-runner.ts b/src/helpers/responses-tool-runner.ts index eb10844dd0..3c10dda793 100644 --- a/src/helpers/responses-tool-runner.ts +++ b/src/helpers/responses-tool-runner.ts @@ -479,6 +479,15 @@ function outputRecord(value: unknown, keys: readonly string[]): Record [key, field.value])); } +function suppliedBreakpoint(value: unknown) { + const breakpoint = outputRecord(value, ['mode', 'ttl']); + const { mode, ttl } = breakpoint; + if (mode !== 'explicit' || (ttl !== undefined && ttl !== '30m' && ttl !== '12h')) { + throw new TypeError('Supplied output has an invalid cache breakpoint.'); + } + return { mode, ...(ttl === undefined ? {} : { ttl }) }; +} + function suppliedBlock(value: unknown): ResponseFunctionCallOutputItem { const block = outputRecord(value, [ 'type', @@ -537,11 +546,7 @@ function suppliedBlock(value: unknown): ResponseFunctionCallOutputItem { } const cache = block['prompt_cache_breakpoint']; if (cache !== undefined && cache !== null) { - const breakpoint = outputRecord(cache, ['mode']); - if (breakpoint['mode'] !== 'explicit') { - throw new TypeError('Supplied output has an invalid cache breakpoint.'); - } - block['prompt_cache_breakpoint'] = { mode: 'explicit' }; + block['prompt_cache_breakpoint'] = suppliedBreakpoint(cache); } if (type === 'input_text') { const { text } = block; diff --git a/tests/helpers/browser-capabilities.test.ts b/tests/helpers/browser-capabilities.test.ts index 1c6655e2b9..064bb40408 100644 --- a/tests/helpers/browser-capabilities.test.ts +++ b/tests/helpers/browser-capabilities.test.ts @@ -890,6 +890,43 @@ describe('explicit browser capabilities', () => { expect(Object.getOwnPropertyDescriptor(detail, 'toJSON')).toBeUndefined(); }); + test.each(['30m', '12h'] as const)('preserves browser breakpoint TTL %s on the wire', async (ttl) => { + const breakpoint = { mode: 'explicit' as const, ttl }; + const image = { + type: 'input_image' as const, + file_id: 'file_fixture', + prompt_cache_breakpoint: breakpoint, + }; + const browser = new BrowserToolset({ state, screenshot: () => image }); + const output = await browser.execute(call('screenshot', { tab_id: null })); + breakpoint.ttl = ttl === '12h' ? '30m' : '12h'; + const received: unknown[] = []; + const client = new OpenAI({ + apiKey: 'synthetic-test-key', + maxRetries: 0, + fetch: async (url, init) => { + received.push(await new Request(url, init).json()); + return Response.json({ id: 'resp_fixture', object: 'response', status: 'completed', output: [] }); + }, + }); + await client.responses.create({ model: 'fixture', input: [output] }); + expect(received).toMatchObject([ + { + input: [ + { + output: expect.arrayContaining([ + { + type: 'input_image', + file_id: 'file_fixture', + prompt_cache_breakpoint: { mode: 'explicit', ttl }, + }, + ]), + }, + ], + }, + ]); + }); + test.each(['image', 'declined', 'failed'] as const)( 'direct browser %s output keeps its owned structure through a native Responses request', async (kind) => { @@ -1115,6 +1152,10 @@ describe('explicit browser capabilities', () => { { image_url: ' ' }, { file_id: 'file_ok', prompt_cache_breakpoint: { mode: 'automatic' } }, { file_id: 'file_ok', prompt_cache_breakpoint: {} }, + ...[null, 42, 'invalid'].map((ttl) => ({ + file_id: 'file_ok', + prompt_cache_breakpoint: { mode: 'explicit', ttl }, + })), { file_id: 'file_ok', prompt_cache_breakpoint: { mode: 'explicit', private: 'omit' } }, ].map(async (invalid) => { const image = { type: 'input_image' as const, detail: 'auto' as const }; diff --git a/tests/helpers/browser-runner.test.ts b/tests/helpers/browser-runner.test.ts index 51f445235c..d9ffd32c38 100644 --- a/tests/helpers/browser-runner.test.ts +++ b/tests/helpers/browser-runner.test.ts @@ -8779,85 +8779,99 @@ describe('ResponsesToolRunner iteration', () => { }, ); - test('supplies permitted native results without changing call IDs, mixed order or pending arguments', async () => { - const effects: string[] = []; - const browser = new BrowserToolset( - { - state, - click: ({ target }) => { - effects.push(target); + test.each([undefined, '30m', '12h'] as const)( + 'supplies native results with TTL %s without changing call IDs or order', + async (ttl) => { + const effects: string[] = []; + const browser = new BrowserToolset( + { + state, + click: ({ target }) => { + effects.push(target); + }, }, - }, - { confirm: () => true }, - ); - const execute = vi.fn(() => 'automatic'); - const runner = new ResponsesToolRunner({ - browser, - functions: [ + { confirm: () => true }, + ); + const execute = vi.fn(() => 'automatic'); + const runner = new ResponsesToolRunner({ + browser, + functions: [ + { + tool: { type: 'function', name: 'record', parameters: {}, strict: false }, + execute, + allowOutputOverride: true, + }, + ], + }); + const h = harness([ + { output: [wireCall('record', {}), wireCall('browser_click', { target: 'original', tab_id: null })] }, + { output: [message] }, + ]); + const iterator = runner.iterate(h.client, input); + const first = await iterator.next(); + expect(first.done).toBe(false); + if (first.done) { + throw new Error('Missing turn'); + } + const breakpoint = { mode: 'explicit' as const, ...(ttl === undefined ? {} : { ttl }) }; + const expectedBreakpoint = { ...breakpoint }; + const supplied = [ { - tool: { type: 'function', name: 'record', parameters: {}, strict: false }, - execute, - allowOutputOverride: true, + type: 'input_text' as const, + text: 'human result', + prompt_cache_breakpoint: breakpoint, }, - ], - }); - const h = harness([ - { output: [wireCall('record', {}), wireCall('browser_click', { target: 'original', tab_id: null })] }, - { output: [message] }, - ]); - const iterator = runner.iterate(h.client, input); - const first = await iterator.next(); - expect(first.done).toBe(false); - if (first.done) { - throw new Error('Missing turn'); - } - const supplied = [ - { - type: 'input_text' as const, - text: 'human result', - prompt_cache_breakpoint: { mode: 'explicit' as const }, - }, - { - type: 'input_image' as const, - image_url: 'https://fixture.invalid/image', - detail: 'original' as const, - }, - { type: 'input_file' as const, file_id: 'file_fixture' }, - ]; - first.value.supplyOutputs([{ type: 'function_call_output', call_id: 'call_record', output: supplied }]); - const [text] = supplied; - if (text?.type !== 'input_text') { - throw new Error('Missing text output'); - } - text.text = 'changed later'; - const [, pending] = first.value.response.output; - if (pending?.type !== 'function_call') { - throw new Error('Missing pending call'); - } - pending.arguments = JSON.stringify({ target: 'retargeted', tab_id: null }); - pending.call_id = 'changed'; - const final = await iterator.next(); - expect(final.value).toMatchObject({ - step: 2, - final: true, - attemptedCallIds: ['call_record', 'call_browser_click'], - completedCallIds: ['call_record', 'call_browser_click'], - }); - expect(execute).not.toHaveBeenCalled(); - expect(effects).toEqual(['original']); - expect(h.bodies[1]?.['input']).toContainEqual({ - type: 'function_call_output', - call_id: 'call_record', - output: [ - { type: 'input_text', text: 'human result', prompt_cache_breakpoint: { mode: 'explicit' } }, - { type: 'input_image', image_url: 'https://fixture.invalid/image', detail: 'original' }, - { type: 'input_file', file_id: 'file_fixture' }, - ], - }); - expect(JSON.stringify(h.bodies[1]?.['input'])).not.toContain('retargeted'); - expect(() => first.value.supplyOutputs([])).toThrow('does not accept'); - await iterator.return(); - }); + { + type: 'input_image' as const, + image_url: 'https://fixture.invalid/image', + detail: 'original' as const, + prompt_cache_breakpoint: breakpoint, + }, + { type: 'input_file' as const, file_id: 'file_fixture', prompt_cache_breakpoint: breakpoint }, + ]; + first.value.supplyOutputs([{ type: 'function_call_output', call_id: 'call_record', output: supplied }]); + const [text] = supplied; + if (text?.type !== 'input_text') { + throw new Error('Missing text output'); + } + text.text = 'changed later'; + if (ttl !== undefined) { + breakpoint.ttl = ttl === '12h' ? '30m' : '12h'; + } + const [, pending] = first.value.response.output; + if (pending?.type !== 'function_call') { + throw new Error('Missing pending call'); + } + pending.arguments = JSON.stringify({ target: 'retargeted', tab_id: null }); + pending.call_id = 'changed'; + const final = await iterator.next(); + expect(final.value).toMatchObject({ + step: 2, + final: true, + attemptedCallIds: ['call_record', 'call_browser_click'], + completedCallIds: ['call_record', 'call_browser_click'], + }); + expect(execute).not.toHaveBeenCalled(); + expect(effects).toEqual(['original']); + expect(h.bodies[1]?.['input']).toContainEqual({ + type: 'function_call_output', + call_id: 'call_record', + output: [ + { type: 'input_text', text: 'human result', prompt_cache_breakpoint: expectedBreakpoint }, + { + type: 'input_image', + image_url: 'https://fixture.invalid/image', + detail: 'original', + prompt_cache_breakpoint: expectedBreakpoint, + }, + { type: 'input_file', file_id: 'file_fixture', prompt_cache_breakpoint: expectedBreakpoint }, + ], + }); + expect(JSON.stringify(h.bodies[1]?.['input'])).not.toContain('retargeted'); + expect(() => first.value.supplyOutputs([])).toThrow('does not accept'); + await iterator.return(); + }, + ); test.each(['executor', 'supplied'] as const)('rejects hidden output fields from %s', async (source) => { const block = Object.defineProperty( @@ -8942,6 +8956,29 @@ describe('ResponsesToolRunner iteration', () => { [allowed, { ...allowed, call_id: 'unknown' }], [{ ...allowed, output: [{ type: 'input_text', text: 42 }] }], [{ ...allowed, output: [{ type: 'input_image', image_url: { private: true } }] }], + ...[null, 42, 'invalid'].map((ttl) => [ + { + ...allowed, + output: [ + { type: 'input_text', text: 'fixture', prompt_cache_breakpoint: { mode: 'explicit', ttl } }, + ], + }, + ]), + [ + { + ...allowed, + output: [ + { + type: 'input_text', + text: 'fixture', + prompt_cache_breakpoint: Object.defineProperty({ mode: 'explicit' }, 'ttl', { + get: getter, + enumerable: true, + }), + }, + ], + }, + ], [{ ...allowed, output: cyclic }], [{ ...allowed, toJSON: () => ({}) }], [accessor],