diff --git a/src/core/openai.ts b/src/core/openai.ts index d2e657f..879ffee 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -229,12 +229,6 @@ function contentText(content: OpenAIChatMessage['content']): string { .join('\n\n'); } -function contentParts(content: OpenAIChatMessage['content']): OpenAIContentPart[] { - if (typeof content === 'string') return [{ type: 'text', text: content }]; - if (Array.isArray(content)) return content.slice(); - return []; -} - function setTextContent(msg: OpenAIChatMessage, text: string): void { if (Array.isArray(msg.content)) { const kept = msg.content.filter((p) => !isTextPart(p)); @@ -647,11 +641,17 @@ export async function transformOpenAIChatCompletions( info.imagePngs = images.map((img) => img.png); info.imageDims = images.map((img) => ({ width: img.width, height: img.height })); - const firstUserMsg = req.messages[firstUserIdx]!; - firstUserMsg.content = [ - ...imageParts, - { type: 'text', text: '[End of rendered GPT system/tool context.]' }, - ...contentParts(firstUserMsg.content), + const slabUserMsg: OpenAIChatMessage = { + role: 'user', + content: [ + ...imageParts, + { type: 'text', text: '[End of rendered GPT system/tool context.]' }, + ], + }; + req.messages = [ + ...req.messages.slice(0, firstUserIdx), + slabUserMsg, + ...req.messages.slice(firstUserIdx), ]; for (const msg of req.messages) { @@ -660,9 +660,9 @@ export async function transformOpenAIChatCompletions( setTextContent(msg, CHAT_POINTER); } - // Collapse the OLD conversation prefix into history image(s). The first user - // message (firstUserIdx) carries the static slab and is protected; the bulk is - // the transcript OpenCode resends every turn. + // Collapse the OLD conversation prefix into history image(s). The inserted slab + // item carries static images and is protected; the original opening user prompt + // remains collapsible history instead of looking like the live request. if (o.collapseHistory) { const turns = chatMessagesToTurns(req.messages); const profitable = (text: string, cols: number) => @@ -842,12 +842,18 @@ export async function transformOpenAIResponses( ], }]; } else { - // Prepend images to the first user item's content. - const firstUserItem = inputItems[firstUserIdx] as ResponsesInputItem; - const originalContent = typeof firstUserItem.content === 'string' - ? [{ type: 'input_text', text: firstUserItem.content } as ResponsesInputTextPart] - : (firstUserItem.content as ResponsesContentPart[]).slice(); - firstUserItem.content = [...imagePartsResp, endMarker, ...originalContent]; + // Insert a dedicated static-slab item. Do not attach it to the opening real + // user prompt: that prompt is old history on long stateless Responses calls, + // and protecting it made stale first-turn requests look live. + const slabUserItem: ResponsesInputItem = { + role: 'user', + content: [...imagePartsResp, endMarker], + }; + inputItems = [ + ...inputItems.slice(0, firstUserIdx), + slabUserItem, + ...inputItems.slice(firstUserIdx), + ]; req.input = inputItems; } @@ -868,9 +874,9 @@ export async function transformOpenAIResponses( } } - // Collapse the OLD conversation prefix into history image(s). The static slab - // is small; the transcript OpenCode resends every turn is the real cost. Skip - // for bare-string input (single message, nothing to collapse). + // Collapse the OLD conversation prefix into history image(s). The inserted slab + // item is protected; the transcript OpenCode resends every turn is the real cost. + // Skip for bare-string input (single message, nothing to collapse). if (o.collapseHistory && !inputWasString) { const turns = responsesItemsToTurns(inputItems); const profitable = (text: string, cols: number) => diff --git a/tests/openai-gpt5.test.ts b/tests/openai-gpt5.test.ts index ea88e19..6cdccc0 100644 --- a/tests/openai-gpt5.test.ts +++ b/tests/openai-gpt5.test.ts @@ -333,20 +333,27 @@ describe('transformOpenAIResponses (gpt-5.6)', () => { // -- Task 4: GPT history-image collapse (the growing transcript) --------------- const BIG_SLAB = 'You are a coding agent with detailed instructions. '.repeat(80); // ~4k chars +const OPENING_PROMPT_MARKER = 'OPENING_PROMPT_SHOULD_BE_HISTORY'; +const LIVE_PROMPT_MARKER = 'LIVE_CURRENT_PROMPT_SHOULD_STAY_TEXT'; /** A long Responses `input`: first user, then many closed tool-call turns + a * recent tail. Each turn is ~600 chars so the collapsed prefix clears the 8000 * minCollapseChars floor. */ function buildResponsesInput(turns: number): Array> { const items: Array> = [ - { role: 'user', content: 'Start the task. '.repeat(40) }, + { role: 'user', content: `${OPENING_PROMPT_MARKER} `.repeat(40) }, ]; for (let i = 0; i < turns; i++) { const id = `call_${i}`; items.push({ role: 'assistant', content: `Working on step ${i}. `.repeat(30) }); items.push({ type: 'function_call', call_id: id, name: 'read', arguments: `{"path":"f${i}"}` }); items.push({ type: 'function_call_output', call_id: id, output: `result ${i} `.repeat(50) }); - items.push({ role: 'user', content: `Continue with ${i}. `.repeat(20) }); + items.push({ + role: 'user', + content: i === turns - 1 + ? `${LIVE_PROMPT_MARKER} `.repeat(20) + : `Continue with ${i}. `.repeat(20), + }); } return items; } @@ -354,7 +361,7 @@ function buildResponsesInput(turns: number): Array> { function buildChatMessages(turns: number): Array> { const msgs: Array> = [ { role: 'system', content: BIG_SLAB }, - { role: 'user', content: 'Start the task. '.repeat(40) }, + { role: 'user', content: `${OPENING_PROMPT_MARKER} `.repeat(40) }, ]; for (let i = 0; i < turns; i++) { const id = `call_${i}`; @@ -364,7 +371,12 @@ function buildChatMessages(turns: number): Array> { tool_calls: [{ id, type: 'function', function: { name: 'read', arguments: `{"path":"f${i}"}` } }], }); msgs.push({ role: 'tool', tool_call_id: id, content: `result ${i} `.repeat(50) }); - msgs.push({ role: 'user', content: `Continue with ${i}. `.repeat(20) }); + msgs.push({ + role: 'user', + content: i === turns - 1 + ? `${LIVE_PROMPT_MARKER} `.repeat(20) + : `Continue with ${i}. `.repeat(20), + }); } return msgs; } @@ -397,11 +409,14 @@ describe('transformOpenAIResponses — history collapse', () => { ); }); expect(historyItems).toHaveLength(1); + const serialized = JSON.stringify(out.input); + expect(serialized).not.toContain(OPENING_PROMPT_MARKER); + expect(serialized).toContain(LIVE_PROMPT_MARKER); // The recent tail is still raw text items (function_call / user), not collapsed. const lastUser = [...out.input].reverse().find( (item) => (item as { role?: string }).role === 'user', ) as { content?: string }; - expect(typeof lastUser.content === 'string' && lastUser.content.includes('Continue with 19')).toBe(true); + expect(typeof lastUser.content === 'string' && lastUser.content.includes(LIVE_PROMPT_MARKER)).toBe(true); }); it('produces a byte-stable history image sha across identical requests', async () => { @@ -454,6 +469,9 @@ describe('transformOpenAIChatCompletions — history collapse', () => { ); }); expect(historyMsgs).toHaveLength(1); + const serialized = JSON.stringify(out.messages); + expect(serialized).not.toContain(OPENING_PROMPT_MARKER); + expect(serialized).toContain(LIVE_PROMPT_MARKER); }); });