13 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/ui/message-queue-merge.md | 2026-07-31 | 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 otherActionMessageQueue*constants (line ~120).
Message-queue package (apps/backend/internal/orchestrator/messagequeue/)
types.go- Add
MetadataSenderTaskID = "sender_task_id"constant next to the otherMetadata*constants (reused by the merge sender-task equality check). - Add sentinel error
ErrNoMergeTarget = errors.New("no mergeable message above")next toErrEntryNotFound— 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).
- Add
repository.go- Add to the
Repositoryinterface:MergeIntoAbove(ctx context.Context, sessionID, sourceID string, queuedBy string) (*QueuedMessage, error).
- Add to the
repository_sqlite.go- Implement
MergeIntoAboveinside one transaction:- SELECT the source row by
(id, session_id)→ErrEntryNotFoundwhen missing. - SELECT the target = the entry in the session with the greatest
positionstrictly below the source'sposition→ErrNoMergeTargetwhen none (source is head). - Kind/ownership gate (reject with
ErrNoMergeTargetunless all hold):- source
queued_by == QueuedByUser: targetqueued_by == queuedBy(caller, non-empty, non-reserved) — user merges into user. - source
queued_by == QueuedByAgent: targetqueued_by == QueuedByAgentand bothmetadata[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.
- source
- Merged fields:
- content =
joinMergeContent(target.Content, source.Content)— join with"\n\n", but usesource.Contentverbatim when the target content is empty (no leading blank line). - attachments =
append(target.Attachments, source.Attachments...). - metadata = copy of target metadata with
MetadataEntityReferencesreplaced by the union of both entries' reference lists, deduplicated by canonicalref(normalizeEntityReferences-style dedupe — reuse the existingMetadataEntityReferenceskey; seetypes.goand the memory repo'sapplyMetadataUpdates). The deduplicated union must not exceed the shared per-message cap (entityrefs.MaxReferencesPerMessage): an over-cap union fails withErrMergeReferenceOverflowbefore either row is written, leaving both entries unchanged (atomic rejection, sqlite and memory alike).
- content =
UPDATEthe target row (content/attachments/metadata, scoped byid+session_id), thenDELETEthe source row (scoped the same), then commit. Return the merged target entry.
- SELECT the source row by
- Keep the function ≤80 lines/≤50 statements; extract the kind gate and the reference-union helper if needed.
- Implement
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 tos.repo.MergeIntoAbovewith the sames.logger.Infolog pattern asRemoveEntry.
- Add
WS handler (apps/backend/internal/orchestrator/handlers/queue_handlers.go)
- Extend the
QueueServiceinterface withMergeIntoAbove. - Register
ws.ActionMessageQueueMerge→wsMergeIntoAboveinRegisterHandlers. - New request type
wsMergeIntoAboveRequest{SessionID, EntryID, UserID}. wsMergeIntoAbove:- validate
session_id/entry_idrequired (validation error), reject reserveduser_idviaIsReservedQueuedBy+reservedIdentityError(mirrorswsUpdateMessage), default emptyuser_idtoQueuedByUser. - call
queueService.MergeIntoAbove; mapErrEntryNotFound→entry_not_found,ErrNoMergeTarget→ws.ErrorCodeValidation, else internal error. publishStatus(ctx, sessionID)on success; respond{entry_id}.
- validate
- 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 }>sendingmessage.queue.mergethroughclient.request, wrapped inrethrowQueueErrorlikeremoveQueuedEntry.
Hook (apps/web/hooks/domains/session/use-queue.ts)
- Add
mergeEntry(entryId: string)action inuseQueueActions: callmergeQueuedEntry({ session_id, entry_id }), thenrefetch(sessionId); onQueueEntryNotFoundErrorrefetch (drain race) and rethrow so the caller can toast, matching theeditEntrypattern. No optimistic store mutation (the merged entry's content isn't computed client-side). Return it fromuseQueue.
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-emptymetadata[sender_task_id];canMergeWithAbove(entry, above)→ both mergeable and same sender kind and equal non-emptysender_task_idvalues for agent entries (mirrors the backendmergeAllowedrule so the UI never offers a merge the backend will reject). Reuse the existingsenderKindOf. - Add optional
canMerge: boolean+onMerge: () => void | Promise<void>props toQueuedGhostMessage; pass through toDisplayView. - Render a Merge with above ghost button (title
t("chat:mergeWithAbove"),data-testid="queue-entry-merge",IconArrowMergefrom@tabler/icons-react, same styling as Edit/Remove) before the Edit button, only whencanMergeand the row is not in edit mode.
- Export a mergeability helper:
queued-ghost-list.tsx- In
QueuePanel, compute each entry'scanMergeascanMergeWithAbove(entry, entries[index - 1])and passonMerge={() => onMerge(entry.id)}. - Thread
onMergethroughQueuePanelPropsanduseQueuePanelHandlers(newhandleMerge→mergeEntry; on failure,toast.error(t("chat:failedToMergeQueuedMessages"))or, for an over-cap reference union,toast.error(t("chat:mergeReferenceOverflow")), matchinghandleRemove). - Pull
mergeEntryfromuseQueue(sessionId)inQueueAffordance.
- In
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): samesender_task_id→ allowed, merged entry keeps target's agent identity; differentsender_task_id→ErrNoMergeTarget. - Repository — ownership guard (
repository_sqlite_merge_test.go): user merge with callerqueuedBynot 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 overentityrefs.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): mergeCintoB, thenBintoA→ one entry withA+B+Cin order. - Service (
service_test.go):MergeIntoAbovedelegates 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; missingsession_id/entry_id→ validation; head merge → validation; reserveduser_idrejected; drained source →entry_not_found. - Frontend hook (
use-queueviaqueued-ghost-list.test.tsxor a hook test):mergeEntrycalls the API and refetches; refetch onQueueEntryNotFoundError. - 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 callsonMerge. - Frontend list wiring (
queued-ghost-list.test.tsx):onMergepasses the clicked entry id tomergeEntry.
E2E Tests
- Scenario (spec): two queued messages
AandBwithBbehindA; user clicks Merge with above onB→ queue contains one messageA + 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-mergeon 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).
- File:
Implementation Waves And Parallel Candidates
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
(Frontend: lint, i18n checks + ratchet, tests, build — ✓) and
#30742525164
(Backend: gofmt, golangci-lint, go test -race incl. Postgres — ✓), E2E
#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)