diff --git a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts index b707be8eb4..5d93f87be2 100644 --- a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts +++ b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts @@ -19,7 +19,11 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { + buildOpenAiCodexHistoryCompactor, extractOpenAiCodexCompactionState, shouldFallbackFromOpenAiCodexHistoryCompaction, withOpenAiCodexHistoryCompactionFallback, @@ -129,3 +133,128 @@ describe('OpenAI Codex compaction output', () => { ); }); }); + +describe('OpenAI Codex compaction input', () => { + test('keeps an interrupted late tool step after the intervening assistant step', async () => { + const model = compactionModel(); + const summarize = buildOpenAiCodexHistoryCompactor({ + resolveModel: () => model, + connectionId: 'codex-subscription', + modelId: 'gpt-5.3-codex', + }); + + await summarize({ + sessionId: 'session-1', + turnId: 'turn-current', + source: { + foldedRuntimeEvents: [ + assistantTextEvent('text-a', 'shared-step', 'Text A'), + assistantTextEvent('text-b', 'intervening-step', 'Text B'), + toolCallEvent(), + toolResultEvent(), + ], + }, + }); + + assert.deepEqual( + model.doStreamCalls[0]?.prompt.map((message) => ({ + role: message.role, + parts: Array.isArray(message.content) + ? message.content.map((part) => (part.type === 'text' ? `text:${part.text}` : part.type)) + : [`text:${message.content}`], + })), + [ + { role: 'assistant', parts: ['text:Text A'] }, + { role: 'assistant', parts: ['text:Text B'] }, + { role: 'assistant', parts: ['tool-call'] }, + { role: 'tool', parts: ['tool-result'] }, + ], + ); + }); +}); + +function assistantTextEvent(id: string, stepId: string, text: string): RuntimeEvent { + return runtimeEvent({ + id, + role: 'model', + author: 'agent', + refs: { providerEventId: stepId }, + content: { kind: 'text', text }, + }); +} + +function toolCallEvent(): RuntimeEvent { + return runtimeEvent({ + id: 'tool-call', + role: 'model', + author: 'agent', + refs: { stepId: 'shared-step' }, + content: { + kind: 'function_call', + id: 'read-1', + name: 'Read', + args: { path: 'notes.md' }, + }, + }); +} + +function toolResultEvent(): RuntimeEvent { + return runtimeEvent({ + id: 'tool-result', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'read-1', + name: 'Read', + result: { kind: 'text', text: 'contents' }, + isError: false, + }, + }); +} + +function runtimeEvent( + input: Pick & Pick, +): RuntimeEvent { + return { + id: input.id, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-previous', + ts: 1, + partial: false, + role: input.role, + author: input.author, + content: input.content, + refs: input.refs, + }; +} + +function compactionModel(): MockLanguageModelV4 { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { + type: 'custom', + kind: 'openai.compaction', + providerMetadata: { openai: { itemId: 'cmp-1', encryptedContent: 'encrypted-1' } }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 0, reasoning: 0 }, + }, + }, + ]; + return new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); +} diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index c9018cbdce..11c705ab87 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -204,7 +204,7 @@ function openAiCodexCompactionMessages( text?: Text; }; type TimelineEntry = - | { kind: 'step'; stepId: string } + | { kind: 'step'; stepId: string; value: Step } | { kind: 'legacy_call'; call: ToolCall } | { kind: 'text'; item: Text } | { kind: 'thinking'; item: Thinking }; @@ -218,15 +218,12 @@ function openAiCodexCompactionMessages( if (item.kind === 'tool_result') results.set(item.toolCallId, item); } - const steps = new Map(); const timeline: TimelineEntry[] = []; const step = (stepId: string): Step => { - let value = steps.get(stepId); - if (!value) { - value = { calls: [], reasoning: [] }; - steps.set(stepId, value); - timeline.push({ kind: 'step', stepId }); - } + const last = timeline.at(-1); + if (last?.kind === 'step' && last.stepId === stepId) return last.value; + const value = { calls: [], reasoning: [] }; + timeline.push({ kind: 'step', stepId, value }); return value; }; for (const item of items) { @@ -322,7 +319,7 @@ function openAiCodexCompactionMessages( for (const entry of timeline) { if (entry.kind === 'step') { - pushStep(steps.get(entry.stepId)!); + pushStep(entry.value); } else if (entry.kind === 'legacy_call') { pushStep({ calls: [entry.call], reasoning: [] }); } else if (entry.kind === 'thinking') {