mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user