Files
hapi/hub/src/store/messages.test.ts
T
Junmo KimandGitHub f44c9ff3e6 feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear

* feat(opencode): open a fresh session on clear

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

* fix(clear): settle prompts when clear is cancelled

* fix(clear): make fresh-session handoff durable

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
2026-08-03 18:06:39 +08:00

513 lines
22 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('recordMessagesConsumed', () => {
it('rolls back the invocation transition when the session namespace cannot be verified', () => {
const store = makeStore()
const session = makeSession(store, 'consumed-rollback-wrong-namespace')
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }, 'local-rollback')
const originalUpdatedAt = store.sessions.getSession(session.id)?.updatedAt
expect(() => store.recordMessagesConsumed(session.id, ['local-rollback'], 2_000, 'other-namespace'))
.toThrow('session not found after messages-consumed transition')
expect(store.messages.getLocalMessageStates(session.id, ['local-rollback']))
.toEqual([{ localId: 'local-rollback', invokedAt: null }])
expect(store.sessions.getSession(session.id)?.updatedAt).toBe(originalUpdatedAt)
})
})
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()
})
})
describe('moveUninvokedScheduledMessages', () => {
it('atomically moves only pending scheduled rows to the replacement session', () => {
const store = makeStore()
const source = makeSession(store, 'scheduled-source')
const replacement = makeSession(store, 'scheduled-replacement')
const now = Date.now()
const scheduled = store.messages.addMessage(source.id, { text: 'later' }, 'scheduled-local', now + 60_000)
store.messages.addMessage(source.id, { text: 'ordinary queued' }, 'ordinary-local')
expect(store.messages.moveUninvokedScheduledMessages(source.id, replacement.id)).toBe(1)
expect(store.messages.getAllMessages(source.id).map((message) => message.id)).not.toContain(scheduled.id)
expect(store.messages.getAllMessages(replacement.id)).toEqual([
expect.objectContaining({ id: scheduled.id, localId: 'scheduled-local', scheduledAt: now + 60_000, invokedAt: null })
])
expect(store.messages.getUninvokedLocalMessages(source.id)).toEqual([
expect.objectContaining({ localId: 'ordinary-local', scheduledAt: null })
])
})
})
describe('markUninvokedImmediateMessages', () => {
it('settles immediate queued rows while preserving scheduled rows for clear transfer', () => {
const store = makeStore()
const source = makeSession(store, 'clear-source-immediate')
const invokedAt = Date.now()
store.messages.addMessage(source.id, { text: 'immediate' }, 'immediate-local')
store.messages.addMessage(source.id, { text: 'scheduled' }, 'scheduled-local', invokedAt + 60_000)
expect(store.messages.markUninvokedImmediateMessages(source.id, invokedAt)).toEqual(['immediate-local'])
expect(store.messages.getAllMessages(source.id)).toEqual(expect.arrayContaining([
expect.objectContaining({ localId: 'immediate-local', invokedAt }),
expect.objectContaining({ localId: 'scheduled-local', invokedAt: null })
]))
})
})
describe('content codec integration', () => {
it('stores large agent content compressed and returns it truncated on read', () => {
const store = makeStore()
const session = makeSession(store, 'codec-agent')
const stdout = 'line\n'.repeat(60_000) // ~300KB, above the truncate limit
const content = {
role: 'agent',
content: { type: 'codex', data: { type: 'tool-call-result', callId: 'c1', output: { stdout } } }
}
const added = store.messages.addMessage(session.id, content)
const read = store.messages.getMessages(session.id, 10)
expect(read).toHaveLength(1)
const readContent = read[0]!.content as typeof content
expect(readContent.content.data.output.stdout.length).toBeLessThan(stdout.length)
expect(readContent.content.data.output.stdout).toContain('[hapi: truncated')
expect(readContent.content.data.callId).toBe('c1')
// addMessage's return value matches what a later read sees (SSE broadcast uses it)
expect(added.content).toEqual(read[0]!.content)
})
it('round-trips large queued user prompts verbatim (delivery path must not truncate)', () => {
const store = makeStore()
const session = makeSession(store, 'codec-user')
const text = 'prompt '.repeat(30_000) // ~210KB user paste
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text } },
'lid-big',
Date.now() + 60_000
)
const scheduled = store.messages.getMatureScheduledMessages(Date.now() + 120_000)
expect(scheduled).toHaveLength(1)
const delivered = scheduled[0]!.content as { content: { text: string } }
expect(delivered.content.text).toBe(text)
})
})