Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Codex cyber-block native clone

**Goal:** Remove Cybersecurity Block must write a new Codex rollout that is a sliced copy of the source bytes, not a native-resume reconstruction. The reconstruction is what made session `7ef23509` unusable.

**Bug:** #869. Evidence: source `01a08846-936d-7dd3-a91b-0f933d0d9f29` had 30 `response_item.agent_message` records and array-shaped tool outputs; the fork dropped all of them because `projectNativeResume` drops opaques and re-encodes tool results as JSON strings.

**Fix:** After computing the last-model-step cut on the decoded conversation, copy raw JSONL records with `index < cutLine`, rewrite `session_meta.id`, and append a clean `task_complete` only if the prefix does not already end on one. Do not call `projectNativeResume` on this path.

**Tests:** A committed real-shaped JSONL fixture (redacted from the observed rollout) must keep `agent_message`, `inter_agent_communication_metadata`, and array tool outputs, and must drop the last tool cycle plus `cyber_policy`.

**Out of scope:** Changing rewind/duplicate. They have the same projector loss; that is a separate issue.
123 changes: 123 additions & 0 deletions src/main/providerSwitch/cloneCodexCyberPolicyRollout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
classifyCodexDocument,
decodeCodexConversation,
decodeJsonl,
codexNativeResumeProjector,
} from 'agent-transcript-parser'

import { cloneCodexCyberPolicyRollout } from './cloneCodexCyberPolicyRollout.js'

const FIXTURE = new URL(
'../../../testing/fixtures/codex-cyber-policy-native-clone/source.jsonl',
import.meta.url,
)

function loadFixture(): string {
return readFileSync(FIXTURE, 'utf8')
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function kinds(values: readonly Record<string, unknown>[]): string[] {
return values.map(value => {
const payload = value.payload
const inner = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {}
if (value.type === 'response_item' && inner.type === 'custom_tool_call') {
return `call:${inner.call_id}`
}
if (value.type === 'response_item' && inner.type === 'custom_tool_call_output') {
return `result:${inner.call_id}`
}
if (value.type === 'response_item' && inner.type === 'message') {
return String(inner.role)
}
if (value.type === 'response_item') return `item:${inner.type}`
if (value.type === 'event_msg') return `event:${inner.type}`
return String(value.type)
})
}

describe('cloneCodexCyberPolicyRollout', () => {
it('keeps native multi-agent records and array tool outputs from a real-shaped rollout', () => {
// Fixture is a redacted tail of
// rollout-2026-09-09T15-25-21-01a08846-936d-7dd3-a91b-0f933d0d9f29.jsonl.
// The lossy native-resume projector dropped every agent_message and
// stringified every tool output on the live fork 7ef23509. This test is
// the contract that would have failed that write.
const values = cloneCodexCyberPolicyRollout(loadFixture(), {
targetSessionId: 'clone-session',
now: '2026-09-11T02:52:59.701Z',
})

expect(kinds(values)).toEqual([
'session_meta',
'developer',
'inter_agent_communication_metadata',
'item:agent_message',
'user',
'assistant',
'call:call-kept',
'result:call-kept',
'item:reasoning',
'call:call-prev',
'result:call-prev',
'event:task_complete',
])

const meta = values[0]?.payload as Record<string, unknown>
expect(meta.id).toBe('clone-session')
expect(meta.session_id).toBe('clone-session')
expect(meta.timestamp).toBe('2026-09-11T02:52:59.701Z')
expect(meta.originator).toBe('codex-tui')
expect(meta.cwd).toBe('/project')

const keptOutput = values.find(value => {
const payload = value.payload as Record<string, unknown> | undefined
return payload?.type === 'custom_tool_call_output' && payload.call_id === 'call-kept'
})?.payload as Record<string, unknown>
expect(Array.isArray(keptOutput.output)).toBe(true)

const lastRecord = values.at(-1) as Record<string, unknown>
const last = lastRecord.payload as Record<string, unknown>
expect(last.type).toBe('task_complete')
expect(last.error).toBeUndefined()
expect(last.last_agent_message).toBe('I’m writing the database and CLI contracts now.')
expect(last.turn_id).toBe('clone-session')
expect(lastRecord.ordinal).toBe(11)
expect(JSON.stringify(values)).not.toContain('cyber_policy')
expect(JSON.stringify(values)).not.toContain('call-last')
})

it('documents why this path cannot use projectNativeResume', () => {
const conversation = decodeCodexConversation(
classifyCodexDocument(decodeJsonl(loadFixture())).records,
)
const projected = codexNativeResumeProjector.projectNativeResume(conversation, {
cwd: '/project',
targetSessionId: 'clone-session',
now: '2026-09-11T02:52:59.701Z',
cliVersion: '0.154.0',
modelProvider: 'openai',
model: 'gpt-5',
})
const hasNativeAgentMessage = projected.values.some(value => (
value.type === 'response_item'
&& isRecord(value.payload)
&& value.payload.type === 'agent_message'
))
expect(hasNativeAgentMessage).toBe(false)
expect(JSON.stringify(projected.values)).not.toContain('inter_agent_communication_metadata')
const outputs = projected.values.filter(value => {
const payload = value.payload as Record<string, unknown> | undefined
return payload?.type === 'custom_tool_call_output'
})
expect(outputs.length).toBeGreaterThan(0)
expect(outputs.every(value => typeof (value.payload as Record<string, unknown>).output === 'string')).toBe(true)
})
})
136 changes: 136 additions & 0 deletions src/main/providerSwitch/cloneCodexCyberPolicyRollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
classifyCodexDocument,
decodeCodexConversation,
decodeJsonl,
} from 'agent-transcript-parser'

