Files
hapi/hub/src/store/messages.test.ts
T

422 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'bun:test'
import { Store } from './index'
function makeStore(): Store {
return new Store(':memory:')
}
function makeSession(store: Store, tag: string) {
return store.sessions.getOrCreateSession(tag, { path: `/tmp/${tag}` }, null, 'default')
}
describe('cancelQueuedMessage', () => {
it('happy path: deletes queued message, returns status=cancelled with localId', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-happy')
const msg = store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'lid-1')
const result = store.messages.cancelQueuedMessage(session.id, msg.id)
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.localId).toBe('lid-1')
}
// Row should be gone from uninvoked list
const remaining = store.messages.getUninvokedLocalMessages(session.id)
expect(remaining).toHaveLength(0)
expect(store.messages.getMessageEpoch(session.id)).toBe(1)
})
it('already-invoked: returns status=invoked with full message row, row stays in DB', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-already-invoked')
const content = { role: 'user', content: { type: 'text', text: 'hello' } }
const msg = store.messages.addMessage(session.id, content, 'lid-2')
const invokedAt = Date.now()
// Simulate CLI invoke ack
store.messages.markMessagesInvoked(session.id, ['lid-2'], invokedAt)
const result = store.messages.cancelQueuedMessage(session.id, msg.id)
expect(result.status).toBe('invoked')
// Must include the invoked row so the web client can restore authoritative state
if (result.status === 'invoked') {
expect(result.message.id).toBe(msg.id)
expect(result.message.localId).toBe('lid-2')
expect(result.message.invokedAt).toBe(invokedAt)
}
// Row still exists (with invoked_at set)
const messages = store.messages.getMessages(session.id)
expect(messages.some(m => m.id === msg.id)).toBe(true)
})
it('cancel × 2 idempotent: second call returns status=cancelled with localId=null (row gone)', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-idempotent')
const msg = store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'lid-3')
const first = store.messages.cancelQueuedMessage(session.id, msg.id)
expect(first.status).toBe('cancelled')
if (first.status === 'cancelled') {
expect(first.localId).toBe('lid-3')
}
const second = store.messages.cancelQueuedMessage(session.id, msg.id)
expect(second.status).toBe('cancelled')
if (second.status === 'cancelled') {
expect(second.localId).toBeNull()
}
expect(store.messages.getMessageEpoch(session.id)).toBe(1)
})
it('non-existent messageId: returns status=cancelled with localId=null', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-nonexistent')
const result = store.messages.cancelQueuedMessage(session.id, 'nonexistent-id')
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.localId).toBeNull()
}
})
it('wrong sessionId: returns status=cancelled with localId=null, message from other session untouched', () => {
const store = makeStore()
const sessionA = makeSession(store, 'cancel-session-a')
const sessionB = makeSession(store, 'cancel-session-b')
const msg = store.messages.addMessage(sessionA.id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'lid-A')
const result = store.messages.cancelQueuedMessage(sessionB.id, msg.id)
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.localId).toBeNull()
}
// Original message still exists
const remaining = store.messages.getUninvokedLocalMessages(sessionA.id)
expect(remaining).toHaveLength(1)
})
it('cancelled localId is propagated from the deleted row', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-localid-propagate')
const msg = store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'lid-propagate')
const result = store.messages.cancelQueuedMessage(session.id, msg.id)
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.localId).toBe('lid-propagate')
}
})
it('cancel by localId before server echo: localId match returns status=cancelled with localId', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-by-localid')
// Simulate the optimistic row: server has stored it with local_id but web client
// still holds msg.id === localId (server echo not yet received).
const localId = 'local:pre-echo-id'
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, localId)
// The web client passes localId as messageId (before server echo replaces it)
const result = store.messages.cancelQueuedMessage(session.id, localId)
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.localId).toBe(localId)
}
// Row should be gone
const remaining = store.messages.getUninvokedLocalMessages(session.id)
expect(remaining).toHaveLength(0)
})
it('cancel by localId × 2 idempotent: second call returns status=cancelled with localId=null', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-by-localid-idempotent')
const localId = 'local:idem-id'
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, localId)
const first = store.messages.cancelQueuedMessage(session.id, localId)
expect(first.status).toBe('cancelled')
if (first.status === 'cancelled') {
expect(first.localId).toBe(localId)
}
// Second cancel by the same localId — row is already gone
const second = store.messages.cancelQueuedMessage(session.id, localId)
expect(second.status).toBe('cancelled')
if (second.status === 'cancelled') {
expect(second.localId).toBeNull()
}
})
it('cancel by localId when invoked: returns status=invoked with message row', () => {
const store = makeStore()
const session = makeSession(store, 'cancel-by-localid-invoked')
const localId = 'local:invoked-id'
const msg = store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, localId)
const invokedAt = Date.now()
store.messages.markMessagesInvoked(session.id, [localId], invokedAt)
// Web client passes localId as messageId — should detect invoked_at IS NOT NULL
const result = store.messages.cancelQueuedMessage(session.id, localId)
expect(result.status).toBe('invoked')
if (result.status === 'invoked') {
expect(result.message.id).toBe(msg.id)
expect(result.message.localId).toBe(localId)
expect(result.message.invokedAt).toBe(invokedAt)
}
// Row still exists
const messages = store.messages.getMessages(session.id)
expect(messages.some(m => m.id === msg.id)).toBe(true)
})
})
describe('position pagination and structural epochs', () => {
it('returns rows strictly after a cursor and respects an inclusive snapshot head', () => {
const store = makeStore()
const session = makeSession(store, 'position-after')
const first = store.messages.addMessage(session.id, { text: 'first' })
const second = store.messages.addMessage(session.id, { text: 'second' })
const third = store.messages.addMessage(session.id, { text: 'third' })
store.messages.addMessage(session.id, { text: 'fourth' })
const rows = store.messages.getMessagesAfterPosition(
session.id,
10,
{ at: first.invokedAt ?? first.createdAt, seq: first.seq },
{ at: third.invokedAt ?? third.createdAt, seq: third.seq }
)
expect(rows.map((message) => message.id)).toEqual([second.id, third.id])
})
it('reports the newest composite position', () => {
const store = makeStore()
const session = makeSession(store, 'position-head')
const first = store.messages.addMessage(session.id, { text: 'first' })
const second = store.messages.addMessage(session.id, { text: 'second' })
expect(store.messages.getNewestMessagePosition(session.id)).toEqual({
at: second.invokedAt ?? second.createdAt,
seq: second.seq
})
expect(first.seq).toBeLessThan(second.seq)
})
it('bumps both epochs when session history is merged', () => {
const store = makeStore()
const source = makeSession(store, 'epoch-merge-source')
const target = makeSession(store, 'epoch-merge-target')
store.messages.addMessage(source.id, { text: 'source' })
store.messages.addMessage(target.id, { text: 'target' })
const result = store.messages.mergeSessionMessages(source.id, target.id)
expect(result.moved).toBe(1)
expect(store.messages.getMessageEpoch(source.id)).toBe(1)
expect(store.messages.getMessageEpoch(target.id)).toBe(1)
})
it('bumps the target epoch when a copied message lands behind the cached head', () => {
const store = makeStore()
const target = makeSession(store, 'epoch-copy-target')
const head = store.messages.addMessage(target.id, { text: 'head' })
const headPosition = {
at: head.invokedAt ?? head.createdAt,
seq: head.seq
}
const copied = store.messages.copyMessageToSession(target.id, {
content: { text: 'historical' },
createdAt: headPosition.at - 1_000,
localId: null,
invokedAt: headPosition.at - 1_000,
scheduledAt: null
})
expect(copied.seq).toBeGreaterThan(head.seq)
expect(store.messages.getMessagesAfterPosition(target.id, 10, headPosition)).toEqual([])
expect(store.messages.getMessageEpoch(target.id)).toBe(1)
})
})
describe('addMessage: scheduledAt invariants', () => {
it('rejects scheduledAt without a localId — would silently invoke immediately', () => {
const store = makeStore()
const session = makeSession(store, 'sched-invariant')
const future = Date.now() + 60_000
expect(() =>
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'orphan scheduled' } },
undefined,
future
)
).toThrow(/scheduledAt requires a localId/)
})
it('accepts scheduledAt when paired with a localId and keeps invoked_at NULL', () => {
const store = makeStore()
const session = makeSession(store, 'sched-ok')
const future = Date.now() + 60_000
const msg = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'queued for later' } },
'lid-sched',
future
)
expect(msg.scheduledAt).toBe(future)
expect(msg.invokedAt).toBeNull()
})
})
describe('getDeliverableMessagesAfter: CLI backfill excludes future-scheduled rows', () => {
it('omits rows whose scheduled_at > now (would otherwise be replayed early on reconnect)', () => {
const store = makeStore()
const session = makeSession(store, 'backfill-future-sched')
const now = Date.now()
const future = now + 60_000
const past = now - 60_000
const immediate = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'immediate' } },
'lid-immediate'
)
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'future-scheduled' } },
'lid-future',
future
)
const matureSched = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'mature-scheduled' } },
'lid-mature',
past
)
const delivered = store.messages.getDeliverableMessagesAfter(session.id, 0, now)
const ids = delivered.map((m) => m.id)
expect(ids).toContain(immediate.id)
expect(ids).toContain(matureSched.id)
expect(ids).not.toContain('lid-future')
const localIds = delivered.map((m) => m.localId)
expect(localIds).not.toContain('lid-future')
})
it('returns the row once now advances past scheduled_at (release boundary)', () => {
const store = makeStore()
const session = makeSession(store, 'backfill-release-boundary')
const fireAt = Date.now() - 60_000
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'boundary' } },
'lid-bnd',
fireAt
)
const before = store.messages.getDeliverableMessagesAfter(session.id, 0, fireAt - 1)
expect(before.find((m) => m.localId === 'lid-bnd')).toBeUndefined()
const exact = store.messages.getDeliverableMessagesAfter(session.id, 0, fireAt)
expect(exact.find((m) => m.localId === 'lid-bnd')).toBeDefined()
})
it('respects afterSeq alongside the scheduled_at filter (2-axis interaction)', () => {
// Verifies the seq cursor and the scheduled-at filter compose correctly:
// a row that satisfies one axis but fails the other must be excluded.
const store = makeStore()
const session = makeSession(store, 'backfill-2axis')
const now = Date.now()
const m1 = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'first' } },
'lid-1'
)
const m2 = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'second' } },
'lid-2'
)
// afterSeq = m1.seq → only m2 should be returned.
const onlyM2 = store.messages.getDeliverableMessagesAfter(session.id, m1.seq, now)
expect(onlyM2.map((m) => m.id)).toEqual([m2.id])
// afterSeq = m2.seq → nothing (cursor at the end).
const empty = store.messages.getDeliverableMessagesAfter(session.id, m2.seq, now)
expect(empty).toHaveLength(0)
})
})
describe('countFutureScheduledLocalMessages', () => {
it('counts only future scheduled uninvoked local messages', () => {
const store = makeStore()
const session = makeSession(store, 'sched-count')
const now = Date.now()
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'immediate queued' } },
'local-immediate'
)
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'future scheduled' } },
'local-future',
now + 60_000
)
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'mature scheduled' } },
'local-mature',
now - 1
)
expect(store.messages.countFutureScheduledLocalMessages(session.id, now)).toBe(1)
})
it('batch query returns counts keyed by session id', () => {
const store = makeStore()
const sessionA = makeSession(store, 'sched-batch-a')
const sessionB = makeSession(store, 'sched-batch-b')
const now = Date.now()
store.messages.addMessage(
sessionA.id,
{ role: 'user', content: { type: 'text', text: 'a1' } },
'a-1',
now + 60_000
)
store.messages.addMessage(
sessionA.id,
{ role: 'user', content: { type: 'text', text: 'a2' } },
'a-2',
now + 120_000
)
store.messages.addMessage(
sessionB.id,
{ role: 'user', content: { type: 'text', text: 'immediate' } },
'b-1'
)
const counts = store.messages.countFutureScheduledBySessionIds([sessionA.id, sessionB.id], now)
expect(counts.get(sessionA.id)).toBe(2)
expect(counts.get(sessionB.id)).toBeUndefined()
const nextAt = store.messages.minFutureScheduledAtBySessionIds([sessionA.id, sessionB.id], now)
expect(nextAt.get(sessionA.id)).toBe(now + 60_000)
expect(nextAt.get(sessionB.id)).toBeUndefined()
})
})