feat(agents): durable per-agent chat message queue + composer Stop#880
feat(agents): durable per-agent chat message queue + composer Stop#880Dani Akash (DaniAkash) merged 6 commits intodevfrom
Conversation
…ive drain attach User feedback round 1 on the message-queue UX: 1) The Stop button matched the send/voice mics at h-10 w-10 with a solid destructive fill, which read as alarming. Shrunk to h-8 w-8, ghost variant with a soft destructive/10 background, smaller filled square glyph. Reads as a calm 'stop' affordance instead of a panic button. 2) The QueueItem's leading <QueueItemIndicator> dot was decorative only — no state, no interaction. Dropped it from QueuePanel along with the import; queue items now render as a clean preview line with the trailing X remove action. 3) When the server drained the queue and started the next turn, the chat panel didn't pick up the live stream until the user navigated away and back. The hook's resume effect previously only fired on agent change, not on listing-observed activeTurnId change. Surface activeTurnId from useHarnessAgents into useAgentConversation; effect now re-runs when the id changes, calls /chat/active, and attaches to the new turn — so a queued message starts streaming the moment the server drain pops it.
…op paths
The Stop button was disappearing while the agent was actively
streaming, even though events were still flowing into the chat. Root
cause: the resume effect's `finally` block reset `streaming`,
`turnIdRef`, and `lastSeqRef` unconditionally — including on the
early-return paths (no active turn, or another mechanism already
owns the stream).
Sequence that triggered it:
1) User sends a message → send() sets streamAbortRef + streaming=true
and starts consuming the SSE.
2) User enqueues another message → enqueue mutation invalidates the
listing query.
3) Listing refetches with the live activeTurnId → the resume
effect re-fires (deps include activeTurnIdDep).
4) attemptResume hits `if (streamAbortRef.current) return` because
send() owns it.
5) The finally clause fires anyway and calls setStreaming(false),
clobbering the live state set by send(). The SSE consumer keeps
running (refs are intact) so text keeps streaming, but the React
flag is wrong, so the Stop button gates off.
Fix: track whether *this* run actually started a stream
(`weStartedStream`). The finally only resets state when it does.
Early-return / no-active-turn paths now leave streaming/turnIdRef/
lastSeqRef alone for whoever does own them.
Also widens the Stop button's visibility (`canStop` prop on
ConversationInput) so it stays steady across the brief gap between
turns when a queue drain is mid-flight; the parent computes
`streaming || activeTurnId !== null || queue.length > 0`. The
visibility widening is independent of the streaming-state fix above
— both are now in place.
Reverts the canStop prop on ConversationInput and the OR-with-queue visibility from AgentCommandConversation. Stop is gated solely on `streaming` again. Between turns (queue draining) the button stays hidden — only the actively-streaming turn is interruptible from the composer, which matches what the user actually expects.
…sume placeholder isn't empty When a queued message drained and started a new turn, the chat panel's resume effect staged a placeholder turn with userText: '' because the hook had no way to know what message kicked off the turn — only the agent-side stream was visible, and the user bubble above it was blank until the user navigated away and back (at which point the session record's history loaded normally). Fix: ActiveTurnRegistry.register now accepts an optional `prompt` that's stashed on the turn and surfaced via describe() / the ActiveTurnInfo response. AgentHarnessService.startTurn passes the incoming message into register. /chat/active returns it. The chat hook's resume effect uses active.prompt as the placeholder turn's userText, so the user bubble shows the queued message text the moment streaming begins. Falls back to '' for older clients that haven't been refetched yet.
✅ Tests passed — 1011/1015
|
Greptile SummaryThis PR adds a durable per-agent FIFO message queue backed by an atomic JSON file, so messages sent while an agent is streaming are enqueued and drained automatically at turn boundaries rather than dropped. It also surfaces a Stop button in the composer and fixes a pre-existing bug where the stop button would disappear mid-stream when listing refetches caused the resume effect to re-fire.
Confidence Score: 3/5Hold for the streamAbortRef stale-ref fix; all server-side queue logic is solid. One P1 in the resume hook: a narrow but real timing race introduced by the new activeTurnIdDep dependency can leave the client permanently stuck in streaming=true with no live stream until navigation. The server-side FileMessageQueue, drain logic, and route layer are well-implemented with good test coverage. packages/browseros-agent/apps/agent/entrypoints/app/agent-command/useAgentConversation.ts — the streamAbortRef.current stale-on-cancel issue at the guard check around line 251. Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User
participant CC as AgentCommandConversation
participant UE as useEnqueueHarnessMessage
participant S as Server /agents/:id/queue
participant HS as AgentHarnessService
participant FMQ as FileMessageQueue
participant TR as TurnRegistry
U->>CC: Send message while streaming=true
CC->>UE: mutate({agentId, message})
UE-->>CC: optimistic cache update
UE->>S: POST /agents/:id/queue
S->>HS: enqueueMessage()
HS->>FMQ: append(agentId, msg)
FMQ-->>HS: QueuedMessage
HS->>TR: getActiveFor(agentId)?
alt no active turn (idle window)
HS->>HS: maybeStartNextFromQueue()
HS->>FMQ: popOldest()
HS->>HS: startTurn()
end
S-->>UE: {queued}
UE-->>CC: cache invalidate + refetch
note over HS,TR: Turn A ends (success/cancel/error)
HS->>HS: notifyTurnEnded()
HS->>HS: maybeStartNextFromQueue(agentId)
HS->>FMQ: popOldest(agentId)
FMQ-->>HS: next QueuedMessage
HS->>TR: getActiveFor? (race guard)
alt race: another turn started
HS->>FMQ: pushFront(agentId, msg)
else idle
HS->>HS: startTurn(next.message)
TR-->>HS: new ActiveTurn(prompt=next.message)
end
note over CC: Listing poll (5s) - activeTurnId flips
CC->>CC: useAgentConversation (activeTurnIdDep changes)
CC->>CC: fetchActiveHarnessTurn - attach SSE
|
…en cancelled Greptile P1 follow-up. The previous `weStartedStream` guard correctly stopped the resume effect's no-op early-returns from clobbering an in-flight `send()` stream — but it also stopped a *cancelled* mid-stream resume from clearing its own `streamAbortRef`. When the cleanup fires (e.g. the 5s listing poll captures a new queue-drain turn id while the SSE for the prior turn is still finishing), the next effect run hits the `if (streamAbortRef.current) return` guard against the now-aborted controller and never reattaches, leaving `streaming === true` with no live stream until the user navigates away. Split the finally block: always release `streamAbortRef` when we owned the controller (so the next run can take over), but only reset the streaming flag / turn id / lastSeq on a clean exit (the new run will set those itself, so resetting on cancel would just flicker).
Summary
The chat composer at `/agents/:agentId` now accepts new messages while the agent is actively streaming. Sent-while-active messages append to a durable FIFO queue rendered with the AI Elements `Queue` primitives between the chat history and the composer. The queue drains automatically when the active turn finishes (success, error, or user cancel) — the next queued message starts immediately. Queues persist across server restarts via an atomic JSON file at `~/.browseros/agent-harness/message-queues.json`.
Two control loops added:
Server
Client
Bug fixes that landed in the same branch
Test plan
Out of scope