feat: add message queue for sequential run processing (#501)

Allow sending multiple messages while a run is active. Messages are
queued on the server and processed sequentially after each run
completes. Each completed assistant message triggers speech playback
independently, and the UI shows queue status with a badge indicator.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-07 10:34:58 +08:00
committed by GitHub
parent 5df8734495
commit 424125843f
17 changed files with 964 additions and 181 deletions
+46 -4
View File
@@ -16,6 +16,7 @@ export interface StartRunRequest {
instructions?: string
session_id?: string
model?: string
queue_id?: string
}
export interface StartRunResponse {
@@ -45,6 +46,8 @@ export interface RunEvent {
}
/** session_id tag added by server for client-side filtering */
session_id?: string
/** Queue length from run.queued event */
queue_length?: number
}
// ============================
@@ -73,6 +76,7 @@ const sessionEventHandlers = new Map<string, {
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
}>()
/**
@@ -179,7 +183,8 @@ function globalRunCompletedHandler(event: RunEvent): void {
handlers.onRunCompleted(event)
}
// Auto-cleanup session handlers on completion
// Auto-cleanup session handlers on completion (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
@@ -195,10 +200,24 @@ function globalRunFailedHandler(event: RunEvent): void {
handlers.onRunFailed(event)
}
// Auto-cleanup session handlers on failure
// Auto-cleanup session handlers on failure (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global run.queued event handler
*/
function globalRunQueuedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunQueued) {
handlers.onRunQueued(event)
}
}
/**
* Global compression.started event handler
*/
@@ -250,6 +269,9 @@ function globalAbortCompletedHandler(event: RunEvent): void {
handlers.onAbortCompleted(event)
}
// If abort completion is followed by queued runs, keep the handler alive so
// the next run.started/message.delta/run.completed events are still received.
if ((event as any).queue_length > 0) return
sessionEventHandlers.delete(sid)
}
@@ -289,6 +311,7 @@ export function registerSessionHandlers(
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
@@ -361,6 +384,7 @@ export function connectChatRun(): Socket {
chatRunSocket.on('run.started', globalRunStartedHandler)
chatRunSocket.on('run.failed', globalRunFailedHandler)
chatRunSocket.on('run.completed', globalRunCompletedHandler)
chatRunSocket.on('run.queued', globalRunQueuedHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
@@ -395,7 +419,7 @@ export function disconnectChatRun(): void {
*/
export function resumeSession(
sessionId: string,
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number }) => void,
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number; queueLength?: number }) => void,
): Socket {
const socket = connectChatRun()
@@ -418,6 +442,18 @@ export function startRunViaSocket(
}
let closed = false
const socket = connectChatRun()
if (sessionEventHandlers.has(sid)) {
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: sid })
}
},
}
}
// Define event handlers for this session
const handlers = {
@@ -453,12 +489,14 @@ export function startRunViaSocket(
onRunCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
onDone()
},
onRunFailed: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
onError(new Error(evt.error || 'Run failed'))
},
@@ -477,6 +515,7 @@ export function startRunViaSocket(
onAbortCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_length > 0) return
closed = true
onDone()
},
@@ -484,13 +523,16 @@ export function startRunViaSocket(
if (closed) return
onEvent(evt)
},
onRunQueued: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
}
// Register handlers in the global session map
sessionEventHandlers.set(sid, handlers)
// Emit run request
const socket = connectChatRun()
socket.emit('run', body)
return {