mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(hub,web): support scheduling messages for future delivery (#590)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+64
-10
@@ -71,6 +71,64 @@ export function isExternalUserMessage(body: RawJSONLines): body is Extract<RawJS
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup filter for messages arriving on the realtime socket and via reconnect
|
||||
* backfill. Keyed by message id (with a bounded LRU) and falls back to the
|
||||
* legacy seq cursor for messages that lack an id.
|
||||
*
|
||||
* Why id-first: scheduled messages keep the seq assigned at insertion time, so
|
||||
* a row scheduled for T+1h (seq=10) can be released after a later immediate
|
||||
* message (seq=11) has already advanced the cursor. A pure seq <= cursor
|
||||
* filter would silently drop the mature emit. See HAPI Bot R3 finding #1.
|
||||
*/
|
||||
export class IncomingMessageFilter {
|
||||
private readonly seenIds = new Set<string>()
|
||||
private readonly capacity: number
|
||||
private lastSeenSeq: number | null = null
|
||||
|
||||
constructor(capacity = 256) {
|
||||
this.capacity = capacity
|
||||
}
|
||||
|
||||
cursorSeq(): number | null {
|
||||
return this.lastSeenSeq
|
||||
}
|
||||
|
||||
/** Returns true if this message should be processed; false to drop as a duplicate. */
|
||||
accept(message: { id?: string | null; seq?: number | null }): boolean {
|
||||
const id = typeof message.id === 'string' && message.id.length > 0 ? message.id : null
|
||||
if (id && this.seenIds.has(id)) {
|
||||
// Refresh recency: the hub re-emits the same id every 5 s until the
|
||||
// CLI acks (releaseMatureScheduledMessages contract). Without a
|
||||
// delete+re-add the entry stays at its first-insert position and can
|
||||
// be evicted by a burst of unrelated ids before the ack lands —
|
||||
// the next re-emit would then be treated as new and double-deliver.
|
||||
this.seenIds.delete(id)
|
||||
this.seenIds.add(id)
|
||||
return false
|
||||
}
|
||||
|
||||
const seq = typeof message.seq === 'number' ? message.seq : null
|
||||
if (!id && seq !== null && this.lastSeenSeq !== null && seq <= this.lastSeenSeq) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (id) {
|
||||
this.seenIds.add(id)
|
||||
if (this.seenIds.size > this.capacity) {
|
||||
// Set iteration is insertion-ordered; with delete+re-add on dedup hit
|
||||
// (above) this becomes a true LRU eviction.
|
||||
const oldest = this.seenIds.values().next().value
|
||||
if (oldest !== undefined) this.seenIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
if (seq !== null) {
|
||||
this.lastSeenSeq = Math.max(this.lastSeenSeq ?? 0, seq)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiSessionClient extends EventEmitter {
|
||||
private readonly token: string
|
||||
readonly sessionId: string
|
||||
@@ -82,7 +140,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
private pendingMessages: { message: UserMessage; localId?: string }[] = []
|
||||
private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null
|
||||
private cancelQueuedMessageCallback: ((localId: string) => boolean) | null = null
|
||||
private lastSeenMessageSeq: number | null = null
|
||||
private readonly incomingFilter = new IncomingMessageFilter()
|
||||
private backfillInFlight: Promise<void> | null = null
|
||||
private needsBackfill = false
|
||||
private hasConnectedOnce = false
|
||||
@@ -274,13 +332,9 @@ export class ApiSessionClient extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private handleIncomingMessage(message: { seq?: number; localId?: string | null; content: unknown }): void {
|
||||
const seq = typeof message.seq === 'number' ? message.seq : null
|
||||
if (seq !== null) {
|
||||
if (this.lastSeenMessageSeq !== null && seq <= this.lastSeenMessageSeq) {
|
||||
return
|
||||
}
|
||||
this.lastSeenMessageSeq = seq
|
||||
private handleIncomingMessage(message: { id?: string; seq?: number; localId?: string | null; content: unknown }): void {
|
||||
if (!this.incomingFilter.accept({ id: message.id, seq: message.seq })) {
|
||||
return
|
||||
}
|
||||
|
||||
const userResult = UserMessageSchema.safeParse(message.content)
|
||||
@@ -311,7 +365,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
const startSeq = this.lastSeenMessageSeq
|
||||
const startSeq = this.incomingFilter.cursorSeq()
|
||||
if (startSeq === null) {
|
||||
logger.debug('[API] Skipping backfill because no last-seen message sequence is available')
|
||||
return
|
||||
@@ -353,7 +407,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
this.handleIncomingMessage(message)
|
||||
}
|
||||
|
||||
const observedSeq = this.lastSeenMessageSeq ?? maxSeq
|
||||
const observedSeq = this.incomingFilter.cursorSeq() ?? maxSeq
|
||||
const nextCursor = Math.max(maxSeq, observedSeq)
|
||||
if (nextCursor <= cursor) {
|
||||
logger.debug('[API] Backfill stopped due to non-advancing cursor', {
|
||||
|
||||
+2
-1
@@ -190,7 +190,8 @@ async function main() {
|
||||
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
|
||||
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload),
|
||||
onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta),
|
||||
onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt)
|
||||
onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt),
|
||||
onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now)
|
||||
})
|
||||
|
||||
syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
|
||||
|
||||
@@ -43,10 +43,11 @@ export type CliHandlersDeps = {
|
||||
onWebappEvent?: (event: SyncEvent) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
onSessionActivity?: (sessionId: string, updatedAt: number) => void
|
||||
onSweepImmediateQueued?: (sessionId: string, now: number) => void
|
||||
}
|
||||
|
||||
export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void {
|
||||
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity } = deps
|
||||
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps
|
||||
const terminalNamespace = io.of('/terminal')
|
||||
const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null
|
||||
|
||||
@@ -107,7 +108,8 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers
|
||||
onSessionEnd,
|
||||
onWebappEvent,
|
||||
onBackgroundTaskDelta,
|
||||
onSessionActivity
|
||||
onSessionActivity,
|
||||
onSweepImmediateQueued
|
||||
})
|
||||
registerMachineHandlers(socket, {
|
||||
store,
|
||||
|
||||
@@ -64,10 +64,12 @@ export type SessionHandlersDeps = {
|
||||
onWebappEvent?: (event: SyncEvent) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
onSessionActivity?: (sessionId: string, updatedAt: number) => void
|
||||
/** Delegates session-end immediate-queue sweep to the MessageService layer. */
|
||||
onSweepImmediateQueued?: (sessionId: string, now: number) => void
|
||||
}
|
||||
|
||||
export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void {
|
||||
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity } = deps
|
||||
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps
|
||||
|
||||
socket.on('message', (data: unknown) => {
|
||||
const parsed = messageSchema.safeParse(data)
|
||||
@@ -299,27 +301,22 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
|
||||
return
|
||||
}
|
||||
|
||||
// Force-invoke any user messages that are still queued at session end.
|
||||
// Without this, the floating bar pins the queued rows after the CLI is
|
||||
// gone — there is no longer an ack path (no CLI to emit
|
||||
// messages-consumed) so they would stay queued forever.
|
||||
// Force-invoke only immediate-queued messages (scheduled_at IS NULL) at
|
||||
// session end. *All* scheduled rows — mature or future — are deliberately
|
||||
// preserved in DB so the mature-scan path (releaseMatureScheduledMessages)
|
||||
// remains the sole emit channel and the CLI ack remains the sole writer of
|
||||
// invoked_at. See HAPI Bot R4: stamping a mature scheduled row here would
|
||||
// make the next mature-scan tick skip it (filter on invoked_at IS NULL) and
|
||||
// silently drop the user's prompt.
|
||||
//
|
||||
// Without this sweep for immediate rows, the floating bar would pin queued
|
||||
// rows after the CLI exits — there is no longer an ack path, so they would
|
||||
// stay queued forever. The 5-second tick in syncEngine.expireInactive
|
||||
// emits scheduled rows when they mature, regardless of session end.
|
||||
try {
|
||||
const queued = store.messages.getUninvokedLocalMessages(data.sid)
|
||||
const localIds = queued
|
||||
.map((m) => m.localId)
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
if (localIds.length > 0) {
|
||||
const invokedAt = Date.now()
|
||||
store.messages.markMessagesInvoked(data.sid, localIds, invokedAt)
|
||||
onWebappEvent?.({
|
||||
type: 'messages-consumed',
|
||||
sessionId: data.sid,
|
||||
localIds,
|
||||
invokedAt
|
||||
})
|
||||
}
|
||||
onSweepImmediateQueued?.(data.sid, Date.now())
|
||||
} catch (err) {
|
||||
console.error('session-end markMessagesInvoked failed', err)
|
||||
console.error('session-end sweep failed', err)
|
||||
}
|
||||
|
||||
onSessionEnd?.(data)
|
||||
|
||||
@@ -41,6 +41,7 @@ export type SocketServerDeps = {
|
||||
onMachineAlive?: (payload: { machineId: string; time: number }) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
onSessionActivity?: (sessionId: string, updatedAt: number) => void
|
||||
onSweepImmediateQueued?: (sessionId: string, now: number) => void
|
||||
}
|
||||
|
||||
export function createSocketServer(deps: SocketServerDeps): {
|
||||
@@ -117,7 +118,8 @@ export function createSocketServer(deps: SocketServerDeps): {
|
||||
onMachineAlive: deps.onMachineAlive,
|
||||
onWebappEvent: deps.onWebappEvent,
|
||||
onBackgroundTaskDelta: deps.onBackgroundTaskDelta,
|
||||
onSessionActivity: deps.onSessionActivity
|
||||
onSessionActivity: deps.onSessionActivity,
|
||||
onSweepImmediateQueued: deps.onSweepImmediateQueued
|
||||
}))
|
||||
|
||||
terminalNs.use(async (socket, next) => {
|
||||
|
||||
+24
-1
@@ -23,7 +23,7 @@ export { PushStore } from './pushStore'
|
||||
export { SessionStore } from './sessionStore'
|
||||
export { UserStore } from './userStore'
|
||||
|
||||
const SCHEMA_VERSION: number = 8
|
||||
const SCHEMA_VERSION: number = 9
|
||||
const REQUIRED_TABLES = [
|
||||
'sessions',
|
||||
'machines',
|
||||
@@ -98,6 +98,7 @@ export class Store {
|
||||
5: () => this.migrateFromV5ToV6(),
|
||||
6: () => this.migrateFromV6ToV7(),
|
||||
7: () => this.migrateFromV7ToV8(),
|
||||
8: () => this.migrateFromV8ToV9(),
|
||||
})
|
||||
|
||||
if (currentVersion === 0) {
|
||||
@@ -193,12 +194,16 @@ export class Store {
|
||||
seq INTEGER NOT NULL,
|
||||
local_id TEXT,
|
||||
invoked_at INTEGER,
|
||||
scheduled_at INTEGER,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_position
|
||||
ON messages(session_id, COALESCE(invoked_at, created_at) DESC, seq DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_scheduled_pending
|
||||
ON messages(scheduled_at)
|
||||
WHERE scheduled_at IS NOT NULL AND invoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -378,6 +383,24 @@ export class Store {
|
||||
`)
|
||||
}
|
||||
|
||||
private migrateFromV8ToV9(): void {
|
||||
const columns = this.getMessageColumnNames()
|
||||
if (columns.size === 0) {
|
||||
// No messages table yet — createSchema will build the up-to-date one.
|
||||
return
|
||||
}
|
||||
if (!columns.has('scheduled_at')) {
|
||||
this.db.exec('ALTER TABLE messages ADD COLUMN scheduled_at INTEGER')
|
||||
}
|
||||
// Partial index for efficient mature scheduled message lookup.
|
||||
// Idempotent via IF NOT EXISTS.
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_scheduled_pending
|
||||
ON messages(scheduled_at)
|
||||
WHERE scheduled_at IS NOT NULL AND invoked_at IS NULL
|
||||
`)
|
||||
}
|
||||
|
||||
private getSessionColumnNames(): Set<string> {
|
||||
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
return new Set(rows.map((row) => row.name))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
import type { StoredMessage } from './types'
|
||||
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
|
||||
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
|
||||
|
||||
export class MessageStore {
|
||||
private readonly db: Database
|
||||
@@ -10,16 +10,16 @@ export class MessageStore {
|
||||
this.db = db
|
||||
}
|
||||
|
||||
addMessage(sessionId: string, content: unknown, localId?: string): StoredMessage {
|
||||
return addMessage(this.db, sessionId, content, localId)
|
||||
addMessage(sessionId: string, content: unknown, localId?: string, scheduledAt?: number | null): StoredMessage {
|
||||
return addMessage(this.db, sessionId, content, localId, scheduledAt)
|
||||
}
|
||||
|
||||
getMessages(sessionId: string, limit: number = 200, beforeSeq?: number): StoredMessage[] {
|
||||
return getMessages(this.db, sessionId, limit, beforeSeq)
|
||||
}
|
||||
|
||||
getMessagesAfter(sessionId: string, afterSeq: number, limit: number = 200): StoredMessage[] {
|
||||
return getMessagesAfter(this.db, sessionId, afterSeq, limit)
|
||||
getDeliverableMessagesAfter(sessionId: string, afterSeq: number, now: number, limit: number = 200): StoredMessage[] {
|
||||
return getDeliverableMessagesAfter(this.db, sessionId, afterSeq, now, limit)
|
||||
}
|
||||
|
||||
getMessagesByPosition(sessionId: string, limit: number, before?: { at: number; seq: number }): StoredMessage[] {
|
||||
@@ -30,6 +30,14 @@ export class MessageStore {
|
||||
return getUninvokedLocalMessages(this.db, sessionId)
|
||||
}
|
||||
|
||||
getMatureScheduledMessages(beforeTime: number): StoredMessage[] {
|
||||
return getMatureScheduledMessages(this.db, beforeTime)
|
||||
}
|
||||
|
||||
getImmediateQueuedLocalMessages(sessionId: string): StoredMessage[] {
|
||||
return getImmediateQueuedLocalMessages(this.db, sessionId)
|
||||
}
|
||||
|
||||
cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult {
|
||||
return cancelQueuedMessage(this.db, sessionId, messageId)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+81
-14
@@ -12,6 +12,7 @@ type DbMessageRow = {
|
||||
seq: number
|
||||
local_id: string | null
|
||||
invoked_at: number | null
|
||||
scheduled_at: number | null
|
||||
}
|
||||
|
||||
function toStoredMessage(row: DbMessageRow): StoredMessage {
|
||||
@@ -22,7 +23,8 @@ function toStoredMessage(row: DbMessageRow): StoredMessage {
|
||||
createdAt: row.created_at,
|
||||
seq: row.seq,
|
||||
localId: row.local_id,
|
||||
invokedAt: row.invoked_at ?? null
|
||||
invokedAt: row.invoked_at ?? null,
|
||||
scheduledAt: row.scheduled_at ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +32,20 @@ export function addMessage(
|
||||
db: Database,
|
||||
sessionId: string,
|
||||
content: unknown,
|
||||
localId?: string
|
||||
localId?: string,
|
||||
scheduledAt?: number | null
|
||||
): StoredMessage {
|
||||
const now = Date.now()
|
||||
|
||||
// Without a localId, invoked_at is stamped immediately below — there is no
|
||||
// ack path to flip it later. A scheduled message in that state would be
|
||||
// skipped by the future-emit branch and never picked up by
|
||||
// getMatureScheduledMessages (which filters on invoked_at IS NULL), so
|
||||
// the schedule would be silently lost.
|
||||
if (scheduledAt != null && !localId) {
|
||||
throw new Error('addMessage: scheduledAt requires a localId for the ack flow')
|
||||
}
|
||||
|
||||
if (localId) {
|
||||
const existing = db.prepare(
|
||||
'SELECT * FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1'
|
||||
@@ -58,9 +70,9 @@ export function addMessage(
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messages (
|
||||
id, session_id, content, created_at, seq, local_id, invoked_at
|
||||
id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at
|
||||
) VALUES (
|
||||
@id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at
|
||||
@id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at
|
||||
)
|
||||
`).run({
|
||||
id,
|
||||
@@ -69,7 +81,8 @@ export function addMessage(
|
||||
created_at: now,
|
||||
seq: msgSeq,
|
||||
local_id: localId ?? null,
|
||||
invoked_at: invokedAt
|
||||
invoked_at: invokedAt,
|
||||
scheduled_at: scheduledAt ?? null
|
||||
})
|
||||
|
||||
const row = db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined
|
||||
@@ -98,18 +111,32 @@ export function getMessages(
|
||||
return rows.reverse().map(toStoredMessage)
|
||||
}
|
||||
|
||||
export function getMessagesAfter(
|
||||
/** CLI reconnect backfill: returns messages above the seq cursor that are
|
||||
* deliverable now, i.e. excludes future-scheduled rows (scheduled_at > now).
|
||||
* Without this filter, a CLI reconnect between schedule time and release time
|
||||
* would replay future-scheduled rows via the normal message stream and the
|
||||
* runner would consume them immediately, bypassing the mature-scan path.
|
||||
* Only the CLI backfill route should use this; the Web thread API still calls
|
||||
* byPosition / getMessages and needs the full set so scheduled rows surface in
|
||||
* the queued floating bar. */
|
||||
export function getDeliverableMessagesAfter(
|
||||
db: Database,
|
||||
sessionId: string,
|
||||
afterSeq: number,
|
||||
now: number,
|
||||
limit: number = 200
|
||||
): StoredMessage[] {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, limit)) : 200
|
||||
const safeAfterSeq = Number.isFinite(afterSeq) ? afterSeq : 0
|
||||
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM messages WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?'
|
||||
).all(sessionId, safeAfterSeq, safeLimit) as DbMessageRow[]
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
AND seq > ?
|
||||
AND (scheduled_at IS NULL OR scheduled_at <= ?)
|
||||
ORDER BY seq ASC
|
||||
LIMIT ?
|
||||
`).all(sessionId, safeAfterSeq, now, safeLimit) as DbMessageRow[]
|
||||
|
||||
return rows.map(toStoredMessage)
|
||||
}
|
||||
@@ -144,9 +171,8 @@ export function getMessagesByPosition(
|
||||
}
|
||||
|
||||
/** Returns user messages that have a localId but no invoked_at.
|
||||
* Used to surface queued messages on refresh / secondary clients even when they
|
||||
* fall outside the latest position-ordered page (their position key is the send
|
||||
* time, but the floating bar still needs to render them). */
|
||||
* Includes future scheduled messages — used to surface all queued messages
|
||||
* (including scheduled) for the Web floating bar on refresh / secondary clients. */
|
||||
export function getUninvokedLocalMessages(
|
||||
db: Database,
|
||||
sessionId: string
|
||||
@@ -157,6 +183,47 @@ export function getUninvokedLocalMessages(
|
||||
return rows.map(toStoredMessage)
|
||||
}
|
||||
|
||||
/** Returns scheduled messages across all sessions whose scheduled_at <= beforeTime
|
||||
* and have not yet been invoked. Used by the hub tick to emit mature messages to CLI.
|
||||
* Ordered by scheduled_at ASC (oldest first). */
|
||||
export function getMatureScheduledMessages(
|
||||
db: Database,
|
||||
beforeTime: number
|
||||
): StoredMessage[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM messages WHERE scheduled_at IS NOT NULL AND scheduled_at <= ? AND invoked_at IS NULL ORDER BY scheduled_at ASC'
|
||||
).all(beforeTime) as DbMessageRow[]
|
||||
return rows.map(toStoredMessage)
|
||||
}
|
||||
|
||||
/** Returns immediate-queued local messages for a session — i.e. rows that have
|
||||
* no scheduled_at (scheduled_at IS NULL). Used by the session-end sweep
|
||||
* (sweepImmediateQueuedOnSessionEnd): these are messages the user posted to a
|
||||
* CLI session that ended before the runner consumed them, so they cannot ever
|
||||
* be delivered and must be force-invoked to clear the floating bar.
|
||||
*
|
||||
* Scheduled rows (scheduled_at IS NOT NULL) are *deliberately excluded*, mature
|
||||
* or not. The mature-scan path (releaseMatureScheduledMessages) is the sole
|
||||
* emit channel for scheduled rows and it does not write invoked_at — the CLI
|
||||
* ack does. If the session-end sweep stamped a mature scheduled row as
|
||||
* invoked, a subsequent CLI re-attach would never see the row in the
|
||||
* mature-scan results (it filters on invoked_at IS NULL), and the user's
|
||||
* scheduled prompt would be silently dropped. See HAPI Bot R4 finding. */
|
||||
export function getImmediateQueuedLocalMessages(
|
||||
db: Database,
|
||||
sessionId: string
|
||||
): StoredMessage[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
AND invoked_at IS NULL
|
||||
AND local_id IS NOT NULL
|
||||
AND scheduled_at IS NULL
|
||||
ORDER BY seq ASC
|
||||
`).all(sessionId) as DbMessageRow[]
|
||||
return rows.map(toStoredMessage)
|
||||
}
|
||||
|
||||
export function getMaxSeq(db: Database, sessionId: string): number {
|
||||
const row = db.prepare(
|
||||
'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?'
|
||||
@@ -221,7 +288,7 @@ export function cancelQueuedMessage(
|
||||
export type LookupQueuedMessageResult =
|
||||
| { status: 'absent' }
|
||||
| { status: 'invoked'; message: StoredMessage }
|
||||
| { status: 'queued'; localId: string | null; resolvedId: string }
|
||||
| { status: 'queued'; localId: string | null; resolvedId: string; scheduledAt: number | null }
|
||||
|
||||
/** Look up a queued message without deleting it.
|
||||
*
|
||||
@@ -251,7 +318,7 @@ export function lookupQueuedMessage(
|
||||
return { status: 'invoked' as const, message: toStoredMessage(row) }
|
||||
}
|
||||
|
||||
return { status: 'queued' as const, localId: row.local_id, resolvedId: row.id }
|
||||
return { status: 'queued' as const, localId: row.local_id, resolvedId: row.id, scheduledAt: row.scheduled_at }
|
||||
}
|
||||
|
||||
/** Delete a queued (invoked_at IS NULL) message by id or local_id.
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Store } from './index'
|
||||
|
||||
/**
|
||||
* Tests for V8→V9 schema migration: adding scheduled_at column to messages table.
|
||||
* Follows the same pattern as migration-v8.test.ts.
|
||||
*/
|
||||
describe('Store V8→V9 migration: scheduled_at column', () => {
|
||||
it('fresh DB has scheduled_at column in messages', () => {
|
||||
const store = new Store(':memory:')
|
||||
const cols = getMessageColumns(store)
|
||||
expect(cols).toContain('scheduled_at')
|
||||
})
|
||||
|
||||
it('V8 DB migrates to V9 via Store: scheduled_at added, existing rows have NULL scheduled_at', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v9-test-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
// Build a V8 DB on disk, insert rows, then open via Store to trigger migration
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV8Schema(db)
|
||||
db.exec('PRAGMA user_version = 8')
|
||||
db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq)
|
||||
VALUES ('s1', 'default', 1000, 1000, 0)`)
|
||||
db.exec(`INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at)
|
||||
VALUES ('m1', 's1', '"hello"', 1000, 1, 'l1', NULL)`)
|
||||
db.exec(`INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at)
|
||||
VALUES ('m2', 's1', '"world"', 2000, 2, NULL, 2000)`)
|
||||
db.close()
|
||||
|
||||
// Open via Store — should auto-migrate V8→V9
|
||||
const store = new Store(dbPath)
|
||||
const cols = getMessageColumns(store)
|
||||
expect(cols).toContain('scheduled_at')
|
||||
|
||||
// Existing rows should have scheduled_at = NULL
|
||||
const msgs = store.messages.getMessages('s1')
|
||||
expect(msgs).toHaveLength(2)
|
||||
const m1 = msgs.find(m => m.id === 'm1')!
|
||||
const m2 = msgs.find(m => m.id === 'm2')!
|
||||
expect(m1.scheduledAt).toBeNull()
|
||||
expect(m2.scheduledAt).toBeNull()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('V7 DB migrates to V9 (multi-hop: V7→V8→V9)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v7-to-v9-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV7Schema(db)
|
||||
db.exec('PRAGMA user_version = 7')
|
||||
db.close()
|
||||
|
||||
const store = new Store(dbPath)
|
||||
const cols = getMessageColumns(store)
|
||||
expect(cols).toContain('invoked_at')
|
||||
expect(cols).toContain('scheduled_at')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('V6 DB migrates to V9 (multi-hop)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v6-to-v9-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV6Schema(db)
|
||||
db.exec('PRAGMA user_version = 6')
|
||||
db.close()
|
||||
|
||||
const store = new Store(dbPath)
|
||||
const cols = getMessageColumns(store)
|
||||
expect(cols).toContain('scheduled_at')
|
||||
const sessionCols = getSessionColumns(store)
|
||||
expect(sessionCols).toContain('model_reasoning_effort')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('V9 DB reopen is idempotent: schema unchanged', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v9-idempotent-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const store1 = new Store(dbPath)
|
||||
const cols1 = getMessageColumns(store1)
|
||||
expect(cols1).toContain('scheduled_at')
|
||||
|
||||
// Re-open same DB — version is already 9, must not throw or alter schema
|
||||
const store2 = new Store(dbPath)
|
||||
const cols2 = getMessageColumns(store2)
|
||||
expect(cols2).toEqual(cols1)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('migrateFromV8ToV9 PRAGMA guard: scheduled_at column appears exactly once', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v9-guard-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV8Schema(db)
|
||||
db.exec('PRAGMA user_version = 8')
|
||||
db.close()
|
||||
|
||||
const store = new Store(dbPath)
|
||||
const cols = getMessageColumns(store)
|
||||
const count = cols.filter(c => c === 'scheduled_at').length
|
||||
expect(count).toBe(1)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('idx_messages_scheduled_pending index exists on fresh DB', () => {
|
||||
const store = new Store(':memory:')
|
||||
const db: Database = (store as any).db
|
||||
const rows = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_messages_scheduled_pending'"
|
||||
).all() as Array<{ name: string }>
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('idx_messages_scheduled_pending index exists after V8→V9 migration', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-index-v8-v9-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV8Schema(db)
|
||||
db.exec('PRAGMA user_version = 8')
|
||||
db.close()
|
||||
|
||||
const store = new Store(dbPath)
|
||||
const db2: Database = (store as any).db
|
||||
const rows = db2.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_messages_scheduled_pending'"
|
||||
).all() as Array<{ name: string }>
|
||||
expect(rows).toHaveLength(1)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store V9: scheduled_at store operations', () => {
|
||||
it('addMessage with scheduledAt stores the value', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const futureMs = Date.now() + 60_000
|
||||
const msg = store.messages.addMessage(session.id, 'hello', 'local-1', futureMs)
|
||||
expect(msg.scheduledAt).toBe(futureMs)
|
||||
})
|
||||
|
||||
it('addMessage without scheduledAt has scheduledAt = null', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const msg = store.messages.addMessage(session.id, 'hello', 'local-1')
|
||||
expect(msg.scheduledAt).toBeNull()
|
||||
})
|
||||
|
||||
it('getMatureScheduledMessages returns messages with scheduled_at <= now', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
const future = now + 60_000
|
||||
|
||||
// Mature: scheduled_at in the past
|
||||
const mature = store.messages.addMessage(session.id, 'mature', 'local-mature', past)
|
||||
// Future: not yet mature
|
||||
store.messages.addMessage(session.id, 'future', 'local-future', future)
|
||||
// No scheduledAt: not scheduled
|
||||
store.messages.addMessage(session.id, 'plain', 'local-plain')
|
||||
|
||||
const results = store.messages.getMatureScheduledMessages(now)
|
||||
expect(results.map(m => m.id)).toContain(mature.id)
|
||||
expect(results).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('getMatureScheduledMessages excludes already-invoked messages', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
|
||||
const msg = store.messages.addMessage(session.id, 'mature', 'local-m', past)
|
||||
// Simulate CLI ack
|
||||
store.messages.markMessagesInvoked(session.id, ['local-m'], now)
|
||||
|
||||
const results = store.messages.getMatureScheduledMessages(now)
|
||||
expect(results.find(m => m.id === msg.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getMatureScheduledMessages returns in scheduled_at ASC order', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const now = Date.now()
|
||||
|
||||
const msg2 = store.messages.addMessage(session.id, 'second', 'local-2', now - 500)
|
||||
const msg1 = store.messages.addMessage(session.id, 'first', 'local-1', now - 1000)
|
||||
|
||||
const results = store.messages.getMatureScheduledMessages(now)
|
||||
expect(results.map(m => m.id)).toEqual([msg1.id, msg2.id])
|
||||
})
|
||||
|
||||
it('getImmediateQueuedLocalMessages: returns only immediate queued, excludes mature AND future scheduled (HAPI Bot R4)', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const now = Date.now()
|
||||
|
||||
// Immediate queued (no scheduledAt) — included
|
||||
const immediate = store.messages.addMessage(session.id, 'immediate', 'local-imm')
|
||||
// Mature scheduled — must be excluded so the mature-scan path can deliver it
|
||||
// with the no-stamp + re-emit-until-ack contract.
|
||||
store.messages.addMessage(session.id, 'mature', 'local-mature', now - 1000)
|
||||
// Future scheduled — must be excluded
|
||||
store.messages.addMessage(session.id, 'future', 'local-future', now + 60_000)
|
||||
|
||||
const results = store.messages.getImmediateQueuedLocalMessages(session.id)
|
||||
const ids = results.map(m => m.id)
|
||||
expect(ids).toEqual([immediate.id])
|
||||
})
|
||||
|
||||
it('getImmediateQueuedLocalMessages excludes already-invoked messages', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const now = Date.now()
|
||||
|
||||
const msg = store.messages.addMessage(session.id, 'q', 'local-q')
|
||||
store.messages.markMessagesInvoked(session.id, ['local-q'], now)
|
||||
|
||||
const results = store.messages.getImmediateQueuedLocalMessages(session.id)
|
||||
expect(results.find(m => m.id === msg.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getUninvokedLocalMessages still includes future scheduled (for Web bar display)', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default')
|
||||
const future = Date.now() + 60_000
|
||||
|
||||
const scheduled = store.messages.addMessage(session.id, 'future', 'local-f', future)
|
||||
|
||||
const results = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(results.map(m => m.id)).toContain(scheduled.id)
|
||||
})
|
||||
|
||||
it('legacy DB (user_version=0 with V8-shape tables): step ladder backfills scheduled_at', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-legacy-v0-v9-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true })
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
createV8Schema(db)
|
||||
// Intentionally do NOT set user_version — leaves it at 0 (legacy)
|
||||
db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq)
|
||||
VALUES ('s1', 'default', 1000, 1000, 0)`)
|
||||
db.exec(`INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at)
|
||||
VALUES ('m1', 's1', '"hi"', 1500, 1, 'l1', NULL)`)
|
||||
db.close()
|
||||
|
||||
const store = new Store(dbPath)
|
||||
const cols = getMessageColumns(store)
|
||||
expect(cols).toContain('scheduled_at')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function getMessageColumns(store: Store): string[] {
|
||||
const db: Database = (store as any).db
|
||||
const rows = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }>
|
||||
return rows.map(r => r.name)
|
||||
}
|
||||
|
||||
function getSessionColumns(store: Store): string[] {
|
||||
const db: Database = (store as any).db
|
||||
const rows = db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
return rows.map(r => r.name)
|
||||
}
|
||||
|
||||
/** V8 schema: messages table with invoked_at but without scheduled_at */
|
||||
function createV8Schema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
machine_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
agent_state TEXT,
|
||||
agent_state_version INTEGER DEFAULT 1,
|
||||
model TEXT,
|
||||
model_reasoning_effort TEXT,
|
||||
effort TEXT,
|
||||
todos TEXT,
|
||||
todos_updated_at INTEGER,
|
||||
team_state TEXT,
|
||||
team_state_updated_at INTEGER,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
runner_state TEXT,
|
||||
runner_state_version INTEGER DEFAULT 1,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
local_id TEXT,
|
||||
invoked_at INTEGER,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_position
|
||||
ON messages(session_id, COALESCE(invoked_at, created_at) DESC, seq DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_user_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(platform, platform_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namespace TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(namespace, endpoint)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace);
|
||||
`)
|
||||
}
|
||||
|
||||
/** V7 schema: messages table without invoked_at (and thus without scheduled_at) */
|
||||
function createV7Schema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
machine_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
agent_state TEXT,
|
||||
agent_state_version INTEGER DEFAULT 1,
|
||||
model TEXT,
|
||||
model_reasoning_effort TEXT,
|
||||
effort TEXT,
|
||||
todos TEXT,
|
||||
todos_updated_at INTEGER,
|
||||
team_state TEXT,
|
||||
team_state_updated_at INTEGER,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
runner_state TEXT,
|
||||
runner_state_version INTEGER DEFAULT 1,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
local_id TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_user_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(platform, platform_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namespace TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(namespace, endpoint)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace);
|
||||
`)
|
||||
}
|
||||
|
||||
/** V6 schema: sessions without model_reasoning_effort; messages without invoked_at */
|
||||
function createV6Schema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
machine_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
agent_state TEXT,
|
||||
agent_state_version INTEGER DEFAULT 1,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
todos TEXT,
|
||||
todos_updated_at INTEGER,
|
||||
team_state TEXT,
|
||||
team_state_updated_at INTEGER,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
runner_state TEXT,
|
||||
runner_state_version INTEGER DEFAULT 1,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
local_id TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
platform_user_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL DEFAULT 'default',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(platform, platform_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
namespace TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(namespace, endpoint)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace);
|
||||
`)
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export type StoredMessage = {
|
||||
seq: number
|
||||
localId: string | null
|
||||
invokedAt: number | null
|
||||
scheduledAt: number | null
|
||||
}
|
||||
|
||||
export type StoredUser = {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* Race-E (partial ack): broadcast ack receives err + [{ removed: true }] → DELETE + status='cancelled'
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { MessageService } from './messageService'
|
||||
import { Store } from '../store'
|
||||
import type { Server } from 'socket.io'
|
||||
@@ -317,3 +320,659 @@ describe('MessageService.cancelQueuedMessage race scenarios', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1 cancel × scheduled mature race (expected behavior documentation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MessageService — cancel × mature race (scheduled messages)', () => {
|
||||
// The 5-second mature tick widens the cancel race window for scheduled
|
||||
// messages compared to immediately-queued ones. When mature fires first,
|
||||
// the CLI shifts the row; a subsequent cancel call gets 'not-found' from
|
||||
// the CLI ack, which stamps invoked_at (PR #568 contract preserved).
|
||||
// The web client surfaces this as "sent". This test documents that the
|
||||
// behaviour is intentional — it is the expected outcome, not a bug.
|
||||
it('cancel after mature-emit stamps invoked_at (race resolved as invoked — expected behavior)', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'race-sched-mature')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
// Add a scheduled message that is already mature
|
||||
const msg = store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'sched' } },
|
||||
'local-sched-race',
|
||||
past
|
||||
)
|
||||
|
||||
// Simulate: mature tick already emitted to CLI, CLI shifted the item.
|
||||
// Cancel arrives and CLI returns not-found (item already shift()-ed).
|
||||
const io = makeIo((callback) => {
|
||||
// CLI cannot remove — it already shift()-ed (mature tick beat the cancel)
|
||||
callback(null, [{ removed: false }])
|
||||
})
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
// Simulate the mature tick firing first (CLI now has the item)
|
||||
// Then cancel arrives — CLI says not-found
|
||||
const result = await service.cancelQueuedMessage(session.id, msg.id)
|
||||
|
||||
// Expected behavior: invoked_at is stamped (PR #568 contract preserved)
|
||||
// Web client will show the message as "sent"
|
||||
expect(result.status).toBe('invoked')
|
||||
if (result.status === 'invoked') {
|
||||
expect(result.message.localId).toBe('local-sched-race')
|
||||
expect(result.message.invokedAt).not.toBeNull()
|
||||
}
|
||||
|
||||
// messages-consumed SSE ensures web clients remove it from the queued bar
|
||||
const consumed = publisher.events.find(e => e.type === 'messages-consumed')
|
||||
expect(consumed).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1 cancel of future-scheduled message: must DELETE (not invoke)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MessageService.cancelQueuedMessage — future-scheduled message', () => {
|
||||
// A future-scheduled message was never emitted to the CLI.
|
||||
// When the user clicks X, the hub contacts the CLI (room has sockets) and
|
||||
// CLI responds not-found — because the message was never there.
|
||||
// The hub MUST treat this as a clean delete (status='cancelled'), NOT as
|
||||
// "CLI already consumed it" (which would stamp invoked_at).
|
||||
it('cancel of future-scheduled msg with CLI online returns cancelled (not invoked)', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'cancel-future-sched')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
const msg = store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'scheduled future' } },
|
||||
'local-future-cancel',
|
||||
futureMs
|
||||
)
|
||||
|
||||
// CLI responds not-found: the message was never emitted there
|
||||
let ackCalled = false
|
||||
const io = makeIo((callback) => {
|
||||
ackCalled = true
|
||||
callback(null, [{ removed: false }])
|
||||
}, 1) // 1 CLI socket online
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
const result = await service.cancelQueuedMessage(session.id, msg.id)
|
||||
|
||||
// Future-scheduled cancel must succeed as 'cancelled', not 'invoked'
|
||||
expect(result.status).toBe('cancelled')
|
||||
|
||||
// Row must be gone from DB (not just invoked_at stamped)
|
||||
const rows = store.messages.getMessages(session.id)
|
||||
const remaining = rows.find(r => r.id === msg.id)
|
||||
expect(remaining).toBeUndefined()
|
||||
|
||||
// message-cancelled SSE must be emitted
|
||||
const cancelled = publisher.events.find(e => e.type === 'message-cancelled')
|
||||
expect(cancelled).toBeDefined()
|
||||
|
||||
// messages-consumed (invoked path) must NOT be emitted
|
||||
const consumedCount = publisher.events.filter(e => e.type === 'messages-consumed').length
|
||||
expect(consumedCount).toBe(0)
|
||||
|
||||
// invoked_at must never have been stamped (row deleted)
|
||||
// (row is gone, so we just verify the cancel result is not invoked)
|
||||
expect(result.status).not.toBe('invoked')
|
||||
|
||||
// Short-circuit must have bypassed the CLI ack round-trip entirely.
|
||||
// ackCalled being false proves the future-scheduled path deleted the row
|
||||
// without ever contacting the CLI.
|
||||
expect(ackCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('cancel of future-scheduled msg when CLI offline also returns cancelled', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'cancel-future-sched-offline')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
const msg = store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'future offline' } },
|
||||
'local-future-offline',
|
||||
futureMs
|
||||
)
|
||||
|
||||
let ackCalled = false
|
||||
const io = makeIo(() => { ackCalled = true }, 0) // CLI offline
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
const result = await service.cancelQueuedMessage(session.id, msg.id)
|
||||
|
||||
expect(result.status).toBe('cancelled')
|
||||
expect(ackCalled).toBe(false)
|
||||
|
||||
// Row must be deleted
|
||||
const rows = store.messages.getMessages(session.id)
|
||||
expect(rows.find(r => r.id === msg.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendMessage with scheduledAt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MessageService.sendMessage with scheduledAt', () => {
|
||||
function makeNoopIo(): Server {
|
||||
let emittedUpdates: unknown[] = []
|
||||
return {
|
||||
of: (_ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => { emittedUpdates.push(data) },
|
||||
timeout: (_ms: number) => ({
|
||||
emit: () => {}
|
||||
})
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
}),
|
||||
_emittedUpdates: emittedUpdates
|
||||
} as unknown as Server
|
||||
}
|
||||
|
||||
it('future scheduledAt: stores message with scheduledAt, does NOT emit to /cli', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-future')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
await service.sendMessage(session.id, {
|
||||
text: 'hello future',
|
||||
localId: 'local-sched',
|
||||
scheduledAt: futureMs
|
||||
})
|
||||
|
||||
// DB must have the message with scheduledAt set
|
||||
const msgs = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0].scheduledAt).toBe(futureMs)
|
||||
|
||||
// CLI must NOT receive the message yet (future scheduled)
|
||||
expect(cliEmitted).toHaveLength(0)
|
||||
|
||||
// Web SSE must still receive message-received so the bar renders
|
||||
const received = publisher.events.find(e => e.type === 'message-received')
|
||||
expect(received).toBeDefined()
|
||||
})
|
||||
|
||||
it('null scheduledAt: immediate send, emits to /cli normally', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-null')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
await service.sendMessage(session.id, { text: 'immediate', localId: 'local-imm' })
|
||||
|
||||
// CLI must receive the message immediately
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
|
||||
// scheduledAt must be null in DB
|
||||
const msgs = store.messages.getMessages(session.id)
|
||||
expect(msgs[0].scheduledAt).toBeNull()
|
||||
})
|
||||
|
||||
it('past scheduledAt (already mature): emits to /cli immediately', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-past')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
|
||||
const pastMs = Date.now() - 5_000
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
await service.sendMessage(session.id, {
|
||||
text: 'past scheduled',
|
||||
localId: 'local-past',
|
||||
scheduledAt: pastMs
|
||||
})
|
||||
|
||||
// Past scheduled_at is already mature → emit to CLI immediately
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
})
|
||||
|
||||
// #11 TOCTOU: isFutureScheduled must use Date.now() at check time, not the
|
||||
// pre-addMessage `now` capture, to avoid a double-emit race window.
|
||||
it('#11 TOCTOU: scheduledAt exactly equal to Date.now() is treated as mature (not future)', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-toctou')
|
||||
const publisher = makePublisher()
|
||||
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
|
||||
// Use a scheduledAt in the past to simulate TOCTOU: addMessage inserts
|
||||
// a row, then the post-insert check should use a fresh Date.now() which
|
||||
// is >= scheduledAt, treating it as mature and emitting to CLI.
|
||||
const scheduledAt = Date.now() - 1
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
await service.sendMessage(session.id, {
|
||||
text: 'toctou',
|
||||
localId: 'local-toctou',
|
||||
scheduledAt
|
||||
})
|
||||
|
||||
// scheduledAt is in the past at emit-check time → must emit to CLI
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Defence-in-depth: REST already rejects scheduledAt + attachments at the
|
||||
// Zod layer, but non-REST callers (Telegram bot, MCP, internal) reach
|
||||
// sendMessage directly and must hit the same invariant — otherwise the CLI
|
||||
// session's upload directory could be purged before the mature emit lands,
|
||||
// leaving @path attachment references pointing at deleted files.
|
||||
it('rejects sendMessage when scheduledAt is set and attachments are non-empty', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-with-attachments')
|
||||
const publisher = makePublisher()
|
||||
const service = new MessageService(store, makeNoopIo(), publisher as any)
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
await expect(
|
||||
service.sendMessage(session.id, {
|
||||
text: 'hello',
|
||||
localId: 'local-att',
|
||||
scheduledAt: futureMs,
|
||||
attachments: [{
|
||||
id: 'att-1',
|
||||
filename: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 10,
|
||||
path: '/tmp/a.png'
|
||||
}]
|
||||
})
|
||||
).rejects.toThrow(/scheduled messages with attachments/)
|
||||
|
||||
// Row must NOT have been inserted (throw is the first statement).
|
||||
const msgs = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(msgs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts sendMessage with scheduledAt and an empty attachments array', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'sched-empty-attachments')
|
||||
const publisher = makePublisher()
|
||||
const service = new MessageService(store, makeNoopIo(), publisher as any)
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
await service.sendMessage(session.id, {
|
||||
text: 'hello',
|
||||
localId: 'local-att-2',
|
||||
scheduledAt: futureMs,
|
||||
attachments: []
|
||||
})
|
||||
|
||||
const msgs = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(msgs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// releaseMatureScheduledMessages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MessageService.releaseMatureScheduledMessages', () => {
|
||||
function makeTrackingIo(): { io: Server; cliEmitted: unknown[] } {
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
return { io, cliEmitted }
|
||||
}
|
||||
|
||||
it('emits mature messages to /cli', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'release-emit')
|
||||
const publisher = makePublisher()
|
||||
const { io, cliEmitted } = makeTrackingIo()
|
||||
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
// Insert mature scheduled message directly via store
|
||||
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-r', past)
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
service.releaseMatureScheduledMessages(now)
|
||||
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does NOT call markMessagesInvoked (pitfall #2 guard): message is re-emitted on next tick', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'release-no-mark')
|
||||
const publisher = makePublisher()
|
||||
const { io, cliEmitted } = makeTrackingIo()
|
||||
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-nm', past)
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
|
||||
// First tick
|
||||
service.releaseMatureScheduledMessages(now)
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
|
||||
// Second tick (simulating hub restart without CLI ack): must re-emit
|
||||
service.releaseMatureScheduledMessages(now + 5_000)
|
||||
expect(cliEmitted).toHaveLength(2)
|
||||
|
||||
// invoked_at must still be NULL (not marked)
|
||||
const msgs = store.messages.getMessages(session.id)
|
||||
const msg = msgs.find(m => m.localId === 'local-nm')!
|
||||
expect(msg.invokedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT emit future scheduled messages', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'release-future')
|
||||
const publisher = makePublisher()
|
||||
const { io, cliEmitted } = makeTrackingIo()
|
||||
|
||||
const now = Date.now()
|
||||
const future = now + 60_000
|
||||
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-f', future)
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
service.releaseMatureScheduledMessages(now)
|
||||
|
||||
expect(cliEmitted).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does NOT emit already-invoked messages', async () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'release-invoked')
|
||||
const publisher = makePublisher()
|
||||
const { io, cliEmitted } = makeTrackingIo()
|
||||
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hi' } }, 'local-inv', past)
|
||||
store.messages.markMessagesInvoked(session.id, ['local-inv'], now - 500)
|
||||
|
||||
const service = new MessageService(store, io, publisher as any)
|
||||
service.releaseMatureScheduledMessages(now)
|
||||
|
||||
expect(cliEmitted).toHaveLength(0)
|
||||
})
|
||||
|
||||
// #10: true cold-start restart simulation — new Store + new MessageService
|
||||
// share the same SQLite file, replicating what hub restart actually does.
|
||||
it('#10 hub cold-start restart: mature message is re-emitted by new Store+Service (true restart sim)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-restart-test-'))
|
||||
const dbPath = join(dir, 'test.db')
|
||||
try {
|
||||
// First "run": write a mature scheduled message to disk
|
||||
const store1 = new Store(dbPath)
|
||||
const session = store1.sessions.getOrCreateSession('restart-test', { path: '/tmp/restart' }, null, 'default')
|
||||
const now = Date.now()
|
||||
const past = now - 2000
|
||||
store1.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'restart me' } }, 'local-restart', past)
|
||||
|
||||
// Simulate hub shutdown — close is implicit when GC'd in Bun, but we move on
|
||||
// Second "run": fresh Store + fresh MessageService (cold start)
|
||||
const store2 = new Store(dbPath)
|
||||
const cliEmitted: unknown[] = []
|
||||
const io2 = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
const publisher2 = { emit: () => {}, events: [] }
|
||||
|
||||
const service2 = new MessageService(store2, io2, publisher2 as any)
|
||||
// After cold start, first tick should discover and emit the mature message
|
||||
service2.releaseMatureScheduledMessages(now + 5_000)
|
||||
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
|
||||
// invoked_at must still be null (CLI hasn't acked yet)
|
||||
const msgs = store2.messages.getMessages(session.id)
|
||||
const msg = msgs.find(m => m.localId === 'local-restart')!
|
||||
expect(msg.invokedAt).toBeNull()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HAPI Bot R4: session-end sweep must not stamp mature scheduled rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MessageService.sweepImmediateQueuedOnSessionEnd — scheduled rows are preserved', () => {
|
||||
function makeNoopIo(): Server {
|
||||
return {
|
||||
of: (_ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: () => {},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
}
|
||||
|
||||
function makeTrackingIo(): { io: Server; cliEmitted: unknown[] } {
|
||||
const cliEmitted: unknown[] = []
|
||||
const io = {
|
||||
of: (ns: string) => ({
|
||||
to: (_room: string) => ({
|
||||
emit: (_event: string, data: unknown) => {
|
||||
if (ns === '/cli') cliEmitted.push(data)
|
||||
},
|
||||
timeout: (_ms: number) => ({ emit: () => {} })
|
||||
}),
|
||||
adapter: { rooms: { get: () => undefined } }
|
||||
})
|
||||
} as unknown as Server
|
||||
return { io, cliEmitted }
|
||||
}
|
||||
|
||||
it('mature scheduled row at session-end stays uninvoked and is emitted by the next mature scan', () => {
|
||||
// R4 race scenario A: CLI dies just after scheduled_at <= now but before
|
||||
// the next 5s mature-scan tick — the sweep must NOT touch the scheduled row.
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'r4-mature-sweep')
|
||||
const publisher = makePublisher()
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
|
||||
store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'mature scheduled' } },
|
||||
'local-mature',
|
||||
past
|
||||
)
|
||||
|
||||
const service = new MessageService(store, makeNoopIo(), publisher as any)
|
||||
|
||||
const result = service.sweepImmediateQueuedOnSessionEnd(session.id, now)
|
||||
expect(result).toBeNull()
|
||||
// No SSE side effect when there is nothing to sweep.
|
||||
expect(publisher.events.filter(e => e.type === 'messages-consumed')).toHaveLength(0)
|
||||
|
||||
// Row is still uninvoked and still mature — the next scan picks it up.
|
||||
const stillQueued = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(stillQueued.find((m) => m.localId === 'local-mature')?.invokedAt).toBeNull()
|
||||
|
||||
// Mature-scan tick after re-attach delivers the row.
|
||||
const { io, cliEmitted } = makeTrackingIo()
|
||||
const service2 = new MessageService(store, io, publisher as any)
|
||||
service2.releaseMatureScheduledMessages(now)
|
||||
expect(cliEmitted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('mature scheduled row already emitted but not yet acked stays uninvoked across session-end and is re-emitted', () => {
|
||||
// R4 race scenario B: mature scan emits at T+0, CLI receives but dies
|
||||
// before sending messages-consumed. Session-end fires while invoked_at
|
||||
// is still NULL. The sweep must preserve the row (scheduled_at IS NOT
|
||||
// NULL filter) so the next mature-scan tick re-emits it — preserving the
|
||||
// documented "re-emit until ack" contract for scheduled rows.
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'r4-emit-noack-sweep')
|
||||
const publisher = makePublisher()
|
||||
const now = Date.now()
|
||||
const past = now - 1000
|
||||
|
||||
store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'emit-noack' } },
|
||||
'local-noack',
|
||||
past
|
||||
)
|
||||
|
||||
// First mature-scan emit — does NOT write invoked_at (R3 contract).
|
||||
const { io: io1, cliEmitted: emitted1 } = makeTrackingIo()
|
||||
const service1 = new MessageService(store, io1, publisher as any)
|
||||
service1.releaseMatureScheduledMessages(now)
|
||||
expect(emitted1).toHaveLength(1)
|
||||
// Confirm invoked_at is still null (the runner crashed before acking).
|
||||
expect(
|
||||
store.messages.getUninvokedLocalMessages(session.id)
|
||||
.find(m => m.localId === 'local-noack')?.invokedAt
|
||||
).toBeNull()
|
||||
|
||||
// Session-end fires. Sweep must leave the row alone.
|
||||
const sweepResult = service1.sweepImmediateQueuedOnSessionEnd(session.id, now)
|
||||
expect(sweepResult).toBeNull()
|
||||
expect(publisher.events.filter(e => e.type === 'messages-consumed')).toHaveLength(0)
|
||||
expect(
|
||||
store.messages.getUninvokedLocalMessages(session.id)
|
||||
.find(m => m.localId === 'local-noack')?.invokedAt
|
||||
).toBeNull()
|
||||
|
||||
// Re-attach: next mature-scan tick re-emits the same row.
|
||||
const { io: io2, cliEmitted: emitted2 } = makeTrackingIo()
|
||||
const service2 = new MessageService(store, io2, publisher as any)
|
||||
service2.releaseMatureScheduledMessages(now + 5000)
|
||||
expect(emitted2).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('immediate-queued (no scheduled_at) IS swept and stamped invoked at session-end', () => {
|
||||
// Confirms the sweep still does its primary job for true immediate rows.
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'r4-immediate-sweep')
|
||||
const publisher = makePublisher()
|
||||
const now = Date.now()
|
||||
|
||||
store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'immediate' } },
|
||||
'local-imm'
|
||||
)
|
||||
|
||||
const service = new MessageService(store, makeNoopIo(), publisher as any)
|
||||
const result = service.sweepImmediateQueuedOnSessionEnd(session.id, now)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.localIds).toEqual(['local-imm'])
|
||||
|
||||
// SSE side effect carries the swept localIds for the floating bar.
|
||||
const consumed = publisher.events.find(e => e.type === 'messages-consumed') as
|
||||
| { type: 'messages-consumed'; sessionId: string; localIds: string[]; invokedAt: number }
|
||||
| undefined
|
||||
expect(consumed).toBeDefined()
|
||||
expect(consumed?.localIds).toEqual(['local-imm'])
|
||||
|
||||
// Row is now stamped — bar can clear.
|
||||
const stillQueued = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(stillQueued.find((m) => m.localId === 'local-imm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('future scheduled (scheduled_at > now) is also preserved by the sweep', () => {
|
||||
const store = makeStore()
|
||||
const session = makeSession(store, 'r4-future-sweep')
|
||||
const publisher = makePublisher()
|
||||
const now = Date.now()
|
||||
const future = now + 60_000
|
||||
|
||||
store.messages.addMessage(
|
||||
session.id,
|
||||
{ role: 'user', content: { type: 'text', text: 'future' } },
|
||||
'local-future',
|
||||
future
|
||||
)
|
||||
|
||||
const service = new MessageService(store, makeNoopIo(), publisher as any)
|
||||
const result = service.sweepImmediateQueuedOnSessionEnd(session.id, now)
|
||||
expect(result).toBeNull()
|
||||
expect(publisher.events.filter(e => e.type === 'messages-consumed')).toHaveLength(0)
|
||||
|
||||
const stillQueued = store.messages.getUninvokedLocalMessages(session.id)
|
||||
expect(stillQueued.find((m) => m.localId === 'local-future')?.invokedAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+154
-22
@@ -40,7 +40,8 @@ export class MessageService {
|
||||
localId: message.localId,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
invokedAt: message.invokedAt
|
||||
invokedAt: message.invokedAt,
|
||||
scheduledAt: message.scheduledAt
|
||||
}))
|
||||
|
||||
let oldestSeq: number | null = null
|
||||
@@ -105,7 +106,8 @@ export class MessageService {
|
||||
localId: message.localId,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
invokedAt: message.invokedAt
|
||||
invokedAt: message.invokedAt,
|
||||
scheduledAt: message.scheduledAt
|
||||
}))
|
||||
|
||||
// The cursor is the oldest row in the actual position-ordered page (pageRows[0]).
|
||||
@@ -135,15 +137,23 @@ export class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
getMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number }): DecryptedMessage[] {
|
||||
const stored = this.store.messages.getMessagesAfter(sessionId, options.afterSeq, options.limit)
|
||||
/** CLI reconnect backfill — excludes future-scheduled rows so the runner does
|
||||
* not consume them ahead of their scheduled_at. See messages.ts:getDeliverableMessagesAfter. */
|
||||
getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] {
|
||||
const stored = this.store.messages.getDeliverableMessagesAfter(
|
||||
sessionId,
|
||||
options.afterSeq,
|
||||
options.now,
|
||||
options.limit
|
||||
)
|
||||
return stored.map((message) => ({
|
||||
id: message.id,
|
||||
seq: message.seq,
|
||||
localId: message.localId,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
invokedAt: message.invokedAt
|
||||
invokedAt: message.invokedAt,
|
||||
scheduledAt: message.scheduledAt
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -169,7 +179,7 @@ export class MessageService {
|
||||
|
||||
// Phase 2: row is still queued. Ask the CLI whether it already shifted the item
|
||||
// (race window between collectBatch() shift and messages-consumed ack).
|
||||
const { localId, resolvedId } = lookup
|
||||
const { localId, resolvedId, scheduledAt } = lookup
|
||||
|
||||
if (!localId) {
|
||||
// No localId — row exists but has no cancel path; treat as cancelled.
|
||||
@@ -178,6 +188,29 @@ export class MessageService {
|
||||
return { status: 'cancelled', localId: null }
|
||||
}
|
||||
|
||||
// Phase 2b: future-scheduled messages were never emitted to the CLI, so they
|
||||
// are not in the CLI's in-memory queue. Asking the CLI whether it can remove
|
||||
// the item would always return 'not-found', which the normal ack path
|
||||
// misinterprets as "CLI already consumed it" and stamps invoked_at.
|
||||
// Short-circuit: delete the row directly without a CLI ack round-trip.
|
||||
//
|
||||
// Single event loop turn: the scheduledAt > now check and the
|
||||
// deleteQueuedMessageById call execute atomically with no await between
|
||||
// them, so the offline-CLI path's re-check pattern is unnecessary here.
|
||||
// The offline path needs the re-check because it awaits the
|
||||
// markInvoked between the lookup and the delete.
|
||||
const now = Date.now()
|
||||
if (scheduledAt !== null && scheduledAt > now) {
|
||||
this.store.messages.deleteQueuedMessageById(sessionId, resolvedId)
|
||||
this.publisher.emit({
|
||||
type: 'message-cancelled',
|
||||
sessionId,
|
||||
messageId,
|
||||
localId,
|
||||
})
|
||||
return { status: 'cancelled', localId }
|
||||
}
|
||||
|
||||
// Phase 2a: if no CLI socket is currently in the session room, the CLI is
|
||||
// offline and there is nobody to ack with. Delete the row immediately so a
|
||||
// later CLI reconnect cannot pick it up via seq-backfill and re-enqueue the
|
||||
@@ -322,8 +355,21 @@ export class MessageService {
|
||||
localId?: string | null
|
||||
attachments?: AttachmentMetadata[]
|
||||
sentFrom?: 'telegram-bot' | 'webapp'
|
||||
scheduledAt?: number | null
|
||||
}
|
||||
): Promise<void> {
|
||||
// Defence-in-depth invariant for non-REST callers (Telegram bot, MCP,
|
||||
// internal callers). Attachment paths live under the CLI session's
|
||||
// upload directory which `cleanupUploadDir` purges on session end; a
|
||||
// mature scheduled emit after the CLI exits would dereference deleted
|
||||
// files via the @path attachment formatter. REST already rejects this
|
||||
// combination at the Zod layer, but enforcing it here keeps the rule in
|
||||
// one structural place — same pattern as `addMessage`'s scheduledAt +
|
||||
// !localId throw.
|
||||
if (payload.scheduledAt != null && (payload.attachments?.length ?? 0) > 0) {
|
||||
throw new Error('sendMessage: scheduled messages with attachments are not supported')
|
||||
}
|
||||
|
||||
const sentFrom = payload.sentFrom ?? 'webapp'
|
||||
|
||||
const content = {
|
||||
@@ -338,27 +384,42 @@ export class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
const msg = this.store.messages.addMessage(sessionId, content, payload.localId ?? undefined)
|
||||
const msg = this.store.messages.addMessage(
|
||||
sessionId,
|
||||
content,
|
||||
payload.localId ?? undefined,
|
||||
payload.scheduledAt ?? null
|
||||
)
|
||||
this.onSessionActivity?.(sessionId, msg.createdAt)
|
||||
|
||||
const update = {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
body: {
|
||||
t: 'new-message' as const,
|
||||
sid: sessionId,
|
||||
message: {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
localId: msg.localId,
|
||||
content: msg.content
|
||||
// Only emit to CLI if the message is not scheduled for the future.
|
||||
// Mature or non-scheduled messages go through immediately; future scheduled
|
||||
// messages wait for the 5-second tick in releaseMatureScheduledMessages.
|
||||
// Re-measure Date.now() after addMessage to avoid a TOCTOU window where
|
||||
// the pre-insert `now` capture could misclassify a borderline scheduledAt
|
||||
// as future when it has already become past by the time we check.
|
||||
const isFutureScheduled = msg.scheduledAt !== null && msg.scheduledAt > Date.now()
|
||||
if (!isFutureScheduled) {
|
||||
const update = {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
body: {
|
||||
t: 'new-message' as const,
|
||||
sid: sessionId,
|
||||
message: {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
localId: msg.localId,
|
||||
content: msg.content
|
||||
}
|
||||
}
|
||||
}
|
||||
this.io.of('/cli').to(`session:${sessionId}`).emit('update', update)
|
||||
}
|
||||
this.io.of('/cli').to(`session:${sessionId}`).emit('update', update)
|
||||
|
||||
// Always emit message-received to Web SSE so the floating bar renders.
|
||||
this.publisher.emit({
|
||||
type: 'message-received',
|
||||
sessionId,
|
||||
@@ -368,8 +429,79 @@ export class MessageService {
|
||||
localId: msg.localId,
|
||||
content: msg.content,
|
||||
createdAt: msg.createdAt,
|
||||
invokedAt: msg.invokedAt
|
||||
invokedAt: msg.invokedAt,
|
||||
scheduledAt: msg.scheduledAt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-invoke all immediate-queued messages for a session at session end.
|
||||
*
|
||||
* Called by sessionHandlers when the CLI sends 'session-end', so that
|
||||
* the floating bar is cleared without leaving queued rows pinned forever.
|
||||
*
|
||||
* **All scheduled rows are intentionally skipped** (mature or future). The
|
||||
* mature-scan path (releaseMatureScheduledMessages) is the sole emit channel
|
||||
* for scheduled rows and relies on the CLI ack to write invoked_at; if this
|
||||
* sweep stamped a mature scheduled row, a subsequent re-attach would never
|
||||
* see the row in the next mature-scan tick and the user's prompt would be
|
||||
* silently dropped. See HAPI Bot R4 finding.
|
||||
*
|
||||
* Returns the list of localIds that were stamped and the invokedAt timestamp,
|
||||
* or null if no messages needed sweeping.
|
||||
*/
|
||||
sweepImmediateQueuedOnSessionEnd(
|
||||
sessionId: string,
|
||||
invokedAt: number
|
||||
): { localIds: string[]; invokedAt: number } | null {
|
||||
const queued = this.store.messages.getImmediateQueuedLocalMessages(sessionId)
|
||||
const localIds = queued
|
||||
.map((m) => m.localId)
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
if (localIds.length === 0) return null
|
||||
this.store.messages.markMessagesInvoked(sessionId, localIds, invokedAt)
|
||||
this.publisher.emit({ type: 'messages-consumed', sessionId, localIds, invokedAt })
|
||||
return { localIds, invokedAt }
|
||||
}
|
||||
|
||||
/** Called by the hub 5-second tick (syncEngine.expireInactive).
|
||||
*
|
||||
* Finds all scheduled messages whose scheduled_at <= now and emits them to
|
||||
* the CLI via socket.io. Does NOT call markMessagesInvoked — the CLI ack
|
||||
* (messages-consumed) handles that. This means a message is re-emitted on
|
||||
* each tick until the CLI acks it, which is the correct behaviour for hub
|
||||
* restart scenarios (pitfall #2 guard).
|
||||
*
|
||||
* Race window with cancel: this tick widens the cancel race to 5 s for
|
||||
* scheduled messages (vs near-zero for immediate-queued ones). If the CLI
|
||||
* has already shift()-ed the row when cancel arrives, cancelQueuedMessage
|
||||
* gets 'not-found' from the CLI ack and stamps invoked_at (PR #568 contract
|
||||
* preserved). Web client surfaces this as 'sent' in the thread.
|
||||
* See messageService.test.ts "cancel × mature race" for the documented
|
||||
* expected behaviour. */
|
||||
releaseMatureScheduledMessages(now: number): void {
|
||||
const mature = this.store.messages.getMatureScheduledMessages(now)
|
||||
for (const msg of mature) {
|
||||
const update = {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
body: {
|
||||
t: 'new-message' as const,
|
||||
sid: msg.sessionId,
|
||||
message: {
|
||||
id: msg.id,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt,
|
||||
localId: msg.localId,
|
||||
content: msg.content
|
||||
}
|
||||
}
|
||||
}
|
||||
this.io.of('/cli').to(`session:${msg.sessionId}`).emit('update', update)
|
||||
// NOTE: do NOT call markMessagesInvoked here (pitfall #2).
|
||||
// CLI ack (messages-consumed) will handle invoked_at stamping.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +187,8 @@ export class SyncEngine {
|
||||
return this.messageService.getMessagesPageByPosition(sessionId, options)
|
||||
}
|
||||
|
||||
getMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number }): DecryptedMessage[] {
|
||||
return this.messageService.getMessagesAfter(sessionId, options)
|
||||
getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] {
|
||||
return this.messageService.getDeliverableMessagesAfter(sessionId, options)
|
||||
}
|
||||
|
||||
handleRealtimeEvent(event: SyncEvent): void {
|
||||
@@ -271,6 +271,9 @@ export class SyncEngine {
|
||||
this.triggerDedupIfNeeded(session.id)
|
||||
}
|
||||
this.machineCache.expireInactive()
|
||||
// Piggybacked on the inactivity tick; not a logical part of expireInactive
|
||||
// but shares its 5s cadence (avoids a second timer).
|
||||
this.messageService.releaseMatureScheduledMessages(Date.now())
|
||||
}
|
||||
|
||||
private reloadAll(): void {
|
||||
@@ -308,6 +311,7 @@ export class SyncEngine {
|
||||
previewUrl?: string
|
||||
}>
|
||||
sentFrom?: 'telegram-bot' | 'webapp'
|
||||
scheduledAt?: number | null
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.messageService.sendMessage(sessionId, payload)
|
||||
@@ -321,6 +325,10 @@ export class SyncEngine {
|
||||
return this.messageService.cancelQueuedMessage(sessionId, messageId)
|
||||
}
|
||||
|
||||
sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void {
|
||||
this.messageService.sweepImmediateQueuedOnSessionEnd(sessionId, invokedAt)
|
||||
}
|
||||
|
||||
async approvePermission(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
|
||||
@@ -147,7 +147,15 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 200
|
||||
const messages = engine.getMessagesAfter(resolved.sessionId, { afterSeq: parsed.data.afterSeq, limit })
|
||||
// Future-scheduled rows are excluded from CLI backfill — see
|
||||
// messages.ts:getDeliverableMessagesAfter for the rationale. The
|
||||
// mature-scan path (releaseMatureScheduledMessages) is the sole
|
||||
// emit channel for scheduled rows.
|
||||
const messages = engine.getDeliverableMessagesAfter(resolved.sessionId, {
|
||||
afterSeq: parsed.data.afterSeq,
|
||||
limit,
|
||||
now: Date.now()
|
||||
})
|
||||
return c.json({ messages })
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Tests for the POST /sessions/:id/messages route.
|
||||
*
|
||||
* Covers:
|
||||
* - #2 server-side scheduledAt upper bound (7-day cap)
|
||||
* - #4 Zod error details exposed in response body (issues field)
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Hono } from 'hono'
|
||||
import type { SyncEngine } from '../../sync/syncEngine'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { createMessagesRoutes } from './messages'
|
||||
|
||||
// TS note: engine is cast to unknown→SyncEngine so test helpers don't need to
|
||||
// satisfy the full SyncEngine shape (only the subset the route under test uses).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createApp(opts: {
|
||||
active?: boolean
|
||||
sendMessage?: (sessionId: string, payload: unknown) => Promise<void>
|
||||
}) {
|
||||
const sentMessages: Array<{ sessionId: string; payload: unknown }> = []
|
||||
const sendMessage = opts.sendMessage ?? (async (sessionId: string, payload: unknown) => {
|
||||
sentMessages.push({ sessionId, payload })
|
||||
})
|
||||
|
||||
const engine = {
|
||||
resolveSessionAccess: () => ({
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
session: { id: 'session-1', active: opts.active !== false }
|
||||
}),
|
||||
sendMessage,
|
||||
cancelQueuedMessage: async () => ({ status: 'cancelled' }),
|
||||
getMessagesPage: () => ({ messages: [], page: {} }),
|
||||
getMessagesPageByPosition: () => ({ messages: [], page: {} }),
|
||||
} as unknown as SyncEngine
|
||||
|
||||
const app = new Hono<WebAppEnv>()
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('namespace', 'default')
|
||||
await next()
|
||||
})
|
||||
app.route('/api', createMessagesRoutes(() => engine as SyncEngine))
|
||||
|
||||
return { app, sentMessages }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2 server-side scheduledAt upper bound
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /api/sessions/:id/messages — #2 scheduledAt upper bound', () => {
|
||||
it('rejects scheduledAt more than 7 days in the future with 400 and clear message', async () => {
|
||||
const { app } = createApp({})
|
||||
|
||||
const eightDaysMs = Date.now() + 8 * 24 * 60 * 60 * 1000
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello', localId: 'local-1', scheduledAt: eightDaysMs })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json() as { error: string; issues?: { _errors?: string[] } }
|
||||
expect(body.error).toBe('Invalid body')
|
||||
// #4: issues field must be present
|
||||
expect(body.issues).toBeDefined()
|
||||
// The 7-day message must appear somewhere in the issues
|
||||
const issuesStr = JSON.stringify(body.issues)
|
||||
expect(issuesStr).toContain('7 days')
|
||||
})
|
||||
|
||||
it('accepts scheduledAt exactly at the 7-day boundary (inclusive)', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
// Use slightly less than 7 days to avoid flakiness at the exact boundary
|
||||
const nearlySevenDays = Date.now() + 7 * 24 * 60 * 60 * 1000 - 1000
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello', localId: 'local-2', scheduledAt: nearlySevenDays })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts null scheduledAt (immediate send)', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello', localId: 'local-3', scheduledAt: null })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts missing scheduledAt (immediate send)', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #4 Zod error details in response body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /api/sessions/:id/messages — #4 Zod error issues in response', () => {
|
||||
it('returns issues when scheduledAt is set but localId is missing', async () => {
|
||||
const { app } = createApp({})
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello', scheduledAt: futureMs })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json() as { error: string; issues?: unknown }
|
||||
expect(body.error).toBe('Invalid body')
|
||||
expect(body.issues).toBeDefined()
|
||||
const issuesStr = JSON.stringify(body.issues)
|
||||
expect(issuesStr).toContain('localId')
|
||||
})
|
||||
|
||||
it('returns issues with a non-string text field', async () => {
|
||||
const { app } = createApp({})
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text: 123 })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json() as { error: string; issues?: unknown }
|
||||
expect(body.error).toBe('Invalid body')
|
||||
expect(body.issues).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HAPI Bot R3 finding 3: scheduledAt + attachments rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /api/sessions/:id/messages — scheduledAt + attachments rejected', () => {
|
||||
it('rejects scheduledAt combined with non-empty attachments with 400', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: 'hello',
|
||||
localId: 'local-att',
|
||||
scheduledAt: futureMs,
|
||||
attachments: [{ id: 'att-1', filename: 'a.png', mimeType: 'image/png', size: 10, path: '/tmp/a.png' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json() as { error: string; issues?: unknown }
|
||||
expect(body.error).toBe('Invalid body')
|
||||
const issuesStr = JSON.stringify(body.issues)
|
||||
expect(issuesStr).toContain('attachments')
|
||||
expect(sentMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts scheduledAt with empty attachments array', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
const futureMs = Date.now() + 60_000
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: 'hello',
|
||||
localId: 'local-att-2',
|
||||
scheduledAt: futureMs,
|
||||
attachments: []
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts immediate send with attachments (no scheduledAt)', async () => {
|
||||
const { app, sentMessages } = createApp({})
|
||||
|
||||
const response = await app.request('/api/sessions/session-1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: 'hello',
|
||||
attachments: [{ id: 'att-2', filename: 'b.png', mimeType: 'image/png', size: 10, path: '/tmp/b.png' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(sentMessages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,30 @@ const querySchema = z.object({
|
||||
const sendMessageBodySchema = z.object({
|
||||
text: z.string(),
|
||||
localId: z.string().min(1).optional(),
|
||||
attachments: z.array(AttachmentMetadataSchema).optional()
|
||||
})
|
||||
attachments: z.array(AttachmentMetadataSchema).optional(),
|
||||
scheduledAt: z.number().int().positive().nullable().optional()
|
||||
}).refine(
|
||||
// Scheduled messages need a localId so the ack flow (markMessagesInvoked
|
||||
// by localId) can flip invoked_at after the CLI consumes them. Without
|
||||
// a localId, addMessage stamps invoked_at immediately, which would
|
||||
// silently swallow the schedule.
|
||||
(data) => data.scheduledAt == null || typeof data.localId === 'string',
|
||||
{ message: 'scheduledAt requires localId', path: ['localId'] }
|
||||
).refine(
|
||||
// Cap scheduledAt at 7 days from now to prevent zombie rows. REST/Telegram/
|
||||
// automation callers bypass the frontend 7-day clamp, so we enforce it here.
|
||||
// Evaluated at request time so Date.now() is fresh on every call.
|
||||
(data) => data.scheduledAt == null || data.scheduledAt <= Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
{ message: 'scheduledAt must be within 7 days from now', path: ['scheduledAt'] }
|
||||
).refine(
|
||||
// Attachment paths are stored under the CLI session's upload directory and
|
||||
// purged on session end (cleanupUploadDir in apiSession.ts:sendSessionDeath).
|
||||
// A scheduled message that matures after the CLI exits would dereference
|
||||
// deleted files via the @path attachment formatter. Reject the combination
|
||||
// until uploads are retained through invocation.
|
||||
(data) => data.scheduledAt == null || !data.attachments?.length,
|
||||
{ message: 'scheduled messages with attachments are not supported', path: ['attachments'] }
|
||||
)
|
||||
|
||||
export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Hono<WebAppEnv> {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
@@ -83,7 +105,7 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
const body = await c.req.json().catch(() => null)
|
||||
const parsed = sendMessageBodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid body' }, 400)
|
||||
return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400)
|
||||
}
|
||||
|
||||
// Require text or attachments
|
||||
@@ -95,7 +117,8 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
text: parsed.data.text,
|
||||
localId: parsed.data.localId,
|
||||
attachments: parsed.data.attachments,
|
||||
sentFrom: 'webapp'
|
||||
sentFrom: 'webapp',
|
||||
scheduledAt: parsed.data.scheduledAt
|
||||
})
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -171,7 +171,8 @@ export const DecryptedMessageSchema = z.object({
|
||||
localId: z.string().nullable(),
|
||||
content: z.unknown(),
|
||||
createdAt: z.number(),
|
||||
invokedAt: z.number().nullable().optional()
|
||||
invokedAt: z.number().nullable().optional(),
|
||||
scheduledAt: z.number().nullable().optional()
|
||||
})
|
||||
|
||||
export type DecryptedMessage = z.infer<typeof DecryptedMessageSchema>
|
||||
|
||||
@@ -322,13 +322,14 @@ export class ApiClient {
|
||||
return response.sessionId
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise<void> {
|
||||
async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
localId: localId ?? undefined,
|
||||
attachments: attachments ?? undefined
|
||||
attachments: attachments ?? undefined,
|
||||
scheduledAt: scheduledAt ?? undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ConversationStatus } from '@/realtime/types'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { ScheduleTimePicker } from './ScheduleTimePicker'
|
||||
import type { PendingSchedule } from './ScheduleTimePicker'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
function ScheduleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polyline points="12 7 12 12 15.5 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceAssistantIcon() {
|
||||
return (
|
||||
@@ -319,9 +341,22 @@ export function ComposerButtons(props: {
|
||||
onVoiceToggle: () => void
|
||||
onVoiceMicToggle?: () => void
|
||||
onSend: () => void
|
||||
pendingSchedule?: PendingSchedule | null
|
||||
onSchedule?: (pending: PendingSchedule) => void
|
||||
onClearSchedule?: () => void
|
||||
// The backend rejects scheduled-send + attachment combinations (the per-CLI
|
||||
// upload directory is torn down before a mature emit could read the files).
|
||||
// The composer must surface that constraint at UI time so the user never
|
||||
// builds a submission the hub will reject — see hub/web/routes/messages.ts.
|
||||
hasAttachments?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isVoiceConnected = props.voiceStatus === 'connected'
|
||||
const [showSchedulePicker, setShowSchedulePicker] = useState(false)
|
||||
const scheduleButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const hasSchedule = props.pendingSchedule != null
|
||||
const hasAttachments = props.hasAttachments ?? false
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
@@ -329,7 +364,7 @@ export function ComposerButtons(props: {
|
||||
<ComposerPrimitive.AddAttachment
|
||||
aria-label={t('composer.attach')}
|
||||
title={t('composer.attach')}
|
||||
disabled={props.controlsDisabled}
|
||||
disabled={props.controlsDisabled || hasSchedule}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<AttachmentIcon />
|
||||
@@ -402,6 +437,44 @@ export function ComposerButtons(props: {
|
||||
<SpeakerIcon muted={props.voiceMicMuted} />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Schedule button — only shown when onSchedule handler is provided */}
|
||||
{props.onSchedule ? (
|
||||
<>
|
||||
<button
|
||||
ref={scheduleButtonRef}
|
||||
type="button"
|
||||
aria-label={t('composer.scheduleSend')}
|
||||
title={t('composer.scheduleSend')}
|
||||
disabled={props.controlsDisabled || hasAttachments}
|
||||
onClick={() => {
|
||||
if (hasSchedule && props.onClearSchedule) {
|
||||
props.onClearSchedule()
|
||||
} else {
|
||||
setShowSchedulePicker((v) => !v)
|
||||
}
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
hasSchedule
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'text-[var(--app-fg)]/60 hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]'
|
||||
}`}
|
||||
>
|
||||
<ScheduleIcon />
|
||||
</button>
|
||||
{showSchedulePicker && (
|
||||
<ScheduleTimePicker
|
||||
anchorRef={scheduleButtonRef}
|
||||
onSchedule={(pending) => {
|
||||
props.onSchedule!(pending)
|
||||
setShowSchedulePicker(false)
|
||||
}}
|
||||
onClose={() => setShowSchedulePicker(false)}
|
||||
pendingSchedule={props.pendingSchedule}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<UnifiedButton
|
||||
|
||||
@@ -28,6 +28,7 @@ import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
|
||||
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
|
||||
import { StatusBar } from '@/components/AssistantChat/StatusBar'
|
||||
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions'
|
||||
@@ -76,6 +77,10 @@ export function HappyComposer(props: {
|
||||
voiceMicMuted?: boolean
|
||||
onVoiceToggle?: () => void
|
||||
onVoiceMicToggle?: () => void
|
||||
// Schedule props (lifted from internal state when provided)
|
||||
pendingSchedule?: PendingSchedule | null
|
||||
onSchedule?: (pending: PendingSchedule) => void
|
||||
onClearSchedule?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -111,7 +116,10 @@ export function HappyComposer(props: {
|
||||
voiceStatus = 'disconnected',
|
||||
voiceMicMuted = false,
|
||||
onVoiceToggle,
|
||||
onVoiceMicToggle
|
||||
onVoiceMicToggle,
|
||||
pendingSchedule: pendingScheduleProp,
|
||||
onSchedule: onScheduleProp,
|
||||
onClearSchedule: onClearScheduleProp
|
||||
} = props
|
||||
|
||||
// Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
|
||||
@@ -152,6 +160,11 @@ export function HappyComposer(props: {
|
||||
const [isAborting, setIsAborting] = useState(false)
|
||||
const [isSwitching, setIsSwitching] = useState(false)
|
||||
const [showContinueHint, setShowContinueHint] = useState(false)
|
||||
// pendingSchedule is controlled externally when onSchedule prop is provided; otherwise local state
|
||||
const [pendingScheduleLocal, setPendingScheduleLocal] = useState<PendingSchedule | null>(null)
|
||||
const isControlled = onScheduleProp !== undefined
|
||||
const pendingSchedule = isControlled ? (pendingScheduleProp ?? null) : pendingScheduleLocal
|
||||
const setPendingSchedule = isControlled ? onScheduleProp : setPendingScheduleLocal
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const prevControlledByUser = useRef(controlledByUser)
|
||||
@@ -427,6 +440,16 @@ export function HappyComposer(props: {
|
||||
|
||||
if (imageFiles.length === 0) return
|
||||
|
||||
// The backend rejects scheduledAt + attachments (per-CLI upload dir is
|
||||
// torn down before a mature emit could read the files). The button-based
|
||||
// attachment flow is disabled by ComposerButtons.hasAttachments, but the
|
||||
// paste path bypasses that — guard here so a pasted image while a
|
||||
// schedule is active cannot produce a submission the hub will reject.
|
||||
if (pendingSchedule != null) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
@@ -436,7 +459,7 @@ export function HappyComposer(props: {
|
||||
} catch (error) {
|
||||
console.error('Error adding pasted image:', error)
|
||||
}
|
||||
}, [api])
|
||||
}, [api, pendingSchedule])
|
||||
|
||||
const handleSettingsToggle = useCallback(() => {
|
||||
haptic('light')
|
||||
@@ -503,6 +526,11 @@ export function HappyComposer(props: {
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
api.composer().send()
|
||||
// SessionChat owns clearing the schedule — it clears only after awaiting
|
||||
// the send hook's accepted result, which covers both pre-mutation guards
|
||||
// and async inactive-session resume failure. Clearing here unconditionally
|
||||
// would race ahead of that check and drop the user's schedule on every
|
||||
// rejected send path.
|
||||
}, [api])
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
@@ -829,6 +857,10 @@ export function HappyComposer(props: {
|
||||
onVoiceToggle={onVoiceToggle ?? (() => {})}
|
||||
onVoiceMicToggle={onVoiceMicToggle}
|
||||
onSend={handleSend}
|
||||
pendingSchedule={pendingSchedule}
|
||||
onSchedule={setPendingSchedule}
|
||||
onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)}
|
||||
hasAttachments={hasAttachments}
|
||||
/>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeCanCancel } from './QueuedMessagesBar'
|
||||
import type { DecryptedMessage } from '@/types/api'
|
||||
import { computeCanCancel, computeEditPendingSchedule, formatScheduledTime, sortQueuedMessages } from './QueuedMessagesBar'
|
||||
|
||||
/**
|
||||
* Unit tests for computeCanCancel — the race guard that prevents sending
|
||||
@@ -56,3 +57,100 @@ describe('computeCanCancel', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #4 computeEditPendingSchedule — edit restores scheduledAt as absolute pending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeEditPendingSchedule', () => {
|
||||
it('returns null for immediate-queued message (no scheduledAt)', () => {
|
||||
const now = Date.now()
|
||||
expect(computeEditPendingSchedule(null, now)).toBeNull()
|
||||
expect(computeEditPendingSchedule(undefined, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for scheduledAt in the past (message matured)', () => {
|
||||
const now = Date.now()
|
||||
const past = now - 5000 // 5 seconds ago
|
||||
expect(computeEditPendingSchedule(past, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns absolute PendingSchedule for future scheduledAt', () => {
|
||||
const now = Date.now()
|
||||
const future = now + 60_000 // 1 minute from now
|
||||
const result = computeEditPendingSchedule(future, now)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('absolute')
|
||||
if (result?.type === 'absolute') {
|
||||
expect(result.ms).toBe(future)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortQueuedMessages', () => {
|
||||
const make = (id: string, createdAt: number, scheduledAt: number | null = null): DecryptedMessage => ({
|
||||
id,
|
||||
localId: id,
|
||||
createdAt,
|
||||
seq: createdAt,
|
||||
scheduledAt,
|
||||
invokedAt: null,
|
||||
content: { role: 'user', content: { type: 'text', text: id } },
|
||||
} as unknown as DecryptedMessage)
|
||||
|
||||
it('places immediate-queued messages before scheduled ones', () => {
|
||||
const a = make('a-immediate', 1000)
|
||||
const b = make('b-scheduled-soon', 500, Date.now() + 60_000)
|
||||
const result = sortQueuedMessages([b, a])
|
||||
expect(result.map((m) => m.id)).toEqual(['a-immediate', 'b-scheduled-soon'])
|
||||
})
|
||||
|
||||
it('orders immediate-queued messages by createdAt ascending', () => {
|
||||
const older = make('older', 1000)
|
||||
const newer = make('newer', 2000)
|
||||
const result = sortQueuedMessages([newer, older])
|
||||
expect(result.map((m) => m.id)).toEqual(['older', 'newer'])
|
||||
})
|
||||
|
||||
it('orders scheduled messages by scheduledAt ascending (soonest first)', () => {
|
||||
const later = make('fires-later', 1000, 10_000)
|
||||
const sooner = make('fires-sooner', 2000, 5_000)
|
||||
const result = sortQueuedMessages([later, sooner])
|
||||
expect(result.map((m) => m.id)).toEqual(['fires-sooner', 'fires-later'])
|
||||
})
|
||||
|
||||
it('combined: immediate first, then scheduled in fire-time order', () => {
|
||||
const im1 = make('im1', 1000)
|
||||
const im2 = make('im2', 2000)
|
||||
const sched1 = make('sched-near', 500, 5_000)
|
||||
const sched2 = make('sched-far', 600, 10_000)
|
||||
const result = sortQueuedMessages([sched2, im2, sched1, im1])
|
||||
expect(result.map((m) => m.id)).toEqual(['im1', 'im2', 'sched-near', 'sched-far'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatScheduledTime — cross-year support (#8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatScheduledTime', () => {
|
||||
it('omits year for a date in the current year', () => {
|
||||
const now = new Date()
|
||||
// Use a date 1 month ahead in the same year, guarding against Dec edge case
|
||||
const sameYearDate = new Date(now.getFullYear(), now.getMonth() + 1 < 12 ? now.getMonth() + 1 : 0, 15, 10, 30)
|
||||
if (sameYearDate.getFullYear() !== now.getFullYear()) {
|
||||
// Wrapped to next year — skip (edge case in late December)
|
||||
return
|
||||
}
|
||||
const result = formatScheduledTime(sameYearDate.getTime())
|
||||
// Year digits should not appear
|
||||
expect(result).not.toContain(String(now.getFullYear()))
|
||||
})
|
||||
|
||||
it('includes year for a date in a different year', () => {
|
||||
const nextYear = new Date().getFullYear() + 1
|
||||
const crossYearDate = new Date(nextYear, 0, 15, 10, 30) // Jan 15 next year
|
||||
const result = formatScheduledTime(crossYearDate.getTime())
|
||||
expect(result).toContain(String(nextYear))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAssistantApi } from '@assistant-ui/react'
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import { getMessageWindowState, subscribeMessageWindow } from '@/lib/message-window-store'
|
||||
import { isQueuedForInvocation } from '@/lib/messages'
|
||||
@@ -7,6 +7,9 @@ import { EMPTY_STATE } from '@/hooks/queries/useMessages'
|
||||
import { normalizeDecryptedMessage } from '@/chat/normalize'
|
||||
import type { DecryptedMessage } from '@/types/api'
|
||||
import { useCancelQueuedMessage } from '@/hooks/mutations/useCancelQueuedMessage'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { useToast } from '@/lib/toast-context'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
@@ -28,6 +31,28 @@ function ClockIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders queued messages so the floating bar reads top-down as a single timeline:
|
||||
* 1. Immediate-queued messages first, in the order they were submitted.
|
||||
* 2. Scheduled messages after, ordered by their fire time (soonest first).
|
||||
*
|
||||
* Without this the bar follows insertion order, which mixes immediate and
|
||||
* scheduled rows arbitrarily and makes the "what fires next" question
|
||||
* harder to answer at a glance.
|
||||
*
|
||||
* @internal Exported for unit testing.
|
||||
*/
|
||||
export function sortQueuedMessages(msgs: DecryptedMessage[]): DecryptedMessage[] {
|
||||
return [...msgs].sort((a, b) => {
|
||||
const aSched = a.scheduledAt != null
|
||||
const bSched = b.scheduledAt != null
|
||||
if (aSched !== bSched) return aSched ? 1 : -1
|
||||
// Both scheduledAt values are non-null here (aSched && bSched is true above).
|
||||
if (aSched && bSched) return a.scheduledAt! - b.scheduledAt!
|
||||
return (a.createdAt ?? 0) - (b.createdAt ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns user messages that haven't been invoked yet (invokedAt == null and not sent/failed).
|
||||
* Covers both optimistic (status='queued') and server-loaded (status=undefined, invokedAt=null) cases.
|
||||
@@ -42,8 +67,12 @@ function useQueuedMessages(sessionId: string): DecryptedMessage[] {
|
||||
// `invokedAt` is the source of truth for invocation; see isQueuedForInvocation
|
||||
// (lib/messages) for the shared predicate used by the thread filter and the
|
||||
// window store trim helpers.
|
||||
const allMessages = [...state.messages, ...state.pending]
|
||||
return allMessages.filter(isQueuedForInvocation)
|
||||
// useSyncExternalStore guarantees a stable reference when the snapshot is
|
||||
// unchanged, so [state] as the dependency avoids unnecessary re-sorts.
|
||||
return useMemo(() => {
|
||||
const allMessages = [...state.messages, ...state.pending]
|
||||
return sortQueuedMessages(allMessages.filter(isQueuedForInvocation))
|
||||
}, [state])
|
||||
}
|
||||
|
||||
function getTextFromMessage(msg: DecryptedMessage): string {
|
||||
@@ -65,6 +94,24 @@ function getTextFromMessage(msg: DecryptedMessage): string {
|
||||
return attachments.map((a) => a.filename ?? 'attachment').join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the PendingSchedule to restore when editing a queued message.
|
||||
*
|
||||
* - If the message has a future scheduledAt, return { type: 'absolute', ms } so the
|
||||
* user can re-send with the same specific time (or adjust it).
|
||||
* - If scheduledAt is null, undefined, or in the past (message already matured),
|
||||
* return null so the re-sent message goes out immediately.
|
||||
*
|
||||
* @internal Exported for unit testing.
|
||||
*/
|
||||
export function computeEditPendingSchedule(
|
||||
scheduledAt: number | null | undefined,
|
||||
now: number
|
||||
): PendingSchedule | null {
|
||||
if (scheduledAt == null || scheduledAt <= now) return null
|
||||
return { type: 'absolute', ms: scheduledAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the user can cancel or edit a queued message.
|
||||
*
|
||||
@@ -100,10 +147,41 @@ export function computeCanCancel({
|
||||
* Edit = client-side cancel + prefill composer with message text (Codex dialect).
|
||||
* Cancel = DELETE /sessions/:id/messages/:messageId with optimistic removal.
|
||||
*/
|
||||
export function QueuedMessagesBar({ sessionId, api }: { sessionId: string; api: ApiClient | null }) {
|
||||
/** @internal Exported for unit testing. */
|
||||
export function formatScheduledTime(scheduledAt: number): string {
|
||||
const date = new Date(scheduledAt)
|
||||
const now = new Date()
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
if (date.getFullYear() !== now.getFullYear()) {
|
||||
opts.year = 'numeric'
|
||||
}
|
||||
return date.toLocaleString(undefined, opts)
|
||||
}
|
||||
|
||||
export function QueuedMessagesBar({
|
||||
sessionId,
|
||||
api,
|
||||
onEdit,
|
||||
}: {
|
||||
sessionId: string
|
||||
api: ApiClient | null
|
||||
/**
|
||||
* Called when the user clicks Edit on a queued message.
|
||||
* The parent should restore `text` into the composer and `pendingSchedule` into the schedule state.
|
||||
* Edit is always cancel + prefill, regardless of whether the message is scheduled or immediate.
|
||||
*/
|
||||
onEdit?: (params: { text: string; pendingSchedule: PendingSchedule | null }) => void
|
||||
}) {
|
||||
const queued = useQueuedMessages(sessionId)
|
||||
const assistantApi = useAssistantApi()
|
||||
const cancelMutation = useCancelQueuedMessage(api)
|
||||
const { t } = useTranslation()
|
||||
const { addToast } = useToast()
|
||||
|
||||
if (queued.length === 0) {
|
||||
return null
|
||||
@@ -142,7 +220,10 @@ export function QueuedMessagesBar({ sessionId, api }: { sessionId: string; api:
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!canCancel) return
|
||||
// Edit = cancel + prefill composer (Codex dialect: no separate edit mode).
|
||||
// Edit = cancel + restore composer (text + schedule).
|
||||
// Works the same for immediate-queued and future-scheduled messages.
|
||||
const restoredPendingSchedule = computeEditPendingSchedule(msg.scheduledAt, Date.now())
|
||||
|
||||
cancelMutation.mutate(
|
||||
{
|
||||
sessionId,
|
||||
@@ -152,32 +233,53 @@ export function QueuedMessagesBar({ sessionId, api }: { sessionId: string; api:
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
// Race guard: if the agent already consumed this message, skip prefill.
|
||||
// The hook's own onSuccess already reverted the optimistic removal.
|
||||
if (result.status === 'invoked') return
|
||||
// Only prefill if text is available; attachment-only rows get empty string.
|
||||
const prefillText = text
|
||||
if (prefillText) {
|
||||
assistantApi.composer().setText(prefillText)
|
||||
// Race guard: if the agent already consumed this message, skip prefill
|
||||
// and inform the user so they aren't confused by the row disappearing.
|
||||
if (result.status === 'invoked') {
|
||||
addToast({
|
||||
title: t('queuedMessages.editAlreadyInvoked'),
|
||||
body: '',
|
||||
sessionId,
|
||||
url: window.location.href,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Restore text into composer
|
||||
if (text) {
|
||||
assistantApi.composer().setText(text)
|
||||
}
|
||||
// Restore schedule via parent callback (if provided)
|
||||
onEdit?.({ text, pendingSchedule: restoredPendingSchedule })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const canEdit = canCancel
|
||||
|
||||
return (
|
||||
<li
|
||||
key={msg.localId ?? msg.id}
|
||||
className="flex items-start gap-2 min-w-0 rounded-lg bg-[var(--app-secondary-bg)] px-3 py-2 shadow-sm"
|
||||
>
|
||||
<span className="flex-1 line-clamp-3 whitespace-pre-wrap break-words text-[var(--app-fg)]">
|
||||
{text}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-3 whitespace-pre-wrap break-words text-[var(--app-fg)]">
|
||||
{text}
|
||||
</span>
|
||||
{msg.scheduledAt != null && msg.scheduledAt > Date.now() && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-[var(--app-hint)]">
|
||||
<ClockIcon />
|
||||
<span>
|
||||
{t('queuedMessages.scheduledFor', { time: formatScheduledTime(msg.scheduledAt) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit queued message"
|
||||
disabled={!canCancel}
|
||||
disabled={!canEdit}
|
||||
onClick={handleEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-border)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampToMaxDays, parsePreset, validateSpecificDatetime, resolvePendingSchedule } from './ScheduleTimePicker'
|
||||
import type { PendingSchedule } from './ScheduleTimePicker'
|
||||
|
||||
/**
|
||||
* Unit tests for ScheduleTimePicker pure functions.
|
||||
* Tests the clamping, preset parsing, and validation logic independent of React.
|
||||
*/
|
||||
describe('parsePreset', () => {
|
||||
it('+5m returns Date.now() + 5 minutes in ms', () => {
|
||||
const now = Date.now()
|
||||
const result = parsePreset('+5m', now)
|
||||
expect(result).toBe(now + 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('+30m returns Date.now() + 30 minutes in ms', () => {
|
||||
const now = Date.now()
|
||||
const result = parsePreset('+30m', now)
|
||||
expect(result).toBe(now + 30 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('+1h returns Date.now() + 1 hour in ms', () => {
|
||||
const now = Date.now()
|
||||
const result = parsePreset('+1h', now)
|
||||
expect(result).toBe(now + 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('+4h returns Date.now() + 4 hours in ms', () => {
|
||||
const now = Date.now()
|
||||
const result = parsePreset('+4h', now)
|
||||
expect(result).toBe(now + 4 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampToMaxDays', () => {
|
||||
it('returns value unchanged when within 7 days', () => {
|
||||
const now = Date.now()
|
||||
const future = now + 2 * 24 * 60 * 60 * 1000 // 2 days
|
||||
expect(clampToMaxDays(future, now, 7)).toBe(future)
|
||||
})
|
||||
|
||||
it('clamps value to now + 7 days when beyond limit', () => {
|
||||
const now = Date.now()
|
||||
const tooFar = now + 8 * 24 * 60 * 60 * 1000 // 8 days
|
||||
const expected = now + 7 * 24 * 60 * 60 * 1000
|
||||
expect(clampToMaxDays(tooFar, now, 7)).toBe(expected)
|
||||
})
|
||||
|
||||
it('returns exact boundary (7 days) unchanged', () => {
|
||||
const now = Date.now()
|
||||
const boundary = now + 7 * 24 * 60 * 60 * 1000
|
||||
expect(clampToMaxDays(boundary, now, 7)).toBe(boundary)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #3 PendingSchedule + resolvePendingSchedule — send-time base for presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolvePendingSchedule', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(resolvePendingSchedule(null, Date.now())).toBeNull()
|
||||
})
|
||||
|
||||
it('preset: resolves delay relative to sendNow (not pick time)', () => {
|
||||
const pickTime = Date.now() - 30_000 // picked 30s ago
|
||||
const sendNow = Date.now()
|
||||
const pending: PendingSchedule = { type: 'preset', preset: '+5m' }
|
||||
const result = resolvePendingSchedule(pending, sendNow)
|
||||
// Should be sendNow + 5 min, NOT pickTime + 5 min
|
||||
expect(result).toBe(sendNow + 5 * 60 * 1000)
|
||||
// Confirm it differs from "pick-time base"
|
||||
const pickBase = pickTime + 5 * 60 * 1000
|
||||
expect(result).not.toBe(pickBase)
|
||||
})
|
||||
|
||||
it('preset +30m resolves correctly', () => {
|
||||
const sendNow = 1_700_000_000_000
|
||||
const pending: PendingSchedule = { type: 'preset', preset: '+30m' }
|
||||
expect(resolvePendingSchedule(pending, sendNow)).toBe(sendNow + 30 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('preset +1h resolves correctly', () => {
|
||||
const sendNow = 1_700_000_000_000
|
||||
const pending: PendingSchedule = { type: 'preset', preset: '+1h' }
|
||||
expect(resolvePendingSchedule(pending, sendNow)).toBe(sendNow + 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('preset +4h resolves correctly', () => {
|
||||
const sendNow = 1_700_000_000_000
|
||||
const pending: PendingSchedule = { type: 'preset', preset: '+4h' }
|
||||
expect(resolvePendingSchedule(pending, sendNow)).toBe(sendNow + 4 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('absolute: returns stored ms unchanged regardless of sendNow', () => {
|
||||
const ms = 1_700_000_000_000 + 60_000
|
||||
const sendNow = 1_700_000_000_000 + 999_999 // very different from pick time
|
||||
const pending: PendingSchedule = { type: 'absolute', ms }
|
||||
expect(resolvePendingSchedule(pending, sendNow)).toBe(ms)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSpecificDatetime', () => {
|
||||
it('returns null for a future datetime within 7 days', () => {
|
||||
const now = Date.now()
|
||||
const future = now + 60 * 60 * 1000 // 1 hour from now
|
||||
expect(validateSpecificDatetime(future, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error key for a past datetime', () => {
|
||||
const now = Date.now()
|
||||
const past = now - 60 * 1000
|
||||
expect(validateSpecificDatetime(past, now)).toBe('scheduleErrorPast')
|
||||
})
|
||||
|
||||
it('returns error key for a datetime beyond 7 days', () => {
|
||||
const now = Date.now()
|
||||
const tooFar = now + 8 * 24 * 60 * 60 * 1000
|
||||
expect(validateSpecificDatetime(tooFar, now)).toBe('scheduleErrorTooFar')
|
||||
})
|
||||
|
||||
it('returns null for datetime exactly at now + 1s (boundary)', () => {
|
||||
const now = Date.now()
|
||||
const boundary = now + 1000
|
||||
expect(validateSpecificDatetime(boundary, now)).toBeNull()
|
||||
})
|
||||
|
||||
// #9: 30-second grace period — datetime-local minute resolution means the
|
||||
// selected minute can become "in the past" by the time the user clicks.
|
||||
it('#9 grace period: returns null for datetime up to 30s in the past (click delay)', () => {
|
||||
const now = Date.now()
|
||||
const slightlyPast = now - 29_000 // 29 seconds ago — within grace
|
||||
expect(validateSpecificDatetime(slightlyPast, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('#9 grace period: returns error for datetime more than 30s in the past', () => {
|
||||
const now = Date.now()
|
||||
const tooOld = now - 31_000 // 31 seconds ago — beyond grace
|
||||
expect(validateSpecificDatetime(tooOld, now)).toBe('scheduleErrorPast')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PendingSchedule — discriminated union for "what the user chose"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Represents a schedule selection before it is resolved to an absolute epoch-ms.
|
||||
*
|
||||
* - preset: user clicked a relative preset (e.g. '+5m'). The absolute time is
|
||||
* computed at send time (Date.now() + delay) so "5 minutes from now" always
|
||||
* means 5 minutes from when the user hits Send, not when they clicked the preset.
|
||||
* - absolute: user picked a specific datetime-local value. Stored as epoch-ms;
|
||||
* unchanged at send time.
|
||||
*/
|
||||
export type PendingSchedule =
|
||||
| { type: 'preset'; preset: '+5m' | '+30m' | '+1h' | '+4h' }
|
||||
| { type: 'absolute'; ms: number }
|
||||
|
||||
/**
|
||||
* Convert a PendingSchedule to an absolute epoch-ms at send time.
|
||||
* Returns null if pending is null.
|
||||
*
|
||||
* For 'preset' entries the base time is sendNow (the moment the user hits Send),
|
||||
* so "5 minutes from now" is always relative to the actual send action.
|
||||
* For 'absolute' entries the stored ms value is returned unchanged.
|
||||
*/
|
||||
export function resolvePendingSchedule(pending: PendingSchedule | null, sendNow: number): number | null {
|
||||
if (pending === null) return null
|
||||
if (pending.type === 'preset') return parsePreset(pending.preset, sendNow)
|
||||
return pending.ms
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure utility functions (exported for unit testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse a preset string like '+5m', '+30m', '+1h', '+4h' into an epoch-ms timestamp. */
|
||||
export function parsePreset(preset: string, now: number): number {
|
||||
if (preset === '+5m') return now + 5 * 60 * 1000
|
||||
if (preset === '+30m') return now + 30 * 60 * 1000
|
||||
if (preset === '+1h') return now + 60 * 60 * 1000
|
||||
if (preset === '+4h') return now + 4 * 60 * 60 * 1000
|
||||
throw new Error(`Unknown preset: ${preset}`)
|
||||
}
|
||||
|
||||
/** Clamp an epoch-ms value so it does not exceed now + maxDays days. */
|
||||
export function clampToMaxDays(value: number, now: number, maxDays: number): number {
|
||||
const max = now + maxDays * 24 * 60 * 60 * 1000
|
||||
return Math.min(value, max)
|
||||
}
|
||||
|
||||
/** Validate a specific datetime (epoch ms) against now.
|
||||
* Returns null if valid, or a translation key string if invalid.
|
||||
*
|
||||
* A 30-second grace period is allowed for values slightly in the past:
|
||||
* datetime-local inputs have minute resolution, so the selected minute can
|
||||
* become "in the past" by the time the user clicks Submit. This avoids a
|
||||
* frustrating stale-invalid UX with no visible error cause. */
|
||||
export function validateSpecificDatetime(
|
||||
value: number,
|
||||
now: number
|
||||
): 'scheduleErrorPast' | 'scheduleErrorTooFar' | null {
|
||||
const GRACE_MS = 30_000 // 30 seconds
|
||||
if (value < now - GRACE_MS) return 'scheduleErrorPast'
|
||||
const maxFuture = now + 7 * 24 * 60 * 60 * 1000
|
||||
if (value > maxFuture) return 'scheduleErrorTooFar'
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESETS = ['+5m', '+30m', '+1h', '+4h'] as const
|
||||
type Preset = typeof PRESETS[number]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScheduleTimePickerProps {
|
||||
/** Called with a PendingSchedule when user confirms a schedule selection. */
|
||||
onSchedule: (pending: PendingSchedule) => void
|
||||
/** Called when the panel should close without scheduling. */
|
||||
onClose: () => void
|
||||
/**
|
||||
* The anchor element (the clock button). Used to position the panel with
|
||||
* `position: fixed` so it escapes any `overflow: hidden` ancestor.
|
||||
*/
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>
|
||||
/** Currently active pending schedule, used to highlight the selected preset. */
|
||||
pendingSchedule?: PendingSchedule | null
|
||||
}
|
||||
|
||||
export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSchedule }: ScheduleTimePickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [tab, setTab] = useState<'relative' | 'specific'>('relative')
|
||||
const [specificValue, setSpecificValue] = useState('')
|
||||
const [specificError, setSpecificError] = useState<string | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Compute fixed position above the anchor button, re-measure on resize/scroll
|
||||
useLayoutEffect(() => {
|
||||
function measure() {
|
||||
const anchor = anchorRef.current
|
||||
const panel = panelRef.current
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const panelHeight = panel ? panel.offsetHeight : 280 // fallback estimate
|
||||
const topAbove = rect.top - panelHeight - 8
|
||||
const topBelow = rect.bottom + 8
|
||||
const fitsAbove = topAbove >= 8
|
||||
setPos({
|
||||
top: fitsAbove ? topAbove : topBelow,
|
||||
left: rect.left,
|
||||
})
|
||||
}
|
||||
measure()
|
||||
window.addEventListener('resize', measure, { passive: true })
|
||||
window.addEventListener('scroll', measure, { passive: true, capture: true })
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure)
|
||||
window.removeEventListener('scroll', measure, true)
|
||||
}
|
||||
// anchorRef is a useRef object — stable identity, so this effect runs once on mount.
|
||||
}, [anchorRef])
|
||||
|
||||
// Click-outside closes the panel.
|
||||
//
|
||||
// Anchor-button guard: the schedule button toggles open/closed via onClick.
|
||||
// pointerdown fires before click on the same gesture, so without this guard
|
||||
// a click on the anchor would (1) close the picker via the document listener,
|
||||
// then (2) reopen it via the button's onClick — making the button only able
|
||||
// to OPEN, never to close. Skip pointerdown events whose target is inside
|
||||
// the anchor so the click handler is the sole toggle path.
|
||||
useEffect(() => {
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
const target = e.target as Node
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (anchorRef.current?.contains(target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
// Compute max value for datetime-local input (7 days from now)
|
||||
const maxDatetimeLocal = (() => {
|
||||
const now = new Date()
|
||||
const max = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
// Format: YYYY-MM-DDTHH:mm (datetime-local format, no seconds)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${max.getFullYear()}-${pad(max.getMonth() + 1)}-${pad(max.getDate())}T${pad(max.getHours())}:${pad(max.getMinutes())}`
|
||||
})()
|
||||
|
||||
const minDatetimeLocal = (() => {
|
||||
const now = new Date(Date.now() + 60 * 1000) // at least 1 min ahead
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
})()
|
||||
|
||||
const handlePresetClick = (preset: Preset) => {
|
||||
// Store the preset key only — absolute ms is computed at send time (send-time base).
|
||||
onSchedule({ type: 'preset', preset })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSpecificSubmit = () => {
|
||||
if (!specificValue) return
|
||||
const parsed = new Date(specificValue).getTime()
|
||||
if (isNaN(parsed)) return
|
||||
const now = Date.now()
|
||||
const error = validateSpecificDatetime(parsed, now)
|
||||
if (error) {
|
||||
const errorKeyMap = {
|
||||
scheduleErrorPast: 'composer.scheduleErrorPast',
|
||||
scheduleErrorTooFar: 'composer.scheduleErrorTooFar',
|
||||
} as const
|
||||
setSpecificError(t(errorKeyMap[error]))
|
||||
return
|
||||
}
|
||||
onSchedule({ type: 'absolute', ms: parsed })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSpecificChange = (value: string) => {
|
||||
setSpecificValue(value)
|
||||
if (specificError) setSpecificError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('composer.scheduleSend')}
|
||||
style={
|
||||
pos
|
||||
? { position: 'fixed', top: pos.top, left: pos.left }
|
||||
: { position: 'fixed', visibility: 'hidden' }
|
||||
}
|
||||
className="z-50 w-72 rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 pt-3 pb-2">
|
||||
<p className="text-xs font-semibold text-[var(--app-hint)]">
|
||||
{t('composer.scheduleSend')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="flex px-3 gap-1 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('relative')}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
tab === 'relative'
|
||||
? 'bg-[var(--app-secondary-bg)] text-[var(--app-fg)]'
|
||||
: 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
|
||||
}`}
|
||||
>
|
||||
{t('composer.scheduleRelativeTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('specific')}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
tab === 'specific'
|
||||
? 'bg-[var(--app-secondary-bg)] text-[var(--app-fg)]'
|
||||
: 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
|
||||
}`}
|
||||
>
|
||||
{t('composer.scheduleSpecificTab')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="px-3 pb-3">
|
||||
{tab === 'relative' ? (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PRESETS.map((preset) => {
|
||||
const isSelected = pendingSchedule?.type === 'preset' && pendingSchedule.preset === preset
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] hover:border-[var(--app-link)]'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={specificValue}
|
||||
min={minDatetimeLocal}
|
||||
max={maxDatetimeLocal}
|
||||
onChange={(e) => handleSpecificChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)] focus:outline-none focus:ring-1 focus:ring-[var(--app-link)]"
|
||||
/>
|
||||
{specificError ? (
|
||||
<p className="text-xs text-red-500">{specificError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--app-hint)]">
|
||||
{t('composer.scheduleSpecificHint')}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!specificValue}
|
||||
onClick={handleSpecificSubmit}
|
||||
className="w-full rounded-lg bg-blue-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t('composer.scheduleSend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldAutoClearPendingSchedule } from './SessionChat'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
|
||||
/**
|
||||
* Unit tests for shouldAutoClearPendingSchedule.
|
||||
*
|
||||
* The useEffect in SessionChat auto-clears only 'absolute' pending schedules
|
||||
* when the chosen time expires. 'preset' schedules must NOT be auto-cleared
|
||||
* because they are relative to send time and have no fixed expiry.
|
||||
*
|
||||
* This test guards against future refactors that accidentally break the
|
||||
* preset-stays-alive invariant (a silent break: the effect would cancel the
|
||||
* preset with no user-visible error before send time).
|
||||
*/
|
||||
describe('shouldAutoClearPendingSchedule', () => {
|
||||
it('returns false for null (no schedule set)', () => {
|
||||
expect(shouldAutoClearPendingSchedule(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for preset schedule — presets do not expire before send', () => {
|
||||
const preset: PendingSchedule = { type: 'preset', preset: '+5m' }
|
||||
expect(shouldAutoClearPendingSchedule(preset)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for all preset values', () => {
|
||||
const presets: Array<'+5m' | '+30m' | '+1h' | '+4h'> = ['+5m', '+30m', '+1h', '+4h']
|
||||
for (const p of presets) {
|
||||
const pending: PendingSchedule = { type: 'preset', preset: p }
|
||||
expect(shouldAutoClearPendingSchedule(pending)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns true for absolute schedule — absolute schedules have a fixed expiry instant', () => {
|
||||
const absolute: PendingSchedule = { type: 'absolute', ms: Date.now() + 60_000 }
|
||||
expect(shouldAutoClearPendingSchedule(absolute)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for expired absolute schedule (ms in the past)', () => {
|
||||
const expired: PendingSchedule = { type: 'absolute', ms: Date.now() - 1000 }
|
||||
expect(shouldAutoClearPendingSchedule(expired)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import { buildConversationOutline } from '@/chat/outline'
|
||||
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
|
||||
import { isQueuedForInvocation } from '@/lib/messages'
|
||||
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { HappyThread } from '@/components/AssistantChat/HappyThread'
|
||||
import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar'
|
||||
import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||
@@ -34,6 +36,19 @@ import { useVoiceOptional } from '@/lib/voice-context'
|
||||
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
|
||||
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
|
||||
|
||||
/**
|
||||
* Returns whether a PendingSchedule should trigger an auto-clear timer.
|
||||
*
|
||||
* Only 'absolute' schedules expire (the chosen instant passes).
|
||||
* 'preset' schedules are relative to send time and have no fixed expiry.
|
||||
*
|
||||
* Used both by the auto-clear useEffect and by unit tests, so a future
|
||||
* variant of PendingSchedule only needs to update this single helper.
|
||||
*/
|
||||
export function shouldAutoClearPendingSchedule(pending: PendingSchedule | null): boolean {
|
||||
return pending !== null && pending.type === 'absolute'
|
||||
}
|
||||
|
||||
function getOutlineTitle(session: Session): string {
|
||||
if (session.metadata?.name) {
|
||||
return session.metadata.name
|
||||
@@ -78,7 +93,11 @@ export function SessionChat(props: {
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
onLoadMore: () => Promise<unknown>
|
||||
onSend: (text: string, attachments?: AttachmentMetadata[]) => void
|
||||
// Resolves true when the send was accepted by the underlying mutation, false when
|
||||
// pre-mutation guards (no-api / no-session / pending) rejected the call OR async
|
||||
// inactive-session resume failed. Composer state that should only be cleared on
|
||||
// actual send (pendingSchedule) must await this — see handleSend below.
|
||||
onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
|
||||
onFlushPending: () => void
|
||||
onAtBottomChange: (atBottom: boolean) => void
|
||||
onRetryMessage?: (localId: string) => void
|
||||
@@ -409,8 +428,42 @@ export function SessionChat(props: {
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const handleSend = useCallback((text: string, attachments?: AttachmentMetadata[]) => {
|
||||
props.onSend(text, attachments)
|
||||
// Scheduled message state — lifted here so useHappyRuntime can read the ref.
|
||||
//
|
||||
// pendingSchedule holds what the user selected (preset or absolute ms).
|
||||
// The ref is read at send time; resolvePendingSchedule converts it to an
|
||||
// absolute epoch-ms using Date.now() at that moment (send-time base for presets).
|
||||
const [pendingSchedule, setPendingSchedule] = useState<PendingSchedule | null>(null)
|
||||
const pendingScheduleRef = useRef<PendingSchedule | null>(null)
|
||||
// Keep render ref in sync so onNew can snapshot at send time
|
||||
pendingScheduleRef.current = pendingSchedule
|
||||
|
||||
// Auto-clear absolute-type pendingSchedule when the chosen time expires so
|
||||
// the composer clock button doesn't stay active past the scheduled instant.
|
||||
// Preset-type schedules are relative so they don't expire until send — the
|
||||
// shouldAutoClearPendingSchedule predicate is the single source of truth so
|
||||
// adding a new PendingSchedule variant only needs to update that helper.
|
||||
useEffect(() => {
|
||||
if (!shouldAutoClearPendingSchedule(pendingSchedule)) return
|
||||
// Narrowed to 'absolute' by the predicate above.
|
||||
const ms = (pendingSchedule as Extract<PendingSchedule, { type: 'absolute' }>).ms
|
||||
const remaining = ms - Date.now()
|
||||
if (remaining <= 0) {
|
||||
setPendingSchedule(null)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setPendingSchedule(null), remaining)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pendingSchedule])
|
||||
|
||||
const handleSend = useCallback(async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => {
|
||||
const accepted = await props.onSend(text, attachments, scheduledAt)
|
||||
if (!accepted) return
|
||||
// Clear pendingSchedule only after the mutation is actually accepted —
|
||||
// covers both pre-mutation guards AND async inactive-session resume
|
||||
// failure. SessionChat is the single owner of schedule clear (HappyComposer
|
||||
// no longer clears on its own send path).
|
||||
setPendingSchedule(null)
|
||||
setForceScrollToken((token) => token + 1)
|
||||
}, [props.onSend])
|
||||
|
||||
@@ -429,7 +482,8 @@ export function SessionChat(props: {
|
||||
onSendMessage: handleSend,
|
||||
onAbort: handleAbort,
|
||||
attachmentAdapter,
|
||||
allowSendWhenInactive: true
|
||||
allowSendWhenInactive: true,
|
||||
pendingScheduleRef
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -492,13 +546,23 @@ export function SessionChat(props: {
|
||||
) : null}
|
||||
|
||||
<div className="px-3">
|
||||
<QueuedMessagesBar sessionId={props.session.id} api={props.api} />
|
||||
<QueuedMessagesBar
|
||||
sessionId={props.session.id}
|
||||
api={props.api}
|
||||
onEdit={({ pendingSchedule: restored }) => {
|
||||
// Restore the schedule so the clock button re-activates
|
||||
setPendingSchedule(restored)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HappyComposer
|
||||
key={props.session.id}
|
||||
sessionId={props.session.id}
|
||||
disabled={props.isSending}
|
||||
pendingSchedule={pendingSchedule}
|
||||
onSchedule={setPendingSchedule}
|
||||
onClearSchedule={() => setPendingSchedule(null)}
|
||||
permissionMode={props.session.permissionMode}
|
||||
collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined}
|
||||
threadGoal={reduced.latestGoal}
|
||||
|
||||
@@ -117,4 +117,121 @@ describe('useSendMessage', () => {
|
||||
expect(onBlocked).toHaveBeenCalledWith('no-api')
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves true when the send is accepted', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A'),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false when blocked (no api) so the caller can preserve schedule state', async () => {
|
||||
const onBlocked = vi.fn()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(null, 'session-A', { onBlocked }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
expect(onBlocked).toHaveBeenCalledWith('no-api')
|
||||
})
|
||||
|
||||
it('resolves false when blocked (no session)', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, null),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves false when resolveSessionId throws (inactive-session resume failure)', async () => {
|
||||
const api = createMockApi()
|
||||
const resumeError = new Error('resume failed')
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A', {
|
||||
resolveSessionId: async () => { throw resumeError },
|
||||
onSessionResolved: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves true after async resolveSessionId succeeds and mutation starts', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-original', {
|
||||
resolveSessionId: async () => 'session-resolved',
|
||||
onSessionResolved: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('preserves scheduledAt when retrying a failed scheduled message', async () => {
|
||||
const sendMock = vi.fn(async () => {})
|
||||
const api = createMockApi(sendMock)
|
||||
const scheduledAt = Date.now() + 5 * 60_000
|
||||
|
||||
const { getMessageWindowState } = await import('@/lib/message-window-store')
|
||||
vi.mocked(getMessageWindowState).mockReturnValueOnce({
|
||||
messages: [],
|
||||
pending: [{
|
||||
id: 'local-retry-1',
|
||||
seq: null,
|
||||
localId: 'local-retry-1',
|
||||
content: { role: 'user', content: { type: 'text', text: 'hi later' } },
|
||||
createdAt: 1_000,
|
||||
invokedAt: null,
|
||||
scheduledAt,
|
||||
status: 'failed',
|
||||
originalText: 'hi later',
|
||||
} as never],
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A'),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.retryMessage('local-retry-1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// api.sendMessage(sessionId, text, localId, attachments, scheduledAt)
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
'session-A',
|
||||
'hi later',
|
||||
'local-retry-1',
|
||||
undefined,
|
||||
scheduledAt,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ type SendMessageInput = {
|
||||
localId: string
|
||||
createdAt: number
|
||||
attachments?: AttachmentMetadata[]
|
||||
scheduledAt?: number | null
|
||||
}
|
||||
|
||||
type BlockedReason = 'no-api' | 'no-session' | 'pending'
|
||||
@@ -48,6 +49,7 @@ function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'se
|
||||
// response that omits the field entirely (`undefined`) is treated as
|
||||
// already-invoked and stays in the thread, not the floating bar.
|
||||
invokedAt: null,
|
||||
scheduledAt: input.scheduledAt ?? null,
|
||||
status,
|
||||
originalText: input.text,
|
||||
}
|
||||
@@ -72,8 +74,14 @@ export function useSendMessage(
|
||||
sessionId: string | null,
|
||||
options?: UseSendMessageOptions
|
||||
): {
|
||||
sendMessage: (text: string, attachments?: AttachmentMetadata[]) => void
|
||||
retryMessage: (localId: string) => void
|
||||
// Resolves true when a mutation was actually started, false when the call was
|
||||
// rejected pre-mutation (no-api / no-session / pending) OR the async
|
||||
// resolveSessionId step threw. Async is required because inactive-session
|
||||
// resume happens before mutation.mutate(), and a sync `true` would let the
|
||||
// caller clear UI state (e.g. pendingSchedule) before knowing whether
|
||||
// resume succeeded — see SessionChat.handleSend.
|
||||
sendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
|
||||
retryMessage: (localId: string) => boolean
|
||||
isSending: boolean
|
||||
} {
|
||||
const { haptic } = usePlatform()
|
||||
@@ -87,7 +95,7 @@ export function useSendMessage(
|
||||
if (!api) {
|
||||
throw new Error('API unavailable')
|
||||
}
|
||||
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments)
|
||||
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments, input.scheduledAt)
|
||||
},
|
||||
onMutate: async (input) => {
|
||||
const status = isSessionThinkingRef.current ? 'queued' as const : 'sending' as const
|
||||
@@ -109,71 +117,71 @@ export function useSendMessage(
|
||||
},
|
||||
})
|
||||
|
||||
const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => {
|
||||
const sendMessage = async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<boolean> => {
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
return false
|
||||
}
|
||||
const localId = makeClientSideId('local')
|
||||
const createdAt = Date.now()
|
||||
void (async () => {
|
||||
let targetSessionId = sessionId
|
||||
if (options?.resolveSessionId) {
|
||||
resolveGuardRef.current = true
|
||||
setIsResolving(true)
|
||||
try {
|
||||
const resolved = await options.resolveSessionId(sessionId)
|
||||
if (resolved && resolved !== sessionId) {
|
||||
options.onSessionResolved?.(resolved)
|
||||
targetSessionId = resolved
|
||||
}
|
||||
} catch (error) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to resolve session before send:', error)
|
||||
return
|
||||
} finally {
|
||||
resolveGuardRef.current = false
|
||||
setIsResolving(false)
|
||||
let targetSessionId = sessionId
|
||||
if (options?.resolveSessionId) {
|
||||
resolveGuardRef.current = true
|
||||
setIsResolving(true)
|
||||
try {
|
||||
const resolved = await options.resolveSessionId(sessionId)
|
||||
if (resolved && resolved !== sessionId) {
|
||||
options.onSessionResolved?.(resolved)
|
||||
targetSessionId = resolved
|
||||
}
|
||||
} catch (error) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to resolve session before send:', error)
|
||||
return false
|
||||
} finally {
|
||||
resolveGuardRef.current = false
|
||||
setIsResolving(false)
|
||||
}
|
||||
mutation.mutate({
|
||||
sessionId: targetSessionId,
|
||||
text,
|
||||
localId,
|
||||
createdAt,
|
||||
attachments,
|
||||
})
|
||||
})()
|
||||
}
|
||||
mutation.mutate({
|
||||
sessionId: targetSessionId,
|
||||
text,
|
||||
localId,
|
||||
createdAt,
|
||||
attachments,
|
||||
scheduledAt,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const retryMessage = (localId: string) => {
|
||||
const retryMessage = (localId: string): boolean => {
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const message = findMessageByLocalId(sessionId, localId)
|
||||
if (!message?.originalText) return
|
||||
if (!message?.originalText) return false
|
||||
|
||||
updateMessageStatus(sessionId, localId, 'sending')
|
||||
|
||||
@@ -182,7 +190,9 @@ export function useSendMessage(
|
||||
text: message.originalText,
|
||||
localId,
|
||||
createdAt: message.createdAt,
|
||||
scheduledAt: message.scheduledAt ?? null,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type React from 'react'
|
||||
import type { AppendMessage, AttachmentAdapter, ThreadMessageLike } from '@assistant-ui/react'
|
||||
import { useExternalMessageConverter, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
||||
import { safeStringify } from '@hapi/protocol'
|
||||
import { renderEventLabel } from '@/chat/presentation'
|
||||
import type { ChatBlock, CliOutputBlock, CodexReview, UsageData } from '@/chat/types'
|
||||
@@ -298,10 +301,11 @@ export function useHappyRuntime(props: {
|
||||
blocks: readonly VisibleChatBlock[]
|
||||
isSending: boolean
|
||||
isRunning?: boolean
|
||||
onSendMessage: (text: string, attachments?: AttachmentMetadata[]) => void
|
||||
onSendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => void
|
||||
onAbort: () => Promise<void>
|
||||
attachmentAdapter?: AttachmentAdapter
|
||||
allowSendWhenInactive?: boolean
|
||||
pendingScheduleRef?: React.RefObject<PendingSchedule | null>
|
||||
}) {
|
||||
const isRunning = props.isRunning ?? props.session.thinking
|
||||
|
||||
@@ -316,8 +320,13 @@ export function useHappyRuntime(props: {
|
||||
const onNew = useCallback(async (message: AppendMessage) => {
|
||||
const { text, attachments } = extractMessageContent(message)
|
||||
if (!text && attachments.length === 0) return
|
||||
props.onSendMessage(text, attachments.length > 0 ? attachments : undefined)
|
||||
}, [props.onSendMessage])
|
||||
// Resolve pendingSchedule at send time (Date.now()) so preset-type schedules
|
||||
// ("5 minutes from now") are relative to the actual send action, not the
|
||||
// moment the user clicked the preset button.
|
||||
const sendNow = Date.now()
|
||||
const scheduledAt = resolvePendingSchedule(props.pendingScheduleRef?.current ?? null, sendNow)
|
||||
props.onSendMessage(text, attachments.length > 0 ? attachments : undefined, scheduledAt)
|
||||
}, [props.onSendMessage, props.pendingScheduleRef])
|
||||
|
||||
const onCancel = useCallback(async () => {
|
||||
await props.onAbort()
|
||||
@@ -344,5 +353,9 @@ export function useHappyRuntime(props: {
|
||||
props.attachmentAdapter
|
||||
])
|
||||
|
||||
// Note: pendingScheduleRef is intentionally not in the deps above.
|
||||
// The ref is read at send time inside onNew (not at render time), so changes
|
||||
// to pendingSchedule do not need to invalidate the adapter or re-run onNew.
|
||||
|
||||
return useExternalStoreRuntime(adapter)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,14 @@ export default {
|
||||
'composer.send': 'Send',
|
||||
'composer.stop': 'Stop',
|
||||
'composer.voice': 'Voice assistant',
|
||||
'composer.scheduleSend': 'Schedule send',
|
||||
'composer.scheduleRelativeTab': 'Relative',
|
||||
'composer.scheduleSpecificTab': 'Specific',
|
||||
'composer.scheduleSpecificHint': 'Max 7 days. Requires the hub running; the CLI catches up the next time it connects after that time.',
|
||||
'composer.scheduleErrorPast': 'Scheduled time must be in the future.',
|
||||
'composer.scheduleErrorTooFar': 'Maximum schedule time is 7 days.',
|
||||
'queuedMessages.scheduledFor': 'Scheduled for {time}',
|
||||
'queuedMessages.editAlreadyInvoked': "Message already sent — it can't be edited",
|
||||
'composer.codexSlashUnsupported.title': 'Codex command unavailable',
|
||||
'composer.codexSlashUnsupported.body': 'HAPI remote mode does not yet run built-in Codex slash commands like {command}. Use natural language instead, or run it in the local Codex TUI.',
|
||||
|
||||
|
||||
@@ -321,6 +321,14 @@ export default {
|
||||
'composer.send': '发送',
|
||||
'composer.stop': '停止',
|
||||
'composer.voice': '语音助手',
|
||||
'composer.scheduleSend': '定时发送',
|
||||
'composer.scheduleRelativeTab': '相对时间',
|
||||
'composer.scheduleSpecificTab': '指定时间',
|
||||
'composer.scheduleSpecificHint': '最多 7 天。需 Hub 运行;CLI 下次连接时会接收消息。',
|
||||
'composer.scheduleErrorPast': '发送时间必须在未来。',
|
||||
'composer.scheduleErrorTooFar': '最多只能定时 7 天。',
|
||||
'queuedMessages.scheduledFor': '定时发送: {time}',
|
||||
'queuedMessages.editAlreadyInvoked': '消息已发送,无法编辑',
|
||||
'composer.codexSlashUnsupported.title': '无法执行 Codex 命令',
|
||||
'composer.codexSlashUnsupported.body': 'HAPI 远程模式暂不支持 {command} 这类 Codex 内建 slash command,请改用自然语言,或在本地 Codex TUI 中执行。',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user