From 7dfcfbf7356b0a34f12d4c3d2b48d4a2a7dcfdd5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:11:28 -0700 Subject: [PATCH] fix(codex): resume native chats by conversation ID (#31041) --- src/main/codex/codex-minimum-version.test.ts | 6 +- .../codex/codex-pinned-rollout-proof.test.ts | 73 ------------------- src/main/codex/codex-pinned-rollout-proof.ts | 45 ------------ ...codex-structured-launch-resolution.test.ts | 28 +++---- .../codex-structured-launch-resolution.ts | 15 +--- .../codex-structured-session-adapter.test.ts | 6 +- .../codex/codex-structured-session-state.ts | 1 - .../codex-structured-thread-open.test.ts | 5 +- .../codex/codex-structured-thread-open.ts | 4 +- .../codex/codex-structured-visuals.test.ts | 1 - .../codex-workspace-relative-path.test.ts | 3 +- 11 files changed, 20 insertions(+), 167 deletions(-) delete mode 100644 src/main/codex/codex-pinned-rollout-proof.test.ts delete mode 100644 src/main/codex/codex-pinned-rollout-proof.ts diff --git a/src/main/codex/codex-minimum-version.test.ts b/src/main/codex/codex-minimum-version.test.ts index 1f94f6dca47..1ee7c423bed 100644 --- a/src/main/codex/codex-minimum-version.test.ts +++ b/src/main/codex/codex-minimum-version.test.ts @@ -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( diff --git a/src/main/codex/codex-pinned-rollout-proof.test.ts b/src/main/codex/codex-pinned-rollout-proof.test.ts deleted file mode 100644 index d4bc35a03ff..00000000000 --- a/src/main/codex/codex-pinned-rollout-proof.test.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` - } - await expect( - resolvePinnedCodexRolloutProof('/pinned', THREAD, { - listFiles: files, - readSessionMetaId: async () => OTHER_THREAD - }) - ).resolves.toBeNull() - }) -}) diff --git a/src/main/codex/codex-pinned-rollout-proof.ts b/src/main/codex/codex-pinned-rollout-proof.ts deleted file mode 100644 index 248d2892e32..00000000000 --- a/src/main/codex/codex-pinned-rollout-proof.ts +++ /dev/null @@ -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 - readSessionMetaId?: (filePath: string) => Promise -} - -export async function resolvePinnedCodexRolloutProof( - codexHome: string, - threadId: string, - options: CodexPinnedRolloutProofOptions = {} -): Promise { - 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 -} diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index fdffa9284ac..140421dbfc3 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -59,14 +59,12 @@ function record(overrides: Partial = {}): AgentSessionRecord function resolverFor( value: AgentSessionRecord | null, resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, - resolveRollout: () => Promise = 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', diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index d9b10dc4186..949a69be20e 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -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[] /** 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 { @@ -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 } : {}) }, diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 0da9e619b1e..c43919a6a45 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -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') diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 59e04d086bf..a53a835430c 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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 diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 0508271d188..fb2528115cc 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -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 } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 97189a2cc48..3c68e6642ee 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -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) diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index 41da545c3a4..d39ab6094f5 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -193,7 +193,6 @@ async function acquired( resolveEnvironment: async () => launch.env ?? {}, resolveCommand: () => 'codex', resolveLaunchArgs: () => [], - resolveRollout: async () => null, prepareVisuals: async () => launch.visuals ?? null }) }) diff --git a/src/main/preflight/codex-workspace-relative-path.test.ts b/src/main/preflight/codex-workspace-relative-path.test.ts index d162c4c3b29..118a5d68314 100644 --- a/src/main/preflight/codex-workspace-relative-path.test.ts +++ b/src/main/preflight/codex-workspace-relative-path.test.ts @@ -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]) {