fix(openai): keep opening prompt collapsible

This commit is contained in:
Steven Chong
2026-06-21 17:37:51 -04:00
parent 62b9c872ac
commit f6be3d3f88
2 changed files with 52 additions and 28 deletions
+29 -23
View File
@@ -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) =>
+23 -5
View File
@@ -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<Record<string, unknown>> {
const items: Array<Record<string, unknown>> = [
{ 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<Record<string, unknown>> {
function buildChatMessages(turns: number): Array<Record<string, unknown>> {
const msgs: Array<Record<string, unknown>> = [
{ 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<Record<string, unknown>> {
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);
});
});