10 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| shipped | 2026-07-31 | kandev |
Merge Enqueued Messages Individually
Queue-wide removal and capacity behavior is defined separately by
Manage Pending Message Queues, and admission-time
compaction is defined by
Automatically Merge Consecutive Queued Messages.
This spec governs only the manual merge operation. Its merge_enabled setting
does not control automatic merging, and auto_merge_enabled does not change
the behavior or compatibility rules below.
Why
When a session is busy, users queue follow-up prompts that drain one-per-turn. A burst of small, related prompts (a correction, a follow-up question, extra context) is dispatched as several separate turns, which wastes agent turns and fragments the conversation. Users want to collapse queued messages they no longer want delivered separately — folding one queued message into the one above it — before the agent picks them up.
What
- A queued message that has another queued message above it (i.e. it is not the head of the queue) offers a Merge with above control in the queue panel, next to the existing Edit and Remove controls.
- Clicking Merge with above folds the message into the message directly above it: the above message keeps its identity (id, position, sender, queued-at time) and its content is concatenated with the folded message's content, separated by a blank line.
- The folded message is removed from the queue; the queue count decreases by one and remaining entries keep their FIFO order.
- Attachments from both messages are carried over to the merged message.
- Entity references from both messages are carried over to the merged message, deduplicated by canonical reference.
- Merging is rejected atomically when the deduplicated reference union
would exceed the per-message reference cap (100, mirroring
entityrefs.maxReferencesPerMessage). Nothing is truncated: both rows keep the references they already persisted, and the merge reportsmerge_reference_overflowinstead of silently dropping source references at dispatch time. - The merge is atomic and race-safe: a concurrent queue drain can either win entirely (both rows untouched) or lose entirely (both rows updated); it can never interleave between updating the above message and removing the folded one.
- A message may only be merged into a message of the same sender kind — mismatching kinds never merge. A user-typed message merges only into another user-typed message; an agent (inter-task) message merges only into another agent message. Workflow and system messages are never a merge source or target.
- Two agent messages merge only when they come from the same sender task
(identical
sender_task_idin metadata), so the merged message never loses or conflates message provenance. The merged message keeps the target's sender identity. - The head message (first in the queue) has no merge control: there is no message above it.
- The merge button is hidden entirely when only one message is queued, or when the message above it has a different sender kind (or is not mergeable at all).
API surface
New WebSocket action message.queue.merge (registered alongside the existing
message.queue.update / message.queue.remove actions in
internal/orchestrator/handlers/queue_handlers.go).
Request payload:
session_id string required
entry_id string required — the message to fold into the one above it
user_id string optional — defaults to "user"; reserved identities rejected
Response payload:
entry_id string the id of the surviving (above) message
Errors:
entry_not_found— the folded message was already drained or does not exist, matching themessage.queue.update/message.queue.removesemantics.validation— the folded message is the head of the queue (nothing above it), the message above it is not a valid merge target (different sender kind, an agent message from a different sender task), the folded message is a non-mergeable kind (workflow/system), orsession_id/entry_idis missing.merge_reference_overflow— the combined entity references exceed the per-message cap; both rows are left untouched.
After a successful merge the backend publishes the standard
message.queue.status_changed event so all connected clients refresh their
queue view, exactly like the other queue mutations.
Data model
No schema change. The merge reuses the existing queued_messages table:
- The above entry's
content,attachments_json, andmetadata_jsonare rewritten in place (identity,position,queued_by,queued_at,model,plan_mode,task_idunchanged). - The folded entry's row is deleted (same rule as
DeleteByID: rows withqueued_byin the reserved set are never deleted by this operation). - Positions are not renumbered; gaps are allowed and ordering stays correct
because the queue orders by
(session_id, position).
Permissions
- The folded (source) entry and the above (target) entry must have the same sender kind — user merges into user, agent merges into agent.
- For a user-kind merge, both entries must have
queued_byequal to the caller's identity and must not be in the reserved set (agent,workflow,server), mirroring the existingUpdateContentAndMetadataguard. - For an agent-kind merge, both entries must have
queued_byequal toagentand the source'ssender_task_idmetadata must match the target's; the merge is otherwise rejected because the merged entry keeps the target's sender identity. This is a deliberate carve-out of the ADR 0051 boundary that reserves agent-owned rows for backend mutation: consolidating additive one-way prompts from a single inter-task agent preserves the reserved row's provenance (the merged entry keeps the target's identity and the source'ssender_task_id-matched lineage) while reducing deliveries. The gate is strict — identical non-emptysender_task_id, never caller-tunable — so the merge operation cannot move, reorder, or combine unrelated agent-owned rows. Authorized pending-row deletion is a separate operation governed by Manage Pending Message Queues. - Workflow and system messages are never a merge source or target.
- The WS handler rejects client-supplied reserved
user_idvalues, exactly like the update handler.
Failure modes
- Drain race: if the agent drains the folded message (or the above message)
between the UI rendering and the merge request, the backend returns
entry_not_foundand the frontend refetches the queue to resync, matching the existing edit/remove resync path. - Drain interleave: the update-above and delete-source writes run inside one transaction (serialized per session by the repository's per-session lock and guarded by affected-row counts), so a concurrent drain can never observe a half-merged queue.
- Reference overflow: a merge whose deduplicated reference union exceeds the
per-message cap is rejected atomically (
merge_reference_overflow); the frontend surfaces a focused toast and the queue is unchanged. - Merge failure: the frontend shows the existing toast error pattern and refetches; no partial state is left behind.
Scenarios
- GIVEN two queued messages
AandBwithBbehindA, WHEN the user clicks Merge with above onB, THEN the queue contains one message whose content isA+ blank line +B, the queue count drops by one, and both messages' attachments and entity references are present. - GIVEN a single queued message, WHEN the user opens the queue panel, THEN no Merge with above control is shown on it.
- GIVEN a queued message at the head of the queue, WHEN the merge action targets it, THEN the backend rejects it with a validation error and the queue is unchanged.
- GIVEN two consecutive user messages
AandBwithBbehindA, WHEN the user clicks Merge with above onB, THEN the queue contains one user message whose content isA+ blank line +B, and the merged entry keepsA's identity. - GIVEN two consecutive agent messages from the same sender task (same
sender_task_id), WHEN the user clicks Merge with above on the later one, THEN the queue contains one agent message with the combined content that keeps the earlier entry's sender identity. - GIVEN two consecutive agent messages from different sender tasks, WHEN the merge action targets the later one, THEN the backend rejects it and the queue is unchanged.
- GIVEN a user message whose above message is agent-owned, WHEN the user opens the queue panel, THEN no Merge with above control is shown on that message, and a direct merge request is rejected with a validation error.
- GIVEN a message queued by an agent (inter-task) whose above message is a user message, WHEN the user opens the queue panel, THEN no Merge with above control is shown on that message.
- GIVEN a merge request for a message that was just drained, WHEN the
request arrives, THEN the backend returns
entry_not_foundand the frontend refetches the queue. - GIVEN three queued messages
A,B,C, WHEN the user mergesCintoBand then merges the resulting message intoA, THEN the queue contains a single message with all three contents concatenated in order. - GIVEN an above message with empty content (attachments only), WHEN the user merges a message into it, THEN the merged content is exactly the folded message's content (no leading blank line) and attachments combine.
- GIVEN two messages whose deduplicated entity-reference union exceeds 100,
WHEN the user clicks Merge with above on the later one, THEN the
backend returns
merge_reference_overflow, both rows keep their references, and the frontend shows a focused error toast.
Out of scope
- Merging delivered (already sent) messages — this operates only on the pending queue.
- Reordering queued messages (e.g. drag-to-move); only merge-into-above is offered.
- Merging messages of mismatching sender kinds (e.g. a user message into an agent message), or agent messages from different sender tasks.
- Merging workflow or system (server-owned) messages.
- A preview of the merged result before applying.