mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(codex): resume native chats by conversation ID (#31041)
This commit is contained in:
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user