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
+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 });