--- spec: docs/specs/ui/message-queue-merge.md created: 2026-07-31 status: completed --- # Implementation Plan: Merge Enqueued Messages Individually ## Overview Add a per-entry **Merge with above** control to the queued-message panel. The control folds a queued message into the message directly above it (same sender kind only: user↔user, agent↔agent from the same sender task), concatenating content, attachments, and entity references, then removing the folded entry. The merge is implemented as a new atomic WebSocket action `message.queue.merge` so a concurrent drain can never observe a half-merged queue. Order: repository contract and transaction first, then the service + WS handler that expose it, then the frontend API/hook/components, then E2E. --- ## Backend ### Contract (`apps/backend/pkg/websocket/actions.go`) - Add `ActionMessageQueueMerge = "message.queue.merge"` beside the other `ActionMessageQueue*` constants (line ~120). ### Message-queue package (`apps/backend/internal/orchestrator/messagequeue/`) - `types.go` - Add `MetadataSenderTaskID = "sender_task_id"` constant next to the other `Metadata*` constants (reused by the merge sender-task equality check). - Add sentinel error `ErrNoMergeTarget = errors.New("no mergeable message above")` next to `ErrEntryNotFound` — signals the source exists but has no valid target above it (head, different sender kind, agent sender-task mismatch, or a reserved/in-flight target). - `repository.go` - Add to the `Repository` interface: `MergeIntoAbove(ctx context.Context, sessionID, sourceID string, queuedBy string) (*QueuedMessage, error)`. - `repository_sqlite.go` - Implement `MergeIntoAbove` inside one transaction: 1. SELECT the source row by `(id, session_id)` → `ErrEntryNotFound` when missing. 2. SELECT the target = the entry in the session with the greatest `position` strictly below the source's `position` → `ErrNoMergeTarget` when none (source is head). 3. Kind/ownership gate (reject with `ErrNoMergeTarget` unless all hold): - source `queued_by == QueuedByUser`: target `queued_by == queuedBy` (caller, non-empty, non-reserved) — user merges into user. - source `queued_by == QueuedByAgent`: target `queued_by == QueuedByAgent` and both `metadata[sender_task_id]` values are equal — agent merges into agent from the same sender task. - otherwise (workflow/server/system, mismatched kinds, agent sender mismatch) → `ErrNoMergeTarget`. - a target that `IsReservedInFlight()` is not a valid target → `ErrNoMergeTarget`. 4. Merged fields: - content = `joinMergeContent(target.Content, source.Content)` — join with `"\n\n"`, but use `source.Content` verbatim when the target content is empty (no leading blank line). - attachments = `append(target.Attachments, source.Attachments...)`. - metadata = copy of target metadata with `MetadataEntityReferences` replaced by the union of both entries' reference lists, deduplicated by canonical `ref` (`normalizeEntityReferences`-style dedupe — reuse the existing `MetadataEntityReferences` key; see `types.go` and the memory repo's `applyMetadataUpdates`). The deduplicated union must not exceed the shared per-message cap (`entityrefs.MaxReferencesPerMessage`): an over-cap union fails with `ErrMergeReferenceOverflow` before either row is written, leaving both entries unchanged (atomic rejection, sqlite and memory alike). 5. `UPDATE` the target row (content/attachments/metadata, scoped by `id` + `session_id`), then `DELETE` the source row (scoped the same), then commit. Return the merged target entry. - Keep the function ≤80 lines/≤50 statements; extract the kind gate and the reference-union helper if needed. - `repository_memory.go` - Mirror the same logic under the repository mutex, returning the same errors and merged semantics (used by service/handler tests and the memory repo tests). - `service.go` - Add `MergeIntoAbove(ctx, sessionID, entryID, queuedBy string) (*QueuedMessage, error)` delegating to `s.repo.MergeIntoAbove` with the same `s.logger.Info` log pattern as `RemoveEntry`. ### WS handler (`apps/backend/internal/orchestrator/handlers/queue_handlers.go`) - Extend the `QueueService` interface with `MergeIntoAbove`. - Register `ws.ActionMessageQueueMerge` → `wsMergeIntoAbove` in `RegisterHandlers`. - New request type `wsMergeIntoAboveRequest{SessionID, EntryID, UserID}`. - `wsMergeIntoAbove`: - validate `session_id` / `entry_id` required (validation error), reject reserved `user_id` via `IsReservedQueuedBy` + `reservedIdentityError` (mirrors `wsUpdateMessage`), default empty `user_id` to `QueuedByUser`. - call `queueService.MergeIntoAbove`; map `ErrEntryNotFound` → `entry_not_found`, `ErrNoMergeTarget` → `ws.ErrorCodeValidation`, else internal error. - `publishStatus(ctx, sessionID)` on success; respond `{entry_id}`. - Add a `queueErrorCodeEntryNotFound`-style mapping reuse — the existing constants at the top of the file already cover both codes. ## Frontend ### API client (`apps/web/lib/api/domains/queue-api.ts`) - Add `mergeQueuedEntry(params: { session_id: string; entry_id: string }): Promise<{ entry_id: string }>` sending `message.queue.merge` through `client.request`, wrapped in `rethrowQueueError` like `removeQueuedEntry`. ### Hook (`apps/web/hooks/domains/session/use-queue.ts`) - Add `mergeEntry(entryId: string)` action in `useQueueActions`: call `mergeQueuedEntry({ session_id, entry_id })`, then `refetch(sessionId)`; on `QueueEntryNotFoundError` refetch (drain race) and rethrow so the caller can toast, matching the `editEntry` pattern. No optimistic store mutation (the merged entry's content isn't computed client-side). Return it from `useQueue`. ### Components (`apps/web/components/task/chat/`) - `queued-ghost-message.tsx` - Export a mergeability helper: `canMergeEntry(entry)` → sender kind is `"user"` or `"agent"`, plus — for agent entries — a non-empty `metadata[sender_task_id]`; `canMergeWithAbove(entry, above)` → both mergeable and same sender kind **and** equal non-empty `sender_task_id` values for agent entries (mirrors the backend `mergeAllowed` rule so the UI never offers a merge the backend will reject). Reuse the existing `senderKindOf`. - Add optional `canMerge: boolean` + `onMerge: () => void | Promise` props to `QueuedGhostMessage`; pass through to `DisplayView`. - Render a **Merge with above** ghost button (title `t("chat:mergeWithAbove")`, `data-testid="queue-entry-merge"`, `IconArrowMerge` from `@tabler/icons-react`, same styling as Edit/Remove) before the Edit button, only when `canMerge` and the row is not in edit mode. - `queued-ghost-list.tsx` - In `QueuePanel`, compute each entry's `canMerge` as `canMergeWithAbove(entry, entries[index - 1])` and pass `onMerge={() => onMerge(entry.id)}`. - Thread `onMerge` through `QueuePanelProps` and `useQueuePanelHandlers` (new `handleMerge` → `mergeEntry`; on failure, `toast.error(t("chat:failedToMergeQueuedMessages"))` or, for an over-cap reference union, `toast.error(t("chat:mergeReferenceOverflow"))`, matching `handleRemove`). - Pull `mergeEntry` from `useQueue(sessionId)` in `QueueAffordance`. ## Tests - **Repository — user↔user happy path** (`repository_sqlite_merge_test.go`, `service_test.go`): merge a user entry into the user entry above it → combined content joined with `\n\n`, attachments concatenated, entity references unioned/deduped, target identity (id/position/queued_by/queued_at) preserved, source row gone, queue count drops by one. - **Repository — empty target content** (`repository_sqlite_merge_test.go`): target with empty content → merged content is exactly the source content (no leading blank line). - **Repository — head rejected** (`repository_sqlite_merge_test.go`): merging the head entry → `ErrNoMergeTarget`, queue unchanged. - **Repository — mismatched kinds rejected** (`repository_sqlite_merge_test.go`): user source above agent target → `ErrNoMergeTarget`; agent source above user target → `ErrNoMergeTarget`; workflow/system source → `ErrNoMergeTarget`. - **Repository — agent↔agent** (`repository_sqlite_merge_test.go`): same `sender_task_id` → allowed, merged entry keeps target's agent identity; different `sender_task_id` → `ErrNoMergeTarget`. - **Repository — ownership guard** (`repository_sqlite_merge_test.go`): user merge with caller `queuedBy` not equal to both rows → `ErrNoMergeTarget`. - **Repository — reference overflow rejected atomically** (`repository_sqlite_merge_test.go`, `repository_postgres_merge_test.go`, `service_test.go`): a deduplicated reference union over `entityrefs.MaxReferencesPerMessage` → `ErrMergeReferenceOverflow`, both rows' content/attachments/references unchanged, queue count unchanged (sqlite, postgres, and in-memory repositories all assert the target is untouched on the rejected path). - **Repository — missing source** (`repository_sqlite_merge_test.go`): drained source id → `ErrEntryNotFound`. - **Repository — reserved in-flight target** (`repository_sqlite_merge_test.go`): a durable lifecycle target with the reservation marker → `ErrNoMergeTarget`. - **Repository — three-entry chain** (`repository_sqlite_merge_test.go`): merge `C` into `B`, then `B` into `A` → one entry with `A+B+C` in order. - **Service** (`service_test.go`): `MergeIntoAbove` delegates and returns the merged entry (or the mapped error) through the memory repo. - **WS handler** (`queue_handlers_merge_test.go`): happy path returns `{entry_id}` and publishes status; missing `session_id` / `entry_id` → validation; head merge → validation; reserved `user_id` rejected; drained source → `entry_not_found`. - **Frontend hook** (`use-queue` via `queued-ghost-list.test.tsx` or a hook test): `mergeEntry` calls the API and refetches; refetch on `QueueEntryNotFoundError`. - **Frontend gating** (`queued-ghost-message.test.tsx`): merge button hidden for the first entry, hidden for mismatched sender kinds, hidden for workflow/system entries, shown for a user entry behind a user entry and for an agent entry behind an agent entry, and clicking it calls `onMerge`. - **Frontend list wiring** (`queued-ghost-list.test.tsx`): `onMerge` passes the clicked entry id to `mergeEntry`. ## E2E Tests - **Scenario** (spec): two queued messages `A` and `B` with `B` behind `A`; user clicks **Merge with above** on `B` → queue contains one message `A + B`, count drops by one. - **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts` - **What to verify:** queue two messages while the agent is busy, open the panel, click `queue-entry-merge` on the second row, assert the panel now shows a single entry whose text contains both messages and the chip/label reflects one queued message. Agent-kind merging is covered by backend + unit tests (inter-task dispatch is not practical in this E2E harness). ## Implementation Waves And Parallel Candidates ```text Wave 1: - [x] [task-01-backend-repository](task-01-backend-repository.md) Wave 2: - [x] [task-02-backend-service-handler](task-02-backend-service-handler.md) Wave 3: - [x] [task-03-frontend-merge-ui](task-03-frontend-merge-ui.md) Wave 4: - [x] [task-04-e2e](task-04-e2e.md) ``` All four tasks execute sequentially in the primary conversation. The default is sequential; `task-01` and `task-03` touch disjoint files and are parallel-safe candidates **only with a frozen backend contract** — task-03 consumes the `message.queue.merge` WS action and `mergeEntry` hook from task-02, so it must not merge before task-02 lands. No subagents run unless the user explicitly asks. ## Verification commands All commands pass on the final head (`2b657e61d`, 2026-08-02); CI run [#30742525141](https://github.com/kdlbs/kandev/actions/runs/30742525141) (Frontend: lint, i18n checks + ratchet, tests, build — ✓) and [#30742525164](https://github.com/kdlbs/kandev/actions/runs/30742525164) (Backend: gofmt, golangci-lint, `go test -race` incl. Postgres — ✓), E2E [#30742525129](https://github.com/kdlbs/kandev/actions/runs/30742525129) (all 14 shards + containers + desktop smoke — ✓). - `make fmt` — ✓ 2026-08-02 (gofmt clean on changed Go files) - `make typecheck test lint` (from repo root per AGENTS.md) — ✓ via CI runs above - Targeted Go: `cd apps/backend && go test -race ./internal/orchestrator/messagequeue/... ./internal/orchestrator/handlers/...` — ✓ 2026-08-02 - Targeted frontend: `cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/queued-ghost-message.test.tsx components/task/chat/queued-ghost-list.test.tsx` — ✓ 2026-08-02 - E2E: `cd apps/web && pnpm e2e:run --host tests/chat/message-queue.spec.ts --project=chromium` — ✓ 2026-08-02 (in E2E CI shards above)