Files
hapi/hub/src/notifications/notificationHub.test.ts
T
weishu 37e10a831b feat: rename server package to hub
Rename the `server/` directory to `hub/` and update all references
across CLI, docs, web, and workspace configuration.
2026-01-27 19:51:21 +08:00

157 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine'
import type { NotificationChannel } from './notificationTypes'
import { NotificationHub } from './notificationHub'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
class FakeSyncEngine {
private readonly listeners: Set<SyncEventListener> = new Set()
private readonly sessions: Map<string, Session> = new Map()
subscribe(listener: SyncEventListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId)
}
setSession(session: Session): void {
this.sessions.set(session.id, session)
}
emit(event: SyncEvent): void {
for (const listener of this.listeners) {
listener(event)
}
}
}
class StubChannel implements NotificationChannel {
readonly readySessions: Session[] = []
readonly permissionSessions: Session[] = []
async sendReady(session: Session): Promise<void> {
this.readySessions.push(session)
}
async sendPermissionRequest(session: Session): Promise<void> {
this.permissionSessions.push(session)
}
}
function createSession(overrides: Partial<Session> = {}): Session {
return {
id: 'session-1',
namespace: 'default',
seq: 1,
createdAt: 0,
updatedAt: 0,
active: true,
activeAt: 0,
metadata: null,
metadataVersion: 0,
agentState: null,
agentStateVersion: 0,
thinking: false,
thinkingAt: 0,
...overrides
}
}
describe('NotificationHub', () => {
it('debounces permission notifications and triggers when request IDs change', async () => {
const engine = new FakeSyncEngine()
const channel = new StubChannel()
const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
permissionDebounceMs: 5,
readyCooldownMs: 5
})
const firstSession = createSession({
agentState: {
requests: {
req1: { tool: 'Edit', arguments: {}, createdAt: 1 }
}
}
})
engine.setSession(firstSession)
engine.emit({ type: 'session-updated', sessionId: firstSession.id })
await sleep(25)
expect(channel.permissionSessions).toHaveLength(1)
engine.emit({ type: 'session-updated', sessionId: firstSession.id })
await sleep(25)
expect(channel.permissionSessions).toHaveLength(1)
const secondSession = createSession({
id: firstSession.id,
namespace: firstSession.namespace,
agentState: {
requests: {
req2: { tool: 'Read', arguments: {}, createdAt: 2 }
}
}
})
engine.setSession(secondSession)
engine.emit({ type: 'session-updated', sessionId: secondSession.id })
await sleep(25)
expect(channel.permissionSessions).toHaveLength(2)
hub.stop()
})
it('throttles ready notifications per session', async () => {
const engine = new FakeSyncEngine()
const channel = new StubChannel()
const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
permissionDebounceMs: 1,
readyCooldownMs: 20
})
const session = createSession()
engine.setSession(session)
const readyEvent: SyncEvent = {
type: 'message-received',
sessionId: session.id,
message: {
id: 'message-1',
seq: 1,
localId: null,
createdAt: 0,
content: {
role: 'agent',
content: {
id: 'event-1',
type: 'event',
data: { type: 'ready' }
}
}
}
}
engine.emit(readyEvent)
await sleep(5)
expect(channel.readySessions).toHaveLength(1)
engine.emit(readyEvent)
await sleep(5)
expect(channel.readySessions).toHaveLength(1)
await sleep(30)
engine.emit(readyEvent)
await sleep(5)
expect(channel.readySessions).toHaveLength(2)
hub.stop()
})
})