import { stripLastCodexCyberPolicyStep } from '@main/providerSwitch/codexCyberPolicy.js'

export type CloneCodexCyberPolicyOptions = {
targetSessionId: string
now: string
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function eventPayload(value: Record<string, unknown>): Record<string, unknown> | null {
if (value.type !== 'event_msg' || !isRecord(value.payload)) return null
return value.payload
}

function isCleanTaskComplete(value: Record<string, unknown> | undefined): boolean {
if (!value) return false
const payload = eventPayload(value)
return payload?.type === 'task_complete' && !isRecord(payload.error)
}

function lastAssistantText(values: readonly Record<string, unknown>[]): string {
for (let index = values.length - 1; index >= 0; index -= 1) {
const value = values[index]
if (!value || value.type !== 'response_item' || !isRecord(value.payload)) continue
if (value.payload.type !== 'message' || value.payload.role !== 'assistant') continue
const content = value.payload.content
if (!Array.isArray(content)) continue
const text = content
.filter(isRecord)
.filter(item => item.type === 'output_text' && typeof item.text === 'string')
.map(item => item.text as string)
.join('\n\n')
if (text) return text
}
return ''
}

function rewriteSessionMeta(
values: Record<string, unknown>[],
options: CloneCodexCyberPolicyOptions,
): void {
const meta = values.find(value => value.type === 'session_meta' && isRecord(value.payload))
if (!meta || !isRecord(meta.payload)) {
throw new Error('Codex rollout has no session_meta to clone.')
}
meta.timestamp = options.now
const previousId = meta.payload.id
const previousSessionId = meta.payload.session_id
// Root threads store the same UUID in id and session_id. Codex resume uses
// session_id as the live thread identity (vendor/codex-src session.rs).
// Rewriting only id leaves a subagent-shaped meta (id != session_id) that
// resumes under the SOURCE thread. Child rollouts already differ; leave
// those session_id values alone.
const isRootThread = typeof previousSessionId !== 'string' || previousSessionId === previousId
meta.payload = {
...meta.payload,
id: options.targetSessionId,
timestamp: options.now,
...(isRootThread ? { session_id: options.targetSessionId } : {}),
}
}

function nextOrdinal(values: readonly Record<string, unknown>[]): number | undefined {
for (let index = values.length - 1; index >= 0; index -= 1) {
const ordinal = values[index]?.ordinal
if (typeof ordinal === 'number' && Number.isFinite(ordinal)) return ordinal + 1
}
return undefined
}

function ensureCleanTaskComplete(
values: Record<string, unknown>[],
options: CloneCodexCyberPolicyOptions,
): void {
if (isCleanTaskComplete(values.at(-1))) return
// The source turn ended on a cyber_policy complete. After the last-model-step
// cut the prefix is mid-turn. Codex would otherwise resume by sampling again
// against the same tail and re-hit the classifier. A clean complete is the
// one synthetic record this path is allowed to add.
//
// Paginated rollouts refuse to open for append when the LAST line has no
// ordinal (vendor/codex-src/codex-rs/rollout/src/ordinal.rs). Copy the
// envelope the source already used: last kept ordinal + 1.
const ordinal = nextOrdinal(values)
values.push({
timestamp: options.now,
...(ordinal !== undefined ? { ordinal } : {}),
type: 'event_msg',
payload: {
type: 'task_complete',
turn_id: options.targetSessionId,
last_agent_message: lastAssistantText(values),
},
})
}

export function cloneCodexCyberPolicyRollout(
jsonl: string,
options: CloneCodexCyberPolicyOptions,
): Record<string, unknown>[] {
// WHY this copies native JSONL instead of projectNativeResume:
// the live fork of 01a08846 → 7ef23509 went through the Codex native-resume
// projector. That path drops opaque records, and decode treats
// response_item.agent_message plus inter_agent_communication_metadata as
// opaque. The projector also re-encodes custom_tool_call_output.output from
// Codex's native array into a JSON string. Same-provider recovery only needs
// a tail cut, so the bytes Codex already accepted are the source of truth.
const document = decodeJsonl(jsonl)
const conversation = decodeCodexConversation(classifyCodexDocument(document).records)
const stripped = stripLastCodexCyberPolicyStep(conversation)
const firstDropped = conversation.entries[stripped.entries.length]
if (!firstDropped) {
throw new Error('Nothing to remove from this Codex session.')
}
const cutLine = firstDropped.source.line
const values: Record<string, unknown>[] = []
for (const line of document.lines) {
if (line.index >= cutLine) break
if (line.kind !== 'record' || !isRecord(line.value)) continue
values.push(structuredClone(line.value))
}
if (values.length === 0) {
throw new Error('Nothing remains after removing the cybersecurity block.')
}
rewriteSessionMeta(values, options)
ensureCleanTaskComplete(values, options)
return values
}
Loading