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
+68 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { isExternalUserMessage } from './apiSession'
import { isExternalUserMessage, IncomingMessageFilter } from './apiSession'
describe('isExternalUserMessage', () => {
const baseUserMsg = {
@@ -96,3 +96,70 @@ describe('isExternalUserMessage', () => {
).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)
})
})