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

This commit is contained in:
Junmo Kim
2026-05-18 09:09:17 +08:00
committed by GitHub
parent 2e96992dd4
commit b2a30c2e39
33 changed files with 3082 additions and 153 deletions
+68 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { isExternalUserMessage } from './apiSession'
import { isExternalUserMessage, IncomingMessageFilter } from './apiSession'
describe('isExternalUserMessage', () => {
const baseUserMsg = {
@@ -96,3 +96,70 @@ describe('isExternalUserMessage', () => {
).toBe(false)
})
})
describe('IncomingMessageFilter (HAPI Bot R3 finding #1)', () => {
it('accepts a mature scheduled message whose seq is below the latest cursor', () => {
// schedule seq=10, immediate seq=11 acks first → cursor=11.
// seq=10 matures: seq-only dedup would drop it; id-based dedup must accept.
const filter = new IncomingMessageFilter()
expect(filter.accept({ id: 'msg-imm', seq: 11 })).toBe(true)
expect(filter.accept({ id: 'msg-sched', seq: 10 })).toBe(true)
})
it('rejects an exact id duplicate (re-emit on the next mature tick)', () => {
const filter = new IncomingMessageFilter()
expect(filter.accept({ id: 'msg-1', seq: 1 })).toBe(true)
expect(filter.accept({ id: 'msg-1', seq: 1 })).toBe(false)
})
it('falls back to seq-only dedup for messages without an id', () => {
const filter = new IncomingMessageFilter()
expect(filter.accept({ seq: 5 })).toBe(true)
// seq <= cursor and no id → drop (legacy behaviour preserved).
expect(filter.accept({ seq: 4 })).toBe(false)
expect(filter.accept({ seq: 5 })).toBe(false)
})
it('advances cursorSeq monotonically regardless of arrival order', () => {
const filter = new IncomingMessageFilter()
filter.accept({ id: 'a', seq: 11 })
filter.accept({ id: 'b', seq: 10 })
expect(filter.cursorSeq()).toBe(11)
})
it('bounds the seen-id set to the configured capacity (LRU eviction)', () => {
const filter = new IncomingMessageFilter(3)
filter.accept({ id: 'a', seq: 1 })
filter.accept({ id: 'b', seq: 2 })
filter.accept({ id: 'c', seq: 3 })
filter.accept({ id: 'd', seq: 4 })
// 'a' should have been evicted — re-presenting it is treated as new.
expect(filter.accept({ id: 'a', seq: 5 })).toBe(true)
// 'd' is still in the set.
expect(filter.accept({ id: 'd', seq: 6 })).toBe(false)
})
it('refreshes recency on dedup hit so re-emits survive bursts of unrelated ids', () => {
// Models the documented contract: the hub re-emits the same id every 5 s
// until the CLI acks. If the dedup were FIFO (insert-order only), a
// burst of capacity-many unrelated ids between re-emits would evict the
// pending id and the next re-emit would double-deliver.
const filter = new IncomingMessageFilter(3)
// Pre-fill so 'pending' is not at the head.
filter.accept({ id: 'a', seq: 1 })
filter.accept({ id: 'pending', seq: 2 })
filter.accept({ id: 'b', seq: 3 })
// Re-emit pending → recency refresh moves it to the tail.
expect(filter.accept({ id: 'pending', seq: 4 })).toBe(false)
// Burst that evicts oldest entries. Without the refresh 'pending' would
// be at insert position 2 and would be evicted; with the refresh it is
// now the newest entry and survives.
filter.accept({ id: 'c', seq: 5 })
filter.accept({ id: 'd', seq: 6 })
// 'a' (oldest) and then 'b' should have been evicted; 'pending' must
// still dedup.
expect(filter.accept({ id: 'pending', seq: 7 })).toBe(false)
expect(filter.accept({ id: 'a', seq: 8 })).toBe(true)
expect(filter.accept({ id: 'b', seq: 9 })).toBe(true)
})
})
+64 -10
View File
@@ -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
View File
@@ -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)
+4 -2
View File
@@ -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,
+17 -20
View File
@@ -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)
+3 -1
View File
@@ -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
View File
@@ -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))
+13 -5
View File
@@ -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)
}
+115
View File
@@ -172,3 +172,118 @@ describe('cancelQueuedMessage', () => {
expect(messages.some(m => m.id === msg.id)).toBe(true)
})
})
describe('addMessage: scheduledAt invariants', () => {
it('rejects scheduledAt without a localId — would silently invoke immediately', () => {
const store = makeStore()
const session = makeSession(store, 'sched-invariant')
const future = Date.now() + 60_000
expect(() =>
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'orphan scheduled' } },
undefined,
future
)
).toThrow(/scheduledAt requires a localId/)
})
it('accepts scheduledAt when paired with a localId and keeps invoked_at NULL', () => {
const store = makeStore()
const session = makeSession(store, 'sched-ok')
const future = Date.now() + 60_000
const msg = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'queued for later' } },
'lid-sched',
future
)
expect(msg.scheduledAt).toBe(future)
expect(msg.invokedAt).toBeNull()
})
})
describe('getDeliverableMessagesAfter: CLI backfill excludes future-scheduled rows', () => {
it('omits rows whose scheduled_at > now (would otherwise be replayed early on reconnect)', () => {
const store = makeStore()
const session = makeSession(store, 'backfill-future-sched')
const now = Date.now()
const future = now + 60_000
const past = now - 60_000
const immediate = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'immediate' } },
'lid-immediate'
)
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'future-scheduled' } },
'lid-future',
future
)
const matureSched = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'mature-scheduled' } },
'lid-mature',
past
)
const delivered = store.messages.getDeliverableMessagesAfter(session.id, 0, now)
const ids = delivered.map((m) => m.id)
expect(ids).toContain(immediate.id)
expect(ids).toContain(matureSched.id)
expect(ids).not.toContain('lid-future')
const localIds = delivered.map((m) => m.localId)
expect(localIds).not.toContain('lid-future')
})
it('returns the row once now advances past scheduled_at (release boundary)', () => {
const store = makeStore()
const session = makeSession(store, 'backfill-release-boundary')
const fireAt = Date.now() - 60_000
store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'boundary' } },
'lid-bnd',
fireAt
)
const before = store.messages.getDeliverableMessagesAfter(session.id, 0, fireAt - 1)
expect(before.find((m) => m.localId === 'lid-bnd')).toBeUndefined()
const exact = store.messages.getDeliverableMessagesAfter(session.id, 0, fireAt)
expect(exact.find((m) => m.localId === 'lid-bnd')).toBeDefined()
})
it('respects afterSeq alongside the scheduled_at filter (2-axis interaction)', () => {
// Verifies the seq cursor and the scheduled-at filter compose correctly:
// a row that satisfies one axis but fails the other must be excluded.
const store = makeStore()
const session = makeSession(store, 'backfill-2axis')
const now = Date.now()
const m1 = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'first' } },
'lid-1'
)
const m2 = store.messages.addMessage(
session.id,
{ role: 'user', content: { type: 'text', text: 'second' } },
'lid-2'
)
// afterSeq = m1.seq → only m2 should be returned.
const onlyM2 = store.messages.getDeliverableMessagesAfter(session.id, m1.seq, now)
expect(onlyM2.map((m) => m.id)).toEqual([m2.id])
// afterSeq = m2.seq → nothing (cursor at the end).
const empty = store.messages.getDeliverableMessagesAfter(session.id, m2.seq, now)
expect(empty).toHaveLength(0)
})
})
+81 -14
View File
@@ -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.
+539
View File
@@ -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);
`)
}
+1
View File
@@ -43,6 +43,7 @@ export type StoredMessage = {
seq: number
localId: string | null
invokedAt: number | null
scheduledAt: number | null
}
export type StoredUser = {
+659
View File
@@ -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
View File
@@ -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.
}
}
}
+10 -2
View File
@@ -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,
+9 -1
View File
@@ -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 })
})
+220
View File
@@ -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)
})
})
+27 -4
View File
@@ -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 })
})
+2 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
)
}
+43
View File
@@ -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)
})
})
+69 -5
View File
@@ -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,
)
})
})
+48 -38
View File
@@ -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 {
+16 -3
View File
@@ -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)
}
+8
View File
@@ -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.',
+8
View File
@@ -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 中执行。',