mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
769 lines
29 KiB
TypeScript
769 lines
29 KiB
TypeScript
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>>
|
|
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<string, Array<(...args: any[]) => 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> = {}): 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 }
|
|
}
|
|
|
|
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<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('keeps an error event when the pending droppable queue overflows during materialization', async () => {
|
|
socketHarness.sockets.length = 0
|
|
const pendingMaterialization = deferred<Session>()
|
|
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<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()
|
|
})
|
|
|
|
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: [
|
|
'<USER_REQUEST>',
|
|
'hi',
|
|
'</USER_REQUEST>',
|
|
'<ADDITIONAL_METADATA>',
|
|
'The current local time is: 2026-08-04T09:00:00+09:00.',
|
|
'</ADDITIONAL_METADATA>',
|
|
'<USER_SETTINGS_CHANGE>',
|
|
'The user changed setting `Model Selection` from None to Gemini 3.6 Flash (Low).',
|
|
'</USER_SETTINGS_CHANGE>',
|
|
].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 <task-notification> messages', () => {
|
|
expect(
|
|
isExternalUserMessage({
|
|
...baseUserMsg,
|
|
message: { role: 'user', content: '<task-notification>\n<task-id>abc123</task-id>\n</task-notification>' },
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns false for <command-name> messages', () => {
|
|
expect(
|
|
isExternalUserMessage({
|
|
...baseUserMsg,
|
|
message: { role: 'user', content: '<command-name>/clear</command-name>' },
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns false for <local-command-caveat> messages', () => {
|
|
expect(
|
|
isExternalUserMessage({
|
|
...baseUserMsg,
|
|
message: { role: 'user', content: '<local-command-caveat>Caveat: ...</local-command-caveat>' },
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('returns false for <system-reminder> messages', () => {
|
|
expect(
|
|
isExternalUserMessage({
|
|
...baseUserMsg,
|
|
message: { role: 'user', content: '<system-reminder>\nToday is 2026.\n</system-reminder>' },
|
|
})
|
|
).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 <task-notification> tag?' },
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('returns false for <task-notification> with leading whitespace', () => {
|
|
expect(
|
|
isExternalUserMessage({
|
|
...baseUserMsg,
|
|
message: { role: 'user', content: ' \n<task-notification>\n<task-id>x</task-id>\n</task-notification>' },
|
|
})
|
|
).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)
|
|
})
|
|
})
|