feat(hub,web): support scheduling messages for future delivery (#590)

This commit is contained in:
Junmo Kim
2026-05-18 09:09:17 +08:00
committed by GitHub
parent 2e96992dd4
commit b2a30c2e39
33 changed files with 3082 additions and 153 deletions
+115
View File
@@ -172,3 +172,118 @@ describe('cancelQueuedMessage', () => {
expect(messages.some(m => m.id === msg.id)).toBe(true)
})
})
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)
})
})