fix(codex): resume native chats by conversation ID (#31041)

This commit is contained in:
Brennan Benson
2026-10-20 23:11:28 -07:00
committed by GitHub
parent 786a40ef1f
commit 7dfcfbf735
11 changed files with 20 additions and 167 deletions
+2 -4
View File
@@ -130,8 +130,7 @@ describe('Codex structured launch version admission', () => {
resolveCommand: () => '/host/bin/codex',
resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }),
resolveLaunchArgs: () => [],
resolveWorkspacePath: async () => '/folder',
resolveRollout: async () => null
resolveWorkspacePath: async () => '/folder'
})
)
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0'))
@@ -182,8 +181,7 @@ describe('Codex structured launch version admission', () => {
resolveCommand: () => '/host/project-aware-codex',
resolveEnvironment: async () => ({ PATH: '/host/bin' }),
resolveLaunchArgs: () => [],
resolveWorkspacePath: async () => '/workspace',
resolveRollout: async () => null
resolveWorkspacePath: async () => '/workspace'
})
)
expect(
@@ -1,73 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70'
const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71'
describe('Codex pinned rollout proof', () => {
it('resolves only the exact session_meta rollout under the pinned account home', async () => {
const files = async function* (): AsyncGenerator<string> {
yield '/pinned/sessions/scratch/rollout-wrong.jsonl'
yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
}
const readSessionMetaId = vi.fn(async () => THREAD)
await expect(
resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId })
).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`)
expect(readSessionMetaId).toHaveBeenCalledTimes(1)
})
it('accepts Codex rollout ids with a distinct rollout suffix', async () => {
const files = async function* (): AsyncGenerator<string> {
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}_019fd900-77aa-7c19-8bd0-2b3c4d5e6f71.jsonl`
}
const readSessionMetaId = vi.fn(async () => THREAD)
await expect(
resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId })
).resolves.toContain(`rollout-now-${THREAD}_`)
})
it('skips a rollout file that vanishes mid-scan instead of aborting the proof', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-rollout-proof-'))
try {
const day = join(root, 'sessions', '2026', '08', '11')
await mkdir(day, { recursive: true })
const real = join(day, `rollout-now-${THREAD}.jsonl`)
await writeFile(
real,
`${JSON.stringify({ type: 'session_meta', payload: { id: THREAD } })}\n`
)
// Listed but already deleted by the time the scan reads it — Codex prunes
// and rewrites rollout files while the scan runs.
const vanished = join(day, `rollout-gone-${THREAD}.jsonl`)
const files = async function* (): AsyncGenerator<string> {
yield vanished
yield real
}
await expect(
resolvePinnedCodexRolloutProof(root, THREAD, { listFiles: files })
).resolves.toBe(real)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects a rollout whose session_meta names another thread', async () => {
const files = async function* (): AsyncGenerator<string> {
yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`
}
await expect(
resolvePinnedCodexRolloutProof('/pinned', THREAD, {
listFiles: files,
readSessionMetaId: async () => OTHER_THREAD
})
).resolves.toBeNull()
})
})
@@ -1,45 +0,0 @@
import { join } from 'node:path'
import { relativePathInsideRoot } from '../../shared/cross-platform-path'
import { readCodexRolloutSessionMetaId } from './codex-rollout-session-meta'
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
export type CodexPinnedRolloutProofOptions = {
listFiles?: (sessionsRoot: string) => AsyncIterable<string>
readSessionMetaId?: (filePath: string) => Promise<string | null>
}
export async function resolvePinnedCodexRolloutProof(
codexHome: string,
threadId: string,
options: CodexPinnedRolloutProofOptions = {}
): Promise<string | null> {
const sessionsRoot = join(codexHome, 'sessions')
const listFiles =
options.listFiles ??
((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 }))
const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId
const expectedThreadSegment = `-${threadId.toLowerCase()}`
for await (const filePath of listFiles(sessionsRoot)) {
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/')
if (
!relativePath ||
!/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) ||
!(() => {
const lower = relativePath.toLowerCase()
const marker = lower.lastIndexOf(expectedThreadSegment)
if (marker === -1) {
return false
}
const after = lower.slice(marker + expectedThreadSegment.length)
return after === '.jsonl' || (after.startsWith('_') && after.endsWith('.jsonl'))
})()
) {
continue
}
if ((await readSessionMetaId(filePath)) === threadId) {
return filePath
}
}
return null
}
@@ -59,14 +59,12 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
function resolverFor(
value: AgentSessionRecord | null,
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
resolveRollout: () => Promise<string | null> = async () => null,
resolveLaunchArgs?: () => string[]
) {
return createCodexStructuredLaunchResolver({
store: { getRecord: () => value, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout,
resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? [])
})
}
@@ -93,7 +91,6 @@ describe('codex structured launch resolution', () => {
: []
}),
undefined,
undefined,
() => args
)
await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/)
@@ -108,19 +105,15 @@ describe('codex structured launch resolution', () => {
}
)
it('starts a new thread after clear without resolving the old rollout', async () => {
const resolveRollout = vi.fn(async () => '/old/rollout.jsonl')
it('starts a new thread after clear without reusing the old handle', async () => {
const value = record({
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
providerHandleChain: []
})
const launch = await resolverFor(
value,
undefined,
resolveRollout
)({ identity: { ...IDENTITY, providerHandle: codexProviderHandle('old-thread') } })
const launch = await resolverFor(value)({
identity: { ...IDENTITY, providerHandle: codexProviderHandle('old-thread') }
})
expect(launch.resumeThreadId).toBeNull()
expect(resolveRollout).not.toHaveBeenCalled()
})
it('resumes a floating session in its pinned folder, not the current floating setting', async () => {
@@ -283,7 +276,7 @@ describe('codex structured launch resolution', () => {
// The chat's mode replaces Agent Permissions: a Yolo terminal setting grants a chat nothing.
it('ignores the bypass flag the agent Arguments carry', async () => {
const launch = await resolverFor(record(), undefined, undefined, () => [
const launch = await resolverFor(record(), undefined, () => [
'--dangerously-bypass-approvals-and-sandbox'
])({ identity: IDENTITY, spawnToken: TEST_SPAWN_TOKEN, permissionMode: 'ask' })
@@ -323,8 +316,7 @@ describe('codex structured launch resolution', () => {
])
})
it('pins resume to the rollout file that proved the durable thread', async () => {
const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl')
it('resumes the durable thread ID under its pinned account home', async () => {
const launch = await resolverFor(
record({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only each link's handle, so the link's other fields stay unset.
@@ -333,12 +325,12 @@ describe('codex structured launch resolution', () => {
{ handle: codexProviderHandle('thread-current') }
] as AgentSessionRecord['providerHandleChain']
}),
async (id) => `/repos/${id}`,
resolveRollout
async (id) => `/repos/${id}`
)({ identity: IDENTITY })
expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current')
expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl')
expect(launch.resumeThreadId).toBe('thread-current')
expect(launch.codexHome).toBe('/home/work/.codex')
expect(launch.env?.CODEX_HOME).toBe('/home/work/.codex')
expect(launch.args).toEqual([
'--enable',
'unified_exec',
@@ -13,7 +13,6 @@ import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structu
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import {
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV,
@@ -25,7 +24,6 @@ export type CodexStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | string[]
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveRollout?: typeof resolvePinnedCodexRolloutProof
}
export type CodexStructuredInvocationDeps = Pick<
@@ -75,7 +73,7 @@ async function readCodexStructuredInvocation(
return { command, environment }
}
/** Codex's part of a launch: its binary, thread arguments, account home, resume proof and visuals. */
/** Codex's part of a launch: its binary, thread arguments, account home and visuals. */
export function codexStructuredLaunchPart(
deps: CodexStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<CodexStructuredLaunch> {
@@ -96,7 +94,6 @@ export function codexStructuredLaunchPart(
// The same saved options every turn sends, so the thread and its turns name one model.
const model = record.options?.model
const visuals = await basis.visuals()
// Before the rollout scan, so a missing floating folder is still the refusal that wins.
await basis.launchDirectory()
return {
launch: {
@@ -112,15 +109,7 @@ export function codexStructuredLaunchPart(
// States every posture outright: a resume inherits the last one for any field left absent.
permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask'),
...(model ? { model } : {}),
...(visuals ? { visuals } : {}),
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
...(visuals ? { visuals } : {})
},
env: withNativeChatVisualsEnv(
{ ...environment, ...(accountHome.path ? { CODEX_HOME: accountHome.path } : {}) },
@@ -164,8 +164,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
it('resumes the thread the durable handle chain names, not the client one', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex, {
resumeThreadId: 'thread-proven',
resumePath: '/rollouts/thread-proven.jsonl'
resumeThreadId: 'thread-proven'
})
await acquireReadyCodexForTest(adapter, {
@@ -178,8 +177,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
method: 'thread/resume',
params: expect.objectContaining({
threadId: 'thread-proven',
cwd: '/work/repo',
path: '/rollouts/thread-proven.jsonl'
cwd: '/work/repo'
})
})
expect(codexStartedLink(adapter)?.origin).toBe('resumed')
@@ -41,7 +41,6 @@ export type CodexStructuredLaunch = {
cwd: string
codexHome: string | null
resumeThreadId: string | null
resumePath?: string | null
/** The resumed thread is this session's own creation: when Codex answers that it holds no
* rollout for it, start a new thread in its place. Never set for a thread a resume proved. */
supersedeIfUnsaved?: boolean
@@ -106,7 +106,7 @@ describe('openCodexThread', () => {
})
})
it('requests metadata-only state when resuming an existing thread', async () => {
it('resumes by ID without pinning a rollout path and requests metadata-only state', async () => {
const request = vi.fn(async () => ({
thread: { id: 'thread-1', path: '/history/thread-1.jsonl' },
model: 'gpt-live'
@@ -115,7 +115,7 @@ describe('openCodexThread', () => {
await expect(
openCodexThread(
connectionFor(request),
{ cwd: '/workspace', resumeThreadId: 'thread-1', resumePath: '/history/thread-1.jsonl' },
{ cwd: '/workspace', resumeThreadId: 'thread-1' },
2_000
)
).resolves.toMatchObject({ threadId: 'thread-1', model: 'gpt-live' })
@@ -125,7 +125,6 @@ describe('openCodexThread', () => {
{
threadId: 'thread-1',
cwd: '/workspace',
path: '/history/thread-1.jsonl',
excludeTurns: true
},
{ timeoutMs: 2_000 }
@@ -64,7 +64,6 @@ export async function openCodexThread(
launch: {
cwd: string
resumeThreadId: string | null
resumePath?: string | null
supersedeIfUnsaved?: boolean
permissionPolicy?: CodexStructuredPermissionPolicy
/** Why: Codex renders a thread's base instructions for its opening model; a first turn on
@@ -102,8 +101,7 @@ export async function openCodexThread(
// No model: naming one makes Codex skip the thread's saved model, provider and effort.
const resumeParams = {
threadId: resumeThreadId,
...threadSettings,
...(launch.resumePath ? { path: launch.resumePath } : {})
...threadSettings
}
try {
throwIfSignalAborted(signal)
@@ -193,7 +193,6 @@ async function acquired(
resolveEnvironment: async () => launch.env ?? {},
resolveCommand: () => 'codex',
resolveLaunchArgs: () => [],
resolveRollout: async () => null,
prepareVisuals: async () => launch.visuals ?? null
})
})
@@ -75,8 +75,7 @@ describe.skipIf(process.platform === 'win32')('workspace-relative Codex selectio
resolveCommand: sources.resolveCommand,
resolveEnvironment: () => environment.resolveAgentEnvironment('codex'),
resolveLaunchArgs: () => [],
resolveWorkspacePath: async () => cwd,
resolveRollout: async () => null
resolveWorkspacePath: async () => cwd
})
)
for (const resume of [false, true]) {