Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/plans/2026-09-27-codex-resumed-compaction-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# A resumed rollout that starts with `compacted` shows its retained prompts (#1393)

Depends on #1386, which maps `compacted` at all.

## Evidence (local corpus)
- **The affected files.** 82 rollouts, 68 sessions, 58 of them with no other local file, have a `compacted` line before any `response_item` or `event_msg`. Its `replacement_history` retained user prompts are the only copy of that earlier conversation in the file.
- **Elsewhere the retained prompts duplicate.** 33,443 of 37,724 retained user items have an exact earlier match. That is why #1386 does not replay `replacement_history`.
- **`session_meta` marks the file head.** Across 2,579 local rollouts, **0** have a `session_meta` after a conversation record: it only ever appears at a file's head.

## Change
- **`createCodexTranscriptEntryMapper` tracks the file head.** The state is set by `session_meta` and cleared by the first `response_item` or `event_msg`.
- **A `compacted` line at the head** also maps its retained user prompts, through the new `mapCodexRetainedUserHistory`, placed before the boundary.
- The prompts go through the same synthetic filter as ordinary `response_item` user rows: the AGENTS.md preamble, environment context and subagent notifications are dropped.
- Developer items and the encrypted `compaction` item are not conversation.
- They carry the compacted line's timestamp and stable `:retained:<i>` uuids.
- **No loader or shared-type change.** A page or live burst that starts mid-file never has a `session_meta` first, so it can never enter the head state. Initial history, an older page that reaches the file start, and a preview from the file start all get the prompts.

## Tests (`resumedHeadCompaction.test.ts`, the real mapper)
Fixture: `testing/fixtures/rendering-shapes/codex/compaction/resumed-head-compacted.json`, the real head of a 0.145.0 rollout that starts with `compacted`. Text is redacted to the same length, except the two bootstrap marker prefixes the filter recognises.
1. **The resumed head** gives 7 user prompts (8 retained, minus the bootstrap) and then the boundary, all at the compacted line's time. Red on #1386's head.
2. **The same page without its `session_meta`** (a mid-file page) gives only the boundary.
3. **A compaction after a conversation record in the same file** gives only that record and the boundary.

Mutations: never mapping retained prompts, never leaving the head state, and treating any non-conversation line as the head each fail a test.

