Files
kandev/docs/plans/message-queue-merge/plan.md
T
2026-08-24 11:09:03 +00:00

245 lines
13 KiB
Markdown

---
spec: docs/specs/ui/requirements/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<void>`
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)