feat(desktop): move queue/steer to the agent-side TurnQueue so queued messages fire without the tab open

Queued messages in the desktop app lived in localStorage and were drained
by a React useEffect on the busy->false edge — if the session tab wasn't
mounted, the effect never ran and queued prompts sat dormant forever.

Move the queue into the agent process where steer already lives:

- agent/turn_queue.py: TurnQueue, a thread-safe FIFO on AIAgent (wired in
  agent_init next to _pending_steer). Entries carry a `source` field
  ("queue" vs "busy_submit") so drain events tell clients whether the
  text was already echoed optimistically.
- tui_gateway: _enqueue_prompt/_drain_queued_prompt delegate to
  agent.turn_queue; new session.queue.add/list/remove/clear/promote/update
  RPCs; queue.updated + queue.drained events; queue in session.info.
  An idle-session enqueue drains immediately — the gateway owns every
  drain path. session.interrupt clears the queue (keep_queue opts out
  for the promote+interrupt "send now" gesture). A leftover pending_steer
  returned by run_conversation is re-queued as the next turn instead of
  being silently dropped.
- steer honesty: new agent._on_steer_event observer fires steer.applied
  at the two real injection sites (pre-API drain + tool-batch drain) and
  steer.dropped when an interrupt discards the pending steer. The desktop
  shows steers as pending in the queue panel and only appends the steer:
  transcript row when the model actually saw the text (both the primary
  composer and session tiles previously painted it at RPC-accept time).
- desktop: composer-queue.ts rewritten as a gateway-backed mirror
  (optimistic updates settled by queue.updated); the auto-drain effect is
  deleted; "send now" promotes on the gateway; attachments resolve to
  @file: refs at enqueue time so queued text is self-contained when the
  gateway drains it later; one-time localStorage migration. Dead code
  removed: fromQueue submit option, shouldAutoDrain, queueStuck i18n.

Tests: tests/tui_gateway/test_turn_queue.py (TurnQueue unit + RPC
integration + drain semantics), composer-queue.test.ts rewritten for the
gateway-backed store.
This commit is contained in:
ethernet 2026-07-15 16:50:18 -04:00
parent c81afd6e50
commit bd70904c26
33 changed files with 1431 additions and 498 deletions

View file

