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:
@@ -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(() => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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