## Not done
The retained prompts render as ordinary user rows. There is no "retained" styling; a visual marker would be a separate UX decision.
27 changes: 24 additions & 3 deletions src/providers/codex/renderer/transcript/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import {
codexHistoryMarker,
codexTurnIdFromRollout,
mapCodexRetainedUserHistory,
mapCodexRolloutToFeedEntries,
stampCodexTurnId,
} from './rollout'
Expand All @@ -37,6 +38,20 @@ export function createCodexTranscriptEntryMapper(
initialTurnCursor: string | null = null,
): TranscriptEntryMapper {
let turnCursor = initialTurnCursor
// WHY the mapper tracks the file head (#1393): a resumed rollout can START
// with a `compacted` line whose retained user prompts are the only copy of
// the earlier conversation in the file (82 local files, 58 sessions with no
// other rollout). Those prompts must be shown there, and only there:
// elsewhere they repeat prompts already in the feed (33,443 of 37,724
// retained user items have an earlier exact match). "At the head" is
// proved by a `session_meta` line with no conversation record
// (`response_item`/`event_msg`) since it. `session_meta` is only ever at a
// file's head (0 of 2,579 local rollouts have one after a conversation
// record), so a history page that starts mid-file, or a live burst, can
// never enter this state, whatever its first line is. Pages and previews
// that DO start at the file head get the prompts, with no loader change.
let atFileHead = false

// WHY (#1395 reviews a and b): one exec_command can have TWO terminal
// carriers. The wrapped function_call_output is always durable. The
// exec_command_end event is transient in current Codex, but the
Expand Down Expand Up @@ -78,9 +93,15 @@ export function createCodexTranscriptEntryMapper(
const payloadTurnId = codexTurnIdFromEventPayload(raw)
if (payloadTurnId !== null) turnCursor = payloadTurnId

const entries = mapCodexRolloutToFeedEntries(raw)
.filter(keepExecTerminal(raw))
.map(entry => stampCodexTurnId(entry, turnCursor))
if (raw.type === 'session_meta') atFileHead = true
else if (raw.type === 'response_item' || raw.type === 'event_msg') atFileHead = false
const retained = atFileHead ? mapCodexRetainedUserHistory(raw) : []

// Retained prompts are user messages, never exec terminal carriers, so
// only the record's own entries go through the exec de-duplication.
const entries = [...retained, ...mapCodexRolloutToFeedEntries(raw).filter(keepExecTerminal(raw))].map(entry =>
stampCodexTurnId(entry, turnCursor),
)
const marker = codexHistoryMarker(raw)

const eventType = codexEventType(raw)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'

import fixture from '../../../../../testing/fixtures/rendering-shapes/codex/compaction/resumed-head-compacted.json'

import { createCodexTranscriptEntryMapper } from '@providers/codex/renderer/transcript/mapper'
import type { Entry } from '@shared/types/transcript'

// #1393: a resumed rollout that STARTS with `compacted` holds its earlier
// user prompts only in that line's replacement_history. The input is the
// REAL head of such a file (Codex 0.145.0; see the fixture's evidence):
// session_meta, compacted (8 retained user items, one of them Codex's
// AGENTS.md + environment bootstrap, 2 developer items, an encrypted
// compaction item), world_state, turn_context.
type Raw = Record<string, unknown> & { type: string; payload?: Record<string, unknown> & { replacement_history?: Array<{ role?: string }> } }
const records = fixture.records as Raw[]
const compacted = records.find(record => record.type === 'compacted')!
const retainedUserItems = compacted.payload!.replacement_history!.filter(item => item.role === 'user').length

function mapAll(lines: Raw[]): Entry[] {
const mapper = createCodexTranscriptEntryMapper()
return lines.flatMap(line => mapper.map(line).entries)
}
const kinds = (entries: Entry[]) => entries.map(entry => (entry as { subtype?: string }).subtype ?? entry.type)

describe('a resumed rollout that starts with compacted (#1393)', () => {
it('shows the retained user prompts, without the bootstrap, before the boundary', () => {
const entries = mapAll(records)
// 8 retained user items; the AGENTS.md + environment bootstrap is dropped
// by the same filter as on the ordinary response_item path.
expect(retainedUserItems).toBe(8)
expect(kinds(entries)).toEqual([...Array(7).fill('user'), 'compact_boundary'])
// They carry the compacted line's time, so they sit where it happened.
for (const entry of entries) expect(entry.timestamp).toBe(compacted.timestamp)
// Stable identities, distinct from the boundary's.
expect(new Set(entries.map(entry => entry.uuid)).size).toBe(entries.length)
})

it('shows only the boundary when the page does not start at the file head', () => {
// An older-history page or a live burst can begin with a compacted line
// whose predecessors are in the previous page: without a session_meta
// there is no proof the retained prompts are not already in the feed.
const withoutHead = records.filter(record => record.type !== 'session_meta')
expect(kinds(mapAll(withoutHead))).toEqual(['compact_boundary'])
})

it('shows only the boundary for a compaction that follows conversation in the same file', () => {
const prompt: Raw = { type: 'response_item', timestamp: '2026-07-23T16:33:17.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'earlier prompt' }] } }
const [meta, ...rest] = records
expect(kinds(mapAll([meta!, prompt, ...rest]))).toEqual(['user', 'compact_boundary'])
})
})
45 changes: 35 additions & 10 deletions src/providers/codex/renderer/transcript/rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,14 @@ function codexCompactBoundaryEntry(
// already in the feed: 17,326 of 20,343 sampled replacement messages
// duplicated an earlier user message.
//
// KNOWN GAP (review a of #1386, follow-up #1393): it is NOT
// always a duplicate. 82 local rollouts (68 sessions) are resumed files that
// START with a `compacted` line, so its retained user prompts are the only
// copy of that earlier conversation in the file. This line-at-a-time mapper
// cannot tell that case apart (a paged older-history load can also start a
// page with a `compacted` line that has predecessors in the previous page),
// so the fix belongs where the loader knows it is mapping from file offset 0.
// Before #1386 no `compacted` line rendered at all, so this is not a
// regression. WHY the summary is conditional: `message` is empty in every
// 0.15x rollout, where the summary is encrypted; only 56 of about 1,500 local
// EXCEPT at the head of a resumed file (#1393): 82 local rollouts START with
// a `compacted` line, so its retained user prompts are the only copy of that
// earlier conversation in the file. This line-at-a-time function cannot tell
// that case apart (a paged older-history load can start a page with a
// `compacted` line that has predecessors in the previous page), so the
// stateful transcript mapper decides, and calls mapCodexRetainedUserHistory
// below. WHY the summary is conditional: `message` is empty in every 0.15x
// rollout, where the summary is encrypted; only 56 of about 1,500 local
// compactions (older CLIs) carry readable text.
function mapCodexCompacted(
uuid: string,
Expand All @@ -252,6 +250,33 @@ function mapCodexCompacted(
return out
}

/**
* The retained USER prompts of a `compacted` line, as ordinary user entries
* (#1393). Only for a compacted line at the head of a resumed rollout, where
* nothing earlier in the file holds them; createCodexTranscriptEntryMapper
* decides that. Developer items and the encrypted `compaction` item are not
* conversation. Codex's bootstrap (AGENTS.md preamble, environment context)
* and subagent notifications are dropped by the same synthetic filter the
* ordinary `response_item` path uses. Each entry carries the compacted line's
* timestamp and a stable `:retained:<index>` uuid, and they sort before the
* boundary the same line produces.
*/
export function mapCodexRetainedUserHistory(entry: Record<string, unknown>): Entry[] {
if (entry.type !== 'compacted') return []
const payload = asRecord(entry.payload)
const history = Array.isArray(payload?.replacement_history) ? payload.replacement_history : []
const uuid = codexRolloutIdentity(entry)
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : undefined
const out: Entry[] = []
history.forEach((raw, index) => {
const item = asRecord(raw)
if (!item || item.role !== 'user') return
const mapped = codexConversationEntryFromMessageItem(`${uuid}:retained:${index}`, timestamp, item)
if (mapped) out.push(mapped)
})
return out
}

function codexCompactSummaryEntry(
uuid: string,
timestamp: string | undefined,
Expand Down
Loading
Loading