@ -597,6 +597,21 @@ def init_agent(
agent._pending_steer: Optional[str] = None
agent._pending_steer_lock = threading.Lock()
# Steer lifecycle observer — set by the gateway/CLI to learn when a
# pending steer is actually injected into the conversation ("applied")
# or discarded by a hard interrupt ("dropped"). Lets clients render the
# steer in the transcript at the moment it really lands instead of
# optimistically painting it the instant the RPC returns.
# Signature: callback(kind: str, text: str) — kind is "applied"|"dropped".
agent._on_steer_event = None
# Turn queue — prompts to run as the next turn(s) after the current one
# finishes. Replaces the ad-hoc session["queued_prompt"] dict slot in the
# TUI gateway and the localStorage-backed queue in the desktop renderer.
# Lives on the agent so it drains even when no client window is open.
from agent.turn_queue import TurnQueue
agent.turn_queue = TurnQueue()
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
# runs each tool on its own ThreadPoolExecutor worker — those worker
# threads have tids distinct from `_execution_thread_id`, so

View file

@ -3216,6 +3216,10 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
messages[target_idx]["content"] = f"{existing_content}{marker}"
else:
messages[target_idx]["content"] = existing_content + marker
try:
agent._emit_steer_event("applied", steer_text)
except Exception:
pass
_ra().logger.info(
"Delivered /steer to agent after tool batch (%d chars): %s",
len(steer_text),

View file

@ -752,6 +752,11 @@ def run_conversation(
_si,
)
break
if _injected:
try:
agent._emit_steer_event("applied", _pre_api_steer)
except Exception:
pass
if not _injected:
# No tool message to inject into — put it back so
# the post-tool-execution drain picks it up later.

188
agent/turn_queue.py Normal file
View file

@ -0,0 +1,188 @@
"""TurnQueue — a thread-safe FIFO of prompts to run as the next turn(s).
This is the agent-side queue that unifies message queueing across CLI, TUI
gateway, desktop, and messaging platforms. It lives on the AIAgent instance,
alongside the existing ``_pending_steer`` mechanism.
Unlike steer (which injects into the *current* turn's tool results), queued
prompts become the *next* user turn after the current one finishes. The
drain point is the agent-loop / gateway-runner tail wherever the current
turn ends and ``running`` flips to False.
Thread-safety: all mutations go through ``self._lock``. Safe to call from
gateway threads, CLI process_loop, and the agent execution thread.
"""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class QueuedTurn:
"""A single queued prompt entry."""
id: str
text: str
mode: str = "queue" # "queue" | "interrupt" | "steer"
transport: Any = None # pinned transport for the drained turn
queued_at: float = field(default_factory=time.time)
attachments: list = field(default_factory=list)
# Where the entry came from: "queue" (explicit session.queue.add — the
# client shows it in a queue panel, not the transcript) or "busy_submit"
# (a prompt.submit that landed mid-turn — the client already echoed it
# as an optimistic user message). Lets drain events tell clients whether
# the text still needs painting.
source: str = "queue"
def to_dict(self) -> dict:
"""Serialise for RPC / event emission (no transport internals)."""
return {
"id": self.id,
"text": self.text,
"mode": self.mode,
"queued_at": self.queued_at,
"attachments": self.attachments,
"source": self.source,
}
class TurnQueue:
"""Thread-safe FIFO of prompts waiting to become the next turn.
Replaces the ad-hoc ``session["queued_prompt"]`` dict slot in the TUI
gateway and the ``localStorage``-backed queue in the desktop renderer.
The queue lives in the agent process so it drains even when no client
window is open.
"""
def __init__(self) -> None:
self._entries: list[QueuedTurn] = []
self._lock = threading.Lock()
# ── enqueue ──────────────────────────────────────────────
def enqueue(
self,
text: str,
mode: str = "queue",
transport: Any = None,
attachments: list | None = None,
source: str = "queue",
) -> QueuedTurn:
"""Add a prompt to the back of the queue.
Consecutive text entries are *not* merged here the gateway's
``_handle_busy_submit`` still does merge if it wants to (mirroring
``repair_message_sequence``). Callers that want merge semantics
should check ``peek()`` first.
Returns the created :class:`QueuedTurn`.
"""
entry = QueuedTurn(
id=str(uuid.uuid4()),
text=text,
mode=mode,
transport=transport,
attachments=list(attachments) if attachments else [],
source=source,
)
with self._lock:
self._entries.append(entry)
return entry
def enqueue_front(
self,
text: str,
mode: str = "queue",
transport: Any = None,
attachments: list | None = None,
) -> QueuedTurn:
"""Add a prompt to the *front* of the queue (priority send)."""
entry = QueuedTurn(
id=str(uuid.uuid4()),
text=text,
mode=mode,
transport=transport,
attachments=list(attachments) if attachments else [],
)
with self._lock:
self._entries.insert(0, entry)
return entry
# ── drain ────────────────────────────────────────────────
def drain(self) -> Optional[QueuedTurn]:
"""Pop and return the head entry, or None if the queue is empty."""
with self._lock:
if not self._entries:
return None
return self._entries.pop(0)
# ── peek / inspect ───────────────────────────────────────
def peek(self) -> list[QueuedTurn]:
"""Return a snapshot copy of pending entries (for UI sync)."""
with self._lock:
return list(self._entries)
def is_empty(self) -> bool:
with self._lock:
return len(self._entries) == 0
def __len__(self) -> int:
with self._lock:
return len(self._entries)
def __bool__(self) -> bool:
return len(self) > 0
# ── mutate ───────────────────────────────────────────────
def remove(self, entry_id: str) -> bool:
"""Remove a specific entry by id. Returns True if found."""
with self._lock:
before = len(self._entries)
self._entries = [e for e in self._entries if e.id != entry_id]
return len(self._entries) < before
def promote(self, entry_id: str) -> bool:
"""Move an entry to the front of the queue. Returns True if found."""
with self._lock:
idx = None
for i, e in enumerate(self._entries):
if e.id == entry_id:
idx = i
break
if idx is None or idx == 0:
return False
entry = self._entries.pop(idx)
self._entries.insert(0, entry)
return True
def update_text(self, entry_id: str, text: str) -> bool:
"""Update the text of a queued entry. Returns True if found."""
with self._lock:
for e in self._entries:
if e.id == entry_id:
e.text = text
return True
return False
def clear(self) -> int:
"""Clear all entries. Returns the number removed."""
with self._lock:
n = len(self._entries)
self._entries.clear()
return n
# ── serialisation ────────────────────────────────────────
def to_list(self) -> list[dict]:
"""Return a list of plain dicts for RPC / event emission."""
with self._lock:
return [e.to_dict() for e in self._entries]

View file

@ -1,22 +1,17 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { useSessionSlice } from '@/lib/use-session-slice'
import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$pendingSteersBySession,
$queuedPromptsBySession,
enqueueQueuedPrompt,
MAX_AUTO_DRAIN_ATTEMPTS,
migrateLegacyQueue,
migrateQueuedPrompts,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrain,
updateQueuedPrompt
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { useComposerScope } from '../scope'
@ -30,21 +25,18 @@ interface UseComposerQueueArgs {
draftRef: RefObject<string>
focusInput: () => void
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
onCancel: ChatBarProps['onCancel']
onSubmit: ChatBarProps['onSubmit']
onQueue: ChatBarProps['onQueue']
queueEditRef: RefObject<QueueEditState | null>
queueSessionKey: ChatBarProps['queueSessionKey']
sessionId: string | null | undefined
}
/**
* The composer's queue engine everything about queued turns: the per-session
* queue store binding, in-place queued-prompt editing (begin/step/exit), the
* shared drain lock + send-then-remove sequence, manual send-now, and the
* edge-independent auto-drain with bounded retries. It consumes the draft API
* (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the
* coordinator-owned `queueEditRef` so the draft engine can read the edit state
* without a back-reference. Behaviour-identical to the inline original.
* The composer's queue view the gateway owns the queue (agent.turn_queue)
* and drains it at the end of every turn, so there is NO auto-drain here and
* no drain lock. This hook covers what's left client-side: the per-session
* mirror binding, in-place queued-prompt editing (begin/step/exit), enqueueing
* the current draft, and "send now" (promote + interrupt on the gateway).
*/
export function useComposerQueue({
activeQueueSessionKey,
@ -54,13 +46,11 @@ export function useComposerQueue({
draftRef,
focusInput,
loadIntoComposer,
onCancel,
onSubmit,
onQueue,
queueEditRef,
queueSessionKey,
sessionId
sessionId: _sessionId
}: UseComposerQueueArgs) {
const { t } = useI18n()
const scope = useComposerScope()
// Per-session slice (edge): re-renders only when THIS session's queue changes,
@ -68,6 +58,9 @@ export function useComposerQueue({
// write; the keyed array does not).
const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey)
// Steers accepted by the gateway but not yet injected into the live turn.
const pendingSteers = useSessionSlice($pendingSteersBySession, activeQueueSessionKey)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
queueEditRef.current = queueEdit
@ -82,8 +75,6 @@ export function useComposerQueue({
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const prevQueueKeyRef = useRef(activeQueueSessionKey)
const drainingQueueRef = useRef(false)
const drainFailuresRef = useRef(new Map<string, number>())
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) {
@ -117,7 +108,6 @@ export function useComposerQueue({
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
attachments: cloneAttachments(attachments),
text: draftRef.current
})
@ -144,13 +134,12 @@ export function useComposerQueue({
if (action === 'save') {
const text = draftRef.current
const next = cloneAttachments(attachments)
if (!text.trim() && next.length === 0) {
if (!text.trim() && attachments.length === 0) {
return false
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { text })
triggerHaptic(saved ? 'success' : 'selection')
} else {
triggerHaptic('cancel')
@ -163,14 +152,21 @@ export function useComposerQueue({
return true
}
const queueCurrentDraft = useCallback(() => {
// Queue the current draft on the gateway. onQueue (use-prompt-actions'
// queuePromptText) resolves attachments into refs and fires
// session.queue.add; the gateway drains it as the next turn, so nothing
// more happens client-side. Clears the draft only after the gateway
// accepts — a rejected enqueue keeps the words in the composer.
const queueCurrentDraft = useCallback(async () => {
const text = draftRef.current
if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) {
if (!activeQueueSessionKey || !onQueue || (!text.trim() && attachments.length === 0)) {
return false
}
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) {
const accepted = await Promise.resolve(onQueue(text, { attachments: cloneAttachments(attachments) }))
if (!accepted) {
return false
}
@ -179,124 +175,41 @@ export function useComposerQueue({
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
const runDrain = useCallback(
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
if (drainingQueueRef.current || !activeQueueSessionKey) {
return false
}
const entry = pickEntry(queuedPrompts)
if (!entry) {
return false
}
drainingQueueRef.current = true
try {
const accepted = await Promise.resolve(
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
)
if (accepted === false) {
return false
}
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const pickDrainHead = useCallback(
(entries: QueuedPromptEntry[]) => {
const skip = queueEditRef.current?.entryId
return skip ? entries.find(e => e.id !== skip) : entries[0]
},
[queueEditRef] // reads the edit id off a ref so the lock-holder always sees the latest
)
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, onQueue, scope.attachments])
// "Send now": promote the entry to the queue head and interrupt the live
// turn on the gateway (queue preserved). The gateway drains the promoted
// entry the moment the turn unwinds — no client-side send at all. When
// idle the gateway drains promoted entries on the same RPC.
const sendQueuedNow = useCallback(
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
return false
}
if (busy) {
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
promoteQueuedPrompt(activeQueueSessionKey, id)
triggerHaptic('selection')
void Promise.resolve(onCancel())
triggerHaptic('selection')
return true
}
// A manual send clears the auto-drain backoff so a stuck entry the user
// taps gets a fresh attempt (and re-enables auto-retry on success).
drainFailuresRef.current.delete(id)
return runDrain(entries => entries.find(e => e.id === id))
return promoteQueuedPrompt(activeQueueSessionKey, id, { interrupt: busy })
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
[activeQueueSessionKey, busy, queueEdit]
)
// Edge-independent auto-drain: send the head whenever the session is idle and
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
// Manual "fire the next queued turn" gesture (Cmd/Ctrl+Shift+K, empty
// Enter). The gateway normally drains on its own the moment the session
// idles; this nudges a head entry that got stuck (e.g. its idle-drain
// attempt failed while the backend was restarting).
const drainNextQueued = useCallback(() => {
const head = queuedPrompts.find(e => e.id !== queueEditRef.current?.entryId)
const entry = pickDrainHead(queuedPrompts)
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
return
}
const onFail = () => {
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
drainFailuresRef.current.set(entry.id, fails)
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
notify({
id: 'composer-queue-stuck',
kind: 'error',
title: t.composer.queueStuckTitle,
message: t.composer.queueStuckBody
})
}
}
void runDrain(() => entry)
.then(sent => {
if (!sent) {
onFail()
}
})
.catch(onFail)
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
return head ? sendQueuedNow(head.id) : false
}, [queueEditRef, queuedPrompts, sendQueuedNow])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
// sessionId) churns on a backend bounce/resume of the same conversation.
// Local-mirror-only: the gateway re-syncs authoritative state via
// session.info/queue.updated after the resume.
useEffect(() => {
const prev = prevQueueKeyRef.current
prevQueueKeyRef.current = activeQueueSessionKey
@ -308,14 +221,12 @@ export function useComposerQueue({
migrateQueuedPrompts(prev, activeQueueSessionKey)
}, [activeQueueSessionKey, queueSessionKey])
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. To cancel queued turns, the user deletes them from the panel.
// One-time legacy migration: entries stranded in the localStorage queue
// (from before the queue moved to the gateway) are pushed to
// session.queue.add and the storage key is cleared.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, queuedPrompts.length])
migrateLegacyQueue(activeQueueSessionKey)
}, [activeQueueSessionKey])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@ -343,6 +254,7 @@ export function useComposerQueue({
drainNextQueued,
editingQueuedPrompt,
exitQueuedEdit,
pendingSteers,
queueCurrentDraft,
queueEdit,
queuedPrompts,

View file

@ -4,7 +4,7 @@ import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { type QueuedPromptEntry } from '@/store/composer-queue'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
@ -21,16 +21,17 @@ interface UseComposerSubmitArgs {
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
drainNextQueued: () => Promise<boolean>
drainNextQueued: () => boolean
editorRef: RefObject<HTMLDivElement | null>
exitQueuedEdit: (action: 'cancel' | 'save') => boolean
focusInput: () => void
inputDisabled: boolean
loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void
onCancel: ChatBarProps['onCancel']
onQueue: ChatBarProps['onQueue']
onSteer: ChatBarProps['onSteer']
onSubmit: ChatBarProps['onSubmit']
queueCurrentDraft: () => boolean
queueCurrentDraft: () => Promise<boolean>
queueEdit: QueueEditState | null
queuedPrompts: QueuedPromptEntry[]
sessionId: string | null | undefined
@ -63,6 +64,7 @@ export function useComposerSubmit({
inputDisabled,
loadIntoComposer,
onCancel,
onQueue,
onSteer,
onSubmit,
queueCurrentDraft,
@ -152,7 +154,7 @@ export function useComposerSubmit({
clearDraft()
dispatchSubmit(text)
} else if (payloadPresent) {
queueCurrentDraft()
void queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
@ -175,7 +177,8 @@ export function useComposerSubmit({
// Steer the live turn (nudge without interrupting). Clears the draft up front
// for snappy feedback; if the gateway rejects (no live tool window) the words
// are re-queued so nothing is lost — same safety net as a plain queue.
// are re-queued on the gateway so nothing is lost — same safety net as a
// plain queue.
const steerDraft = () => {
if (!onSteer || !canSteer) {
return
@ -187,8 +190,8 @@ export function useComposerSubmit({
clearDraft()
void Promise.resolve(onSteer(text)).then(accepted => {
if (!accepted && activeQueueSessionKey) {
enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] })
if (!accepted && onQueue) {
void Promise.resolve(onQueue(text))
}
})
}

View file

@ -81,6 +81,7 @@ export function ChatBar({
onPickImages,
onRemoveAttachment,
onSteer,
onQueue,
onSubmit: onSubmitProp,
onTranscribeAudio
}: ChatBarProps) {
@ -180,13 +181,14 @@ export function ChatBar({
onAddUrl
})
// The queue engine — queued turns, in-place editing, the shared drain lock,
// and bounded auto-drain. Consumes the draft API and writes `queueEditRef`.
// The queue view — queued turns + in-place editing. The gateway owns the
// queue and drains it (agent.turn_queue); there is no client auto-drain.
const {
beginQueuedEdit,
drainNextQueued,
editingQueuedPrompt,
exitQueuedEdit,
pendingSteers,
queueCurrentDraft,
queueEdit,
queuedPrompts,
@ -200,8 +202,7 @@ export function ChatBar({
draftRef,
focusInput,
loadIntoComposer,
onCancel,
onSubmit,
onQueue,
queueEditRef,
queueSessionKey,
sessionId
@ -236,6 +237,7 @@ export function ChatBar({
inputDisabled,
loadIntoComposer,
onCancel,
onQueue,
onSteer,
onSubmit,
queueCurrentDraft,
@ -883,7 +885,7 @@ export function ChatBar({
accounts for it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
activeQueueSessionKey && (queuedPrompts.length > 0 || pendingSteers.length > 0) ? (
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
@ -895,6 +897,7 @@ export function ChatBar({
}}
onEdit={beginQueuedEdit}
onSendNow={id => void sendQueuedNow(id)}
pendingSteers={pendingSteers}
/>
) : null
}

View file

@ -6,12 +6,13 @@ import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, iconSize, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
import type { PendingSteerEntry, QueuedPromptEntry } from '@/store/composer-queue'
interface QueuePanelProps {
busy: boolean
editingId: null | string
entries: QueuedPromptEntry[]
pendingSteers?: PendingSteerEntry[]
onDelete: (id: string) => void
onEdit: (entry: QueuedPromptEntry) => void
onSendNow: (id: string) => void
@ -20,19 +21,39 @@ interface QueuePanelProps {
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
export function QueuePanel({
busy,
editingId,
entries,
onDelete,
onEdit,
onSendNow,
pendingSteers = []
}: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
if (entries.length === 0) {
if (entries.length === 0 && pendingSteers.length === 0) {
return null
}
return (
<StatusSection
icon={<Codicon className="text-muted-foreground/70" name="layers" size="0.8rem" />}
label={c.queued(entries.length)}
label={c.queued(entries.length + pendingSteers.length)}
>
{/* Steers accepted by the gateway but not yet injected into the live
turn they land in the transcript on the steer.applied event. */}
{pendingSteers.map(steer => (
<StatusRow key={steer.id}>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{steer.text}</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
<span>{c.steerPending}</span>
</div>
</div>
</StatusRow>
))}
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length

View file

@ -52,10 +52,10 @@ export interface ChatBarProps {
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
onSteer?: (text: string) => Promise<boolean> | boolean
onSubmit: (
value: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean> | boolean
/** Queue text on the gateway's agent-side turn queue (session.queue.add).
* The gateway drains it as the next turn even with the tab closed. */
onQueue?: (text: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean> | boolean
onSubmit: (value: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean> | boolean
onTranscribeAudio?: (audio: Blob) => Promise<string>
}

View file

@ -74,10 +74,8 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onPickImages: () => void
onRemoveAttachment: (id: string) => void
onSteer: (text: string) => Promise<boolean> | boolean
onSubmit: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean> | boolean
onQueue?: (text: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean> | boolean
onSubmit: (text: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean> | boolean
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
@ -223,6 +221,7 @@ export function ChatView({
onPickImages,
onRemoveAttachment,
onSteer,
onQueue,
onSubmit,
onThreadMessagesChange,
onEdit,
@ -515,6 +514,7 @@ export function ChatView({
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onQueue={onQueue}
onRemoveAttachment={onRemoveAttachment}
onSteer={onSteer}
onSubmit={onSubmit}

View file

@ -15,11 +15,11 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { useI18n } from '@/i18n'
import { textPart } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearClarifyRequest } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { addPendingSteer, enqueueQueuedPrompt } from '@/store/composer-queue'
import { resetSessionBackground } from '@/store/composer-status'
import { notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
@ -170,19 +170,6 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
[scope.attachments.$attachments, submitPromptText]
)
const appendSystemNote = useCallback(
(text: string) => {
update(state => ({
...state,
messages: [
...state.messages,
{ id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'system', parts: [textPart(text)] }
]
}))
},
[update]
)
const cancelRun = useCallback(async () => {
const sessionId = runtimeIdRef.current
@ -226,7 +213,10 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
if (result?.status === 'queued') {
triggerHaptic('submit')
appendSystemNote(`steer:${text}`)
// Pending until the gateway's steer.applied event lands — the
// transcript row is appended there (use-message-stream), when the
// model actually saw the nudge.
addPendingSteer(runtimeIdRef.current, text)
return true
}
@ -236,9 +226,21 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
return false
},
[appendSystemNote, requestGateway]
[requestGateway]
)
// Queue on the gateway's agent-side turn queue (the tile equivalent of the
// primary queuePromptText, minus attachment sync — tiles queue text-only).
const queuePromptText = useCallback(async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
if (!text) {
return false
}
return (await enqueueQueuedPrompt(runtimeIdRef.current, { text })) !== null
}, [])
// Rewind primitive (interrupt-first for live turns, busy-retry) — shared with
// the primary chat so the two can't diverge.
const submitRewind = useCallback(
@ -349,11 +351,22 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
dismissError,
editMessage,
handleThreadMessagesChange,
queuePromptText,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText
}),
[cancelRun, dismissError, editMessage, handleThreadMessagesChange, reloadFromMessage, restoreToMessage, steerPrompt, submitText]
[
cancelRun,
dismissError,
editMessage,
handleThreadMessagesChange,
queuePromptText,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText
]
)
}

View file

@ -149,6 +149,7 @@ function TileChat({
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}
onQueue={actions.queuePromptText}
onReload={actions.reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={actions.restoreToMessage}

View file

@ -152,6 +152,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
onPickFiles={actions.onPickFiles}
onPickFolders={actions.onPickFolders}
onPickImages={actions.onPickImages}
onQueue={actions.onQueue}
onReload={actions.onReload}
onRemoveAttachment={actions.onRemoveAttachment}
onRestoreToMessage={actions.onRestoreToMessage}

View file

@ -46,6 +46,7 @@ export type ChatActions = Pick<
| 'onRestoreToMessage'
| 'onRetryResume'
| 'onSteer'
| 'onQueue'
| 'onSubmit'
| 'onThreadMessagesChange'
| 'onToggleSelectedPin'

View file

@ -492,6 +492,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
editMessage,
executeSlashCommand,
handleThreadMessagesChange,
queuePromptText,
reloadFromMessage,
restoreToMessage,
steerPrompt,
@ -726,6 +727,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
},
onRetryResume: sessionId => void resumeSession(sessionId, true),
onSteer: steerPrompt,
onQueue: queuePromptText,
onSubmit: submitText,
onThreadMessagesChange: handleThreadMessagesChange,
onToggleSelectedPin: toggleSelectedPin,

View file

@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { setGatewayRequester } from '@/store/composer-queue'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'
@ -134,5 +135,13 @@ export function useGatewayRequest() {
[ensureGatewayOpen]
)
// Give the gateway-backed composer-queue store a way to fire RPCs without
// being a React component (module-level seam, set once per mount).
useEffect(() => {
setGatewayRequester(requestGateway)
return () => setGatewayRequester(null)
}, [requestGateway])
return { connectionRef, gatewayRef, requestGateway }
}

View file

@ -15,6 +15,7 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { setSessionQueue, settlePendingSteer } from '@/store/composer-queue'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
@ -136,11 +137,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// model output or tool event proves summarization has finished and the
// turn has resumed, so retire the phase label without waiting for the
// whole turn to complete.
if (
sessionId &&
COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
compactedTurnRef.current.has(sessionId)
) {
if (sessionId && COMPACTION_RESUME_EVENT_TYPES.has(event.type) && compactedTurnRef.current.has(sessionId)) {
setSessionCompacting(sessionId, false)
}
@ -275,6 +272,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
reportInstallMethodWarning(payload?.install_warning)
}
if (sessionId && Array.isArray(payload?.queue)) {
setSessionQueue(sessionId, payload.queue)
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
@ -534,7 +535,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
choices: Array.isArray(payload?.choices) ? payload.choices.filter(choice => typeof choice === 'string') : undefined,
choices: Array.isArray(payload?.choices)
? payload.choices.filter(choice => typeof choice === 'string')
: undefined,
command,
description,
sessionId: sessionId ?? null,
@ -658,6 +661,62 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
]
}))
}
} else if (event.type === 'queue.updated') {
// Agent-side turn queue changed (enqueue/drain/remove/clear/promote).
// The gateway owns the queue; this mirror only feeds the panel UI.
if (sessionId && Array.isArray(payload?.entries)) {
setSessionQueue(sessionId, payload.entries)
}
} else if (event.type === 'queue.drained') {
// A queued entry just became the next user turn. Entries queued from
// the panel (source "queue") were never in the transcript — paint them
// now. Busy-submit entries ("busy_submit") were already echoed
// optimistically at submit time; painting again would duplicate them.
if (sessionId && payload?.source !== 'busy_submit') {
const text = coerceGatewayText(payload?.text).trim()
if (text) {
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `queued-user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: 'user',
parts: [textPart(text)]
}
]
}))
}
}
} else if (event.type === 'steer.applied') {
// The steer text actually reached the model (injected into a tool
// result). NOW it belongs in the transcript — painting it at RPC-accept
// time would show a nudge the model hadn't seen yet (and might never
// see, e.g. when an interrupt drops it).
if (sessionId) {
const text = coerceGatewayText(payload?.text).trim()
if (text) {
settlePendingSteer(sessionId, text)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `steer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: 'system',
parts: [textPart(`steer:${text}`)]
}
]
}))
}
}
} else if (event.type === 'steer.dropped') {
// An interrupt discarded the pending steer before the model saw it.
if (sessionId) {
settlePendingSteer(sessionId, coerceGatewayText(payload?.text))
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)

View file

@ -59,7 +59,7 @@ interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>
submitText: (text: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean>
}
function Harness({
@ -505,40 +505,11 @@ describe('usePromptActions submit / queue drain semantics', () => {
)
})
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
// drained queue send would otherwise hit the busy guard and silently no-op.
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
await actRender(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued message', { fromQueue: true })
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
session_id: RUNTIME_SESSION_ID,
text: 'queued message'
},
1_800_000
)
})
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
// A stale-session 404 must not strand the queued entry: submitPrompt returns
// false on failure so the composer keeps it, and the edge-independent
// auto-drain re-attempts once the session is idle again. storedSessionId is
// null so the session.resume recovery path is skipped and the error surfaces.
it('a failed submit returns false and a later retry sends it', async () => {
// A stale-session 404 must not strand the user's words: submitPrompt
// returns false on failure so the composer keeps the draft for a retry.
// storedSessionId is null so the session.resume recovery path is skipped
// and the error surfaces.
let attempt = 0
const requestGateway = vi.fn(async (method: string) => {
@ -563,10 +534,10 @@ describe('usePromptActions submit / queue drain semantics', () => {
/>
)
const first = await handle!.submitText('please send me', { fromQueue: true })
const first = await handle!.submitText('please send me')
expect(first).toBe(false)
const second = await handle!.submitText('please send me', { fromQueue: true })
const second = await handle!.submitText('please send me')
expect(second).toBe(true)
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',

View file

@ -18,6 +18,7 @@ import {
setComposerAttachmentUploadState,
updateComposerAttachment
} from '@/store/composer'
import { addPendingSteer, enqueueQueuedPrompt } from '@/store/composer-queue'
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
@ -594,8 +595,13 @@ export function usePromptActions({
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
// (desktop parity with `/steer`). Returns false on reject (no live tool
// window) so the caller can fall back to queueing the words for the next turn.
// (desktop parity with `/steer`). The RPC accepting the steer does NOT mean
// the model saw it yet — that happens at the next tool-batch boundary, and
// the gateway announces it with a `steer.applied` event. We track the steer
// as *pending* here; the transcript row is appended by the steer.applied
// handler in use-message-stream, so the chat log reflects when the nudge
// actually landed. Returns false on reject so the caller can fall back to
// queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = sanitizeComposerInput(rawText).trim()
@ -610,10 +616,7 @@ export function usePromptActions({
if (result?.status === 'queued') {
triggerHaptic('submit')
// Inline note (not a toast) so the nudge lives in the transcript next
// to the turn it steered. The `steer:` prefix is rendered as a codicon
// row by SystemMessage (see STEER_NOTE_RE), same style as slash output.
appendSessionTextMessage(sessionId, 'system', `steer:${text}`)
addPendingSteer(sessionId, text)
return true
}
@ -623,7 +626,7 @@ export function usePromptActions({
return false
},
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
[activeSessionId, activeSessionIdRef, requestGateway]
)
const reloadFromMessage = useCallback(
@ -789,6 +792,55 @@ export function usePromptActions({
[activeSessionIdRef, updateSessionState]
)
// Queue a prompt on the gateway's agent-side turn queue. Attachments are
// synced (uploaded / rewritten to @file: refs) NOW, at enqueue time — the
// drain happens later in the gateway process where no attachment bytes
// exist, so the queued text must already be self-contained.
const queuePromptText = useCallback(
async (rawText: string, options?: { attachments?: ComposerAttachment[] }): Promise<boolean> => {
const visibleText = sanitizeComposerInput(rawText).trim()
const sessionId = activeSessionId || activeSessionIdRef.current
const attachments = (options?.attachments ?? []).filter((a): a is ComposerAttachment => Boolean(a))
if ((!visibleText && attachments.length === 0) || !sessionId) {
return false
}
let text = visibleText
if (attachments.length > 0) {
try {
const synced = await syncAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: false
})
const contextRefs = synced
.filter((a): a is ComposerAttachment => Boolean(a))
.map(a => a.refText)
.filter(Boolean)
.join('\n')
text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
(synced.some(a => a?.kind === 'image') ? 'What do you see in this image?' : '')
} catch (err) {
notifyError(err, copy.promptFailed)
return false
}
}
if (!text) {
return false
}
const entry = await enqueueQueuedPrompt(sessionId, { text })
return entry !== null
},
[activeSessionId, activeSessionIdRef, copy.promptFailed, syncAttachmentsForSubmit]
)
return {
cancelRun,
editMessage,
@ -797,6 +849,7 @@ export function usePromptActions({
executeSlashCommand,
handleThreadMessagesChange,
handoffSession,
queuePromptText,
reloadFromMessage,
restoreToMessage,
steerPrompt,

View file

@ -59,10 +59,7 @@ interface SlashCommandDeps {
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
startFreshSessionDraft: () => void
submitPromptText: (
rawText: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitPromptText: (rawText: string, options?: { attachments?: ComposerAttachment[] }) => Promise<boolean>
}
/** The /slash command dispatcher, extracted from usePromptActions. */

View file

@ -24,7 +24,6 @@ import {
inlineErrorMessage,
isGatewayTimeoutError,
isProviderSetupError,
isSessionBusyError,
isSessionNotFoundError,
type SubmitTextOptions,
withSessionBusyRetry
@ -127,13 +126,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
)
}
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
if (!hasSendable || busyRef.current) {
return false
}
@ -147,8 +142,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
let startingRouteToken = getRouteToken()
const sessionContextDrifted = (): boolean =>
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken
// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns.
@ -366,10 +360,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
if (
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
startingStoredSessionId
) {
if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) {
// Re-register the session in the gateway and get a fresh live ID.
// Timeouts recover the same way as "session not found": a starved
// backend loop (#55578 symptom d) rejects the submit even though
@ -415,13 +406,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
} catch (err) {
releaseBusy()
// A queued drain that raced a not-yet-settled turn gets a transient
// "session busy" (4009). Don't surface an error bubble/toast — the entry
// stays queued and the composer's bounded auto-drain retries when idle.
if (options?.fromQueue && isSessionBusyError(err)) {
return false
}
const message = inlineErrorMessage(err, copy.promptFailed)
updateSessionState(sessionId, state => ({

View file

@ -225,5 +225,4 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ
export interface SubmitTextOptions {
attachments?: ComposerAttachment[]
fromQueue?: boolean
}

View file

@ -1755,8 +1755,7 @@ export const en: Translations = {
queueSendNext: 'Next',
queueSend: 'Send',
queueDelete: 'Delete',
queueStuckTitle: 'Queued message not sent',
queueStuckBody: 'A queued turn kept failing to send. It is still in the queue — try sending it again.',
steerPending: 'Steering — lands at the next tool step',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,

View file

@ -1672,9 +1672,6 @@ export const ja = defineLocale({
queueSendNext: '次に送信',
queueSend: '送信',
queueDelete: '削除',
queueStuckTitle: 'キュー内のメッセージを送信できません',
queueStuckBody:
'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
previewUnavailable: 'プレビューは利用できません',
previewLabel: label => `${label} のプレビュー`,
couldNotPreview: label => `${label} をプレビューできませんでした`,

View file

@ -1446,8 +1446,7 @@ export interface Translations {
queueSendNext: string
queueSend: string
queueDelete: string
queueStuckTitle: string
queueStuckBody: string
steerPending: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string

View file

@ -1622,8 +1622,6 @@ export const zhHant = defineLocale({
queueSendNext: '下一個',
queueSend: '傳送',
queueDelete: '刪除',
queueStuckTitle: '佇列訊息未送出',
queueStuckBody: '佇列中的對話多次傳送失敗。它仍在佇列中,請重試傳送。',
previewUnavailable: '預覽不可用',
previewLabel: label => `預覽 ${label}`,
couldNotPreview: label => `無法預覽 ${label}`,

View file

@ -1931,8 +1931,7 @@ export const zh: Translations = {
queueSendNext: '下一个',
queueSend: '发送',
queueDelete: '删除',
queueStuckTitle: '排队消息未发送',
queueStuckBody: '排队的对话多次发送失败。它仍在队列中,请重试发送。',
steerPending: '正在引导 — 将在下一个工具步骤生效',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,

View file

@ -83,6 +83,11 @@ export type GatewayEventPayload = {
label?: string
index?: number
aggregator?: string
// queue.updated (agent-side turn queue mirror) + session.info.queue
entries?: unknown[]
queue?: unknown[]
// queue.drained — where the drained entry came from ("queue" | "busy_submit")
source?: string
}
export function textPart(text: string): ChatMessagePart {

View file

@ -1,170 +1,167 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComposerAttachment } from './composer'
import {
$pendingSteersBySession,
$queuedPromptsBySession,
addPendingSteer,
clearQueuedPrompts,
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getPendingSteers,
getQueuedPrompts,
migrateQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrain,
updateQueuedPrompt,
setGatewayRequester,
setSessionQueue,
settlePendingSteer,
updateQueuedPromptText
} from './composer-queue'
const SESSION_KEY = 'session-abc'
const QUEUE_STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
const SESSION_KEY = 'session-1'
function attachment(id: string, kind: ComposerAttachment['kind'] = 'file'): ComposerAttachment {
return {
id,
kind,
label: id,
refText: `@file:${id}`
}
}
describe('composer-queue (gateway-backed)', () => {
const requester = vi.fn()
describe('composer queue store', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
$pendingSteersBySession.set({})
requester.mockReset()
requester.mockResolvedValue({ entry_id: 'gw-1', status: 'queued' })
setGatewayRequester(requester as never)
})
it('queues prompts in FIFO order', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('first')
expect(dequeueQueuedPrompt(SESSION_KEY)?.text).toBe('second')
expect(dequeueQueuedPrompt(SESSION_KEY)).toBeNull()
afterEach(() => {
setGatewayRequester(null)
})
it('clones attachments when queueing', () => {
const source = [attachment('a-1')]
const queued = enqueueQueuedPrompt(SESSION_KEY, { attachments: source, text: 'check clones' })
it('enqueue fires session.queue.add and mirrors the entry locally', async () => {
const entry = await enqueueQueuedPrompt(SESSION_KEY, { text: 'queued draft' })
expect(queued).not.toBeNull()
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).toEqual(source[0])
expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0])
expect(requester).toHaveBeenCalledWith('session.queue.add', { session_id: SESSION_KEY, text: 'queued draft' })
expect(entry?.id).toBe('gw-1')
expect(getQueuedPrompts(SESSION_KEY).map(e => e.text)).toEqual(['queued draft'])
})
it('updates and removes queued entries by id', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' })
it('enqueue returns null (and keeps the mirror empty) when the gateway rejects', async () => {
requester.mockRejectedValue(new Error('gateway down'))
expect(first).not.toBeNull()
expect(second).not.toBeNull()
const entry = await enqueueQueuedPrompt(SESSION_KEY, { text: 'lost?' })
expect(updateQueuedPromptText(SESSION_KEY, first!.id, 'draft one edited')).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft one edited', 'draft two'])
expect(removeQueuedPrompt(SESSION_KEY, first!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
expect(entry).toBeNull()
expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
})
it('promotes a queued entry to the front', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
it('enqueue returns null with no requester wired', async () => {
setGatewayRequester(null)
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(third).not.toBeNull()
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
expect(await enqueueQueuedPrompt(SESSION_KEY, { text: 'no gateway' })).toBeNull()
})
it('updates queued text and attachment snapshot', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
it('setSessionQueue replaces the mirror with the gateway entry list', () => {
setSessionQueue(SESSION_KEY, [
{ id: 'a', text: 'first', queued_at: 1700000000.5 },
{ id: 'b', text: 'second' }
])
expect(first).not.toBeNull()
expect(
updateQueuedPrompt(SESSION_KEY, first!.id, {
attachments: editedAttachments,
text: 'edited text'
})
).toBe(true)
const entries = getQueuedPrompts(SESSION_KEY)
expect(entries.map(e => e.text)).toEqual(['first', 'second'])
expect(entries[0]?.queuedAt).toBe(1700000000500)
const queue = getQueuedPrompts(SESSION_KEY)
expect(queue[0]?.text).toBe('edited text')
expect(queue[0]?.attachments).toEqual(editedAttachments)
expect(queue[0]?.attachments[0]).not.toBe(editedAttachments[0])
setSessionQueue(SESSION_KEY, [])
expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
})
it('clears queue state for a session', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('img-1', 'image')], text: 'queued' })
it('removeQueuedPrompt updates the mirror optimistically and fires the RPC', () => {
setSessionQueue(SESSION_KEY, [
{ id: 'a', text: 'first' },
{ id: 'b', text: 'second' }
])
expect(removeQueuedPrompt(SESSION_KEY, 'a')).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(e => e.id)).toEqual(['b'])
expect(requester).toHaveBeenCalledWith('session.queue.remove', { session_id: SESSION_KEY, entry_id: 'a' })
expect(removeQueuedPrompt(SESSION_KEY, 'missing')).toBe(false)
})
it('promoteQueuedPrompt moves the entry to the head and forwards interrupt', () => {
setSessionQueue(SESSION_KEY, [
{ id: 'a', text: 'first' },
{ id: 'b', text: 'second' },
{ id: 'c', text: 'third' }
])
expect(promoteQueuedPrompt(SESSION_KEY, 'c', { interrupt: true })).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(e => e.id)).toEqual(['c', 'a', 'b'])
expect(requester).toHaveBeenCalledWith('session.queue.promote', {
session_id: SESSION_KEY,
entry_id: 'c',
interrupt: true
})
// Head entry: still accepted (fires the RPC so an idle gateway can drain).
expect(promoteQueuedPrompt(SESSION_KEY, 'c')).toBe(true)
expect(promoteQueuedPrompt(SESSION_KEY, 'missing')).toBe(false)
})
it('updateQueuedPromptText edits in place and fires the RPC', () => {
setSessionQueue(SESSION_KEY, [{ id: 'a', text: 'before' }])
expect(updateQueuedPromptText(SESSION_KEY, 'a', 'after')).toBe(true)
expect(getQueuedPrompts(SESSION_KEY)[0]?.text).toBe('after')
expect(requester).toHaveBeenCalledWith('session.queue.update', {
session_id: SESSION_KEY,
entry_id: 'a',
text: 'after'
})
expect(updateQueuedPromptText(SESSION_KEY, 'a', 'after')).toBe(false)
})
it('clearQueuedPrompts wipes the mirror and fires the RPC', () => {
setSessionQueue(SESSION_KEY, [{ id: 'a', text: 'first' }])
addPendingSteer(SESSION_KEY, 'nudge')
clearQueuedPrompts(SESSION_KEY)
expect(getQueuedPrompts(SESSION_KEY)).toEqual([])
expect($queuedPromptsBySession.get()[SESSION_KEY]).toBeUndefined()
expect(window.localStorage.getItem(QUEUE_STORAGE_KEY)).toBeNull()
expect(getPendingSteers(SESSION_KEY)).toEqual([])
expect(requester).toHaveBeenCalledWith('session.queue.clear', { session_id: SESSION_KEY })
})
it('persists queue entries into local storage', () => {
enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'persist me' })
const raw = window.localStorage.getItem(QUEUE_STORAGE_KEY)
expect(raw).toBeTruthy()
const parsed = JSON.parse(String(raw)) as Record<string, { text: string }[]>
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
})
})
describe('migrateQueuedPrompts', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
})
it('moves entries from a dead runtime key onto the live one', () => {
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'stranded' })
it('migrateQueuedPrompts re-keys the local mirror on a runtime id change', () => {
setSessionQueue('rt-old', [{ id: 'a', text: 'stranded' }])
setSessionQueue('rt-new', [{ id: 'b', text: 'already here' }])
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(true)
expect(getQueuedPrompts('rt-old')).toEqual([])
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['stranded'])
// The dead key is dropped from the store entirely.
expect($queuedPromptsBySession.get()['rt-old']).toBeUndefined()
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'stranded'])
expect(migrateQueuedPrompts('rt-new', 'rt-new')).toBe(false)
expect(migrateQueuedPrompts('rt-empty', 'rt-new')).toBe(false)
})
it('appends after existing target entries (FIFO preserved)', () => {
enqueueQueuedPrompt('rt-new', { attachments: [], text: 'already here' })
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'migrated' })
describe('pending steers', () => {
it('tracks a steer until steer.applied settles it', () => {
addPendingSteer(SESSION_KEY, 'go left')
addPendingSteer(SESSION_KEY, 'then right')
migrateQueuedPrompts('rt-old', 'rt-new')
expect(getPendingSteers(SESSION_KEY).map(s => s.text)).toEqual(['go left', 'then right'])
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'migrated'])
})
// The agent concatenates queued steers with newlines before injecting —
// one applied event can cover both.
settlePendingSteer(SESSION_KEY, 'go left\nthen right')
it('is a no-op when source is empty or keys match', () => {
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
})
})
describe('shouldAutoDrain', () => {
it('drains whenever idle with a non-empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true)
})
it('drains on mount/reconnect with no observed busy edge', () => {
// The whole point of dropping the edge: a remount resets the busy ref, so an
// edge-gated drain would strand the entry. Idle + non-empty must still fire.
expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true)
})
it('does not drain mid-turn', () => {
expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false)
})
it('does not drain an empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
expect(getPendingSteers(SESSION_KEY)).toEqual([])
})
it('settles only matching entries', () => {
addPendingSteer(SESSION_KEY, 'go left')
addPendingSteer(SESSION_KEY, 'stay')
settlePendingSteer(SESSION_KEY, 'go left')
expect(getPendingSteers(SESSION_KEY).map(s => s.text)).toEqual(['stay'])
})
})
})

View file

@ -1,7 +1,24 @@
/**
* Gateway-backed turn queue store for the desktop renderer.
*
* The queue lives on the agent (`agent.turn_queue`) in the gateway process
* this store is a mirror, populated from `queue.updated` events and the
* `queue` field of `session.info`. The renderer never owns the queue: the
* gateway drains it at the end of every turn (and immediately on enqueue when
* the session is idle), so queued messages fire even when the session tab is
* closed or the window is hidden. There is NO client-side auto-drain.
*
* Mutations are optimistic: the local mirror updates immediately for snappy
* UI, then the RPC fires and the authoritative `queue.updated` event settles
* the final state.
*/
import { atom } from 'nanostores'
import type { ComposerAttachment } from './composer'
export type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
export interface QueuedPromptEntry {
id: string
text: string
@ -9,57 +26,43 @@ export interface QueuedPromptEntry {
queuedAt: number
}
/** A steer accepted by the gateway but not yet injected into the live turn. */
export interface PendingSteerEntry {
id: string
text: string
steeredAt: number
}
type QueueState = Record<string, QueuedPromptEntry[]>
type SteerState = Record<string, PendingSteerEntry[]>
const STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
// Legacy localStorage key from the client-owned queue era. Read once by
// migrateLegacyQueue() to push stranded entries to the gateway, then cleared.
const LEGACY_STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
const load = (): QueueState => {
if (typeof window === 'undefined') {
return {}
export const $queuedPromptsBySession = atom<QueueState>({})
export const $pendingSteersBySession = atom<SteerState>({})
// Module-level gateway request function — set once by the gateway boot hook so
// this store can fire RPCs without being a React component.
let _callGateway: GatewayRequester | null = null
export const setGatewayRequester = (fn: GatewayRequester | null) => {
_callGateway = fn
}
const callGateway = async <T = unknown>(method: string, params?: Record<string, unknown>): Promise<T | null> => {
if (!_callGateway) {
return null
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : null
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as QueueState) : {}
return await _callGateway<T>(method, params)
} catch {
return {}
return null
}
}
const save = (state: QueueState) => {
if (typeof window === 'undefined') {
return
}
try {
if (Object.keys(state).length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
} else {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
} catch {
// best-effort: storage may be unavailable, queue still works in-memory
}
}
export const $queuedPromptsBySession = atom<QueueState>(load())
const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
const current = $queuedPromptsBySession.get()
const next = { ...current }
if (queue.length === 0) {
delete next[sid]
} else {
next[sid] = queue
}
$queuedPromptsBySession.set(next)
save(next)
}
const sidOf = (key: string | null | undefined): null | string => {
const trimmed = key?.trim()
@ -68,9 +71,45 @@ const sidOf = (key: string | null | undefined): null | string => {
const queueFor = (sid: string) => $queuedPromptsBySession.get()[sid] ?? []
const nextId = () => `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
const next = { ...$queuedPromptsBySession.get() }
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
if (queue.length === 0) {
delete next[sid]
} else {
next[sid] = queue
}
$queuedPromptsBySession.set(next)
}
interface GatewayQueueEntry {
id?: string
text?: string
queued_at?: number
}
/** Replace a session's mirror with the gateway's authoritative entry list
* (called from `queue.updated` events and `session.info.queue`). */
export const setSessionQueue = (key: string | null | undefined, entries: unknown[]) => {
const sid = sidOf(key)
if (!sid) {
return
}
writeSession(
sid,
entries
.filter((e): e is GatewayQueueEntry => Boolean(e) && typeof e === 'object')
.map(e => ({
id: String(e.id ?? ''),
text: String(e.text ?? ''),
attachments: [],
queuedAt: typeof e.queued_at === 'number' ? Math.round(e.queued_at * 1000) : 0
}))
)
}
export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEntry[] => {
const sid = sidOf(key)
@ -78,46 +117,48 @@ export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEn
return sid ? queueFor(sid) : []
}
export const enqueueQueuedPrompt = (
// ── RPC-backed mutations ─────────────────────────────────────────────
// Each mutation updates the local mirror optimistically, then fires the RPC;
// the gateway's queue.updated event is the authoritative settle.
/**
* Queue a prompt on the gateway. The gateway drains it as the next turn (or
* immediately when the session is idle). Returns the created entry on accept,
* null when the gateway is unreachable the caller keeps the draft so no
* words are lost.
*/
export const enqueueQueuedPrompt = async (
key: string | null | undefined,
payload: { text: string; attachments: ComposerAttachment[] }
): null | QueuedPromptEntry => {
payload: { text: string }
): Promise<null | QueuedPromptEntry> => {
const sid = sidOf(key)
if (!sid) {
return null
}
const result = await callGateway<{ entry_id?: string; status?: string }>('session.queue.add', {
session_id: sid,
text: payload.text
})
if (result?.status !== 'queued') {
return null
}
const entry: QueuedPromptEntry = {
id: nextId(),
id: result.entry_id ?? `local-${Date.now()}`,
text: payload.text,
attachments: cloneAttachments(payload.attachments),
attachments: [],
queuedAt: Date.now()
}
writeSession(sid, [...queueFor(sid), entry])
// Optimistic append — queue.updated settles the authoritative list.
writeSession(sid, [...queueFor(sid).filter(e => e.id !== entry.id), entry])
return entry
}
export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => {
const sid = sidOf(key)
if (!sid) {
return null
}
const [head, ...rest] = queueFor(sid)
if (!head) {
return null
}
writeSession(sid, rest)
return head
}
export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
@ -133,11 +174,21 @@ export const removeQueuedPrompt = (key: string | null | undefined, id: string):
}
writeSession(sid, next)
void callGateway('session.queue.remove', { session_id: sid, entry_id: id })
return true
}
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
/**
* Move an entry to the head so it fires next. With `interrupt`, also winds
* down the live turn (queue preserved) so the entry fires as soon as the turn
* settles the "send now" gesture.
*/
export const promoteQueuedPrompt = (
key: string | null | undefined,
id: string,
options?: { interrupt?: boolean }
): boolean => {
const sid = sidOf(key)
if (!sid) {
@ -147,12 +198,20 @@ export const promoteQueuedPrompt = (key: string | null | undefined, id: string):
const queue = queueFor(sid)
const index = queue.findIndex(e => e.id === id)
if (index <= 0) {
if (index < 0) {
return false
}
const entry = queue[index]!
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
if (index > 0) {
const entry = queue[index]!
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
}
void callGateway('session.queue.promote', {
session_id: sid,
entry_id: id,
interrupt: Boolean(options?.interrupt)
})
return true
}
@ -172,19 +231,13 @@ export const updateQueuedPrompt = (
let changed = false
const next = queue.map(entry => {
if (entry.id !== id) {
return entry
}
const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments
if (entry.text === update.text && !update.attachments) {
if (entry.id !== id || entry.text === update.text) {
return entry
}
changed = true
return { ...entry, text: update.text, attachments }
return { ...entry, text: update.text }
})
if (!changed) {
@ -192,6 +245,7 @@ export const updateQueuedPrompt = (
}
writeSession(sid, next)
void callGateway('session.queue.update', { session_id: sid, entry_id: id, text: update.text })
return true
}
@ -199,23 +253,25 @@ export const updateQueuedPrompt = (
export const updateQueuedPromptText = (key: string | null | undefined, id: string, text: string): boolean =>
updateQueuedPrompt(key, id, { text })
/** Clear the local mirror and the gateway queue (session close/delete). */
export const clearQueuedPrompts = (key: string | null | undefined) => {
const sid = sidOf(key)
if (!sid || !(sid in $queuedPromptsBySession.get())) {
if (!sid) {
return
}
writeSession(sid, [])
if (sid in $queuedPromptsBySession.get()) {
writeSession(sid, [])
}
clearPendingSteers(sid)
void callGateway('session.queue.clear', { session_id: sid })
}
/**
* Move pending entries from a dead session key onto a live one, preserving FIFO
* (existing target entries first, migrated entries appended). A backend bounce /
* resume can mint a fresh runtime session id for the *same* conversation; the
* entries enqueued under the old id would otherwise be stranded under a key
* nothing reads anymore. No-op unless both keys resolve and differ.
*/
/** Local-only mirror re-key when a backend bounce mints a fresh runtime id
* for the same conversation. The gateway re-syncs via session.info/queue
* events; this just keeps the panel from flashing empty in between. */
export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey: string | null | undefined): boolean => {
const from = sidOf(fromKey)
const to = sidOf(toKey)
@ -235,29 +291,123 @@ export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey:
next[to] = [...queueFor(to), ...pending]
$queuedPromptsBySession.set(next)
save(next)
return true
}
/** Inputs to {@link shouldAutoDrain}. */
export interface AutoDrainInput {
isBusy: boolean
queueLength: number
/** One-time migration: push any entries stranded in the legacy localStorage
* queue (client-owned era) to the gateway, then clear the storage key. */
export const migrateLegacyQueue = (key: string | null | undefined) => {
const sid = sidOf(key)
if (!sid || typeof window === 'undefined') {
return
}
try {
const raw = window.localStorage.getItem(LEGACY_STORAGE_KEY)
if (!raw) {
return
}
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
window.localStorage.removeItem(LEGACY_STORAGE_KEY)
return
}
const state = parsed as Record<string, { text?: string }[]>
const entries = state[sid]
if (entries?.length) {
for (const entry of entries) {
if (entry?.text?.trim()) {
void enqueueQueuedPrompt(sid, { text: entry.text })
}
}
}
delete state[sid]
if (Object.keys(state).length === 0) {
window.localStorage.removeItem(LEGACY_STORAGE_KEY)
} else {
window.localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(state))
}
} catch {
// Best-effort — a broken legacy blob shouldn't take down the composer.
}
}
/**
* Decide whether the composer should auto-drain the next queued prompt.
*
* Edge-independent on purpose: the queue must advance whenever the session is
* idle and has pending entries, NOT only on an observed busy true false edge.
* A backend bounce / websocket reconnect remounts the composer and resets the
* busy ref to the current value, swallowing the settle edge an edge-gated
* drain would then strand the entry forever. The caller's drain lock
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
*/
export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0
// ── Pending steers ───────────────────────────────────────────────────
// A steer RPC is accepted instantly, but the text only reaches the model at
// the next tool-batch boundary. These helpers track that in-between state so
// the transcript can show the steer when it actually lands (steer.applied)
// instead of pretending it was instant.
/** Auto-drain attempts for one entry before we stop retrying and toast. The
* entry stays queued for a manual send; a remount/reconnect resets the count. */
export const MAX_AUTO_DRAIN_ATTEMPTS = 4
const steersFor = (sid: string) => $pendingSteersBySession.get()[sid] ?? []
const writeSteers = (sid: string, steers: PendingSteerEntry[]) => {
const next = { ...$pendingSteersBySession.get() }
if (steers.length === 0) {
delete next[sid]
} else {
next[sid] = steers
}
$pendingSteersBySession.set(next)
}
export const getPendingSteers = (key: string | null | undefined): PendingSteerEntry[] => {
const sid = sidOf(key)
return sid ? steersFor(sid) : []
}
export const addPendingSteer = (key: string | null | undefined, text: string) => {
const sid = sidOf(key)
if (!sid) {
return
}
writeSteers(sid, [
...steersFor(sid),
{ id: `steer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, text, steeredAt: Date.now() }
])
}
/** Settle pending steers covered by an applied/dropped gateway event. The
* agent concatenates queued-up steers with newlines before injecting, so one
* event can cover several pending entries match by containment. */
export const settlePendingSteer = (key: string | null | undefined, text: string) => {
const sid = sidOf(key)
if (!sid) {
return
}
const settled = new Set(
text
.split('\n')
.map(line => line.trim())
.filter(Boolean)
)
writeSteers(
sid,
steersFor(sid).filter(entry => !settled.has(entry.text.trim()))
)
}
export const clearPendingSteers = (key: string | null | undefined) => {
const sid = sidOf(key)
if (sid && sid in $pendingSteersBySession.get()) {
writeSteers(sid, [])
}
}

View file

@ -2829,8 +2829,28 @@ class AIAgent:
# late injection on the post-interrupt turn.
_steer_lock = getattr(self, "_pending_steer_lock", None)
if _steer_lock is not None:
_dropped = None
with _steer_lock:
_dropped = self._pending_steer
self._pending_steer = None
if _dropped:
self._emit_steer_event("dropped", _dropped)
def _emit_steer_event(self, kind: str, text: str) -> None:
"""Notify the steer lifecycle observer (if any).
``kind`` is ``"applied"`` when the steer text was really injected
into the conversation (the model will see it), or ``"dropped"`` when
a hard interrupt discarded it. Never raises the observer is a
client-side courtesy, not part of the turn's control flow.
"""
callback = getattr(self, "_on_steer_event", None)
if callback is None:
return
try:
callback(kind, text)
except Exception:
pass
def steer(self, text: str) -> bool:
"""

View file

@ -0,0 +1,300 @@
"""Tests for the agent-side TurnQueue and the session.queue.* gateway RPCs.
The queue lives on the agent (agent.turn_queue) so it drains even when no
client window is open see agent/turn_queue.py. The gateway RPCs are thin
manipulators over it; _drain_queued_prompt is the single drain point.
"""
import io
import sys
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from agent.turn_queue import TurnQueue
_original_stdout = sys.stdout
@pytest.fixture(autouse=True)
def _restore_stdout():
yield
sys.stdout = _original_stdout
# ── TurnQueue unit behaviour ─────────────────────────────────────────
def test_enqueue_drain_fifo():
q = TurnQueue()
a = q.enqueue("first")
b = q.enqueue("second")
assert len(q) == 2
assert bool(q)
assert q.drain().id == a.id
assert q.drain().id == b.id
assert q.drain() is None
assert not q
def test_remove_promote_update_clear():
q = TurnQueue()
a = q.enqueue("a")
b = q.enqueue("b")
c = q.enqueue("c")
assert q.promote(c.id) is True
assert [e.id for e in q.peek()] == [c.id, a.id, b.id]
# Already at head → False (callers treat membership as "will go next").
assert q.promote(c.id) is False
assert q.promote("missing") is False
assert q.update_text(a.id, "a2") is True
assert q.update_text("missing", "x") is False
assert q.remove(b.id) is True
assert q.remove(b.id) is False
assert q.clear() == 2
assert q.drain() is None
def test_to_dict_shape_excludes_transport():
q = TurnQueue()
q.enqueue("hello", transport=object(), source="busy_submit")
(d,) = q.to_list()
assert d["text"] == "hello"
assert d["source"] == "busy_submit"
assert "transport" not in d
assert isinstance(d["queued_at"], float)
def test_thread_safety_under_concurrent_enqueue_drain():
q = TurnQueue()
drained = []
def producer():
for i in range(200):
q.enqueue(f"msg-{i}")
def consumer():
deadline = time.time() + 5
while len(drained) < 200 and time.time() < deadline:
entry = q.drain()
if entry is not None:
drained.append(entry.text)
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start(); t2.start()
t1.join(); t2.join()
assert len(drained) == 200
assert len(set(drained)) == 200
# ── Gateway RPC integration ──────────────────────────────────────────
@pytest.fixture()
def server():
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
def _make_session(server, sid, running=True):
agent = MagicMock(spec=["turn_queue", "steer", "interrupt"])
agent.turn_queue = TurnQueue()
session = {
"session_key": sid,
"agent": agent,
"running": running,
"history_lock": threading.Lock(),
"last_active": 0,
}
server._sessions[sid] = session
return session
def test_queue_add_lists_and_emits(server):
sid = "s1"
session = _make_session(server, sid, running=True)
events = []
with patch.object(server, "_emit", side_effect=lambda ev, s, p=None: events.append((ev, p))):
resp = server.handle_request({
"id": "r1",
"method": "session.queue.add",
"params": {"session_id": sid, "text": "queued msg"},
})
assert "error" not in resp
assert resp["result"]["status"] == "queued"
entry_id = resp["result"]["entry_id"]
assert entry_id
# Mirrors into the agent queue; busy session → NOT drained.
assert [e.text for e in session["agent"].turn_queue.peek()] == ["queued msg"]
assert ("queue.updated", {"entries": session["agent"].turn_queue.to_list()}) in events
listed = server.handle_request({
"id": "r2",
"method": "session.queue.list",
"params": {"session_id": sid},
})
assert [e["id"] for e in listed["result"]["entries"]] == [entry_id]
def test_queue_add_empty_text_rejected(server):
_make_session(server, "s1")
resp = server.handle_request({
"id": "r1",
"method": "session.queue.add",
"params": {"session_id": "s1", "text": " "},
})
assert resp["error"]["code"] == 4002
def test_queue_add_idle_session_drains_immediately(server):
sid = "s1"
session = _make_session(server, sid, running=False)
drained = threading.Event()
submitted = {}
def fake_submit(rid, s, sess, text):
submitted["text"] = text
drained.set()
with patch.object(server, "_run_prompt_submit", side_effect=fake_submit), \
patch.object(server, "_emit"):
resp = server.handle_request({
"id": "r1",
"method": "session.queue.add",
"params": {"session_id": sid, "text": "run now"},
})
assert resp["result"]["status"] == "queued"
assert drained.wait(timeout=5), "idle enqueue should drain immediately"
assert submitted["text"] == "run now"
assert session["agent"].turn_queue.drain() is None
def test_queue_remove_promote_update_clear_rpcs(server):
sid = "s1"
session = _make_session(server, sid, running=True)
q = session["agent"].turn_queue
a = q.enqueue("a")
b = q.enqueue("b")
with patch.object(server, "_emit"):
promoted = server.handle_request({
"id": "r1", "method": "session.queue.promote",
"params": {"session_id": sid, "entry_id": b.id},
})
assert promoted["result"]["promoted"] is True
assert [e.id for e in q.peek()] == [b.id, a.id]
updated = server.handle_request({
"id": "r2", "method": "session.queue.update",
"params": {"session_id": sid, "entry_id": a.id, "text": "a-edited"},
})
assert updated["result"]["updated"] is True
removed = server.handle_request({
"id": "r3", "method": "session.queue.remove",
"params": {"session_id": sid, "entry_id": b.id},
})
assert removed["result"]["removed"] is True
cleared = server.handle_request({
"id": "r4", "method": "session.queue.clear",
"params": {"session_id": sid},
})
assert cleared["result"]["cleared"] == 1
assert q.drain() is None
def test_queue_promote_interrupt_forwards_to_agent(server):
sid = "s1"
session = _make_session(server, sid, running=True)
q = session["agent"].turn_queue
q.enqueue("a")
b = q.enqueue("b")
with patch.object(server, "_emit"):
resp = server.handle_request({
"id": "r1", "method": "session.queue.promote",
"params": {"session_id": sid, "entry_id": b.id, "interrupt": True},
})
assert resp["result"]["promoted"] is True
session["agent"].interrupt.assert_called_once()
# The queue survives the interrupt — that's the whole point of send-now.
assert len(q) == 2
def test_queue_rpcs_unknown_session(server):
for method in (
"session.queue.list",
"session.queue.clear",
):
resp = server.handle_request({
"id": "r", "method": method, "params": {"session_id": "nope"},
})
assert resp["error"]["code"] == 4001
def test_drain_queued_prompt_pops_agent_queue_and_emits_drained(server):
sid = "s1"
session = _make_session(server, sid, running=False)
session["agent"].turn_queue.enqueue("next turn", source="queue")
events = []
with patch.object(server, "_run_prompt_submit"), \
patch.object(server, "_emit", side_effect=lambda ev, s, p=None: events.append((ev, p))):
assert server._drain_queued_prompt("rid", sid, session) is True
kinds = [ev for ev, _ in events]
assert "queue.updated" in kinds
drained = dict(events)["queue.drained"]
assert drained == {"text": "next turn", "source": "queue"}
def test_drain_queued_prompt_skips_when_running(server):
sid = "s1"
session = _make_session(server, sid, running=True)
session["agent"].turn_queue.enqueue("waiting")
with patch.object(server, "_run_prompt_submit") as submit:
assert server._drain_queued_prompt("rid", sid, session) is False
submit.assert_not_called()
assert len(session["agent"].turn_queue) == 1
def test_busy_submit_enqueues_on_agent_queue(server):
"""_handle_busy_submit routes mid-turn prompts into agent.turn_queue with
source=busy_submit so drain events tell clients the text was already echoed."""
sid = "s1"
session = _make_session(server, sid, running=True)
with patch.object(server, "_emit"), \
patch.object(server, "_load_busy_input_mode", return_value="queue"):
resp = server._handle_busy_submit("rid", sid, session, "mid-turn msg", None)
assert resp["result"]["status"] == "queued"
(entry,) = session["agent"].turn_queue.peek()
assert entry.text == "mid-turn msg"
assert entry.source == "busy_submit"

View file

@ -3355,6 +3355,13 @@ def _current_profile_name() -> str:
DESKTOP_BACKEND_CONTRACT = 3
def _queue_info(agent) -> list:
"""Return the agent's turn queue as a list of dicts (for session.info)."""
if agent is not None and hasattr(agent, "turn_queue"):
return agent.turn_queue.to_list()
return []
def _session_info(agent, session: dict | None = None) -> dict:
if session is None:
for candidate in _sessions.values():
@ -3411,6 +3418,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
"branch": _git_branch_for_cwd(cwd),
"personality": str(personality or ""),
"running": bool((session or {}).get("running")),
"queue": _queue_info(agent),
"title": _session_live_title(session or {}, session_key) if session_key else "",
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
"version": "",
@ -5101,15 +5109,35 @@ def _clear_inflight_turn(session: dict) -> None:
session["inflight_turn"] = None
def _emit_queue_update(sid: str, session: dict) -> None:
"""Emit a ``queue.updated`` event so clients can mirror queue state.
Called after every mutation (enqueue, drain, remove, clear, promote).
The desktop renderer subscribes to this instead of managing its own
localStorage-backed queue.
"""
agent = session.get("agent")
if agent is None or not hasattr(agent, "turn_queue"):
return
_emit("queue.updated", sid, {"entries": agent.turn_queue.to_list()})
def _enqueue_prompt(session: dict, text: Any, transport: Any) -> None:
"""Stash a message to run as the very next turn once the live one ends.
Used when a prompt arrives mid-turn (see ``_handle_busy_submit``). A single
slot is kept; a second arrival is merged (lossless, mirroring the
consecutive-user merge in ``repair_message_sequence``) so nothing the user
typed is dropped. ``transport`` is pinned so the drained turn streams back to
the client that sent it even if the session transport is rebound meanwhile.
Used when a prompt arrives mid-turn (see ``_handle_busy_submit``). The
message is appended to the agent's :class:`TurnQueue` — the queue lives
on the agent so it drains even when no client window is open.
``transport`` is pinned so the drained turn streams back to the client
that sent it even if the session transport is rebound meanwhile.
"""
agent = session.get("agent")
if agent is not None and hasattr(agent, "turn_queue"):
agent.turn_queue.enqueue(text=str(text), transport=transport, source="busy_submit")
return
# Fallback: the legacy single-slot session dict, for sessions without a
# built agent yet (shouldn't happen in normal flow — prompt.submit builds
# the agent before any busy-submit can occur — but keeps the contract safe).
existing = session.get("queued_prompt")
if (
existing
@ -5152,6 +5180,7 @@ def _handle_busy_submit(rid, sid: str, session: dict, text: Any, transport: Any)
pass
_enqueue_prompt(session, text, transport)
session["last_active"] = time.time()
_emit_queue_update(sid, session)
return _ok(rid, {"status": "queued"})
@ -5162,14 +5191,45 @@ def _drain_queued_prompt(rid, sid: str, session: dict) -> bool:
lower-priority follow-ups this cycle the user's message wins). Mirrors the
claim-under-lock pattern used by the goal-continuation re-fire.
"""
agent = session.get("agent")
with session["history_lock"]:
queued = session.get("queued_prompt")
if not queued or session.get("running"):
if session.get("running"):
return False
session["queued_prompt"] = None
# Drain from the agent's TurnQueue (preferred), falling back to the
# legacy session-dict slot (populated only when no agent existed at
# enqueue time).
queued = None
if agent is not None and hasattr(agent, "turn_queue"):
entry = agent.turn_queue.drain()
if entry is not None:
queued = {
"text": entry.text,
"transport": entry.transport,
"source": entry.source,
}
if queued is None:
legacy = session.get("queued_prompt")
if not legacy:
return False
session["queued_prompt"] = None
queued = {
"text": legacy.get("text"),
"transport": legacy.get("transport"),
"source": "busy_submit",
}
session["running"] = True
if queued.get("transport") is not None:
session["transport"] = queued["transport"]
_emit_queue_update(sid, session)
# Tell clients the drained text is becoming the next user turn so they
# can move it from their queue panel into the transcript. `source` lets
# them skip painting text they already echoed optimistically
# (busy_submit) vs panel-queued entries that were never in the transcript.
_emit(
"queue.drained",
sid,
{"text": queued.get("text") or "", "source": queued.get("source") or "queue"},
)
try:
_run_prompt_submit(rid, sid, session, queued["text"])
except Exception as exc:
@ -8174,7 +8234,17 @@ def _(rid, params: dict) -> dict:
session["agent"].interrupt()
with session["history_lock"]:
session["_turn_cancel_requested"] = True
# Stop discards pending next-turn prompts too (they'd otherwise fire
# the instant the turn unwinds — the opposite of what Stop means).
# `keep_queue` opts out for the promote-then-interrupt "send queued
# entry now" flow, where the queued entry must survive to be drained
# as the next turn.
if not params.get("keep_queue"):
agent = (session or {}).get("agent")
if agent is not None and hasattr(agent, "turn_queue"):
agent.turn_queue.clear()
session["queued_prompt"] = None
_emit_queue_update(str(params.get("session_id", "")), session or {})
if not run_thread_alive:
with session["history_lock"]:
if session.get("running"):
@ -8449,6 +8519,144 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text})
# ── Methods: session.queue ────────────────────────────────────────────
#
# The turn queue lives on the agent (agent.turn_queue) — see
# agent/turn_queue.py. These RPCs let clients manipulate it without owning
# it; the gateway drains automatically at the end of every turn
# (_drain_queued_prompt), so queued messages fire even when no client
# window is open. Every mutation emits queue.updated so clients mirror
# state instead of persisting their own copy.
@method("session.queue.add")
def _(rid, params: dict) -> dict:
"""Add a prompt to the session's turn queue.
The prompt runs as the next turn once the current one finishes. When the
session is already idle, the entry is drained immediately the gateway
owns all drain paths, so clients never need a "send it myself" fallback.
"""
text = str(params.get("text") or "")
if not text.strip():
return _err(rid, 4002, "text is required")
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
sid = str(params.get("session_id", ""))
agent = session.get("agent")
if agent is None or not hasattr(agent, "turn_queue"):
return _err(rid, 4010, "session has no turn queue")
entry = agent.turn_queue.enqueue(text=text)
session["last_active"] = time.time()
_emit_queue_update(sid, session)
# Idle session → drain right away (in a thread; the drain runs a whole
# turn and this RPC must return promptly).
if not session.get("running"):
threading.Thread(
target=_drain_queued_prompt,
args=(rid, sid, session),
daemon=True,
).start()
return _ok(rid, {"status": "queued", "entry_id": entry.id})
@method("session.queue.list")
def _(rid, params: dict) -> dict:
"""List pending queued prompts for the session."""
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
agent = session.get("agent")
return _ok(rid, {"entries": _queue_info(agent)})
@method("session.queue.remove")
def _(rid, params: dict) -> dict:
"""Remove a specific queued entry by id."""
entry_id = str(params.get("entry_id") or "")
if not entry_id:
return _err(rid, 4002, "entry_id is required")
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
sid = str(params.get("session_id", ""))
agent = session.get("agent")
removed = False
if agent is not None and hasattr(agent, "turn_queue"):
removed = agent.turn_queue.remove(entry_id)
_emit_queue_update(sid, session)
return _ok(rid, {"removed": removed})
@method("session.queue.clear")
def _(rid, params: dict) -> dict:
"""Clear all queued entries for the session."""
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
sid = str(params.get("session_id", ""))
agent = session.get("agent")
count = 0
if agent is not None and hasattr(agent, "turn_queue"):
count = agent.turn_queue.clear()
session["queued_prompt"] = None
_emit_queue_update(sid, session)
return _ok(rid, {"cleared": count})
@method("session.queue.promote")
def _(rid, params: dict) -> dict:
"""Move a queued entry to the front of the queue (send-next priority).
With ``interrupt=True``, also interrupts the live turn (keeping the
queue intact) so the promoted entry fires as soon as the turn unwinds
the "send now" gesture.
"""
entry_id = str(params.get("entry_id") or "")
if not entry_id:
return _err(rid, 4002, "entry_id is required")
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
sid = str(params.get("session_id", ""))
agent = session.get("agent")
promoted = False
if agent is not None and hasattr(agent, "turn_queue"):
# promote() returns False for an entry already at the head — that
# still counts as "it will go next", so verify membership instead.
promoted = agent.turn_queue.promote(entry_id) or any(
e.id == entry_id for e in agent.turn_queue.peek()
)
_emit_queue_update(sid, session)
if promoted and params.get("interrupt") and session.get("running"):
if hasattr(agent, "interrupt"):
try:
agent.interrupt()
except Exception:
pass
return _ok(rid, {"promoted": promoted})
@method("session.queue.update")
def _(rid, params: dict) -> dict:
"""Update the text of a queued entry."""
entry_id = str(params.get("entry_id") or "")
text = str(params.get("text") or "")
if not entry_id or not text.strip():
return _err(rid, 4002, "entry_id and text are required")
session, err = _sess_nowait(params, rid)
if err or session is None:
return err or _err(rid, 4001, "session not found")
sid = str(params.get("session_id", ""))
agent = session.get("agent")
updated = False
if agent is not None and hasattr(agent, "turn_queue"):
updated = agent.turn_queue.update_text(entry_id, text)
_emit_queue_update(sid, session)
return _ok(rid, {"updated": updated})
@method("terminal.resize")
def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)
@ -8964,6 +9172,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
agent.clear_interrupt()
except Exception:
pass
# Steer lifecycle → client events. A steer is accepted instantly by the
# RPC but only *lands* when the agent loop injects it into a tool result
# (or drops it on interrupt). Clients render the steer as pending until
# steer.applied arrives, so the transcript reflects what the model
# actually saw, when it saw it.
try:
agent._on_steer_event = lambda kind, text: _emit(
f"steer.{kind}", sid, {"text": text}
)
except Exception:
pass
_emit("message.start", sid)
def run():
@ -9150,6 +9369,15 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
last_reasoning = None
status_note = None
if isinstance(result, dict):
# A /steer that landed after the final assistant message has
# no tool batch left to inject into — run_conversation hands
# it back as pending_steer. Queue it as the next user turn
# (the post-turn drain below fires it) instead of silently
# dropping it.
_leftover_steer = result.get("pending_steer")
if _leftover_steer and hasattr(agent, "turn_queue"):
agent.turn_queue.enqueue(text=str(_leftover_steer))
_emit_queue_update(sid, session)
if isinstance(result.get("messages"), list):
with session["history_lock"]:
current_version = int(session.get("history_version", 0))