mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
422 lines
17 KiB
TypeScript
422 lines
17 KiB
TypeScript
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()
|
||
})
|
||
})
|