--- status: shipped created: 2026-07-31 owner: kandev --- # Merge Enqueued Messages Individually Queue-wide removal and capacity behavior is defined separately by [Manage Pending Message Queues](message-queue-management.md). This spec governs only the merge operation. ## 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 reports `merge_reference_overflow` instead 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_id` in 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 the `message.queue.update` / `message.queue.remove` semantics. - `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), or `session_id` / `entry_id` is 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`, and `metadata_json` are rewritten in place (identity, `position`, `queued_by`, `queued_at`, `model`, `plan_mode`, `task_id` unchanged). - The folded entry's row is deleted (same rule as `DeleteByID`: rows with `queued_by` in 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_by` equal to the caller's identity and must not be in the reserved set (`agent`, `workflow`, `server`), mirroring the existing `UpdateContentAndMetadata` guard. - For an agent-kind merge, both entries must have `queued_by` equal to `agent` and the source's `sender_task_id` metadata 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's `sender_task_id`-matched lineage) while reducing deliveries. The gate is strict — identical non-empty `sender_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](message-queue-management.md). - Workflow and system messages are never a merge source or target. - The WS handler rejects client-supplied reserved `user_id` values, 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_found` and 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 `A` and `B` with `B` behind `A`, **WHEN** the user clicks **Merge with above** on `B`, **THEN** the queue contains one message whose content is `A` + 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 `A` and `B` with `B` behind `A`, **WHEN** the user clicks **Merge with above** on `B`, **THEN** the queue contains one user message whose content is `A` + blank line + `B`, and the merged entry keeps `A`'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_found` and the frontend refetches the queue. - **GIVEN** three queued messages `A`, `B`, `C`, **WHEN** the user merges `C` into `B` and then merges the resulting message into `A`, **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.