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
30 changes: 30 additions & 0 deletions src/main/codex/codex-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
Expand Down Expand Up @@ -114,6 +115,35 @@ describe('codex structured launch resolution', () => {
expect(launch.resumeThreadId).toBe('thread-current')
})

it('lets only a thread this session created be superseded when Codex never saved it', async () => {
const link = (
origin: AgentSessionProviderHandleLink['origin'],
mintedAtFence: number
): AgentSessionProviderHandleLink => ({
linkId: `link-${mintedAtFence}`,
handle: { provider: 'codex', threadId: 't' },
origin,
mintedAtFence,
observedAt: 1
})
const chainFor = (origin: 'created' | 'resumed' | 'adopted') =>
origin === 'resumed' ? [link('created', 1), link('resumed', 2)] : [link(origin, 1)]

const created = await resolverFor(record({ providerHandleChain: chainFor('created') }))({
identity: IDENTITY
})
expect(created).toMatchObject({ resumeThreadId: 't', supersedeIfUnsaved: true })
for (const origin of ['resumed', 'adopted'] as const) {
const launch = await resolverFor(record({ providerHandleChain: chainFor(origin) }))({
identity: IDENTITY
})
expect(launch.resumeThreadId).toBe('t')
expect(launch).not.toHaveProperty('supersedeIfUnsaved')
}
const fresh = await resolverFor(record())({ identity: IDENTITY })
expect(fresh).not.toHaveProperty('supersedeIfUnsaved')
})

