mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
fix(codex): defer session persistence until user activity
This commit is contained in:
@@ -1,5 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isExternalUserMessage, IncomingMessageFilter } from './apiSession'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Session } from './types'
|
||||
|
||||
const socketHarness = vi.hoisted(() => ({
|
||||
sockets: [] as Array<{
|
||||
connected: boolean
|
||||
connectCalls: number
|
||||
connectImmediately: boolean
|
||||
emitted: Array<{ event: string; args: unknown[] }>
|
||||
listeners: Map<string, Array<(...args: any[]) => void>>
|
||||
triggerConnect: () => void
|
||||
triggerConnectError: () => void
|
||||
}>
|
||||
}))
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: () => {
|
||||
const state = {
|
||||
connected: false,
|
||||
connectCalls: 0,
|
||||
connectImmediately: true,
|
||||
emitted: [] as Array<{ event: string; args: unknown[] }>,
|
||||
listeners: new Map<string, Array<(...args: any[]) => void>>(),
|
||||
triggerConnect: () => {},
|
||||
triggerConnectError: () => {}
|
||||
}
|
||||
const triggerConnect = () => {
|
||||
state.connected = true
|
||||
for (const listener of state.listeners.get('connect') ?? []) listener()
|
||||
}
|
||||
state.triggerConnect = triggerConnect
|
||||
state.triggerConnectError = () => {
|
||||
for (const listener of state.listeners.get('connect_error') ?? []) {
|
||||
listener(new Error('connect failed'))
|
||||
}
|
||||
}
|
||||
const socket = {
|
||||
get connected() {
|
||||
return state.connected
|
||||
},
|
||||
on: (event: string, listener: (...args: any[]) => void) => {
|
||||
const listeners = state.listeners.get(event) ?? []
|
||||
listeners.push(listener)
|
||||
state.listeners.set(event, listeners)
|
||||
return socket
|
||||
},
|
||||
off: (event: string, listener: (...args: any[]) => void) => {
|
||||
const listeners = state.listeners.get(event) ?? []
|
||||
state.listeners.set(event, listeners.filter((candidate) => candidate !== listener))
|
||||
return socket
|
||||
},
|
||||
emit: (event: string, ...args: unknown[]) => {
|
||||
state.emitted.push({ event, args })
|
||||
return socket
|
||||
},
|
||||
emitWithAck: async () => ({}),
|
||||
timeout: () => ({ emitWithAck: async () => ({}) }),
|
||||
connect: () => {
|
||||
state.connectCalls += 1
|
||||
if (state.connectImmediately) {
|
||||
triggerConnect()
|
||||
}
|
||||
return socket
|
||||
},
|
||||
disconnect: () => {
|
||||
state.connected = false
|
||||
return socket
|
||||
}
|
||||
}
|
||||
Object.assign(socket, { volatile: socket })
|
||||
socketHarness.sockets.push(state)
|
||||
return socket
|
||||
}
|
||||
}))
|
||||
|
||||
import { ApiSessionClient, isExternalUserMessage, IncomingMessageFilter } from './apiSession'
|
||||
|
||||
function createSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
namespace: 'pending',
|
||||
seq: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
active: false,
|
||||
activeAt: 1,
|
||||
metadata: null,
|
||||
metadataVersion: 0,
|
||||
agentState: { controlledByUser: false },
|
||||
agentStateVersion: 0,
|
||||
thinking: false,
|
||||
thinkingAt: 1,
|
||||
todos: [],
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
serviceTier: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('ApiSessionClient lazy materialization', () => {
|
||||
it('does not connect or materialize without a real user message', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const materialize = vi.fn(async () => createSession())
|
||||
const client = new ApiSessionClient('token', createSession(), { materialize })
|
||||
|
||||
client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' }))
|
||||
client.sendSessionEvent({ type: 'ready' })
|
||||
client.keepAlive(false, 'local')
|
||||
await client.flush({ timeoutMs: 100 })
|
||||
|
||||
expect(client.getState()).toBe('pending')
|
||||
expect(materialize).not.toHaveBeenCalled()
|
||||
expect(socketHarness.sockets[0]?.connectCalls).toBe(0)
|
||||
expect(socketHarness.sockets[0]?.emitted).toEqual([])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('materializes on the first user message and replays queued events', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const materialize = vi.fn(async (snapshot) => createSession({
|
||||
namespace: 'default',
|
||||
metadata: snapshot.metadata,
|
||||
metadataVersion: 1,
|
||||
agentState: snapshot.agentState,
|
||||
agentStateVersion: 1
|
||||
}))
|
||||
const client = new ApiSessionClient('token', createSession(), { materialize })
|
||||
client.updateMetadata(() => ({ path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' }))
|
||||
client.sendSessionEvent({ type: 'ready' })
|
||||
|
||||
client.sendUserMessage('hello')
|
||||
expect(await client.materialize()).toBe(true)
|
||||
|
||||
expect(materialize).toHaveBeenCalledWith({
|
||||
metadata: { path: '/tmp/project', host: 'localhost', codexSessionId: 'codex-thread' },
|
||||
agentState: { controlledByUser: false }
|
||||
}, expect.any(AbortSignal))
|
||||
expect(client.getState()).toBe('active')
|
||||
expect(socketHarness.sockets[0]?.connectCalls).toBe(1)
|
||||
expect(socketHarness.sockets[0]?.emitted.map((entry) => entry.event)).toEqual([
|
||||
'message',
|
||||
'message',
|
||||
'session-alive'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('materializes on non-text user activity and preserves following agent events', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const pendingMaterialization = deferred<Session>()
|
||||
const materialize = vi.fn(async () => await pendingMaterialization.promise)
|
||||
const client = new ApiSessionClient('token', createSession(), { materialize })
|
||||
|
||||
client.notifyUserActivity()
|
||||
client.sendAgentMessage({ type: 'message', message: 'image response' })
|
||||
expect(client.getState()).toBe('materializing')
|
||||
|
||||
pendingMaterialization.resolve(createSession({ namespace: 'default' }))
|
||||
expect(await client.materialize()).toBe(true)
|
||||
|
||||
expect(materialize).toHaveBeenCalledTimes(1)
|
||||
const messages = socketHarness.sockets[0]?.emitted.filter((entry) => entry.event === 'message')
|
||||
expect(messages).toHaveLength(1)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves all replayed transcript messages while materialization is in flight', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const pendingMaterialization = deferred<Session>()
|
||||
const client = new ApiSessionClient('token', createSession(), {
|
||||
materialize: async () => await pendingMaterialization.promise
|
||||
})
|
||||
const expectedMessages: string[] = []
|
||||
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
const userMessage = `user-${index}`
|
||||
const agentMessage = `agent-${index}`
|
||||
expectedMessages.push(userMessage, agentMessage)
|
||||
client.sendUserMessage(userMessage)
|
||||
client.sendAgentMessage({ type: 'message', message: agentMessage })
|
||||
}
|
||||
|
||||
pendingMaterialization.resolve(createSession({ namespace: 'default' }))
|
||||
expect(await client.materialize()).toBe(true)
|
||||
|
||||
const emittedMessages = socketHarness.sockets[0]?.emitted
|
||||
.filter((entry) => entry.event === 'message')
|
||||
.map((entry) => {
|
||||
const payload = entry.args[0] as {
|
||||
message: {
|
||||
role: 'user' | 'agent'
|
||||
content: { text?: string; data?: { message?: string } }
|
||||
}
|
||||
}
|
||||
return payload.message.role === 'user'
|
||||
? payload.message.content.text
|
||||
: payload.message.content.data?.message
|
||||
})
|
||||
|
||||
expect(emittedMessages).toEqual(expectedMessages)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('drains in-flight materialization and initial socket delivery before closing', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const pendingMaterialization = deferred<Session>()
|
||||
const client = new ApiSessionClient('token', createSession(), {
|
||||
materialize: async () => await pendingMaterialization.promise
|
||||
})
|
||||
const socket = socketHarness.sockets[0]
|
||||
if (!socket) throw new Error('expected socket')
|
||||
socket.connectImmediately = false
|
||||
|
||||
client.sendUserMessage('persist me')
|
||||
client.sendAgentMessage({ type: 'message', message: 'persist response' })
|
||||
client.sendSessionDeath('completed')
|
||||
|
||||
let flushed = false
|
||||
const flushTask = client.flush({ timeoutMs: 1_000 }).then(() => {
|
||||
flushed = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(flushed).toBe(false)
|
||||
|
||||
pendingMaterialization.resolve(createSession({ namespace: 'default' }))
|
||||
await vi.waitFor(() => expect(socket.connectCalls).toBe(1))
|
||||
expect(flushed).toBe(false)
|
||||
|
||||
socket.triggerConnectError()
|
||||
await Promise.resolve()
|
||||
expect(flushed).toBe(false)
|
||||
|
||||
socket.triggerConnect()
|
||||
await flushTask
|
||||
|
||||
expect(socket.emitted.map((entry) => entry.event)).toEqual([
|
||||
'message',
|
||||
'message',
|
||||
'session-end',
|
||||
'session-alive'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('skips materialization backoff and performs one final attempt during shutdown drain', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const materialize = vi.fn(async () => {
|
||||
if (materialize.mock.calls.length === 1) {
|
||||
throw Object.assign(new Error('hub unavailable'), { isAxiosError: true })
|
||||
}
|
||||
return createSession({ namespace: 'default' })
|
||||
})
|
||||
const client = new ApiSessionClient('token', createSession(), { materialize })
|
||||
|
||||
client.sendUserMessage('hello')
|
||||
await vi.waitFor(() => expect(materialize).toHaveBeenCalledTimes(1))
|
||||
await Promise.resolve()
|
||||
|
||||
await client.flush({ timeoutMs: 500 })
|
||||
|
||||
expect(materialize).toHaveBeenCalledTimes(2)
|
||||
expect(client.getState()).toBe('active')
|
||||
expect(socketHarness.sockets[0]?.emitted.some((entry) => entry.event === 'message')).toBe(true)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('aborts in-flight materialization when closed', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const observedSignals: AbortSignal[] = []
|
||||
const materialize = vi.fn(async (_snapshot, signal: AbortSignal) => {
|
||||
observedSignals.push(signal)
|
||||
return await new Promise<Session>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
})
|
||||
})
|
||||
const client = new ApiSessionClient('token', createSession(), { materialize })
|
||||
|
||||
const task = client.materialize()
|
||||
await Promise.resolve()
|
||||
client.close()
|
||||
|
||||
expect(await task).toBe(false)
|
||||
expect(observedSignals[0]?.aborted).toBe(true)
|
||||
expect(client.getState()).toBe('closed')
|
||||
})
|
||||
|
||||
it('reconnects a disconnected active session during final flush', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
|
||||
const socket = socketHarness.sockets[0]
|
||||
if (!socket) throw new Error('expected socket')
|
||||
socket.connected = false
|
||||
socket.connectImmediately = false
|
||||
client.sendSessionDeath('completed')
|
||||
|
||||
let flushed = false
|
||||
const flushTask = client.flush({ timeoutMs: 500 }).then(() => {
|
||||
flushed = true
|
||||
})
|
||||
await vi.waitFor(() => expect(socket.connectCalls).toBe(2))
|
||||
expect(flushed).toBe(false)
|
||||
|
||||
socket.triggerConnect()
|
||||
await flushTask
|
||||
|
||||
expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true)
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExternalUserMessage', () => {
|
||||
const baseUserMsg = {
|
||||
|
||||
Reference in New Issue
Block a user