feat(web,hub): cancel queued messages (#568)

This commit is contained in:
Junmo Kim
2026-05-06 13:32:45 +08:00
committed by GitHub
parent de69027926
commit 8185f0287e
30 changed files with 1365 additions and 15 deletions
+174
View File
@@ -0,0 +1,174 @@
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)
})
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()
}
})
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)
})
})