// Agent Permissions is the only thing derived from the arguments field. app-server owns it on
// the thread RPC rather than through the interactive CLI's process flags.
it('resolves the bypass posture as app-server thread policy', async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/main/codex/codex-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export function createCodexStructuredLaunchResolver(
// An empty chain is a session that has never proved a thread, so it
// starts one; anything else resumes the last link this session proved.
resumeThreadId,
// Only a thread this session created may still be one Codex never saved: a resumed,
// forked or adopted head names a conversation Codex held.
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
...(permissionPolicy ? { permissionPolicy } : {}),
...(resumeThreadId
? {
Expand Down
21 changes: 20 additions & 1 deletion src/main/codex/codex-structured-owner-identity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { codexProcessIdentity } from './codex-structured-owner-identity'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'

const IDENTITY = {
sessionId: 'session-identity',
Expand Down Expand Up @@ -46,3 +46,22 @@ describe('codex process identity', () => {
expect(readStartTime).toHaveBeenCalledTimes(3)
})
})

describe('codex provider handle link', () => {
it('names the unsaved thread a creation superseded, and nothing else can', () => {
expect(
codexProviderHandleLink({
threadId: 'thread-new',
resumed: false,
supersedesThreadId: 'thread-unsaved',
fence: 3,
observedAt: 1
})
).toMatchObject({ origin: 'created', supersedesKey: 'codex:"thread-unsaved"' })
const base = { threadId: 'thread-new', fence: 3, observedAt: 1 }
// @ts-expect-error an adopted conversation was never an unsaved creation
codexProviderHandleLink({ ...base, resumed: false, origin: 'adopted', supersedesThreadId: 't' })
// @ts-expect-error a resume proved the thread it named, so it replaces nothing
codexProviderHandleLink({ ...base, resumed: true, supersedesThreadId: 't' })
})
})
29 changes: 23 additions & 6 deletions src/main/codex/codex-structured-owner-identity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import {
agentSessionProviderHandleKey,
type AgentSessionProviderHandleLink
} from '../../shared/agent-session-provider-handle'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe'

Expand Down Expand Up @@ -46,19 +49,33 @@ export async function codexProcessIdentity(
}
}

export function codexProviderHandleLink(input: {
type CodexProviderHandleLinkInput = {
threadId: string
resumed: boolean
origin?: 'adopted'
fence: number
linkId?: string
observedAt: number
}): AgentSessionProviderHandleLink {
} & (
| { origin?: 'adopted'; resumed: boolean; supersedesThreadId?: never }
/** A new thread started in place of this unsaved one; only a creation can supersede. */
| { origin?: never; resumed: false; supersedesThreadId: string }
)

export function codexProviderHandleLink(
input: CodexProviderHandleLinkInput
): AgentSessionProviderHandleLink {
return {
linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128),
handle: { provider: 'codex', threadId: input.threadId },
origin: input.origin ?? (input.resumed ? 'resumed' : 'created'),
mintedAtFence: input.fence,
observedAt: input.observedAt
observedAt: input.observedAt,
...(input.supersedesThreadId
? {
supersedesKey: agentSessionProviderHandleKey({
provider: 'codex',
threadId: input.supersedesThreadId
})
}
: {})
}
}
4 changes: 3 additions & 1 deletion src/main/codex/codex-structured-session-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ export async function acquireCodexStructuredSession(input: {
process,
link: codexProviderHandleLink({
threadId: opened.threadId,
resumed: launch.resumeThreadId !== null,
...(opened.supersededThreadId
? { resumed: false, supersedesThreadId: opened.supersededThreadId }
: { resumed: launch.resumeThreadId !== null }),
fence: acquireInput.fence,
linkId: deps.mintLinkId?.(),
observedAt: deps.now?.() ?? Date.now()
Expand Down
36 changes: 36 additions & 0 deletions src/main/codex/codex-structured-session-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,42 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' })
})

it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => {
const codex = fakeCodex({
'thread/resume': () => {
throw new CodexAppServerRequestError(
'thread/resume',
-32600,
'codex app-server thread/resume failed: no rollout found for thread id thread-unsaved'
)
}
})
const adapter = adapterFor(codex, {
resumeThreadId: 'thread-unsaved',
supersedeIfUnsaved: true
})

const acquisition = await adapter.acquire({
identity: identityFor('session-1'),
fence: 9,
spawnToken: 'spawn-9'
})

expect(codex.connections[0].calls.map((call) => call.method)).toEqual([
'thread/resume',
'thread/start'
])
expect(acquisition.link).toEqual({
linkId: `codex-9-${THREAD_ID}`,
handle: { provider: 'codex', threadId: THREAD_ID },
origin: 'created',
supersedesKey: 'codex:"thread-unsaved"',
mintedAtFence: 9,
observedAt: 1_700_000_000_500
})
expect(codex.connections[0].closeCount).toBe(0)
})

it('refuses a resume that lands on a different thread and reaps the child', async () => {
const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) })
const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' })
Expand Down
3 changes: 3 additions & 0 deletions src/main/codex/codex-structured-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export type CodexStructuredLaunch = {
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
permissionPolicy?: CodexStructuredPermissionPolicy
env?: Record<string, string>
}
Expand Down
120 changes: 120 additions & 0 deletions src/main/codex/codex-structured-thread-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
CodexAppServerFrameSizeError,
CodexAppServerRequestError,
openCodexAppServerConnection,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy'
Expand Down Expand Up @@ -216,4 +217,123 @@ describe('openCodexThread', () => {
).resolves.toMatchObject({ threadId: 'thread-1' })
expect(request).toHaveBeenCalledTimes(2)
})

describe('a thread Codex never saved', () => {
const noRollout = (threadId: string, code = -32600) =>
new CodexAppServerRequestError(
'thread/resume',
code,
`codex app-server thread/resume failed: no rollout found for thread id ${threadId}`
)
function codexWithoutRollout(error: Error = noRollout('thread-unsaved')) {
return vi.fn(async (method: string, _params?: Record<string, unknown>) => {
if (method === 'thread/resume') {
throw error
}
return { thread: { id: 'thread-new' }, model: 'gpt-live' }
})
}

it('starts a new thread in its place when Codex proves it holds no rollout', async () => {
const request = codexWithoutRollout()

await expect(
openCodexThread(
connectionFor(request),
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
2_000
)
).resolves.toMatchObject({
threadId: 'thread-new',
supersededThreadId: 'thread-unsaved',
model: 'gpt-live'
})
expect(request.mock.calls.map(([method]) => method)).toEqual([
'thread/resume',
'thread/start'
])
expect(request).toHaveBeenLastCalledWith(
'thread/start',
{ cwd: '/workspace' },
{
timeoutMs: 2_000
}
)
})

it('keeps the resume failure for a thread a resume already proved', async () => {
const request = codexWithoutRollout()

await expect(
openCodexThread(
connectionFor(request),
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved' },
2_000
)
).rejects.toThrow('no rollout found for thread id thread-unsaved')
expect(request).toHaveBeenCalledOnce()
})

it('treats no other resume failure as proof that nothing was saved', async () => {
const failures = [
noRollout('thread-other'),
noRollout('thread-unsaved', -32603),
new CodexAppServerRequestError(
'thread/resume',
-32600,
'codex app-server thread/resume failed: thread not found'
),
new Error('codex app-server exited')
]
for (const failure of failures) {
const request = codexWithoutRollout(failure)
await expect(
openCodexThread(
connectionFor(request),
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
2_000
)
).rejects.toBe(failure)
expect(request).toHaveBeenCalledOnce()
}
})

// Every other test here builds the error itself; this one sends Codex's raw JSON-RPC frame
// through the real connection, so a change to Orca's own error wording cannot hide the proof.
it('recognizes the raw frame Codex sends, through the real connection', async () => {
const fakeAppServer = String.raw`
const readline = require('node:readline')
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const message = JSON.parse(line)
if (message.method === 'initialize') return send({ id: message.id, result: {} })
if (message.method === 'thread/resume') {
const threadId = message.params.threadId
return send({
id: message.id,
error: { code: -32600, message: 'no rollout found for thread id ' + threadId }
})
}
if (message.method === 'thread/start') {
return send({ id: message.id, result: { thread: { id: 'thread-new' } } })
}
})
`
const connection = await openCodexAppServerConnection({
command: process.execPath,
args: ['-e', fakeAppServer]
})
try {
await expect(
openCodexThread(
connection,
{ cwd: '/workspace', resumeThreadId: 'thread-unsaved', supersedeIfUnsaved: true },
5_000
)
).resolves.toMatchObject({ threadId: 'thread-new', supersededThreadId: 'thread-unsaved' })
} finally {
await connection.close()
}
})
})
})
Loading
Loading