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 void>> trigger: (event: string, ...args: any[]) => void triggerConnect: () => void triggerConnectError: () => void }> })) const axiosHarness = vi.hoisted(() => ({ get: vi.fn() })) vi.mock('socket.io-client', () => ({ io: () => { const state: (typeof socketHarness.sockets)[number] = { connected: false, connectCalls: 0, connectImmediately: true, emitted: [] as Array<{ event: string; args: unknown[] }>, listeners: new Map void>>(), trigger: () => {}, triggerConnect: () => {}, triggerConnectError: () => {} } state.trigger = (event: string, ...args: any[]) => { for (const listener of state.listeners.get(event) ?? []) { listener(...args) } } const triggerConnect = () => { state.connected = true state.trigger('connect') } state.triggerConnect = triggerConnect state.triggerConnectError = () => { state.trigger('connect_error', 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 } })) vi.mock('axios', () => ({ default: { get: axiosHarness.get, isAxiosError: (error: unknown) => ( typeof error === 'object' && error !== null && 'isAxiosError' in error && error.isAxiosError === true ) } })) import { ApiSessionClient, isExternalUserMessage, IncomingMessageFilter } from './apiSession' function createSession(overrides: Partial = {}): 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() { let resolve!: (value: T) => void let reject!: (error: unknown) => void const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve reject = promiseReject }) return { promise, resolve, reject } } function triggerIncomingUserMessage( socket: (typeof socketHarness.sockets)[number], message: { id?: string seq: number text: string sentFrom: 'cli' | 'webapp' | 'telegram-bot' } ): void { socket.trigger('update', { body: { t: 'new-message', message: { id: message.id, seq: message.seq, localId: null, content: { role: 'user', content: { type: 'text', text: message.text }, meta: { sentFrom: message.sentFrom } } } } }) } 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() 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() 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('keeps an error event when the pending droppable queue overflows during materialization', async () => { socketHarness.sockets.length = 0 const pendingMaterialization = deferred() const client = new ApiSessionClient('token', createSession(), { materialize: async () => await pendingMaterialization.promise }) client.notifyUserActivity() client.sendSessionEvent({ type: 'error', message: 'Antigravity quota reached' }) for (let index = 0; index < 300; index += 1) { client.sendSessionEvent({ type: 'ready' }) } pendingMaterialization.resolve(createSession({ namespace: 'default' })) expect(await client.materialize()).toBe(true) const events = socketHarness.sockets[0]?.emitted .filter((entry) => entry.event === 'message') .map((entry) => (entry.args[0] as { message: { content: { data: { type: string; message?: string } } } }).message.content.data) expect(events).toContainEqual({ type: 'error', message: 'Antigravity quota reached' }) client.close() }) it('drains in-flight materialization and initial socket delivery before closing', async () => { socketHarness.sockets.length = 0 const pendingMaterialization = deferred() 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((_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() }) it('reports an unconfirmed final flush when the socket cannot reconnect before the deadline', 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('cleared') await expect(client.flush({ timeoutMs: 20 })).resolves.toBe(false) expect(socket.connectCalls).toBeGreaterThan(0) client.close() }) }) describe('ApiSessionClient agy transcript messages', () => { it('renders only the USER_REQUEST body, not the sections agy appends', () => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) const socket = socketHarness.sockets[0] if (!socket) throw new Error('expected socket') // The shape agy actually writes for every terminal-typed message. client.sendAgySessionMessage({ step_index: 0, source: 'USER_EXPLICIT', type: 'USER_INPUT', status: 'DONE', created_at: '2026-08-04T00:00:00Z', content: [ '', 'hi', '', '', 'The current local time is: 2026-08-04T09:00:00+09:00.', '', '', 'The user changed setting `Model Selection` from None to Gemini 3.6 Flash (Low).', '', ].join('\n'), } as never) const emitted = socket.emitted.find((entry) => entry.event === 'message') expect(emitted).toBeDefined() expect((emitted!.args[0] as any).message.content.text).toBe('hi') client.close() }) }) describe('ApiSessionClient incoming user messages', () => { it.each([true, false])('replays explicitly marked native queue input only for shared sessions (%s)', shared => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', createSession({ metadata: { path: '/tmp', host: 'test', capabilities: { concurrentClients: shared } } })) const socket = socketHarness.sockets[0] const received = vi.fn() client.onUserMessage(received) const message = { id: 'native-queued', seq: 10, localId: 'native-client-id', content: { role: 'user', content: { type: 'text', text: 'queued before exit' }, meta: { sentFrom: 'cli', isNativeQueuedMessage: true } } } socket.trigger('update', { body: { t: 'new-message', message } }) socket.trigger('update', { body: { t: 'new-message', message } }) expect(received).toHaveBeenCalledTimes(shared ? 1 : 0) if (shared) expect(received).toHaveBeenCalledWith(expect.objectContaining({ content: { type: 'text', text: 'queued before exit' } }), 'native-client-id') client.close() }) it('ignores CLI-originated transcript messages while advancing the incoming cursor', () => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) const socket = socketHarness.sockets[0] if (!socket) throw new Error('expected socket') const onUserMessage = vi.fn() client.onUserMessage(onUserMessage) triggerIncomingUserMessage(socket, { id: 'historical-cli-message', seq: 10, text: 'historical prompt from the local transcript', sentFrom: 'cli' }) triggerIncomingUserMessage(socket, { seq: 10, text: 'legacy duplicate at the filtered cursor', sentFrom: 'webapp' }) triggerIncomingUserMessage(socket, { id: 'live-web-message', seq: 11, text: 'new prompt from the phone', sentFrom: 'webapp' }) expect(onUserMessage).toHaveBeenCalledTimes(1) expect(onUserMessage).toHaveBeenCalledWith( expect.objectContaining({ content: expect.objectContaining({ text: 'new prompt from the phone' }) }), undefined ) client.close() }) it('delivers only remote prompts from a mixed reconnect backfill', async () => { socketHarness.sockets.length = 0 axiosHarness.get.mockReset() axiosHarness.get.mockResolvedValue({ data: { messages: [ { id: 'backfilled-cli-message', seq: 2, createdAt: 2, localId: null, content: { role: 'user', content: { type: 'text', text: 'historical local prompt' }, meta: { sentFrom: 'cli' } } }, { id: 'backfilled-web-message', seq: 3, createdAt: 3, localId: null, content: { role: 'user', content: { type: 'text', text: 'remote prompt after reconnect' }, meta: { sentFrom: 'webapp' } } } ] } }) const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) const socket = socketHarness.sockets[0] if (!socket) throw new Error('expected socket') const receivedTexts: string[] = [] client.onUserMessage((message) => { receivedTexts.push(message.content.text) }) triggerIncomingUserMessage(socket, { id: 'initial-web-message', seq: 1, text: 'initial remote prompt', sentFrom: 'webapp' }) socket.connected = false socket.trigger('disconnect', 'transport close') socket.triggerConnect() await vi.waitFor(() => expect(axiosHarness.get).toHaveBeenCalledOnce()) await vi.waitFor(() => expect(receivedTexts).toEqual([ 'initial remote prompt', 'remote prompt after reconnect' ])) expect(axiosHarness.get).toHaveBeenCalledWith( expect.stringContaining('/cli/sessions/'), expect.objectContaining({ params: { afterSeq: 1, limit: 200 } }) ) client.close() }) it.each(['webapp', 'telegram-bot'] as const)( 'delivers %s-originated user messages', (sentFrom) => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', createSession({ namespace: 'default' })) const socket = socketHarness.sockets[0] if (!socket) throw new Error('expected socket') const onUserMessage = vi.fn() client.onUserMessage(onUserMessage) triggerIncomingUserMessage(socket, { id: `${sentFrom}-message`, seq: 1, text: `prompt from ${sentFrom}`, sentFrom }) expect(onUserMessage).toHaveBeenCalledOnce() expect(onUserMessage).toHaveBeenCalledWith( expect.objectContaining({ meta: { sentFrom } }), undefined ) client.close() } ) }) describe('isExternalUserMessage', () => { const baseUserMsg = { type: 'user' as const, uuid: 'test-uuid', userType: 'external' as const, isSidechain: false, message: { role: 'user', content: 'hello' }, } it('returns true for a real user text message', () => { expect(isExternalUserMessage(baseUserMsg)).toBe(true) }) it('returns false when isMeta is true (skill injections)', () => { expect(isExternalUserMessage({ ...baseUserMsg, isMeta: true })).toBe(false) }) it('returns false when isSidechain is true', () => { expect(isExternalUserMessage({ ...baseUserMsg, isSidechain: true })).toBe(false) }) it('returns true when content is an array of text blocks', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: [{ type: 'text', text: 'hello array' }] }, } as never) ).toBe(true) }) it('returns false when content is a non-text array (tool results)', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'y' }] }, } as never) ).toBe(false) }) it('returns false for assistant messages', () => { expect( isExternalUserMessage({ type: 'assistant', uuid: 'test-uuid', message: { role: 'assistant', content: 'hi' }, } as never) ).toBe(false) }) // System-injected content detection it('returns false for messages', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: '\nabc123\n' }, }) ).toBe(false) }) it('returns false for messages', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: '/clear' }, }) ).toBe(false) }) it('returns false for messages', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: 'Caveat: ...' }, }) ).toBe(false) }) it('returns false for messages', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: '\nToday is 2026.\n' }, }) ).toBe(false) }) it('returns true for user text that mentions XML-like strings but is not injected', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: 'How do I use the tag?' }, }) ).toBe(true) }) it('returns false for with leading whitespace', () => { expect( isExternalUserMessage({ ...baseUserMsg, message: { role: 'user', content: ' \n\nx\n' }, }) ).toBe(false) }) }) describe('IncomingMessageFilter (HAPI Bot R3 finding #1)', () => { it('accepts a mature scheduled message whose seq is below the latest cursor', () => { // schedule seq=10, immediate seq=11 acks first → cursor=11. // seq=10 matures: seq-only dedup would drop it; id-based dedup must accept. const filter = new IncomingMessageFilter() expect(filter.accept({ id: 'msg-imm', seq: 11 })).toBe(true) expect(filter.accept({ id: 'msg-sched', seq: 10 })).toBe(true) }) it('rejects an exact id duplicate (re-emit on the next mature tick)', () => { const filter = new IncomingMessageFilter() expect(filter.accept({ id: 'msg-1', seq: 1 })).toBe(true) expect(filter.accept({ id: 'msg-1', seq: 1 })).toBe(false) }) it('falls back to seq-only dedup for messages without an id', () => { const filter = new IncomingMessageFilter() expect(filter.accept({ seq: 5 })).toBe(true) // seq <= cursor and no id → drop (legacy behaviour preserved). expect(filter.accept({ seq: 4 })).toBe(false) expect(filter.accept({ seq: 5 })).toBe(false) }) it('advances cursorSeq monotonically regardless of arrival order', () => { const filter = new IncomingMessageFilter() filter.accept({ id: 'a', seq: 11 }) filter.accept({ id: 'b', seq: 10 }) expect(filter.cursorSeq()).toBe(11) }) it('bounds the seen-id set to the configured capacity (LRU eviction)', () => { const filter = new IncomingMessageFilter(3) filter.accept({ id: 'a', seq: 1 }) filter.accept({ id: 'b', seq: 2 }) filter.accept({ id: 'c', seq: 3 }) filter.accept({ id: 'd', seq: 4 }) // 'a' should have been evicted — re-presenting it is treated as new. expect(filter.accept({ id: 'a', seq: 5 })).toBe(true) // 'd' is still in the set. expect(filter.accept({ id: 'd', seq: 6 })).toBe(false) }) it('refreshes recency on dedup hit so re-emits survive bursts of unrelated ids', () => { // Models the documented contract: the hub re-emits the same id every 5 s // until the CLI acks. If the dedup were FIFO (insert-order only), a // burst of capacity-many unrelated ids between re-emits would evict the // pending id and the next re-emit would double-deliver. const filter = new IncomingMessageFilter(3) // Pre-fill so 'pending' is not at the head. filter.accept({ id: 'a', seq: 1 }) filter.accept({ id: 'pending', seq: 2 }) filter.accept({ id: 'b', seq: 3 }) // Re-emit pending → recency refresh moves it to the tail. expect(filter.accept({ id: 'pending', seq: 4 })).toBe(false) // Burst that evicts oldest entries. Without the refresh 'pending' would // be at insert position 2 and would be evicted; with the refresh it is // now the newest entry and survives. filter.accept({ id: 'c', seq: 5 }) filter.accept({ id: 'd', seq: 6 }) // 'a' (oldest) and then 'b' should have been evicted; 'pending' must // still dedup. expect(filter.accept({ id: 'pending', seq: 7 })).toBe(false) expect(filter.accept({ id: 'a', seq: 8 })).toBe(true) expect(filter.accept({ id: 'b', seq: 9 })).toBe(true) }) })