feat(codex): allow historical forks while a turn is running (#2185)

* feat(codex): allow historical forks while a turn is running

* fix(web): fork user-only completed turns during generation
This commit is contained in:
Ananovo
2026-10-28 12:15:11 +08:00
committed by GitHub
parent 575e995480
commit 009e6d7799
14 changed files with 519 additions and 13 deletions
+55
View File
@@ -24,6 +24,61 @@ function createClient(overrides?: {
}
describe('CodexConversationHistory', () => {
it.each([
{ name: 'steered message', localId: 'steer', turns: [
{ id: 'a', status: 'completed', items: [{ type: 'userMessage', clientId: 'first' }, { type: 'userMessage', clientId: 'steer' }] }
] },
{ name: 'unknown turn status', localId: 'first', turns: [
{ id: 'a', items: [{ type: 'userMessage', clientId: 'first' }] }
] },
{ name: 'incomplete prefix', localId: 'second', turns: [
{ id: 'a', status: 'inProgress', items: [{ type: 'userMessage', clientId: 'first' }] },
{ id: 'b', status: 'completed', items: [{ type: 'userMessage', clientId: 'second' }] }
] },
{ name: 'duplicate client id', localId: 'first', turns: [
{ id: 'a', status: 'completed', items: [{ type: 'userMessage', clientId: 'first' }] },
{ id: 'b', status: 'completed', items: [{ type: 'userMessage', clientId: 'first' }] }
] }
])('rejects $name before native mutation while busy', async ({ localId, turns }) => {
const fork = vi.fn(async () => ({ thread: { id: 'child' } }))
const history = new CodexConversationHistory(() => createClient({ fork,
read: async () => ({ thread: { id: 'thread-1', turns } }) }) as never)
history.setThreadId('thread-1')
history.setBusy(true)
await expect(history.fork(localId)).rejects.toThrow()
expect(fork).not.toHaveBeenCalled()
})
it('uses an exclusive boundary for the first completed turn while busy', async () => {
const fork = vi.fn(async () => ({ thread: { id: 'child' } }))
const history = new CodexConversationHistory(() => createClient({ fork,
read: async () => ({ thread: { id: 'thread-1', turns: [
{ id: 'first', status: 'completed', items: [{ type: 'userMessage', clientId: 'first' }] }
] } }) }) as never)
history.setThreadId('thread-1')
history.setBusy(true)
await history.fork('first')
expect(fork).toHaveBeenCalledWith({ threadId: 'thread-1', beforeTurnId: 'first' })
})
it('forks completed history while a later native turn is running', async () => {
const fork = vi.fn(async () => ({ thread: { id: 'child' } }))
const history = new CodexConversationHistory(() => createClient({ fork, read: async () => ({ thread: {
id: 'thread-1', turns: [
{ id: 'turn-a', status: 'completed', items: [{ type: 'userMessage', clientId: 'local-a' }] },
{ id: 'turn-b', status: 'completed', items: [{ type: 'userMessage', clientId: 'local-b' }] },
{ id: 'turn-c', status: 'inProgress', items: [{ type: 'userMessage', clientId: 'local-c' }] }
]
} }) }) as never)
history.setThreadId('thread-1')
history.setBusy(true)
await expect(history.fork('local-b')).resolves.toEqual({ nativeSessionId: 'child' })
expect(fork).toHaveBeenCalledWith({ threadId: 'thread-1', lastTurnId: 'turn-a' })
await expect(history.fork()).rejects.toThrow('busy')
await expect(history.rewind('local-b')).rejects.toThrow('busy')
await expect(history.fork('local-c')).rejects.toThrow('completed')
expect(fork).toHaveBeenCalledTimes(1)
})
it('only publishes methods confirmed by the app server', async () => {
const supportsMethod = vi.fn(async (method: string) => method === 'thread/fork')
const history = new CodexConversationHistory(() => ({
+18 -1
View File
@@ -112,7 +112,7 @@ export class CodexConversationHistory {
}
async fork(messageLocalId?: string): Promise<ForkConversationRpcResult> {
if (this.busy) throw new Error('Session is busy')
if (this.busy && !messageLocalId) throw new Error('Session is busy')
const client = this.getClient()
const threadId = this.threadId
if (!client || !threadId) throw new Error('Codex thread is not ready')
@@ -130,6 +130,23 @@ export class CodexConversationHistory {
if (selectedIndex < 0) {
throw new Error('Selected turn not found')
}
const selected = turns[selectedIndex]!
const running = this.busy || turns.some(turn => turn.status === 'inProgress' || turn.status === 'in_progress')
if (selected.status === 'inProgress' || selected.status === 'in_progress'
|| (running && !['completed', 'interrupted', 'failed'].includes(selected.status ?? ''))) {
throw new Error('Historical fork requires a completed turn')
}
// Excluding a native turn must also exclude its first HAPI user
// message. A steered message inside that turn is not a safe cutoff.
if (running && (selected.clientIds[0] !== messageLocalId || !selected.hasCompleteItems
|| selected.userMessageCount !== selected.clientIds.length
|| turns.flatMap(turn => turn.clientIds).filter(id => id === messageLocalId).length !== 1)) {
throw new Error('Cannot cut inside an ambiguous native turn. Select its first message.')
}
if (running && turns.slice(0, selectedIndex).some(turn =>
!['completed', 'interrupted', 'failed'].includes(turn.status ?? ''))) {
throw new Error('Historical fork requires a completed history prefix')
}
// Prefer stable inclusive lastTurnId of the previous turn. The first
// turn has no predecessor, so fall back to experimental beforeTurnId
// (exclusive) for that single boundary.
+43 -2
View File
@@ -53,7 +53,7 @@ afterEach(async () => {
finally { vi.useRealTimers(); }
});
async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end'] }) {
async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end']; create?: RootHost['create'] }) {
const directory = await mkdtemp('/tmp/hapi-shared-root-');
let state: AgentState = { steeringActive: true };
let metadata: Metadata = { path: directory, host: 'test', flavor: 'codex' };
@@ -82,7 +82,7 @@ async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end'] })
const end = opts?.end ?? (async () => { throw new Error('Unexpected root archive'); });
const root = new SharedCodexRoot({ session, workingDirectory: directory } as SessionBootstrapResult, {
directory, generation: 'test', endpoint: 'mock', settingsFor: () => undefined,
create: async () => { throw new Error('Unexpected root creation'); },
create: opts?.create ?? (async () => { throw new Error('Unexpected root creation'); }),
end
} satisfies RootHost);
cleanups.push(async () => { await root.close(false); await rm(directory, { recursive: true, force: true }); });
@@ -119,6 +119,47 @@ async function startCompaction(f: Awaited<ReturnType<typeof fixture>>, id = 'inp
return turn;
}
describe('historical fork during generation', () => {
it('rejects steered and unknown-status boundaries before creating a child', async () => {
const create = vi.fn<RootHost['create']>()
const f = await fixture({ create })
f.native.thread.turns = [
{ id: 'earlier', status: 'completed', items: [
{ id: 'u1', type: 'userMessage', clientId: 'first' },
{ id: 'u2', type: 'userMessage', clientId: 'steer' }
] },
{ id: 'unknown', status: 'unknown', items: [{ id: 'u3', type: 'userMessage', clientId: 'unknown' }] },
{ id: 'running', status: 'inProgress', items: [] }
]
await f.root.refresh()
await f.root.activate()
await expect(f.rpc.get(RPC_METHODS.ForkConversation)!({ messageLocalId: 'steer' })).rejects.toThrow('first message')
await expect(f.rpc.get(RPC_METHODS.ForkConversation)!({ messageLocalId: 'unknown' })).rejects.toThrow('completed')
expect(create).not.toHaveBeenCalled()
})
it('creates a child before a completed turn without interrupting the active source', async () => {
const initialSettings = vi.fn(async () => {})
const create = vi.fn<RootHost['create']>(async () => ({ threadId: 'child',
session: { sessionId: 'child-session' }, initialSettings }) as unknown as SharedCodexRoot)
const f = await fixture({ create })
f.native.thread.turns = [
{ id: 'earlier', status: 'completed', items: [{ id: 'u1', type: 'userMessage', clientId: 'local-earlier' }] },
{ id: 'running', status: 'inProgress', items: [{ id: 'u2', type: 'userMessage', clientId: 'local-running' }] }
]
await f.root.refresh()
await f.root.activate()
const request = vi.spyOn(f.root.client, 'request')
const fork = f.rpc.get(RPC_METHODS.ForkConversation)!
await expect(fork({ messageLocalId: 'local-earlier' })).resolves.toEqual({ nativeSessionId: 'child', sessionId: 'child-session' })
expect(create).toHaveBeenCalledWith('thread/fork', expect.objectContaining({ threadId: 'thread', beforeTurnId: 'earlier' }), f.root)
expect(f.state().steeringActive).toBe(true)
expect(request.mock.calls.some(([method]) => method === 'turn/interrupt')).toBe(false)
await expect(fork({})).rejects.toThrow('busy')
await expect(fork({ messageLocalId: 'local-running' })).rejects.toThrow('completed')
expect(create).toHaveBeenCalledTimes(1)
})
})
describe('compaction delivery confirmation', () => {
it('does not turn uncertainty discovered by a cold snapshot into live compaction evidence', async () => {
const f = await fixture();
+10 -2
View File
@@ -535,10 +535,18 @@ export class SharedCodexRoot {
});
rpc.registerHandler(RPC_METHODS.ForkConversation, async raw => {
const { messageLocalId } = z.object({ messageLocalId: z.string().optional() }).parse(raw);
if (this.currentTurn) throw new Error('Session is busy');
if (this.currentTurn && !messageLocalId) throw new Error('Session is busy');
const beforeTurnId = messageLocalId ? this.projection.turnFor(messageLocalId) : undefined;
if (messageLocalId && !beforeTurnId) throw new Error('No native history point for this message');
if (messageLocalId) await this.assertBoundary(messageLocalId, beforeTurnId!);
if (messageLocalId) {
const thread = await this.assertBoundary(messageLocalId, beforeTurnId!);
const turns = (Array.isArray(thread.turns) ? thread.turns : []).map(record);
const index = turns.findIndex(turn => turn.id === beforeTurnId);
if (index < 0 || turns.slice(0, index + 1).some(turn =>
!['completed', 'interrupted', 'failed'].includes(string(turn.status) ?? ''))) {
throw new Error('Historical fork requires a completed turn and history prefix');
}
}
const child = await this.host.create('thread/fork', { ...this.freshParams(), threadId: this.threadId,
...(beforeTurnId ? { beforeTurnId } : {}) }, this);
await child.initialSettings({ collaborationMode: this.settings.collaborationMode });