From aabe3419dd400089e21834015a6d45c5b7ada2cb Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 18:54:54 -0700 Subject: [PATCH 01/21] Add design spec for agent messages feature Persist agent-to-agent messages, surface them in a per-agent sidebar Messages tab, and add a Messages tab to the History detail view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-30-agent-messages-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-agent-messages-design.md diff --git a/docs/superpowers/specs/2026-06-30-agent-messages-design.md b/docs/superpowers/specs/2026-06-30-agent-messages-design.md new file mode 100644 index 00000000..11afdd47 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-agent-messages-design.md @@ -0,0 +1,194 @@ +# Design: Agent Messages — persistence, sidebar tab, and History + +**Date:** 2026-06-30 +**Status:** Approved (design phase) + +## Problem + +Agents can already message each other with the `dispatch_send_message` MCP tool, but +those messages are **ephemeral**: the send handler injects the message text straight +into the target agent's tmux session and keeps no record. There is no database row, no +SSE broadcast, and no UI. Unlike pins and brain artifacts — which persist and are surfaced +in the sidebar and History — messages leave no trace to view. + +We want agent-to-agent messages to be viewable with a UX similar to pins/brain artifacts: +a dedicated **Messages** tab in the agent sidebar, and a **Messages** view in the History +section. + +## Scope decisions (confirmed with the user) + +1. **Persist + view only.** Build persistence, broadcast, and the two read surfaces. Keep + the existing same-repo-only send rule. Design the schema so cross-repo becomes a later + config flip rather than a migration — but do **not** unblock cross-repo sending now. +2. **Sidebar tab is per selected agent** and shows *that agent's* conversations (messages it + sent or received), grouped by the other participant. +3. **History placement is a new tab in the per-agent detail view**, alongside + Events / Media / Pins / Feedback. +4. **View-only from the UI.** Humans do not compose or reply to messages from the UI in this + feature; agents continue to send via the MCP tool. + +### Explicitly out of scope (YAGNI) + +- Unblocking cross-repo *send* (schema is prepared for it; the send rule is unchanged). +- Composing / replying to messages from the UI. +- Message search / filtering within the sidebar tab. + +## Current-state facts (from codebase exploration) + +- **MCP tool:** `dispatch_send_message` — defined in + `apps/server/src/shared/mcp/messaging-tools.ts` (registration) and handled by the + `sendMessage` handler in `apps/server/src/server/mcp-handlers.ts` (~lines 847–947). + It resolves the target (agent id `agt_*` or fuzzy name match), enforces + same-repo-only via `senderRepoRoot` filtering, blocks self-send and non-running + targets, then injects a delimited envelope through `sendAgentPrompt` → + `injectAgentPrompt` (`apps/server/src/server/agent-prompts.ts`), which uses tmux + `sendCommand`. +- **No persistence today.** No message table exists. `agent_events` + (`apps/server/src/db/migrations/0001_baseline.sql`) logs state transitions only. +- **SSE:** `GET /api/v1/events` (`apps/server/src/routes/agents/events-routes.ts`) + broadcasts UI events (`agent.upsert`, `feedback.created`, `brain.changed`, …) via the + `publishUiEvent` broker. There is no message event type. +- **Sidebar tabs:** `apps/web/src/components/app/media-sidebar.tsx` renders the + Pins / Media / Brain tab bar and panels. Panels stay mounted and toggle via a `hidden` + class. Active tab is a per-agent Jotai atom family persisted to localStorage + (`MediaSidebarTab` union in `apps/web/src/lib/store.ts`; + `use-media-sidebar-state.ts`). Media shows an unseen-count badge. +- **History:** route `/activity/history[/:agentId]`. List in + `apps/web/src/components/app/agent-history-tab.tsx`; detail in + `agent-history-detail.tsx` with a `DetailTabs` component (Events / Media / Pins / + Feedback, each with a count badge). Backed by `GET /api/v1/history/agents/:id` + (`apps/server/src/routes/activity.ts`), which runs parallel queries for events, token + usage, media, and feedback. Hooks in `apps/web/src/hooks/use-agent-history.ts`. + +## Architecture + +Messages become first-class **persisted artifacts**, mirroring pins/brain/feedback. Three +layers, all additive — the existing ephemeral tmux delivery is unchanged: + +1. **Persistence** — a new `agent_messages` table. The `sendMessage` handler writes a row + right after attempting tmux injection (injection-first, so a DB error never blocks + delivery — see Error handling). +2. **Broadcast** — a new `message.created` SSE UI event, published through the same broker + as existing events, so live UIs update without polling. +3. **Surfacing** — a `Messages` sidebar tab (per selected agent) and a `Messages` tab in the + History detail view. Both read from the same API + query hooks. + +## Data model — `agent_messages` (new migration) + +New migration file under `apps/server/src/db/migrations/` following the existing numbering. + +| column | type | notes | +|---|---|---| +| `id` | uuid / text PK | consistent with existing tables | +| `sender_agent_id` | text FK → `agents.id` | | +| `recipient_agent_id` | text FK → `agents.id` | | +| `sender_name` | text | denormalized snapshot — names change, agents get archived | +| `recipient_name` | text | denormalized snapshot | +| `content` | text | message body | +| `delivered` | boolean | whether tmux injection succeeded | +| `read_at` | timestamptz null | powers unread badges; null = unread | +| `sender_repo_root` | text | stored now even though equal to recipient's today — the "cross-repo later is a config flip, not a migration" hook | +| `recipient_repo_root` | text | as above | +| `created_at` | timestamptz | default now | + +Indexes: `sender_agent_id`, `recipient_agent_id`, `created_at`. + +Rationale for a dedicated table (vs. reusing `agent_events`): messages have distinct +semantics (two participants, read state, delivery state) and distinct read patterns +(grouped by conversation). Denormalizing names + repo roots keeps History correct after +either agent is deleted. + +## Write path (server) + +In the `sendMessage` handler (`apps/server/src/server/mcp-handlers.ts`), after the target is +resolved and tmux injection is attempted: + +1. Insert one `agent_messages` row, capturing `delivered` from the injection result and the + resolved sender/recipient names and repo roots. +2. Publish a `message.created` UI event via the existing broker. + +Failed deliveries are still recorded (rendered as "not delivered"), so a message sent to a +stopped agent is not silently lost. The same-repo filter and all existing validation remain +in place ahead of this write. + +## Sidebar tab (per selected agent) + +- Add `"messages"` to the `MediaSidebarTab` union (`apps/web/src/lib/store.ts`). +- Add a fourth button to the tab bar in `media-sidebar.tsx`, with an **unread badge** reusing + the Media tab's badge pattern (count of messages with `read_at IS NULL` for the selected + agent). +- New `MessagesPanel` component: the selected agent's sent + received messages **grouped by + the other participant** into collapsible conversation threads (mirrors Brain's + Objects / Lists / Events sectioning). Each row shows direction (→ sent / ← received), the + other agent's name, content, and relative time. +- New hook `useAgentMessages(agentId)` → `GET /api/v1/agents/:id/messages`, query key + `["messages", agentId]`, invalidated on the `message.created` SSE event (wired in + `apps/web/src/hooks/use-sse.ts`). +- Panel stays mounted via the `hidden` class, like the other panels. +- Opening the tab marks the agent's visible messages read (clears the badge), consistent + with Media's "seen" behavior. This calls a small `POST`/`PATCH` mark-read endpoint that + sets `read_at`. + +### New server route + +`GET /api/v1/agents/:id/messages` (under `apps/server/src/routes/`, alongside the media +route) — returns messages where the agent is sender or recipient, ordered by `created_at`. +Plus a mark-read endpoint to set `read_at` for the agent's received messages. + +## History detail tab + +- Extend `DetailTab` to include `"messages"` in `agent-history-detail.tsx`; add a `Messages` + tab with a count badge next to Events / Media / Pins / Feedback. +- Extend `GET /api/v1/history/agents/:id` (`apps/server/src/routes/activity.ts`) to include a + `messages` array via a new parallel query — no new endpoint. +- Extend the `HistoryAgentDetail` type + `useHistoryAgentDetail` hook + (`apps/web/src/hooks/use-agent-history.ts`) to carry `messages`. +- New `MessageTimeline` component modeled on the existing `FeedbackTimeline` / + `EventTimeline` (`agent-history-timeline.tsx`), rendering messages chronologically with + sender → recipient labels and delivery state. + +## Data flow summary + +``` +agent A calls dispatch_send_message + -> sendMessage handler (same-repo filter, target resolution, self/running checks) + -> tmux injection (unchanged) ------------------> agent B's session + -> INSERT agent_messages row (delivered=?) + -> publishUiEvent("message.created") + -> SSE /api/v1/events + -> web: invalidate ["messages", agentId] and history detail query + -> MessagesPanel (sidebar) + MessageTimeline (history) re-render +``` + +## Error handling + +- Tmux injection failure: still insert the row with `delivered = false`; the UI shows a + "not delivered" affordance. The MCP tool response keeps its existing `delivered` field. +- DB insert failure must not break message delivery to the agent — injection happens first; + a persistence error is logged and surfaced in the tool result but does not throw past + delivery. (Confirm ordering during implementation so a running agent still receives the + message even if the write fails.) +- Deleted/archived participants: History and sidebar render from denormalized names, so rows + remain readable. + +## Testing + +- **Unit (vitest):** + - `sendMessage` writes an `agent_messages` row and emits `message.created`. + - `delivered` reflects injection success vs. failure (target stopped → row with + `delivered = false`). + - Same-repo filter still enforced (cross-repo target rejected as today). + - `GET /api/v1/agents/:id/messages` returns sent + received, correct ordering. + - History detail query includes messages. + - Mark-read sets `read_at` only for the agent's received messages. +- **E2E (Playwright):** + - Launch two agents in one repo, send a message between them. + - Assert it appears in the sidebar Messages tab with an unread badge, and the badge clears + on open. + - Assert it appears in the History detail Messages tab. + - Screenshot published via `dispatch_share`. + +## Pre-completion checks + +`pnpm run check`; `pnpm run finalize:web` (web changed); `pnpm run test:e2e`; +`pnpm run test` (backend logic changed). From 1601bb7b5f5ca9608a00ded1c08cd0e733fcd4bb Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 19:03:05 -0700 Subject: [PATCH 02/21] Add implementation plan for agent messages feature Task-by-task TDD plan: agent_messages table + MessageStore, persist/ broadcast on send, REST endpoints, useAgentMessages hook + SSE, sidebar Messages tab, History detail Messages tab, and E2E coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-30-agent-messages.md | 1242 +++++++++++++++++ 1 file changed, 1242 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-agent-messages.md diff --git a/docs/superpowers/plans/2026-06-30-agent-messages.md b/docs/superpowers/plans/2026-06-30-agent-messages.md new file mode 100644 index 00000000..9a5237ce --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-agent-messages.md @@ -0,0 +1,1242 @@ +# Agent Messages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist agent-to-agent messages and surface them in a per-agent sidebar "Messages" tab and a "Messages" tab in the History detail view. + +**Architecture:** Messages become persisted artifacts (like pins/brain/feedback). The existing `dispatch_send_message` handler keeps its ephemeral tmux delivery unchanged, but additionally writes a row to a new `agent_messages` table and publishes a `message.created` SSE event. The web app reads messages via new REST endpoints and renders them in two places, subscribing to SSE for live updates. + +**Tech Stack:** Fastify + PostgreSQL (`pg`) backend, `node-pg-migrate` migrations, React + Vite + TanStack Query frontend, Vitest (unit), Playwright (E2E). Package manager is `pnpm`; the server's `pnpm` scripts are Bun-based and auto-run `prepare:runtime-assets` (which re-embeds migration SQL) before check/test/build. + +## Global Constraints + +- Use `pnpm` for all commands (never `npm`). +- Same-repo-only send rule is **unchanged** — do not lift the cross-repo restriction in `sendMessage`. The schema stores repo roots so cross-repo can be enabled later without a migration. +- This feature is **view-only** in the UI — no composing/replying from the browser. +- Backend imports use relative paths with `.js` extensions (e.g. `../messages/store.js`). +- New migrations are picked up automatically by `prepare:runtime-assets`; no manual codegen step. Migrations run on server start (and via `repo_dev_up`). +- Follow existing patterns: DB query modules live under `apps/server/src//`; camelCase is exposed at the API boundary. +- Before marking complete: `pnpm run check`, `pnpm run finalize:web` (web changed), `pnpm run test` (backend changed), `pnpm run test:e2e`. + +--- + +### Task 1: `agent_messages` table + `MessageStore` query module + +**Files:** +- Create: `apps/server/src/db/migrations/0029_agent-messages.sql` +- Create: `apps/server/src/messages/store.ts` +- Test: `apps/server/test/message-store.test.ts` + +**Interfaces:** +- Produces: + - `type StoredMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; senderRepoRoot: string | null; recipientRepoRoot: string | null; createdAt: string }` + - `class MessageStore { constructor(pool: Pool); insertMessage(input: InsertMessageInput): Promise; listForAgent(agentId: string): Promise; countUnreadForAgent(agentId: string): Promise; markReadForAgent(agentId: string): Promise }` + - `type InsertMessageInput = Omit` + +- [ ] **Step 1: Write the migration SQL** + +Create `apps/server/src/db/migrations/0029_agent-messages.sql`: + +```sql +-- Agent-to-agent messages: durable record of dispatch_send_message deliveries. +-- Delivery itself stays ephemeral (tmux injection); this table is for viewing. + +CREATE TABLE IF NOT EXISTS agent_messages ( + id uuid PRIMARY KEY, + sender_agent_id text NOT NULL, + recipient_agent_id text NOT NULL, + sender_name text NOT NULL, + recipient_name text NOT NULL, + content text NOT NULL, + delivered boolean NOT NULL DEFAULT false, + read_at timestamptz, + -- Stored even though sender/recipient repo roots are equal today (same-repo + -- send rule). Present so cross-repo messaging becomes a config flip, not a + -- migration. + sender_repo_root text, + recipient_repo_root text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_messages_sender_created_idx + ON agent_messages (sender_agent_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS agent_messages_recipient_created_idx + ON agent_messages (recipient_agent_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS agent_messages_recipient_unread_idx + ON agent_messages (recipient_agent_id) WHERE read_at IS NULL; +``` + +- [ ] **Step 2: Write the failing test** + +Create `apps/server/test/message-store.test.ts` (modeled on `test/brain-store-queries.test.ts`): + +```ts +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { Pool } from "pg"; + +import { MessageStore } from "../src/messages/store.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; +let store: MessageStore; + +const REPO = "/repo/msg-test"; +const A = "agt_msg_a"; +const B = "agt_msg_b"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new MessageStore(pool); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +describe("MessageStore", () => { + it("inserts and lists messages for both participants", async () => { + const inserted = await store.insertMessage({ + senderAgentId: A, + recipientAgentId: B, + senderName: "Alice", + recipientName: "Bob", + content: "hello bob", + delivered: true, + senderRepoRoot: REPO, + recipientRepoRoot: REPO, + }); + expect(inserted.id).toBeTruthy(); + expect(inserted.readAt).toBeNull(); + expect(inserted.delivered).toBe(true); + + const forSender = await store.listForAgent(A); + const forRecipient = await store.listForAgent(B); + expect(forSender.map((m) => m.content)).toContain("hello bob"); + expect(forRecipient.map((m) => m.content)).toContain("hello bob"); + }); + + it("counts and clears unread for the recipient only", async () => { + await store.insertMessage({ + senderAgentId: B, + recipientAgentId: A, + senderName: "Bob", + recipientName: "Alice", + content: "hi alice", + delivered: true, + senderRepoRoot: REPO, + recipientRepoRoot: REPO, + }); + + // A received one message ("hi alice") -> unread 1. A sent one -> not counted. + expect(await store.countUnreadForAgent(A)).toBe(1); + + const cleared = await store.markReadForAgent(A); + expect(cleared).toBe(1); + expect(await store.countUnreadForAgent(A)).toBe(0); + // Marking A read must not touch B's unread. + expect(await store.countUnreadForAgent(B)).toBe(1); + }); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd apps/server && pnpm exec vitest run test/message-store.test.ts` +Expected: FAIL — `Cannot find module '../src/messages/store.js'` (and/or table does not exist). + +- [ ] **Step 4: Implement `MessageStore`** + +Create `apps/server/src/messages/store.ts`: + +```ts +import { randomUUID } from "node:crypto"; +import type { Pool } from "pg"; + +export type StoredMessage = { + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + senderRepoRoot: string | null; + recipientRepoRoot: string | null; + createdAt: string; +}; + +export type InsertMessageInput = Omit< + StoredMessage, + "id" | "readAt" | "createdAt" +>; + +type Row = { + id: string; + sender_agent_id: string; + recipient_agent_id: string; + sender_name: string; + recipient_name: string; + content: string; + delivered: boolean; + read_at: Date | null; + sender_repo_root: string | null; + recipient_repo_root: string | null; + created_at: Date; +}; + +function toStoredMessage(row: Row): StoredMessage { + return { + id: row.id, + senderAgentId: row.sender_agent_id, + recipientAgentId: row.recipient_agent_id, + senderName: row.sender_name, + recipientName: row.recipient_name, + content: row.content, + delivered: row.delivered, + readAt: row.read_at ? row.read_at.toISOString() : null, + senderRepoRoot: row.sender_repo_root, + recipientRepoRoot: row.recipient_repo_root, + createdAt: row.created_at.toISOString(), + }; +} + +export class MessageStore { + constructor(private readonly pool: Pool) {} + + async insertMessage(input: InsertMessageInput): Promise { + const result = await this.pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, + content, delivered, sender_repo_root, recipient_repo_root) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + randomUUID(), + input.senderAgentId, + input.recipientAgentId, + input.senderName, + input.recipientName, + input.content, + input.delivered, + input.senderRepoRoot, + input.recipientRepoRoot, + ] + ); + return toStoredMessage(result.rows[0]); + } + + async listForAgent(agentId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at ASC`, + [agentId] + ); + return result.rows.map(toStoredMessage); + } + + async countUnreadForAgent(agentId: string): Promise { + const result = await this.pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM agent_messages + WHERE recipient_agent_id = $1 AND read_at IS NULL`, + [agentId] + ); + return Number(result.rows[0].count); + } + + async markReadForAgent(agentId: string): Promise { + const result = await this.pool.query( + `UPDATE agent_messages SET read_at = now() + WHERE recipient_agent_id = $1 AND read_at IS NULL`, + [agentId] + ); + return result.rowCount ?? 0; + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd apps/server && pnpm exec vitest run test/message-store.test.ts` +Expected: PASS (both tests). `prepare:runtime-assets` re-embeds the new migration automatically when the test DB migrates. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/db/migrations/0029_agent-messages.sql apps/server/src/messages/store.ts apps/server/test/message-store.test.ts +git commit -m "feat(messages): add agent_messages table and MessageStore" +``` + +--- + +### Task 2: Persist + broadcast in the `sendMessage` handler + +**Files:** +- Modify: `apps/server/src/server/mcp-handlers.ts` (the `sendMessage` method, ~lines 847–947) +- Test: `apps/server/test/mcp-handlers.test.ts` (extend the existing `dispatch_send_message` / `sendMessage` coverage) + +**Interfaces:** +- Consumes: `MessageStore` from Task 1; existing `pool`, `publishUiEvent`, `appLog`, `sendAgentPrompt`, `resolveRepoRoot` already in the handler factory scope. +- Produces: a persisted row per send + a `{ type: "message.created"; senderAgentId; recipientAgentId }` UI event. + +- [ ] **Step 1: Add the import** + +At the top of `apps/server/src/server/mcp-handlers.ts`, add with the other imports: + +```ts +import { MessageStore } from "../messages/store.js"; +``` + +- [ ] **Step 2: Rewrite the delivery/return block of `sendMessage`** + +Replace the current try/catch + return at the end of `sendMessage` (the block from `const envelope = ...` return, currently ~lines 920–946) with: + +```ts + const envelope = JSON.stringify({ + from: sender.name, + senderId: agentId, + message: input.message, + replyTarget: agentId, + }); + const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nReply with dispatch_send_message using the replyTarget above.`; + + // Deliver first: a persistence failure must never block delivery. + let delivered = false; + let deliveryError: unknown = null; + try { + await sendAgentPrompt(target.id, prompt, { swallowFailure: false }); + delivered = true; + } catch (err) { + deliveryError = err; + appLog.error( + { err, senderId: agentId, targetId: target.id }, + "dispatch_send_message: tmux delivery failed" + ); + } + + // Record the message (including failed deliveries) so it is viewable. + const messageStore = new MessageStore(pool); + await messageStore + .insertMessage({ + senderAgentId: agentId, + recipientAgentId: target.id, + senderName: sender.name, + recipientName: target.name, + content: input.message, + delivered, + senderRepoRoot, + // Same repo today (send rule); stored for future cross-repo support. + recipientRepoRoot: senderRepoRoot, + }) + .catch((err) => + appLog.error( + { err, senderId: agentId, targetId: target.id }, + "dispatch_send_message: failed to persist message" + ) + ); + + publishUiEvent({ + type: "message.created", + senderAgentId: agentId, + recipientAgentId: target.id, + }); + + if (!delivered) { + throw deliveryError instanceof Error + ? deliveryError + : new Error(`Failed to deliver message to "${target.name}".`); + } + + appLog.info( + { senderId: agentId, targetId: target.id }, + "dispatch_send_message: delivered" + ); + return { + delivered: true, + targetAgentId: target.id, + targetAgentName: target.name, + }; +``` + +- [ ] **Step 3: Add a failing test for persistence** + +In `apps/server/test/mcp-handlers.test.ts`, find the existing test(s) exercising the `sendMessage` handler (search the file for `sendMessage` / `dispatch_send_message`). Reuse that harness (same handler-construction helper and mocked `agentManager`/`sendAgentPrompt`/`publishUiEvent`) to add: + +```ts +it("persists a row and emits message.created on send", async () => { + // Arrange the harness so two running agents share a repo root and + // sendAgentPrompt resolves (see the existing sendMessage test for setup). + // publishUiEvent is a vi.fn() captured by the harness. + + await handlers.sendMessage("agt_sender", { + target: "agt_receiver", + message: "ping", + senderRepoRoot: "/repo", + }); + + const rows = await pool.query( + "SELECT sender_agent_id, recipient_agent_id, content, delivered FROM agent_messages WHERE content = $1", + ["ping"] + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0].delivered).toBe(true); + + expect(publishUiEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "message.created" }) + ); +}); +``` + +Note: if the existing `mcp-handlers.test.ts` harness does not already provide a real `pool` (it uses heavy mocks), wire the handler's `pool` to a `setupTestDb()` pool + `runTestMigrations()` in this test's `beforeAll` (as `test/message-store.test.ts` does) so the row is queryable. If integrating a real DB into that mock-heavy file proves noisy, place this test in a new `apps/server/test/send-message-persistence.test.ts` that constructs the handlers with a real pool and stubs only `agentManager`, `sendAgentPrompt`, and `publishUiEvent`. + +- [ ] **Step 4: Run the test to verify it fails, then passes** + +Run: `cd apps/server && pnpm exec vitest run test/mcp-handlers.test.ts` +Expected: FAILS before Step 2's change is in place (no row / no event); PASSES after. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/server/mcp-handlers.ts apps/server/test/mcp-handlers.test.ts +git commit -m "feat(messages): persist and broadcast messages on send" +``` + +--- + +### Task 3: REST endpoints for reading + marking messages read + +**Files:** +- Create: `apps/server/src/routes/messages.ts` +- Modify: `apps/server/src/server.ts` (register the new routes near `registerMediaRoutes`, ~line 572) +- Test: `apps/server/test/messages-routes.test.ts` + +**Interfaces:** +- Consumes: `MessageStore` (Task 1); Fastify `app`, `pool`, `agentManager`, `publishUiEvent` (as passed to `registerMediaRoutes`). +- Produces: + - `GET /api/v1/agents/:id/messages` → `{ messages: StoredMessage[] }` + - `POST /api/v1/agents/:id/messages/read` → `{ ok: true; updated: number }`, and publishes `{ type: "message.read"; agentId }`. + - `registerMessagesRoutes(app, deps)` + +- [ ] **Step 1: Write the failing test** + +Create `apps/server/test/messages-routes.test.ts`. Model the app bootstrap on `test/media-routes.test.ts` (use `test/helpers/inject-app.ts` if it provides an app factory). Minimal shape: + +```ts +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import type { Pool } from "pg"; + +import { registerMessagesRoutes } from "../src/routes/messages.js"; +import { MessageStore } from "../src/messages/store.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let app: FastifyInstance; +let pool: Pool; +const A = "agt_route_a"; +const B = "agt_route_b"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + // Seed agents so the :id existence check passes. + await pool.query( + "INSERT INTO agents (id, name, type, status, cwd) VALUES ($1,$2,'claude','running','/repo'), ($3,$4,'claude','running','/repo') ON CONFLICT (id) DO NOTHING", + [A, "Alice", B, "Bob"] + ); + const store = new MessageStore(pool); + await store.insertMessage({ + senderAgentId: A, recipientAgentId: B, senderName: "Alice", + recipientName: "Bob", content: "hi", delivered: true, + senderRepoRoot: "/repo", recipientRepoRoot: "/repo", + }); + + app = Fastify(); + await registerMessagesRoutes(app, { + pool, + agentManager: { getAgent: async (id: string) => ({ id }) } as never, + publishUiEvent: () => {}, + }); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + await teardownTestDb(); +}); + +describe("messages routes", () => { + it("lists messages for an agent", async () => { + const res = await app.inject({ method: "GET", url: `/api/v1/agents/${B}/messages` }); + expect(res.statusCode).toBe(200); + const body = res.json() as { messages: Array<{ content: string }> }; + expect(body.messages.map((m) => m.content)).toContain("hi"); + }); + + it("marks messages read", async () => { + const res = await app.inject({ method: "POST", url: `/api/v1/agents/${B}/messages/read` }); + expect(res.statusCode).toBe(200); + expect((res.json() as { updated: number }).updated).toBe(1); + }); + + it("404s for unknown agent", async () => { + const res = await app.inject({ method: "GET", url: `/api/v1/agents/agt_missing/messages` }); + expect(res.statusCode).toBe(404); + }); +}); +``` + +(If `test/helpers/inject-app.ts` exposes a shared app builder used by `media-routes.test.ts`, prefer it over hand-building Fastify — match the sibling test's style.) + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd apps/server && pnpm exec vitest run test/messages-routes.test.ts` +Expected: FAIL — `Cannot find module '../src/routes/messages.js'`. + +- [ ] **Step 3: Implement the routes** + +Create `apps/server/src/routes/messages.ts`: + +```ts +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { MessageStore } from "../messages/store.js"; + +type MessagesRouteDeps = { + pool: Pool; + agentManager: AgentManager; + publishUiEvent: (event: unknown) => void; +}; + +export async function registerMessagesRoutes( + app: FastifyInstance, + deps: MessagesRouteDeps +): Promise { + const store = new MessageStore(deps.pool); + + app.get("/api/v1/agents/:id/messages", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ + id, + ]); + if (exists.rows.length === 0) { + return reply.code(404).send({ error: "Agent not found." }); + } + const messages = await store.listForAgent(id); + return { messages }; + }); + + app.post("/api/v1/agents/:id/messages/read", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ + id, + ]); + if (exists.rows.length === 0) { + return reply.code(404).send({ error: "Agent not found." }); + } + const updated = await store.markReadForAgent(id); + if (updated > 0) { + deps.publishUiEvent({ type: "message.read", agentId: id }); + } + return { ok: true, updated }; + }); +} +``` + +- [ ] **Step 4: Register the routes in `server.ts`** + +In `apps/server/src/server.ts`, add the import alongside the other route imports (near line 107): + +```ts +import { registerMessagesRoutes } from "./routes/messages.js"; +``` + +And register it right after the `registerMediaRoutes(...)` call (after ~line 578): + +```ts + await registerMessagesRoutes(app, { + pool, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + }); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd apps/server && pnpm exec vitest run test/messages-routes.test.ts` +Expected: PASS (all three cases). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/routes/messages.ts apps/server/src/server.ts apps/server/test/messages-routes.test.ts +git commit -m "feat(messages): add read + mark-read REST endpoints" +``` + +--- + +### Task 4: Frontend data layer — `useAgentMessages` hook + SSE wiring + +**Files:** +- Create: `apps/web/src/hooks/use-agent-messages.ts` +- Modify: `apps/web/src/hooks/use-sse.ts` (add `message.created` and `message.read` to the `UiEvent` union + handlers) + +**Interfaces:** +- Produces: + - `type AgentMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; createdAt: string }` + - `useAgentMessages(agentId: string | null): { messages: AgentMessage[]; unreadCount: number; markRead: () => void }` + +- [ ] **Step 1: Add the SSE event types** + +In `apps/web/src/hooks/use-sse.ts`, add two members to the `UiEvent` union (after the `brain.changed` entry, ~line 42): + +```ts + | { type: "message.created"; senderAgentId: string; recipientAgentId: string } + | { type: "message.read"; agentId: string } +``` + +- [ ] **Step 2: Handle the events** + +In `use-sse.ts`, inside `handleSSEMessage`, add after the `brain.changed` block (~line 192): + +```ts + if (payload.type === "message.created") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.senderAgentId], + exact: true, + }); + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.recipientAgentId], + exact: true, + }); + return; + } + + if (payload.type === "message.read") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.agentId], + exact: true, + }); + return; + } +``` + +- [ ] **Step 3: Write the hook** + +Create `apps/web/src/hooks/use-agent-messages.ts`: + +```ts +import { useCallback, useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export type AgentMessage = { + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + createdAt: string; +}; + +export function useAgentMessages(agentId: string | null) { + const queryClient = useQueryClient(); + + const { data: messages = [] } = useQuery({ + queryKey: ["messages", agentId], + queryFn: async () => { + const payload = await api<{ messages: AgentMessage[] }>( + `/api/v1/agents/${agentId}/messages` + ); + return payload.messages ?? []; + }, + enabled: !!agentId, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); + + const unreadCount = useMemo( + () => + messages.filter( + (m) => m.recipientAgentId === agentId && m.readAt === null + ).length, + [messages, agentId] + ); + + const markMutation = useMutation({ + mutationFn: async () => { + if (!agentId) return; + await api(`/api/v1/agents/${agentId}/messages/read`, { method: "POST" }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["messages", agentId], + exact: true, + }); + }, + }); + + const markRead = useCallback(() => { + if (agentId && unreadCount > 0) markMutation.mutate(); + }, [agentId, unreadCount, markMutation]); + + return { messages, unreadCount, markRead }; +} +``` + +Note: confirm `api()` accepts a second `RequestInit`-style argument for `{ method: "POST" }` by checking `apps/web/src/lib/api.ts`. If its signature differs (e.g. `api.post(url)`), use that form instead — match the existing call sites (e.g. the media "seen" POST or notifications ack). + +- [ ] **Step 4: Type-check** + +Run: `pnpm run check` +Expected: PASS (no type errors from the new union members or hook). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/hooks/use-agent-messages.ts apps/web/src/hooks/use-sse.ts +git commit -m "feat(messages): add useAgentMessages hook and SSE wiring" +``` + +--- + +### Task 5: `MessagesPanel` component (per-agent conversation threads) + +**Files:** +- Create: `apps/web/src/components/app/messages-panel.tsx` + +**Interfaces:** +- Consumes: `AgentMessage`, `useAgentMessages` (Task 4). +- Produces: `MessagesPanel({ agentId }: { agentId: string | null }): JSX.Element` + +- [ ] **Step 1: Implement the panel** + +Create `apps/web/src/components/app/messages-panel.tsx` (grouping + empty/loading states modeled on `brain-tab-content.tsx`): + +```tsx +import { useMemo } from "react"; +import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; + +import { useAgentMessages, type AgentMessage } from "@/hooks/use-agent-messages"; +import { cn } from "@/lib/utils"; + +type Thread = { + otherId: string; + otherName: string; + messages: AgentMessage[]; +}; + +function groupByParticipant( + messages: AgentMessage[], + agentId: string +): Thread[] { + const threads = new Map(); + for (const m of messages) { + const isSent = m.senderAgentId === agentId; + const otherId = isSent ? m.recipientAgentId : m.senderAgentId; + const otherName = isSent ? m.recipientName : m.senderName; + const existing = threads.get(otherId); + if (existing) { + existing.messages.push(m); + } else { + threads.set(otherId, { otherId, otherName, messages: [m] }); + } + } + return Array.from(threads.values()); +} + +function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + const secs = Math.round((Date.now() - then) / 1000); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; + if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; + return `${Math.round(secs / 86400)}d ago`; +} + +export function MessagesPanel({ + agentId, +}: { + agentId: string | null; +}): JSX.Element { + const { messages } = useAgentMessages(agentId); + + const threads = useMemo( + () => (agentId ? groupByParticipant(messages, agentId) : []), + [messages, agentId] + ); + + if (!agentId) { + return ( +
+
+ +
No agent selected.
+
+
+ ); + } + + if (messages.length === 0) { + return ( +
+
+ +
This agent has no messages yet.
+
+
+ ); + } + + return ( +
+ {threads.map((thread) => ( +
+
+ {thread.otherName} +
+
+ {thread.messages.map((m) => { + const isSent = m.senderAgentId === agentId; + return ( +
+
+ {isSent ? ( + + ) : ( + + )} + {isSent ? "Sent" : "Received"} + · + {relativeTime(m.createdAt)} + {!m.delivered && ( + · not delivered + )} +
+
+ {m.content} +
+
+ ); + })} +
+
+ ))} +
+ ); +} +``` + +Note: `Date.now()`/`new Date()` are fine in browser code — this is the web app, not a workflow script. + +- [ ] **Step 2: Type-check** + +Run: `pnpm run check` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/app/messages-panel.tsx +git commit -m "feat(messages): add MessagesPanel with conversation threads" +``` + +--- + +### Task 6: Wire the Messages tab into the sidebar + +**Files:** +- Modify: `apps/web/src/lib/store.ts` (extend `MediaSidebarTab`, ~line 102) +- Modify: `apps/web/src/components/app/media-sidebar.tsx` (add tab button + mounted panel; extend props) +- Modify: `apps/web/src/components/app/agents-view.tsx` (compute unread count, pass props, mark-read on tab open) + +**Interfaces:** +- Consumes: `MessagesPanel` (Task 5), `useAgentMessages` (Task 4). +- Produces: a fourth sidebar tab `"messages"` with an unread badge. + +- [ ] **Step 1: Extend the tab union** + +In `apps/web/src/lib/store.ts` line 102: + +```ts +export type MediaSidebarTab = "pins" | "media" | "brain" | "messages"; +``` + +- [ ] **Step 2: Add the tab button + panel in `media-sidebar.tsx`** + +Add `unreadMessageCount: number` to the `MediaSidebarContentProps` signature (the destructured params at ~line 388 and the `& { unseenMediaCount: number }` intersection) — extend to `& { unseenMediaCount: number; unreadMessageCount: number }`, and destructure `unreadMessageCount`. + +Add the import at the top: + +```tsx +import { MessagesPanel } from "@/components/app/messages-panel"; +``` + +Add a new tab button after the Brain button (after ~line 472), following the exact pattern of the Media button (destructive badge): + +```tsx + +``` + +Add the mounted panel after the Brain panel `` (after ~line 552): + +```tsx +
+ +
+``` + +- [ ] **Step 3: Wire data + mark-read in `agents-view.tsx`** + +In `apps/web/src/components/app/agents-view.tsx`: + +- Import the hook near the other hook imports: + +```tsx +import { useAgentMessages } from "@/hooks/use-agent-messages"; +``` + +- Call it alongside `useMedia(...)` (~line 230) using the same focused agent id: + +```tsx + const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = + useAgentMessages(focusedAgentId); +``` + +- Mark messages read when the Messages tab becomes active. Add an effect near the sidebar state (after `useMediaSidebarState(...)`, ~line 161): + +```tsx + useEffect(() => { + if (mediaActiveTab === "messages") markMessagesRead(); + }, [mediaActiveTab, markMessagesRead]); +``` + +Use whatever the local variable for the active tab is called from `useMediaSidebarState` (inspect the destructure — it is the `activeTab` value passed to `MediaSidebarContent`). If `useEffect` is not yet imported in this file, add it to the React import. + +- Pass `unreadMessageCount` to **both** `MediaSidebarContent` usages (the two render sites at ~line 704 and the header/props at ~666 already pass `unseenMediaCount`). Add: + +```tsx + unreadMessageCount={unreadMessageCount} +``` + +- [ ] **Step 4: Verify build + type-check** + +Run: `pnpm run check && pnpm run finalize:web` +Expected: PASS (type check + production build). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/store.ts apps/web/src/components/app/media-sidebar.tsx apps/web/src/components/app/agents-view.tsx +git commit -m "feat(messages): add Messages tab to the agent sidebar" +``` + +--- + +### Task 7: History detail — backend query + Messages tab + +**Files:** +- Modify: `apps/server/src/routes/activity.ts` (add a `messages` query to `GET /api/v1/history/agents/:id`, ~lines 582–678) +- Modify: `apps/web/src/hooks/use-agent-history.ts` (add `messages` to `HistoryAgentDetail`) +- Modify: `apps/web/src/components/app/agent-history-detail.tsx` (add `"messages"` to `DetailTab`, tab button, panel) +- Create: `apps/web/src/components/app/message-timeline.tsx` +- Test: `apps/server/test/activity-routes.test.ts` (extend the history-detail coverage) + +**Interfaces:** +- Consumes: `agent_messages` table (Task 1); `AgentMessage` type (Task 4). +- Produces: `HistoryAgentDetail.messages: AgentMessage[]`; a `MessageTimeline` component. + +- [ ] **Step 1: Add the messages query to the history detail endpoint** + +In `apps/server/src/routes/activity.ts`, add a sixth query to the `Promise.all([...])` in `GET /api/v1/history/agents/:id` (after the `feedbackResult` query, ~line 661): + +```ts + deps.pool.query<{ + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + createdAt: string; + }>( + `SELECT id, + sender_agent_id AS "senderAgentId", + recipient_agent_id AS "recipientAgentId", + sender_name AS "senderName", + recipient_name AS "recipientName", + content, delivered, + read_at AS "readAt", + created_at AS "createdAt" + FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at ASC + LIMIT 500`, + [id] + ), +``` + +Update the destructure to capture it: + +```ts + const [ + eventsResult, + tokenResult, + tokenByModelResult, + mediaResult, + feedbackResult, + messagesResult, + ] = await Promise.all([ +``` + +And add to the returned object (after `feedback: feedbackResult.rows,`): + +```ts + messages: messagesResult.rows, +``` + +- [ ] **Step 2: Add a failing backend test** + +In `apps/server/test/activity-routes.test.ts`, find the existing test for `GET /api/v1/history/agents/:id`. Seed a message for the agent under test and assert it comes back: + +```ts +it("includes agent messages in the history detail", async () => { + // Seed a message where the history agent (agt_history) is the sender. + await pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, content, delivered) + VALUES (gen_random_uuid(), $1, 'agt_other', 'Hist', 'Other', 'history msg', true)`, + ["agt_history"] + ); + const res = await app.inject({ + method: "GET", + url: `/api/v1/history/agents/agt_history`, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { messages: Array<{ content: string }> }; + expect(body.messages.map((m) => m.content)).toContain("history msg"); +}); +``` + +Use the agent id that the existing detail test already seeds (adjust `agt_history` / `agt_other` to match that file's fixtures). `gen_random_uuid()` requires `pgcrypto`; if the test DB lacks it, insert an explicit uuid string instead. + +- [ ] **Step 3: Run backend tests (fail → pass)** + +Run: `cd apps/server && pnpm exec vitest run test/activity-routes.test.ts` +Expected: FAILS before Step 1's change (no `messages` key), PASSES after. + +- [ ] **Step 4: Extend the frontend detail type** + +In `apps/web/src/hooks/use-agent-history.ts`, import the message type and add it to `HistoryAgentDetail`: + +```ts +import { type AgentMessage } from "@/hooks/use-agent-messages"; +``` + +Add to the `HistoryAgentDetail` type (after `feedback: HistoryFeedbackItem[];`): + +```ts + messages: AgentMessage[]; +``` + +- [ ] **Step 5: Create the `MessageTimeline` component** + +Create `apps/web/src/components/app/message-timeline.tsx`: + +```tsx +import { type AgentMessage } from "@/hooks/use-agent-messages"; +import { cn } from "@/lib/utils"; + +export function MessageTimeline({ + messages, +}: { + messages: AgentMessage[]; +}): JSX.Element { + return ( +
+ {messages.map((m) => ( +
+
+ {m.senderName} + + {m.recipientName} + · + {new Date(m.createdAt).toLocaleTimeString()} + {!m.delivered && ( + · not delivered + )} +
+
+ {m.content} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 6: Add the Messages tab to the detail view** + +In `apps/web/src/components/app/agent-history-detail.tsx`: + +- Import the timeline + type near the top: + +```tsx +import { MessageTimeline } from "@/components/app/message-timeline"; +import { type AgentMessage } from "@/hooks/use-agent-messages"; +``` + +- Extend the `DetailTab` type (line 97): + +```tsx +type DetailTab = "events" | "media" | "pins" | "feedback" | "messages"; +``` + +- Add `messages` to the `DetailTabs` props (the destructure + type block at ~lines 99–113): + +```tsx + messages, +``` +```tsx + messages: AgentMessage[]; +``` + +- Add to the `tabs` array (after the `feedback` entry, ~line 139): + +```tsx + { key: "messages", label: "Messages", count: messages.length }, +``` + +- Add the panel in the content area (after the `feedback` block, ~line 246): + +```tsx + {tab === "messages" && messages.length > 0 && ( + + )} + {tab === "messages" && messages.length === 0 && ( +

+ No messages recorded. +

+ )} +``` + +- Find where `DetailTabs` is rendered by `AgentHistoryDetail` (below line 269, using `data.feedback` etc.) and pass the new prop: + +```tsx + messages={data.messages} +``` + +- [ ] **Step 7: Verify build + type-check** + +Run: `pnpm run check && pnpm run finalize:web` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add apps/server/src/routes/activity.ts apps/server/test/activity-routes.test.ts apps/web/src/hooks/use-agent-history.ts apps/web/src/components/app/agent-history-detail.tsx apps/web/src/components/app/message-timeline.tsx +git commit -m "feat(messages): surface messages in History detail view" +``` + +--- + +### Task 8: End-to-end test + final verification + +**Files:** +- Create: `e2e/agent-messages.spec.ts` (match the existing e2e spec conventions in `e2e/`) + +**Interfaces:** +- Consumes: the full stack from Tasks 1–7. + +- [ ] **Step 1: Inspect existing e2e patterns** + +Read one existing spec under `e2e/` (e.g. how it launches agents, selects one, and opens the sidebar) so the new spec matches helpers, fixtures, and readiness signals. Per repo rules: use `waitUntil: "domcontentloaded"` and wait on concrete UI signals — never `networkidle`. + +- [ ] **Step 2: Write the E2E spec** + +Create `e2e/agent-messages.spec.ts`. The flow: +1. Start from the isolated e2e stack (the suite provisions its own DB + server). +2. Create/launch two agents in the same repo (reuse the suite's agent-creation helper). +3. Trigger a message from agent A to agent B (via the MCP tool path the suite uses for agent actions, or by seeding through the API the suite exposes). +4. Select agent B, open the sidebar, click the **Messages** tab (`data-testid="message-item"` becomes visible), assert the message content is shown and the unread badge appears then clears after opening. +5. Navigate to `/activity/history/`, open the **Messages** tab, assert `data-testid="history-message"` shows the content. +6. Capture a screenshot and publish it via the `dispatch_share` MCP tool (do not leave it local). + +Use the assertions/selectors the sibling specs use; the two `data-testid`s added in Tasks 5 and 7 (`message-item`, `history-message`) are the anchors. + +- [ ] **Step 3: Run the E2E suite** + +Run: `pnpm run test:e2e` +Expected: PASS, including the new spec. (The suite spins up its own isolated DB + server; safe alongside other agents.) + +- [ ] **Step 4: Full pre-completion checks** + +Run, in order, and fix any failures: + +```bash +pnpm run check +pnpm run finalize:web +pnpm run test +pnpm run test:e2e +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/agent-messages.spec.ts +git commit -m "test(messages): e2e coverage for sidebar + history messages" +``` + +--- + +## Notes for the implementer + +- **Ordering matters:** Tasks 1→3 are backend and independently testable; 4→7 are frontend and depend on the endpoints from 1–3; Task 8 validates the whole stack. Do them in order. +- **Same-repo rule is intentional.** Do not "fix" the repo-root filter in `sendMessage` — cross-repo is explicitly out of scope. The `sender_repo_root`/`recipient_repo_root` columns exist so that lifting the rule later needs no migration. +- **Delivery-first is intentional.** In Task 2, injection happens before the DB write and the write is wrapped in `.catch` so a persistence failure never stops an agent from receiving its message. +- **`api()` signature:** Task 4 assumes `api(url, init)` supports POST. Verify against `apps/web/src/lib/api.ts` and existing POST call sites (media "seen", notifications ack) before finalizing the mutation. From e41e4848fdb63e1f1f64de0e4b230c6c9f81b556 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:09:21 -0700 Subject: [PATCH 03/21] feat(messages): add agent_messages table and MessageStore Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/db/migrations/0029_agent-messages.sql | 28 +++++ apps/server/src/messages/store.ts | 105 ++++++++++++++++++ apps/server/test/message-store.test.ts | 67 +++++++++++ 3 files changed, 200 insertions(+) create mode 100644 apps/server/src/db/migrations/0029_agent-messages.sql create mode 100644 apps/server/src/messages/store.ts create mode 100644 apps/server/test/message-store.test.ts diff --git a/apps/server/src/db/migrations/0029_agent-messages.sql b/apps/server/src/db/migrations/0029_agent-messages.sql new file mode 100644 index 00000000..5c17b67e --- /dev/null +++ b/apps/server/src/db/migrations/0029_agent-messages.sql @@ -0,0 +1,28 @@ +-- Agent-to-agent messages: durable record of dispatch_send_message deliveries. +-- Delivery itself stays ephemeral (tmux injection); this table is for viewing. + +CREATE TABLE IF NOT EXISTS agent_messages ( + id uuid PRIMARY KEY, + sender_agent_id text NOT NULL, + recipient_agent_id text NOT NULL, + sender_name text NOT NULL, + recipient_name text NOT NULL, + content text NOT NULL, + delivered boolean NOT NULL DEFAULT false, + read_at timestamptz, + -- Stored even though sender/recipient repo roots are equal today (same-repo + -- send rule). Present so cross-repo messaging becomes a config flip, not a + -- migration. + sender_repo_root text, + recipient_repo_root text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_messages_sender_created_idx + ON agent_messages (sender_agent_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS agent_messages_recipient_created_idx + ON agent_messages (recipient_agent_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS agent_messages_recipient_unread_idx + ON agent_messages (recipient_agent_id) WHERE read_at IS NULL; diff --git a/apps/server/src/messages/store.ts b/apps/server/src/messages/store.ts new file mode 100644 index 00000000..b9c7c53d --- /dev/null +++ b/apps/server/src/messages/store.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "node:crypto"; +import type { Pool } from "pg"; + +export type StoredMessage = { + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + senderRepoRoot: string | null; + recipientRepoRoot: string | null; + createdAt: string; +}; + +export type InsertMessageInput = Omit< + StoredMessage, + "id" | "readAt" | "createdAt" +>; + +type Row = { + id: string; + sender_agent_id: string; + recipient_agent_id: string; + sender_name: string; + recipient_name: string; + content: string; + delivered: boolean; + read_at: Date | null; + sender_repo_root: string | null; + recipient_repo_root: string | null; + created_at: Date; +}; + +function toStoredMessage(row: Row): StoredMessage { + return { + id: row.id, + senderAgentId: row.sender_agent_id, + recipientAgentId: row.recipient_agent_id, + senderName: row.sender_name, + recipientName: row.recipient_name, + content: row.content, + delivered: row.delivered, + readAt: row.read_at ? row.read_at.toISOString() : null, + senderRepoRoot: row.sender_repo_root, + recipientRepoRoot: row.recipient_repo_root, + createdAt: row.created_at.toISOString(), + }; +} + +export class MessageStore { + constructor(private readonly pool: Pool) {} + + async insertMessage(input: InsertMessageInput): Promise { + const result = await this.pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, + content, delivered, sender_repo_root, recipient_repo_root) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + randomUUID(), + input.senderAgentId, + input.recipientAgentId, + input.senderName, + input.recipientName, + input.content, + input.delivered, + input.senderRepoRoot, + input.recipientRepoRoot, + ] + ); + return toStoredMessage(result.rows[0]); + } + + async listForAgent(agentId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at ASC`, + [agentId] + ); + return result.rows.map(toStoredMessage); + } + + async countUnreadForAgent(agentId: string): Promise { + const result = await this.pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM agent_messages + WHERE recipient_agent_id = $1 AND read_at IS NULL`, + [agentId] + ); + return Number(result.rows[0].count); + } + + async markReadForAgent(agentId: string): Promise { + const result = await this.pool.query( + `UPDATE agent_messages SET read_at = now() + WHERE recipient_agent_id = $1 AND read_at IS NULL`, + [agentId] + ); + return result.rowCount ?? 0; + } +} diff --git a/apps/server/test/message-store.test.ts b/apps/server/test/message-store.test.ts new file mode 100644 index 00000000..4fbfc99d --- /dev/null +++ b/apps/server/test/message-store.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { Pool } from "pg"; + +import { MessageStore } from "../src/messages/store.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; +let store: MessageStore; + +const REPO = "/repo/msg-test"; +const A = "agt_msg_a"; +const B = "agt_msg_b"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new MessageStore(pool); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +describe("MessageStore", () => { + it("inserts and lists messages for both participants", async () => { + const inserted = await store.insertMessage({ + senderAgentId: A, + recipientAgentId: B, + senderName: "Alice", + recipientName: "Bob", + content: "hello bob", + delivered: true, + senderRepoRoot: REPO, + recipientRepoRoot: REPO, + }); + expect(inserted.id).toBeTruthy(); + expect(inserted.readAt).toBeNull(); + expect(inserted.delivered).toBe(true); + + const forSender = await store.listForAgent(A); + const forRecipient = await store.listForAgent(B); + expect(forSender.map((m) => m.content)).toContain("hello bob"); + expect(forRecipient.map((m) => m.content)).toContain("hello bob"); + }); + + it("counts and clears unread for the recipient only", async () => { + await store.insertMessage({ + senderAgentId: B, + recipientAgentId: A, + senderName: "Bob", + recipientName: "Alice", + content: "hi alice", + delivered: true, + senderRepoRoot: REPO, + recipientRepoRoot: REPO, + }); + + // A received one message ("hi alice") -> unread 1. A sent one -> not counted. + expect(await store.countUnreadForAgent(A)).toBe(1); + + const cleared = await store.markReadForAgent(A); + expect(cleared).toBe(1); + expect(await store.countUnreadForAgent(A)).toBe(0); + // Marking A read must not touch B's unread. + expect(await store.countUnreadForAgent(B)).toBe(1); + }); +}); From 2682b5586b1d4dc1c000b1bfc9dfd482829786e6 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:16:44 -0700 Subject: [PATCH 04/21] feat(messages): persist and broadcast messages on send --- apps/server/src/server/mcp-handlers.ts | 40 +++++- .../test/send-message-persistence.test.ts | 116 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 apps/server/test/send-message-persistence.test.ts diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 7afcfc73..02c7804b 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -28,6 +28,7 @@ import { resolveHeadSha } from "../shared/git/worktree.js"; import { isMediaFile, isTextFile, resolveMediaDir } from "../shared/media.js"; import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; import { createReviewHandlers } from "./mcp-review-handlers.js"; +import { MessageStore } from "../messages/store.js"; const AGENT_LATEST_EVENT_TYPES = [ "working", @@ -538,14 +539,51 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { }); const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nReply with dispatch_send_message using the replyTarget above.`; + // Deliver first: a persistence failure must never block delivery. + let delivered = false; + let deliveryError: unknown = null; try { await sendAgentPrompt(target.id, prompt, { swallowFailure: false }); + delivered = true; } catch (err) { + deliveryError = err; appLog.error( { err, senderId: agentId, targetId: target.id }, "dispatch_send_message: tmux delivery failed" ); - throw err; + } + + // Record the message (including failed deliveries) so it is viewable. + const messageStore = new MessageStore(pool); + await messageStore + .insertMessage({ + senderAgentId: agentId, + recipientAgentId: target.id, + senderName: sender.name, + recipientName: target.name, + content: input.message, + delivered, + senderRepoRoot, + // Same repo today (send rule); stored for future cross-repo support. + recipientRepoRoot: senderRepoRoot, + }) + .catch((err) => + appLog.error( + { err, senderId: agentId, targetId: target.id }, + "dispatch_send_message: failed to persist message" + ) + ); + + publishUiEvent({ + type: "message.created", + senderAgentId: agentId, + recipientAgentId: target.id, + }); + + if (!delivered) { + throw deliveryError instanceof Error + ? deliveryError + : new Error(`Failed to deliver message to "${target.name}".`); } appLog.info( diff --git a/apps/server/test/send-message-persistence.test.ts b/apps/server/test/send-message-persistence.test.ts new file mode 100644 index 00000000..98e307ed --- /dev/null +++ b/apps/server/test/send-message-persistence.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Pool } from "pg"; + +vi.mock("../src/shared/git/git-context.js", () => ({ + resolveRepoRoot: vi.fn(async () => "/repo"), + resolveWorktreeRoot: vi.fn(async () => "/repo"), +})); + +import { createMcpHandlers } from "../src/server/mcp-handlers.js"; +import { resolveRepoRoot } from "../src/shared/git/git-context.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; +let handlers: ReturnType; +let sendAgentPrompt: ReturnType; +let publishUiEvent: ReturnType; +let agentManager: { + getAgent: ReturnType; + listAgents: ReturnType; +}; + +const SENDER = { id: "agt_sender", name: "sender-agent", cwd: "/repo", status: "running" }; +const RECEIVER = { id: "agt_receiver", name: "receiver-agent", cwd: "/repo", status: "running" }; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(() => { + vi.mocked(resolveRepoRoot).mockResolvedValue("/repo"); + + sendAgentPrompt = vi.fn(async () => {}); + publishUiEvent = vi.fn(); + agentManager = { + getAgent: vi.fn(async (id: string) => + id === SENDER.id ? SENDER : id === RECEIVER.id ? RECEIVER : null + ), + listAgents: vi.fn(async () => [SENDER, RECEIVER]), + }; + + handlers = createMcpHandlers({ + pool, + mediaRoot: "/tmp/media", + agentManager: agentManager as any, + jobService: {} as any, + slackNotifier: {} as any, + publishUiEvent, + withStreamFlag: vi.fn((agent: any) => ({ ...agent, hasStream: false })), + sendAgentPrompt, + appLog: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + } as any, + } as unknown as Parameters[0]); +}); + +describe("sendMessage persistence", () => { + it("persists a row and emits message.created on send", async () => { + await handlers.sendMessage(SENDER.id, { + target: RECEIVER.id, + message: "ping", + senderRepoRoot: "/repo", + }); + + const rows = await pool.query( + "SELECT sender_agent_id, recipient_agent_id, content, delivered FROM agent_messages WHERE content = $1", + ["ping"] + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0].sender_agent_id).toBe(SENDER.id); + expect(rows.rows[0].recipient_agent_id).toBe(RECEIVER.id); + expect(rows.rows[0].delivered).toBe(true); + + expect(publishUiEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message.created", + senderAgentId: SENDER.id, + recipientAgentId: RECEIVER.id, + }) + ); + }); + + it("records a failed delivery as delivered=false and still throws", async () => { + sendAgentPrompt.mockRejectedValueOnce(new Error("tmux boom")); + + await expect( + handlers.sendMessage(SENDER.id, { + target: RECEIVER.id, + message: "will fail", + senderRepoRoot: "/repo", + }) + ).rejects.toThrow("tmux boom"); + + const rows = await pool.query( + "SELECT delivered FROM agent_messages WHERE content = $1", + ["will fail"] + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0].delivered).toBe(false); + + expect(publishUiEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message.created", + senderAgentId: SENDER.id, + recipientAgentId: RECEIVER.id, + }) + ); + }); +}); From c93f3b06a921a554147c645513567e8a733f4f76 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:22:50 -0700 Subject: [PATCH 05/21] feat(messages): add read + mark-read REST endpoints Add GET /api/v1/agents/:id/messages and POST /api/v1/agents/:id/messages/read, backed by MessageStore. Mark-read publishes a message.read UI event. Also add the message.created and message.read variants to the backend UiEvent union so sender/mark-read publishes type-check against the broker. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/routes/messages.ts | 45 +++++++++ apps/server/src/server.ts | 7 ++ apps/server/src/server/ui-events.ts | 6 ++ apps/server/test/messages-routes.test.ts | 111 +++++++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 apps/server/src/routes/messages.ts create mode 100644 apps/server/test/messages-routes.test.ts diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts new file mode 100644 index 00000000..41204ec2 --- /dev/null +++ b/apps/server/src/routes/messages.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { MessageStore } from "../messages/store.js"; + +type MessagesRouteDeps = { + pool: Pool; + agentManager: AgentManager; + publishUiEvent: (event: unknown) => void; +}; + +export async function registerMessagesRoutes( + app: FastifyInstance, + deps: MessagesRouteDeps +): Promise { + const store = new MessageStore(deps.pool); + + app.get("/api/v1/agents/:id/messages", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ + id, + ]); + if (exists.rows.length === 0) { + return reply.code(404).send({ error: "Agent not found." }); + } + const messages = await store.listForAgent(id); + return { messages }; + }); + + app.post("/api/v1/agents/:id/messages/read", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ + id, + ]); + if (exists.rows.length === 0) { + return reply.code(404).send({ error: "Agent not found." }); + } + const updated = await store.markReadForAgent(id); + if (updated > 0) { + deps.publishUiEvent({ type: "message.read", agentId: id }); + } + return { ok: true, updated }; + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 34f75103..ca5d8412 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -105,6 +105,7 @@ import { registerFeedbackRoutes } from "./routes/feedback.js"; import { registerJobRoutes } from "./routes/jobs.js"; import { registerTemplateRoutes } from "./routes/templates.js"; import { registerMediaRoutes } from "./routes/media.js"; +import { registerMessagesRoutes } from "./routes/messages.js"; import { registerMcpRoutes } from "./routes/mcp.js"; import { registerPersonaReviewRoutes } from "./routes/persona-reviews.js"; import { registerPersonalityRoutes } from "./routes/personalities.js"; @@ -581,6 +582,12 @@ async function registerRoutes() { publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), }); + await registerMessagesRoutes(app, { + pool, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + }); + await registerAgentRoutes(app, { pool, appLog: app.log, diff --git a/apps/server/src/server/ui-events.ts b/apps/server/src/server/ui-events.ts index f020a79f..3472875e 100644 --- a/apps/server/src/server/ui-events.ts +++ b/apps/server/src/server/ui-events.ts @@ -20,6 +20,12 @@ export type UiEvent = | { type: "agent.deleted"; agentId: string } | { type: "media.changed"; agentId: string } | { type: "media.seen"; agentId: string; keys: string[] } + | { + type: "message.created"; + senderAgentId: string; + recipientAgentId: string; + } + | { type: "message.read"; agentId: string } | { type: "stream.started"; agentId: string } | { type: "stream.stopped"; agentId: string } | { diff --git a/apps/server/test/messages-routes.test.ts b/apps/server/test/messages-routes.test.ts new file mode 100644 index 00000000..6645302b --- /dev/null +++ b/apps/server/test/messages-routes.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useInjectApp } from "./helpers/inject-app.js"; +import { MessageStore } from "../src/messages/store.js"; + +const ctx = useInjectApp(); + +async function authedInject( + method: string, + url: string, + opts?: { payload?: unknown; headers?: Record } +): Promise> { + const cookie = await ctx.sessionCookie(); + const headers: Record = { cookie, ...opts?.headers }; + if (opts?.payload !== undefined && !headers["content-type"]) { + headers["content-type"] = "application/json"; + } + return ctx.app.inject({ + method: method as "GET" | "POST", + url, + headers, + ...(opts?.payload !== undefined ? { payload: opts.payload } : {}), + }); +} + +async function createAgent( + name: string +): Promise<{ id: string }> { + const res = await authedInject("POST", "/api/v1/agents", { + payload: { cwd: "/tmp", useWorktree: false, name }, + }); + expect(res.statusCode).toBe(201); + const agent = res.json().agent; + return { id: agent.id }; +} + +let agentA: string; +let agentB: string; + +beforeEach(async () => { + await ctx.pool.query("DELETE FROM agent_messages"); + await ctx.pool.query("DELETE FROM media_seen"); + await ctx.pool.query("DELETE FROM media"); + await ctx.pool.query("DELETE FROM job_runs"); + await ctx.pool.query("DELETE FROM jobs"); + await ctx.pool.query("DELETE FROM agents"); + + const a = await createAgent("Alice"); + const b = await createAgent("Bob"); + agentA = a.id; + agentB = b.id; + + const store = new MessageStore(ctx.pool); + await store.insertMessage({ + senderAgentId: agentA, + recipientAgentId: agentB, + senderName: "Alice", + recipientName: "Bob", + content: "hi", + delivered: true, + senderRepoRoot: "/repo", + recipientRepoRoot: "/repo", + }); +}); + +describe("GET /api/v1/agents/:id/messages (list)", () => { + it("returns 404 for nonexistent agent", async () => { + const res = await authedInject( + "GET", + "/api/v1/agents/agt_nonexistent/messages" + ); + expect(res.statusCode).toBe(404); + }); + + it("lists messages for an agent", async () => { + const res = await authedInject( + "GET", + `/api/v1/agents/${agentB}/messages` + ); + expect(res.statusCode).toBe(200); + const body = res.json() as { messages: Array<{ content: string }> }; + expect(body.messages.map((m) => m.content)).toContain("hi"); + }); +}); + +describe("POST /api/v1/agents/:id/messages/read (mark read)", () => { + it("returns 404 for nonexistent agent", async () => { + const res = await authedInject( + "POST", + "/api/v1/agents/agt_nonexistent/messages/read" + ); + expect(res.statusCode).toBe(404); + }); + + it("marks messages read and returns updated count", async () => { + const res = await authedInject( + "POST", + `/api/v1/agents/${agentB}/messages/read` + ); + expect(res.statusCode).toBe(200); + expect((res.json() as { ok: boolean; updated: number }).updated).toBe(1); + + // Second call should update 0 since the message is already read. + const res2 = await authedInject( + "POST", + `/api/v1/agents/${agentB}/messages/read` + ); + expect(res2.statusCode).toBe(200); + expect((res2.json() as { updated: number }).updated).toBe(0); + }); +}); From b368ed87c6b407c06b48db3472e50191f181f0a4 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:27:48 -0700 Subject: [PATCH 06/21] feat(messages): add useAgentMessages hook and SSE wiring --- apps/web/src/hooks/use-agent-messages.ts | 65 ++++++++++++++++++++++++ apps/web/src/hooks/use-sse.ts | 22 ++++++++ 2 files changed, 87 insertions(+) create mode 100644 apps/web/src/hooks/use-agent-messages.ts diff --git a/apps/web/src/hooks/use-agent-messages.ts b/apps/web/src/hooks/use-agent-messages.ts new file mode 100644 index 00000000..b9aaaaf7 --- /dev/null +++ b/apps/web/src/hooks/use-agent-messages.ts @@ -0,0 +1,65 @@ +import { useCallback, useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export type AgentMessage = { + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + createdAt: string; +}; + +export function useAgentMessages(agentId: string | null): { + messages: AgentMessage[]; + unreadCount: number; + markRead: () => void; +} { + const queryClient = useQueryClient(); + + const { data: messages = [] } = useQuery({ + queryKey: ["messages", agentId], + queryFn: async () => { + const payload = await api<{ messages: AgentMessage[] }>( + `/api/v1/agents/${agentId}/messages` + ); + return payload.messages ?? []; + }, + enabled: !!agentId, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); + + const unreadCount = useMemo( + () => + messages.filter( + (m) => m.recipientAgentId === agentId && m.readAt === null + ).length, + [messages, agentId] + ); + + const markMutation = useMutation({ + mutationFn: async () => { + if (!agentId) return; + await api(`/api/v1/agents/${agentId}/messages/read`, { method: "POST" }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["messages", agentId], + exact: true, + }); + }, + }); + + const markRead = useCallback(() => { + if (agentId && unreadCount > 0) markMutation.mutate(); + }, [agentId, unreadCount, markMutation]); + + return { messages, unreadCount, markRead }; +} diff --git a/apps/web/src/hooks/use-sse.ts b/apps/web/src/hooks/use-sse.ts index 1dce706a..d95c4137 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -43,6 +43,8 @@ type UiEvent = | { type: "job.changed" } | { type: "template.changed" } | { type: "brain.changed"; repoRoot: string } + | { type: "message.created"; senderAgentId: string; recipientAgentId: string } + | { type: "message.read"; agentId: string } | { type: "notification"; notificationId: string; @@ -213,6 +215,26 @@ export function useSSE(authState: AuthState): void { return; } + if (payload.type === "message.created") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.senderAgentId], + exact: true, + }); + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.recipientAgentId], + exact: true, + }); + return; + } + + if (payload.type === "message.read") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.agentId], + exact: true, + }); + return; + } + if (payload.type === "notification") { const shown = showWebNotification(payload); if (shown) { From 397a121d17134c1178e2b6829cf9ba25a40f5652 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:32:39 -0700 Subject: [PATCH 07/21] feat(messages): add MessagesPanel with conversation threads --- .../web/src/components/app/messages-panel.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 apps/web/src/components/app/messages-panel.tsx diff --git a/apps/web/src/components/app/messages-panel.tsx b/apps/web/src/components/app/messages-panel.tsx new file mode 100644 index 00000000..ecb3a97a --- /dev/null +++ b/apps/web/src/components/app/messages-panel.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; + +import { useAgentMessages, type AgentMessage } from "@/hooks/use-agent-messages"; +import { cn } from "@/lib/utils"; + +type Thread = { + otherId: string; + otherName: string; + messages: AgentMessage[]; +}; + +function groupByParticipant( + messages: AgentMessage[], + agentId: string +): Thread[] { + const threads = new Map(); + for (const m of messages) { + const isSent = m.senderAgentId === agentId; + const otherId = isSent ? m.recipientAgentId : m.senderAgentId; + const otherName = isSent ? m.recipientName : m.senderName; + const existing = threads.get(otherId); + if (existing) { + existing.messages.push(m); + } else { + threads.set(otherId, { otherId, otherName, messages: [m] }); + } + } + return Array.from(threads.values()); +} + +function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + const secs = Math.round((Date.now() - then) / 1000); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; + if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; + return `${Math.round(secs / 86400)}d ago`; +} + +export function MessagesPanel({ + agentId, +}: { + agentId: string | null; +}): JSX.Element { + const { messages } = useAgentMessages(agentId); + + const threads = useMemo( + () => (agentId ? groupByParticipant(messages, agentId) : []), + [messages, agentId] + ); + + if (!agentId) { + return ( +
+
+ +
No agent selected.
+
+
+ ); + } + + if (messages.length === 0) { + return ( +
+
+ +
This agent has no messages yet.
+
+
+ ); + } + + return ( +
+ {threads.map((thread) => ( +
+
+ {thread.otherName} +
+
+ {thread.messages.map((m) => { + const isSent = m.senderAgentId === agentId; + return ( +
+
+ {isSent ? ( + + ) : ( + + )} + {isSent ? "Sent" : "Received"} + · + {relativeTime(m.createdAt)} + {!m.delivered && ( + · not delivered + )} +
+
+ {m.content} +
+
+ ); + })} +
+
+ ))} +
+ ); +} From 844298b79e112ea735b939982536877f05f41362 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:37:02 -0700 Subject: [PATCH 08/21] fix(messages): add loading state to MessagesPanel Expose isLoading from useAgentMessages hook and render a spinner while loading, preventing misleading empty state flash on initial mount. Modeled on BrainTabContent's loading pattern for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/app/messages-panel.tsx | 10 +++++++++- apps/web/src/hooks/use-agent-messages.ts | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/app/messages-panel.tsx b/apps/web/src/components/app/messages-panel.tsx index ecb3a97a..4d25722c 100644 --- a/apps/web/src/components/app/messages-panel.tsx +++ b/apps/web/src/components/app/messages-panel.tsx @@ -43,7 +43,7 @@ export function MessagesPanel({ }: { agentId: string | null; }): JSX.Element { - const { messages } = useAgentMessages(agentId); + const { messages, isLoading } = useAgentMessages(agentId); const threads = useMemo( () => (agentId ? groupByParticipant(messages, agentId) : []), @@ -61,6 +61,14 @@ export function MessagesPanel({ ); } + if (isLoading && messages.length === 0) { + return ( +
+
+
+ ); + } + if (messages.length === 0) { return (
diff --git a/apps/web/src/hooks/use-agent-messages.ts b/apps/web/src/hooks/use-agent-messages.ts index b9aaaaf7..e892c189 100644 --- a/apps/web/src/hooks/use-agent-messages.ts +++ b/apps/web/src/hooks/use-agent-messages.ts @@ -18,10 +18,11 @@ export function useAgentMessages(agentId: string | null): { messages: AgentMessage[]; unreadCount: number; markRead: () => void; + isLoading: boolean; } { const queryClient = useQueryClient(); - const { data: messages = [] } = useQuery({ + const { data: messages = [], isLoading } = useQuery({ queryKey: ["messages", agentId], queryFn: async () => { const payload = await api<{ messages: AgentMessage[] }>( @@ -61,5 +62,5 @@ export function useAgentMessages(agentId: string | null): { if (agentId && unreadCount > 0) markMutation.mutate(); }, [agentId, unreadCount, markMutation]); - return { messages, unreadCount, markRead }; + return { messages, unreadCount, markRead, isLoading }; } From 0e8347647d82923c88b905dd532e567d29bdbc34 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:42:39 -0700 Subject: [PATCH 09/21] feat(messages): add Messages tab to the agent sidebar --- apps/web/src/components/app/agents-view.tsx | 10 ++++++ apps/web/src/components/app/media-sidebar.tsx | 35 ++++++++++++++++++- apps/web/src/lib/store.ts | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index b5f9ba8d..c92edc92 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -65,6 +65,7 @@ import { type IdeType } from "@/lib/ide-types"; import { type CenterTab } from "@/lib/store"; import { cn } from "@/lib/utils"; import { useAgentActions } from "@/hooks/use-agent-actions"; +import { useAgentMessages } from "@/hooks/use-agent-messages"; import { useAgents } from "@/hooks/use-agents"; import { useMedia } from "@/hooks/use-media"; import { useMediaSidebarState } from "@/hooks/use-media-sidebar-state"; @@ -329,6 +330,13 @@ export function AgentsView({ refreshMedia, } = useMedia(focusedAgentId, mediaPanelOpen); + const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = + useAgentMessages(focusedAgentId); + + useEffect(() => { + if (mediaActiveTab === "messages") markMessagesRead(); + }, [mediaActiveTab, markMessagesRead]); + const focusedAgentHasStream = focusedAgent?.hasStream ?? false; const focusedAgentStreamUrl = focusedAgentId ? `/api/v1/agents/${focusedAgentId}/stream` @@ -899,6 +907,7 @@ export function AgentsView({ selectedAgentPins={focusedAgent?.pins ?? []} animatingMediaKeys={animatingMediaKeys} unseenMediaCount={unseenMediaCount} + unreadMessageCount={unreadMessageCount} mediaViewportRef={mediaViewportRef} setMediaOpen={setMediaOpen} activeTab={mediaActiveTab} @@ -950,6 +959,7 @@ export function AgentsView({ selectedAgentPins={focusedAgent?.pins ?? []} animatingMediaKeys={animatingMediaKeys} unseenMediaCount={unseenMediaCount} + unreadMessageCount={unreadMessageCount} mediaViewportRef={mediaViewportRef} activeTab={mediaActiveTab} setActiveTab={setMediaActiveTab} diff --git a/apps/web/src/components/app/media-sidebar.tsx b/apps/web/src/components/app/media-sidebar.tsx index 0a722407..b432698c 100644 --- a/apps/web/src/components/app/media-sidebar.tsx +++ b/apps/web/src/components/app/media-sidebar.tsx @@ -20,6 +20,7 @@ import { type MediaSidebarTab } from "@/lib/store"; import { MediaActions } from "@/components/app/media-lightbox"; import { isTextFile, stripTimestamp } from "@/components/app/media-file-utils"; import { BrainTabContent } from "@/components/app/brain-tab-content"; +import { MessagesPanel } from "@/components/app/messages-panel"; import { PinsPanel } from "@/components/app/pins-panel"; import { ReviewsSidebarContent } from "@/components/app/reviews-sidebar"; import { useAgentReviews } from "@/hooks/use-agent-reviews"; @@ -59,6 +60,7 @@ type MediaSidebarSharedProps = { hasStream: boolean; streamUrl: string | null; unseenMediaCount: number; + unreadMessageCount: number; onUploadFile?: (agentId: string, file: File) => Promise; onNavigateToFile?: (filePath: string, lineStart: number | null) => void; }; @@ -408,9 +410,13 @@ export function MediaSidebarContent({ onTogglePin, className, unseenMediaCount, + unreadMessageCount, onUploadFile, onNavigateToFile, -}: MediaSidebarContentProps & { unseenMediaCount: number }): JSX.Element { +}: MediaSidebarContentProps & { + unseenMediaCount: number; + unreadMessageCount: number; +}): JSX.Element { const { reviews } = useAgentReviews(selectedAgentId, !!selectedAgentId); const reviewUnresolvedCount = reviews.reduce( (sum, r) => sum + (r.itemCount - r.resolvedCount), @@ -498,6 +504,25 @@ export function MediaSidebarContent({ )} +
{onTogglePin ? ( @@ -589,6 +614,14 @@ export function MediaSidebarContent({ onNavigateToFile={onNavigateToFile} />
+
+ +
); } diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 55020051..dd3a7d00 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -137,7 +137,7 @@ export function reconcileAgentSidebarOrder( return nextOrder; } -export type MediaSidebarTab = "pins" | "media" | "brain" | "reviews"; +export type MediaSidebarTab = "pins" | "media" | "brain" | "reviews" | "messages"; export type MediaSidebarState = { isOpen: boolean; From fdb5fe7c96013dbcb4ce484e3fe9b4f60c3a502e Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 20:52:04 -0700 Subject: [PATCH 10/21] feat(messages): surface messages in History detail view Add a messages query to GET /api/v1/history/agents/:id and a Messages tab to the History detail view, backed by a new MessageTimeline component. --- apps/server/src/routes/activity.ts | 27 +++++++++++++++++ apps/server/test/activity-routes.test.ts | 20 +++++++++++++ .../components/app/agent-history-detail.tsx | 22 ++++++++++++-- .../src/components/app/message-timeline.tsx | 30 +++++++++++++++++++ apps/web/src/hooks/use-agent-history.ts | 2 ++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/app/message-timeline.tsx diff --git a/apps/server/src/routes/activity.ts b/apps/server/src/routes/activity.ts index 5aad97fa..f887b579 100644 --- a/apps/server/src/routes/activity.ts +++ b/apps/server/src/routes/activity.ts @@ -585,6 +585,7 @@ export async function registerActivityRoutes( tokenByModelResult, mediaResult, feedbackResult, + messagesResult, ] = await Promise.all([ deps.pool.query<{ id: number; @@ -659,6 +660,31 @@ export async function registerActivityRoutes( LIMIT 500`, [id] ), + deps.pool.query<{ + id: string; + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered: boolean; + readAt: string | null; + createdAt: string; + }>( + `SELECT id, + sender_agent_id AS "senderAgentId", + recipient_agent_id AS "recipientAgentId", + sender_name AS "senderName", + recipient_name AS "recipientName", + content, delivered, + read_at AS "readAt", + created_at AS "createdAt" + FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at ASC + LIMIT 500`, + [id] + ), ]); const eventRows: ActivityEventRow[] = eventsResult.rows.map((row) => ({ @@ -674,6 +700,7 @@ export async function registerActivityRoutes( tokenUsage: { ...tokenResult.rows[0], by_model: tokenByModelResult.rows }, media: mediaResult.rows, feedback: feedbackResult.rows, + messages: messagesResult.rows, stateDurations: stats.stateDurations, }; }); diff --git a/apps/server/test/activity-routes.test.ts b/apps/server/test/activity-routes.test.ts index 407fe153..4760b516 100644 --- a/apps/server/test/activity-routes.test.ts +++ b/apps/server/test/activity-routes.test.ts @@ -782,4 +782,24 @@ describe("GET /api/v1/history/agents/:id", () => { expect(feedback[0].description).toBe("potential XSS"); expect(feedback[0].persona).toBe("security-review"); }); + + it("includes agent messages in the history detail", async () => { + const agentId = await createAgent({ name: "history-agent" }); + // Seed a message where the history agent is the sender. + await ctx.pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, content, delivered) + VALUES ('11111111-1111-1111-1111-111111111111', $1, 'agt_other', 'Hist', 'Other', 'history msg', true)`, + [agentId] + ); + + const res = await authedInject("GET", `/api/v1/history/agents/${agentId}`); + expect(res.statusCode).toBe(200); + const body = res.json() as { messages: Array<{ content: string }> }; + expect(body.messages.map((m) => m.content)).toContain("history msg"); + + await ctx.pool.query( + "DELETE FROM agent_messages WHERE id = '11111111-1111-1111-1111-111111111111'" + ); + }); }); diff --git a/apps/web/src/components/app/agent-history-detail.tsx b/apps/web/src/components/app/agent-history-detail.tsx index 9b192393..f423a22e 100644 --- a/apps/web/src/components/app/agent-history-detail.tsx +++ b/apps/web/src/components/app/agent-history-detail.tsx @@ -34,6 +34,8 @@ import { EventTimeline, FeedbackTimeline, } from "@/components/app/agent-history-timeline"; +import { MessageTimeline } from "@/components/app/message-timeline"; +import { type AgentMessage } from "@/hooks/use-agent-messages"; // ── Helpers ───────────────────────────────────────────────────────── @@ -94,13 +96,14 @@ function DurationBar({ durations }: { durations: Record }) { // ── Detail tabs ───────────────────────────────────────────────────── -type DetailTab = "events" | "media" | "pins" | "feedback"; +type DetailTab = "events" | "media" | "pins" | "feedback" | "messages"; function DetailTabs({ events, media, pins, feedback, + messages, agentId, workspaceRoot, }: { @@ -108,6 +111,7 @@ function DetailTabs({ media: HistoryMedia[]; pins: AgentPin[]; feedback: HistoryFeedbackItem[]; + messages: AgentMessage[]; agentId: string; workspaceRoot: string | null; }) { @@ -137,6 +141,7 @@ function DetailTabs({ { key: "media", label: "Media", count: media.length }, { key: "pins", label: "Pins", count: pins.length }, { key: "feedback", label: "Feedback", count: feedback.length }, + { key: "messages", label: "Messages", count: messages.length }, ]; return ( @@ -244,6 +249,15 @@ function DetailTabs({ No feedback received.

)} + + {tab === "messages" && messages.length > 0 && ( + + )} + {tab === "messages" && messages.length === 0 && ( +

+ No messages recorded. +

+ )}
@@ -307,7 +321,8 @@ export function AgentHistoryDetail({ ); } - const { agent, events, tokenUsage, media, feedback, stateDurations } = data; + const { agent, events, tokenUsage, media, feedback, messages, stateDurations } = + data; const durationMs = new Date(agent.updatedAt).getTime() - new Date(agent.createdAt).getTime(); const totalTokens = @@ -443,12 +458,13 @@ export function AgentHistoryDetail({ )} - {/* Tabbed: Events / Media / Pins / Feedback */} + {/* Tabbed: Events / Media / Pins / Feedback / Messages */} diff --git a/apps/web/src/components/app/message-timeline.tsx b/apps/web/src/components/app/message-timeline.tsx new file mode 100644 index 00000000..dfc25650 --- /dev/null +++ b/apps/web/src/components/app/message-timeline.tsx @@ -0,0 +1,30 @@ +import { type AgentMessage } from "@/hooks/use-agent-messages"; +import { cn } from "@/lib/utils"; + +export function MessageTimeline({ + messages, +}: { + messages: AgentMessage[]; +}): JSX.Element { + return ( +
+ {messages.map((m) => ( +
+
+ {m.senderName} + + {m.recipientName} + · + {new Date(m.createdAt).toLocaleTimeString()} + {!m.delivered && ( + · not delivered + )} +
+
+ {m.content} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/hooks/use-agent-history.ts b/apps/web/src/hooks/use-agent-history.ts index ea8a4a98..2f85829b 100644 --- a/apps/web/src/hooks/use-agent-history.ts +++ b/apps/web/src/hooks/use-agent-history.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; import { getRangeBounds, type ActivityRange } from "@/hooks/use-activity"; import { type AgentPin } from "@/components/app/types"; +import { type AgentMessage } from "@/hooks/use-agent-messages"; const HISTORY_QUERY_OPTIONS = { staleTime: 60_000, @@ -112,6 +113,7 @@ export type HistoryAgentDetail = { tokenUsage: HistoryTokenUsage; media: HistoryMedia[]; feedback: HistoryFeedbackItem[]; + messages: AgentMessage[]; stateDurations: Record; }; From ba0c5211a6f15c4ec02d5222140ad9dd71b052bd Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 21:38:33 -0700 Subject: [PATCH 11/21] test(messages): e2e coverage for sidebar + history messages Add seedAgentMessageViaDB helper and agent-messages.spec.ts covering the sidebar Messages tab (unread badge appears then clears on open) and the History detail Messages tab. Also fix a markRead useCallback dependency (depended on the unstable mutation object, causing a request storm and a flickering unread badge). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/hooks/use-agent-messages.ts | 5 +- e2e/agent-messages.spec.ts | 127 +++++++++++++++++++++++ e2e/helpers.ts | 50 +++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 e2e/agent-messages.spec.ts diff --git a/apps/web/src/hooks/use-agent-messages.ts b/apps/web/src/hooks/use-agent-messages.ts index e892c189..ac724ac8 100644 --- a/apps/web/src/hooks/use-agent-messages.ts +++ b/apps/web/src/hooks/use-agent-messages.ts @@ -58,9 +58,10 @@ export function useAgentMessages(agentId: string | null): { }, }); + const { mutate: markMutate } = markMutation; const markRead = useCallback(() => { - if (agentId && unreadCount > 0) markMutation.mutate(); - }, [agentId, unreadCount, markMutation]); + if (agentId && unreadCount > 0) markMutate(); + }, [agentId, unreadCount, markMutate]); return { messages, unreadCount, markRead, isLoading }; } diff --git a/e2e/agent-messages.spec.ts b/e2e/agent-messages.spec.ts new file mode 100644 index 00000000..359d0f07 --- /dev/null +++ b/e2e/agent-messages.spec.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +import { + cleanupE2EAgents, + clickAgentRow, + createAgentViaAPI, + loadApp, + seedAgentMessageViaDB, +} from "./helpers"; + +// Agents run inert (no tmux) in E2E, so the real dispatch_send_message path +// cannot run here. Messages are seeded directly into `agent_messages` to +// exercise the sidebar + history UI that reads them. + +test.describe("Agent messages", () => { + test.afterAll(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("shows seeded messages in the sidebar and history detail", async ({ + page, + request, + }) => { + const agentA = await createAgentViaAPI(request, { + name: `e2e-agent-messages-a-${Date.now()}`, + }); + const otherAgentId = `e2e-agent-messages-other-${Date.now()}`; + + const receivedContent = `Incoming message ${Date.now()}`; + const sentContent = `Outgoing message ${Date.now()}`; + + // Unread message received by agent A — should trigger the sidebar's + // unread badge on the Messages tab. + await seedAgentMessageViaDB({ + senderAgentId: otherAgentId, + recipientAgentId: agentA.id, + senderName: "Other Agent", + recipientName: agentA.name, + content: receivedContent, + delivered: true, + read: false, + }); + + // A message sent by agent A, already read (no badge contribution). + await seedAgentMessageViaDB({ + senderAgentId: agentA.id, + recipientAgentId: otherAgentId, + senderName: agentA.name, + recipientName: "Other Agent", + content: sentContent, + delivered: true, + read: true, + }); + + await loadApp(page); + + await clickAgentRow(page, agentA.id); + const toggle = page.getByTestId("toggle-media-sidebar"); + await expect(toggle).toBeVisible(); + await toggle.click(); + + const mediaSidebar = page.getByTestId("media-sidebar"); + await expect(mediaSidebar).toBeVisible(); + + const messagesTabButton = mediaSidebar.getByRole("button", { + name: "Messages", + }); + const messagesUnreadBadge = messagesTabButton.locator( + "span.bg-destructive" + ); + + // Unread badge should be visible before opening the tab. + await expect(messagesUnreadBadge).toBeVisible(); + await expect(messagesUnreadBadge).toHaveText("1"); + + await messagesTabButton.click(); + + const messageItems = mediaSidebar.getByTestId("message-item"); + await expect( + messageItems.filter({ hasText: receivedContent }) + ).toBeVisible(); + await expect(messageItems.filter({ hasText: sentContent })).toBeVisible(); + + // Opening the tab marks the unread message as read, clearing the badge. + await expect(messagesUnreadBadge).toBeHidden({ timeout: 10_000 }); + // Confirm it stays cleared (guards against the badge re-appearing due to + // a stale re-render before the mark-read mutation settles). + await page.waitForTimeout(500); + await expect(messagesUnreadBadge).toBeHidden(); + + // Visual validation artifact for the sidebar Messages tab (published via + // dispatch_share since no browser MCP is available in this environment). + await page.screenshot({ + path: path.join( + process.env.E2E_SCREENSHOT_DIR ?? "/tmp", + "agent-messages-sidebar.png" + ), + }); + + await page.goto(`/activity/history/${agentA.id}`, { + waitUntil: "domcontentloaded", + }); + + const historyMessagesTabButton = page.getByRole("button", { + name: "Messages", + }); + await expect(historyMessagesTabButton).toBeVisible(); + await historyMessagesTabButton.click(); + + const historyMessages = page.getByTestId("history-message"); + await expect( + historyMessages.filter({ hasText: receivedContent }) + ).toBeVisible(); + await expect( + historyMessages.filter({ hasText: sentContent }) + ).toBeVisible(); + + // Visual validation artifact for the history detail Messages tab. + await page.screenshot({ + path: path.join( + process.env.E2E_SCREENSHOT_DIR ?? "/tmp", + "agent-messages-history.png" + ), + }); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index bf5835e7..49bcd4ff 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Pool } from "pg"; import { type Page, type APIRequestContext } from "@playwright/test"; @@ -238,6 +239,55 @@ export async function setAgentRoleViaDB( } } +/** + * Seed a row directly into `agent_messages`, bypassing the real + * dispatch_send_message path (which requires two running tmux agents and + * cannot run in the inert-runtime E2E stack). `agent_messages` has no FK + * constraints, so the "other" participant id can be any string. + */ +export async function seedAgentMessageViaDB(message: { + senderAgentId: string; + recipientAgentId: string; + senderName: string; + recipientName: string; + content: string; + delivered?: boolean; + read?: boolean; + senderRepoRoot?: string | null; + recipientRepoRoot?: string | null; +}): Promise { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error("DATABASE_URL is required to seed agent messages."); + } + + const id = randomUUID(); + const pool = new Pool({ connectionString, max: 1 }); + try { + await pool.query( + `INSERT INTO agent_messages + (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, + content, delivered, read_at, sender_repo_root, recipient_repo_root) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + id, + message.senderAgentId, + message.recipientAgentId, + message.senderName, + message.recipientName, + message.content, + message.delivered ?? true, + message.read ? new Date() : null, + message.senderRepoRoot ?? null, + message.recipientRepoRoot ?? null, + ] + ); + } finally { + await pool.end(); + } + return id; +} + export async function seedPersonaRecheckFixtureViaDB( parentAgentId: string ): Promise<{ From 3194a145c5f7f18faefd1309d6fef04e6a9640c2 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 21:42:37 -0700 Subject: [PATCH 12/21] docs(messages): clarify History is not live-invalidated Final review found the data-flow diagram implied the History detail tab refetches on message.created; it does not. History is retrospective and matches its sibling tabs (refetch on remount/focus/staleTime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-30-agent-messages-design.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-30-agent-messages-design.md b/docs/superpowers/specs/2026-06-30-agent-messages-design.md index 11afdd47..cd5b48d1 100644 --- a/docs/superpowers/specs/2026-06-30-agent-messages-design.md +++ b/docs/superpowers/specs/2026-06-30-agent-messages-design.md @@ -156,10 +156,15 @@ agent A calls dispatch_send_message -> INSERT agent_messages row (delivered=?) -> publishUiEvent("message.created") -> SSE /api/v1/events - -> web: invalidate ["messages", agentId] and history detail query - -> MessagesPanel (sidebar) + MessageTimeline (history) re-render + -> web: invalidate ["messages", senderAgentId] and ["messages", recipientAgentId] + -> MessagesPanel (sidebar) re-renders live ``` +The **sidebar** Messages tab updates live via SSE. The **History** detail view is a +retrospective surface and is *not* live-invalidated on `message.created` — consistent with +its sibling tabs (Events/Media/Pins/Feedback), it refetches on remount/refocus and per its +`staleTime`. This is intentional; History does not need to reflect in-flight messages. + ## Error handling - Tmux injection failure: still insert the row with `delivered = false`; the UI shows a From c303807b2ff1622d2aef19989420c7e39b4f4fef Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Wed, 8 Jul 2026 15:44:54 -0700 Subject: [PATCH 13/21] style(messages): apply prettier formatting CI `pnpm run format` (prettier --check) flagged these files; the local pre-commit hook was unavailable so formatting wasn't applied on commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/test/messages-routes.test.ts | 9 +- .../test/send-message-persistence.test.ts | 24 +- .../components/app/agent-history-detail.tsx | 11 +- .../src/components/app/message-timeline.tsx | 10 +- .../web/src/components/app/messages-panel.tsx | 5 +- .../plans/2026-06-30-agent-messages.md | 317 ++++++++++-------- .../specs/2026-06-30-agent-messages-design.md | 32 +- 7 files changed, 241 insertions(+), 167 deletions(-) diff --git a/apps/server/test/messages-routes.test.ts b/apps/server/test/messages-routes.test.ts index 6645302b..8e11c39d 100644 --- a/apps/server/test/messages-routes.test.ts +++ b/apps/server/test/messages-routes.test.ts @@ -23,9 +23,7 @@ async function authedInject( }); } -async function createAgent( - name: string -): Promise<{ id: string }> { +async function createAgent(name: string): Promise<{ id: string }> { const res = await authedInject("POST", "/api/v1/agents", { payload: { cwd: "/tmp", useWorktree: false, name }, }); @@ -73,10 +71,7 @@ describe("GET /api/v1/agents/:id/messages (list)", () => { }); it("lists messages for an agent", async () => { - const res = await authedInject( - "GET", - `/api/v1/agents/${agentB}/messages` - ); + const res = await authedInject("GET", `/api/v1/agents/${agentB}/messages`); expect(res.statusCode).toBe(200); const body = res.json() as { messages: Array<{ content: string }> }; expect(body.messages.map((m) => m.content)).toContain("hi"); diff --git a/apps/server/test/send-message-persistence.test.ts b/apps/server/test/send-message-persistence.test.ts index 98e307ed..aa812931 100644 --- a/apps/server/test/send-message-persistence.test.ts +++ b/apps/server/test/send-message-persistence.test.ts @@ -1,4 +1,12 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import type { Pool } from "pg"; vi.mock("../src/shared/git/git-context.js", () => ({ @@ -19,8 +27,18 @@ let agentManager: { listAgents: ReturnType; }; -const SENDER = { id: "agt_sender", name: "sender-agent", cwd: "/repo", status: "running" }; -const RECEIVER = { id: "agt_receiver", name: "receiver-agent", cwd: "/repo", status: "running" }; +const SENDER = { + id: "agt_sender", + name: "sender-agent", + cwd: "/repo", + status: "running", +}; +const RECEIVER = { + id: "agt_receiver", + name: "receiver-agent", + cwd: "/repo", + status: "running", +}; beforeAll(async () => { pool = await setupTestDb(); diff --git a/apps/web/src/components/app/agent-history-detail.tsx b/apps/web/src/components/app/agent-history-detail.tsx index f423a22e..14d0e209 100644 --- a/apps/web/src/components/app/agent-history-detail.tsx +++ b/apps/web/src/components/app/agent-history-detail.tsx @@ -321,8 +321,15 @@ export function AgentHistoryDetail({ ); } - const { agent, events, tokenUsage, media, feedback, messages, stateDurations } = - data; + const { + agent, + events, + tokenUsage, + media, + feedback, + messages, + stateDurations, + } = data; const durationMs = new Date(agent.updatedAt).getTime() - new Date(agent.createdAt).getTime(); const totalTokens = diff --git a/apps/web/src/components/app/message-timeline.tsx b/apps/web/src/components/app/message-timeline.tsx index dfc25650..2aca8c69 100644 --- a/apps/web/src/components/app/message-timeline.tsx +++ b/apps/web/src/components/app/message-timeline.tsx @@ -9,11 +9,17 @@ export function MessageTimeline({ return (
{messages.map((m) => ( -
+
{m.senderName} - {m.recipientName} + + {m.recipientName} + · {new Date(m.createdAt).toLocaleTimeString()} {!m.delivered && ( diff --git a/apps/web/src/components/app/messages-panel.tsx b/apps/web/src/components/app/messages-panel.tsx index 4d25722c..29168659 100644 --- a/apps/web/src/components/app/messages-panel.tsx +++ b/apps/web/src/components/app/messages-panel.tsx @@ -1,7 +1,10 @@ import { useMemo } from "react"; import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; -import { useAgentMessages, type AgentMessage } from "@/hooks/use-agent-messages"; +import { + useAgentMessages, + type AgentMessage, +} from "@/hooks/use-agent-messages"; import { cn } from "@/lib/utils"; type Thread = { diff --git a/docs/superpowers/plans/2026-06-30-agent-messages.md b/docs/superpowers/plans/2026-06-30-agent-messages.md index 9a5237ce..be7936fc 100644 --- a/docs/superpowers/plans/2026-06-30-agent-messages.md +++ b/docs/superpowers/plans/2026-06-30-agent-messages.md @@ -23,11 +23,13 @@ ### Task 1: `agent_messages` table + `MessageStore` query module **Files:** + - Create: `apps/server/src/db/migrations/0029_agent-messages.sql` - Create: `apps/server/src/messages/store.ts` - Test: `apps/server/test/message-store.test.ts` **Interfaces:** + - Produces: - `type StoredMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; senderRepoRoot: string | null; recipientRepoRoot: string | null; createdAt: string }` - `class MessageStore { constructor(pool: Pool); insertMessage(input: InsertMessageInput): Promise; listForAgent(agentId: string): Promise; countUnreadForAgent(agentId: string): Promise; markReadForAgent(agentId: string): Promise }` @@ -276,10 +278,12 @@ git commit -m "feat(messages): add agent_messages table and MessageStore" ### Task 2: Persist + broadcast in the `sendMessage` handler **Files:** + - Modify: `apps/server/src/server/mcp-handlers.ts` (the `sendMessage` method, ~lines 847–947) - Test: `apps/server/test/mcp-handlers.test.ts` (extend the existing `dispatch_send_message` / `sendMessage` coverage) **Interfaces:** + - Consumes: `MessageStore` from Task 1; existing `pool`, `publishUiEvent`, `appLog`, `sendAgentPrompt`, `resolveRepoRoot` already in the handler factory scope. - Produces: a persisted row per send + a `{ type: "message.created"; senderAgentId; recipientAgentId }` UI event. @@ -296,70 +300,70 @@ import { MessageStore } from "../messages/store.js"; Replace the current try/catch + return at the end of `sendMessage` (the block from `const envelope = ...` return, currently ~lines 920–946) with: ```ts - const envelope = JSON.stringify({ - from: sender.name, - senderId: agentId, - message: input.message, - replyTarget: agentId, - }); - const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nReply with dispatch_send_message using the replyTarget above.`; - - // Deliver first: a persistence failure must never block delivery. - let delivered = false; - let deliveryError: unknown = null; - try { - await sendAgentPrompt(target.id, prompt, { swallowFailure: false }); - delivered = true; - } catch (err) { - deliveryError = err; - appLog.error( - { err, senderId: agentId, targetId: target.id }, - "dispatch_send_message: tmux delivery failed" - ); - } - - // Record the message (including failed deliveries) so it is viewable. - const messageStore = new MessageStore(pool); - await messageStore - .insertMessage({ - senderAgentId: agentId, - recipientAgentId: target.id, - senderName: sender.name, - recipientName: target.name, - content: input.message, - delivered, - senderRepoRoot, - // Same repo today (send rule); stored for future cross-repo support. - recipientRepoRoot: senderRepoRoot, - }) - .catch((err) => - appLog.error( - { err, senderId: agentId, targetId: target.id }, - "dispatch_send_message: failed to persist message" - ) - ); - - publishUiEvent({ - type: "message.created", - senderAgentId: agentId, - recipientAgentId: target.id, - }); +const envelope = JSON.stringify({ + from: sender.name, + senderId: agentId, + message: input.message, + replyTarget: agentId, +}); +const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nReply with dispatch_send_message using the replyTarget above.`; + +// Deliver first: a persistence failure must never block delivery. +let delivered = false; +let deliveryError: unknown = null; +try { + await sendAgentPrompt(target.id, prompt, { swallowFailure: false }); + delivered = true; +} catch (err) { + deliveryError = err; + appLog.error( + { err, senderId: agentId, targetId: target.id }, + "dispatch_send_message: tmux delivery failed" + ); +} + +// Record the message (including failed deliveries) so it is viewable. +const messageStore = new MessageStore(pool); +await messageStore + .insertMessage({ + senderAgentId: agentId, + recipientAgentId: target.id, + senderName: sender.name, + recipientName: target.name, + content: input.message, + delivered, + senderRepoRoot, + // Same repo today (send rule); stored for future cross-repo support. + recipientRepoRoot: senderRepoRoot, + }) + .catch((err) => + appLog.error( + { err, senderId: agentId, targetId: target.id }, + "dispatch_send_message: failed to persist message" + ) + ); - if (!delivered) { - throw deliveryError instanceof Error - ? deliveryError - : new Error(`Failed to deliver message to "${target.name}".`); - } +publishUiEvent({ + type: "message.created", + senderAgentId: agentId, + recipientAgentId: target.id, +}); - appLog.info( - { senderId: agentId, targetId: target.id }, - "dispatch_send_message: delivered" - ); - return { - delivered: true, - targetAgentId: target.id, - targetAgentName: target.name, - }; +if (!delivered) { + throw deliveryError instanceof Error + ? deliveryError + : new Error(`Failed to deliver message to "${target.name}".`); +} + +appLog.info( + { senderId: agentId, targetId: target.id }, + "dispatch_send_message: delivered" +); +return { + delivered: true, + targetAgentId: target.id, + targetAgentName: target.name, +}; ``` - [ ] **Step 3: Add a failing test for persistence** @@ -410,11 +414,13 @@ git commit -m "feat(messages): persist and broadcast messages on send" ### Task 3: REST endpoints for reading + marking messages read **Files:** + - Create: `apps/server/src/routes/messages.ts` - Modify: `apps/server/src/server.ts` (register the new routes near `registerMediaRoutes`, ~line 572) - Test: `apps/server/test/messages-routes.test.ts` **Interfaces:** + - Consumes: `MessageStore` (Task 1); Fastify `app`, `pool`, `agentManager`, `publishUiEvent` (as passed to `registerMediaRoutes`). - Produces: - `GET /api/v1/agents/:id/messages` → `{ messages: StoredMessage[] }` @@ -449,9 +455,14 @@ beforeAll(async () => { ); const store = new MessageStore(pool); await store.insertMessage({ - senderAgentId: A, recipientAgentId: B, senderName: "Alice", - recipientName: "Bob", content: "hi", delivered: true, - senderRepoRoot: "/repo", recipientRepoRoot: "/repo", + senderAgentId: A, + recipientAgentId: B, + senderName: "Alice", + recipientName: "Bob", + content: "hi", + delivered: true, + senderRepoRoot: "/repo", + recipientRepoRoot: "/repo", }); app = Fastify(); @@ -470,20 +481,29 @@ afterAll(async () => { describe("messages routes", () => { it("lists messages for an agent", async () => { - const res = await app.inject({ method: "GET", url: `/api/v1/agents/${B}/messages` }); + const res = await app.inject({ + method: "GET", + url: `/api/v1/agents/${B}/messages`, + }); expect(res.statusCode).toBe(200); const body = res.json() as { messages: Array<{ content: string }> }; expect(body.messages.map((m) => m.content)).toContain("hi"); }); it("marks messages read", async () => { - const res = await app.inject({ method: "POST", url: `/api/v1/agents/${B}/messages/read` }); + const res = await app.inject({ + method: "POST", + url: `/api/v1/agents/${B}/messages/read`, + }); expect(res.statusCode).toBe(200); expect((res.json() as { updated: number }).updated).toBe(1); }); it("404s for unknown agent", async () => { - const res = await app.inject({ method: "GET", url: `/api/v1/agents/agt_missing/messages` }); + const res = await app.inject({ + method: "GET", + url: `/api/v1/agents/agt_missing/messages`, + }); expect(res.statusCode).toBe(404); }); }); @@ -559,11 +579,11 @@ import { registerMessagesRoutes } from "./routes/messages.js"; And register it right after the `registerMediaRoutes(...)` call (after ~line 578): ```ts - await registerMessagesRoutes(app, { - pool, - agentManager, - publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), - }); +await registerMessagesRoutes(app, { + pool, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), +}); ``` - [ ] **Step 5: Run the test to verify it passes** @@ -583,10 +603,12 @@ git commit -m "feat(messages): add read + mark-read REST endpoints" ### Task 4: Frontend data layer — `useAgentMessages` hook + SSE wiring **Files:** + - Create: `apps/web/src/hooks/use-agent-messages.ts` - Modify: `apps/web/src/hooks/use-sse.ts` (add `message.created` and `message.read` to the `UiEvent` union + handlers) **Interfaces:** + - Produces: - `type AgentMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; createdAt: string }` - `useAgentMessages(agentId: string | null): { messages: AgentMessage[]; unreadCount: number; markRead: () => void }` @@ -605,25 +627,25 @@ In `apps/web/src/hooks/use-sse.ts`, add two members to the `UiEvent` union (afte In `use-sse.ts`, inside `handleSSEMessage`, add after the `brain.changed` block (~line 192): ```ts - if (payload.type === "message.created") { - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.senderAgentId], - exact: true, - }); - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.recipientAgentId], - exact: true, - }); - return; - } - - if (payload.type === "message.read") { - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.agentId], - exact: true, - }); - return; - } +if (payload.type === "message.created") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.senderAgentId], + exact: true, + }); + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.recipientAgentId], + exact: true, + }); + return; +} + +if (payload.type === "message.read") { + void queryClient.invalidateQueries({ + queryKey: ["messages", payload.agentId], + exact: true, + }); + return; +} ``` - [ ] **Step 3: Write the hook** @@ -713,9 +735,11 @@ git commit -m "feat(messages): add useAgentMessages hook and SSE wiring" ### Task 5: `MessagesPanel` component (per-agent conversation threads) **Files:** + - Create: `apps/web/src/components/app/messages-panel.tsx` **Interfaces:** + - Consumes: `AgentMessage`, `useAgentMessages` (Task 4). - Produces: `MessagesPanel({ agentId }: { agentId: string | null }): JSX.Element` @@ -727,7 +751,10 @@ Create `apps/web/src/components/app/messages-panel.tsx` (grouping + empty/loadin import { useMemo } from "react"; import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; -import { useAgentMessages, type AgentMessage } from "@/hooks/use-agent-messages"; +import { + useAgentMessages, + type AgentMessage, +} from "@/hooks/use-agent-messages"; import { cn } from "@/lib/utils"; type Thread = { @@ -863,11 +890,13 @@ git commit -m "feat(messages): add MessagesPanel with conversation threads" ### Task 6: Wire the Messages tab into the sidebar **Files:** + - Modify: `apps/web/src/lib/store.ts` (extend `MediaSidebarTab`, ~line 102) - Modify: `apps/web/src/components/app/media-sidebar.tsx` (add tab button + mounted panel; extend props) - Modify: `apps/web/src/components/app/agents-view.tsx` (compute unread count, pass props, mark-read on tab open) **Interfaces:** + - Consumes: `MessagesPanel` (Task 5), `useAgentMessages` (Task 4). - Produces: a fourth sidebar tab `"messages"` with an unread badge. @@ -892,38 +921,38 @@ import { MessagesPanel } from "@/components/app/messages-panel"; Add a new tab button after the Brain button (after ~line 472), following the exact pattern of the Media button (destructive badge): ```tsx - + ``` Add the mounted panel after the Brain panel `
` (after ~line 552): ```tsx -
- -
+
+ +
``` - [ ] **Step 3: Wire data + mark-read in `agents-view.tsx`** @@ -939,16 +968,16 @@ import { useAgentMessages } from "@/hooks/use-agent-messages"; - Call it alongside `useMedia(...)` (~line 230) using the same focused agent id: ```tsx - const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = - useAgentMessages(focusedAgentId); +const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = + useAgentMessages(focusedAgentId); ``` - Mark messages read when the Messages tab becomes active. Add an effect near the sidebar state (after `useMediaSidebarState(...)`, ~line 161): ```tsx - useEffect(() => { - if (mediaActiveTab === "messages") markMessagesRead(); - }, [mediaActiveTab, markMessagesRead]); +useEffect(() => { + if (mediaActiveTab === "messages") markMessagesRead(); +}, [mediaActiveTab, markMessagesRead]); ``` Use whatever the local variable for the active tab is called from `useMediaSidebarState` (inspect the destructure — it is the `activeTab` value passed to `MediaSidebarContent`). If `useEffect` is not yet imported in this file, add it to the React import. @@ -956,7 +985,7 @@ Use whatever the local variable for the active tab is called from `useMediaSideb - Pass `unreadMessageCount` to **both** `MediaSidebarContent` usages (the two render sites at ~line 704 and the header/props at ~666 already pass `unseenMediaCount`). Add: ```tsx - unreadMessageCount={unreadMessageCount} +unreadMessageCount = { unreadMessageCount }; ``` - [ ] **Step 4: Verify build + type-check** @@ -976,6 +1005,7 @@ git commit -m "feat(messages): add Messages tab to the agent sidebar" ### Task 7: History detail — backend query + Messages tab **Files:** + - Modify: `apps/server/src/routes/activity.ts` (add a `messages` query to `GET /api/v1/history/agents/:id`, ~lines 582–678) - Modify: `apps/web/src/hooks/use-agent-history.ts` (add `messages` to `HistoryAgentDetail`) - Modify: `apps/web/src/components/app/agent-history-detail.tsx` (add `"messages"` to `DetailTab`, tab button, panel) @@ -983,6 +1013,7 @@ git commit -m "feat(messages): add Messages tab to the agent sidebar" - Test: `apps/server/test/activity-routes.test.ts` (extend the history-detail coverage) **Interfaces:** + - Consumes: `agent_messages` table (Task 1); `AgentMessage` type (Task 4). - Produces: `HistoryAgentDetail.messages: AgentMessage[]`; a `MessageTimeline` component. @@ -1097,11 +1128,17 @@ export function MessageTimeline({ return (
{messages.map((m) => ( -
+
{m.senderName} - {m.recipientName} + + {m.recipientName} + · {new Date(m.createdAt).toLocaleTimeString()} {!m.delivered && ( @@ -1140,6 +1177,7 @@ type DetailTab = "events" | "media" | "pins" | "feedback" | "messages"; ```tsx messages, ``` + ```tsx messages: AgentMessage[]; ``` @@ -1153,14 +1191,18 @@ type DetailTab = "events" | "media" | "pins" | "feedback" | "messages"; - Add the panel in the content area (after the `feedback` block, ~line 246): ```tsx - {tab === "messages" && messages.length > 0 && ( - - )} - {tab === "messages" && messages.length === 0 && ( -

- No messages recorded. -

- )} +{ + tab === "messages" && messages.length > 0 && ( + + ); +} +{ + tab === "messages" && messages.length === 0 && ( +

+ No messages recorded. +

+ ); +} ``` - Find where `DetailTabs` is rendered by `AgentHistoryDetail` (below line 269, using `data.feedback` etc.) and pass the new prop: @@ -1186,9 +1228,11 @@ git commit -m "feat(messages): surface messages in History detail view" ### Task 8: End-to-end test + final verification **Files:** + - Create: `e2e/agent-messages.spec.ts` (match the existing e2e spec conventions in `e2e/`) **Interfaces:** + - Consumes: the full stack from Tasks 1–7. - [ ] **Step 1: Inspect existing e2e patterns** @@ -1198,6 +1242,7 @@ Read one existing spec under `e2e/` (e.g. how it launches agents, selects one, a - [ ] **Step 2: Write the E2E spec** Create `e2e/agent-messages.spec.ts`. The flow: + 1. Start from the isolated e2e stack (the suite provisions its own DB + server). 2. Create/launch two agents in the same repo (reuse the suite's agent-creation helper). 3. Trigger a message from agent A to agent B (via the MCP tool path the suite uses for agent actions, or by seeding through the API the suite exposes). diff --git a/docs/superpowers/specs/2026-06-30-agent-messages-design.md b/docs/superpowers/specs/2026-06-30-agent-messages-design.md index cd5b48d1..141a81e0 100644 --- a/docs/superpowers/specs/2026-06-30-agent-messages-design.md +++ b/docs/superpowers/specs/2026-06-30-agent-messages-design.md @@ -20,7 +20,7 @@ section. 1. **Persist + view only.** Build persistence, broadcast, and the two read surfaces. Keep the existing same-repo-only send rule. Design the schema so cross-repo becomes a later config flip rather than a migration — but do **not** unblock cross-repo sending now. -2. **Sidebar tab is per selected agent** and shows *that agent's* conversations (messages it +2. **Sidebar tab is per selected agent** and shows _that agent's_ conversations (messages it sent or received), grouped by the other participant. 3. **History placement is a new tab in the per-agent detail view**, alongside Events / Media / Pins / Feedback. @@ -29,7 +29,7 @@ section. ### Explicitly out of scope (YAGNI) -- Unblocking cross-repo *send* (schema is prepared for it; the send rule is unchanged). +- Unblocking cross-repo _send_ (schema is prepared for it; the send rule is unchanged). - Composing / replying to messages from the UI. - Message search / filtering within the sidebar tab. @@ -77,19 +77,19 @@ layers, all additive — the existing ephemeral tmux delivery is unchanged: New migration file under `apps/server/src/db/migrations/` following the existing numbering. -| column | type | notes | -|---|---|---| -| `id` | uuid / text PK | consistent with existing tables | -| `sender_agent_id` | text FK → `agents.id` | | -| `recipient_agent_id` | text FK → `agents.id` | | -| `sender_name` | text | denormalized snapshot — names change, agents get archived | -| `recipient_name` | text | denormalized snapshot | -| `content` | text | message body | -| `delivered` | boolean | whether tmux injection succeeded | -| `read_at` | timestamptz null | powers unread badges; null = unread | -| `sender_repo_root` | text | stored now even though equal to recipient's today — the "cross-repo later is a config flip, not a migration" hook | -| `recipient_repo_root` | text | as above | -| `created_at` | timestamptz | default now | +| column | type | notes | +| --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `id` | uuid / text PK | consistent with existing tables | +| `sender_agent_id` | text FK → `agents.id` | | +| `recipient_agent_id` | text FK → `agents.id` | | +| `sender_name` | text | denormalized snapshot — names change, agents get archived | +| `recipient_name` | text | denormalized snapshot | +| `content` | text | message body | +| `delivered` | boolean | whether tmux injection succeeded | +| `read_at` | timestamptz null | powers unread badges; null = unread | +| `sender_repo_root` | text | stored now even though equal to recipient's today — the "cross-repo later is a config flip, not a migration" hook | +| `recipient_repo_root` | text | as above | +| `created_at` | timestamptz | default now | Indexes: `sender_agent_id`, `recipient_agent_id`, `created_at`. @@ -161,7 +161,7 @@ agent A calls dispatch_send_message ``` The **sidebar** Messages tab updates live via SSE. The **History** detail view is a -retrospective surface and is *not* live-invalidated on `message.created` — consistent with +retrospective surface and is _not_ live-invalidated on `message.created` — consistent with its sibling tabs (Events/Media/Pins/Feedback), it refetches on remount/refocus and per its `staleTime`. This is intentional; History does not need to reflect in-flight messages. From 7e243ed1ea33e50fc9deb1d6b085b6e3e8e5fa37 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Wed, 8 Jul 2026 15:54:03 -0700 Subject: [PATCH 14/21] refactor(messages): address persona review findings - Bound MessageStore.listForAgent to the most recent 500 (mirrors the history-detail LIMIT 500); the sidebar refetches this eagerly, so it must not be unbounded. (security #360, architecture #362) - Only publish message.created when the row actually persisted; the insert failure path stays swallow-and-log so delivery is never blocked, but the UI no longer gets an event with no backing row. (security #361) - Serve unreadCount from GET /agents/:id/messages via countUnreadForAgent (uses the partial unread index) and drive the sidebar badge from it, so the count stays accurate despite the list cap. (architecture #363) - Drop the unused agentManager dep from the messages routes. (architecture #364) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/messages/store.ts | 13 +++++++--- apps/server/src/routes/messages.ts | 11 +++++---- apps/server/src/server.ts | 1 - apps/server/src/server/mcp-handlers.ts | 25 +++++++++++++------- apps/server/test/messages-routes.test.ts | 9 +++++-- apps/web/src/hooks/use-agent-messages.ts | 30 ++++++++++++++---------- 6 files changed, 57 insertions(+), 32 deletions(-) diff --git a/apps/server/src/messages/store.ts b/apps/server/src/messages/store.ts index b9c7c53d..b88c19c2 100644 --- a/apps/server/src/messages/store.ts +++ b/apps/server/src/messages/store.ts @@ -76,10 +76,17 @@ export class MessageStore { } async listForAgent(agentId: string): Promise { + // Bound the result set (mirrors the LIMIT 500 cap on the history-detail + // query in activity.ts). Take the most recent 500, then return them in + // ascending order for chronological rendering. const result = await this.pool.query( - `SELECT * FROM agent_messages - WHERE sender_agent_id = $1 OR recipient_agent_id = $1 - ORDER BY created_at ASC`, + `SELECT * FROM ( + SELECT * FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at DESC + LIMIT 500 + ) recent + ORDER BY created_at ASC`, [agentId] ); return result.rows.map(toStoredMessage); diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts index 41204ec2..7c3ed9ec 100644 --- a/apps/server/src/routes/messages.ts +++ b/apps/server/src/routes/messages.ts @@ -1,12 +1,10 @@ import type { FastifyInstance } from "fastify"; import type { Pool } from "pg"; -import type { AgentManager } from "../agents/manager.js"; import { MessageStore } from "../messages/store.js"; type MessagesRouteDeps = { pool: Pool; - agentManager: AgentManager; publishUiEvent: (event: unknown) => void; }; @@ -24,8 +22,13 @@ export async function registerMessagesRoutes( if (exists.rows.length === 0) { return reply.code(404).send({ error: "Agent not found." }); } - const messages = await store.listForAgent(id); - return { messages }; + // unreadCount is computed server-side (uses the partial unread index) so + // the badge stays accurate even though listForAgent is capped. + const [messages, unreadCount] = await Promise.all([ + store.listForAgent(id), + store.countUnreadForAgent(id), + ]); + return { messages, unreadCount }; }); app.post("/api/v1/agents/:id/messages/read", async (request, reply) => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ca5d8412..0c919ab8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -584,7 +584,6 @@ async function registerRoutes() { await registerMessagesRoutes(app, { pool, - agentManager, publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), }); diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 02c7804b..9ff8e8d9 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -554,8 +554,11 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { } // Record the message (including failed deliveries) so it is viewable. + // Persistence must never block delivery, so a failed insert is swallowed + // and logged. Only announce message.created when the row actually landed, + // otherwise the UI would refetch and find nothing. const messageStore = new MessageStore(pool); - await messageStore + const persisted = await messageStore .insertMessage({ senderAgentId: agentId, recipientAgentId: target.id, @@ -567,18 +570,22 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { // Same repo today (send rule); stored for future cross-repo support. recipientRepoRoot: senderRepoRoot, }) - .catch((err) => + .then(() => true) + .catch((err) => { appLog.error( { err, senderId: agentId, targetId: target.id }, "dispatch_send_message: failed to persist message" - ) - ); + ); + return false; + }); - publishUiEvent({ - type: "message.created", - senderAgentId: agentId, - recipientAgentId: target.id, - }); + if (persisted) { + publishUiEvent({ + type: "message.created", + senderAgentId: agentId, + recipientAgentId: target.id, + }); + } if (!delivered) { throw deliveryError instanceof Error diff --git a/apps/server/test/messages-routes.test.ts b/apps/server/test/messages-routes.test.ts index 8e11c39d..694cf84b 100644 --- a/apps/server/test/messages-routes.test.ts +++ b/apps/server/test/messages-routes.test.ts @@ -70,11 +70,16 @@ describe("GET /api/v1/agents/:id/messages (list)", () => { expect(res.statusCode).toBe(404); }); - it("lists messages for an agent", async () => { + it("lists messages for an agent with a server-computed unread count", async () => { const res = await authedInject("GET", `/api/v1/agents/${agentB}/messages`); expect(res.statusCode).toBe(200); - const body = res.json() as { messages: Array<{ content: string }> }; + const body = res.json() as { + messages: Array<{ content: string }>; + unreadCount: number; + }; expect(body.messages.map((m) => m.content)).toContain("hi"); + // Bob is the recipient of the unread "hi" message. + expect(body.unreadCount).toBe(1); }); }); diff --git a/apps/web/src/hooks/use-agent-messages.ts b/apps/web/src/hooks/use-agent-messages.ts index ac724ac8..a7ce2488 100644 --- a/apps/web/src/hooks/use-agent-messages.ts +++ b/apps/web/src/hooks/use-agent-messages.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; @@ -22,13 +22,20 @@ export function useAgentMessages(agentId: string | null): { } { const queryClient = useQueryClient(); - const { data: messages = [], isLoading } = useQuery({ + const { data, isLoading } = useQuery<{ + messages: AgentMessage[]; + unreadCount: number; + }>({ queryKey: ["messages", agentId], queryFn: async () => { - const payload = await api<{ messages: AgentMessage[] }>( - `/api/v1/agents/${agentId}/messages` - ); - return payload.messages ?? []; + const payload = await api<{ + messages: AgentMessage[]; + unreadCount: number; + }>(`/api/v1/agents/${agentId}/messages`); + return { + messages: payload.messages ?? [], + unreadCount: payload.unreadCount ?? 0, + }; }, enabled: !!agentId, staleTime: 0, @@ -37,13 +44,10 @@ export function useAgentMessages(agentId: string | null): { refetchOnReconnect: true, }); - const unreadCount = useMemo( - () => - messages.filter( - (m) => m.recipientAgentId === agentId && m.readAt === null - ).length, - [messages, agentId] - ); + const messages = data?.messages ?? []; + // Server-derived (uses the partial unread index) so the badge stays accurate + // even though the message list is capped. + const unreadCount = data?.unreadCount ?? 0; const markMutation = useMutation({ mutationFn: async () => { From 5f1c1d3c964e28a709ffdb6a5a73c35a72628b21 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Wed, 8 Jul 2026 15:58:46 -0700 Subject: [PATCH 15/21] fix(messages): address frontend UX review findings - Only mark messages read when the sidebar is actually open on the Messages tab; gating on the persisted tab alone silently cleared unread state for a closed sidebar. (#365) - Surface unread messages on the collapsed sidebar toggle (combined with the unseen-media count) so unread is discoverable while collapsed. (#366) - Add an explanatory tooltip to the "not delivered" label in both the sidebar panel and the History timeline. (#367) - Give the populated message list min-h-0 flex-1 so it scrolls within the sidebar instead of overflowing past the bottom edge. (#368) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/app/agents-view.tsx | 12 ++++++++---- apps/web/src/components/app/message-timeline.tsx | 7 ++++++- apps/web/src/components/app/messages-panel.tsx | 9 +++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index c92edc92..9156d97c 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -333,9 +333,13 @@ export function AgentsView({ const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = useAgentMessages(focusedAgentId); + // Only mark read when the sidebar is actually open on the Messages tab. + // MediaSidebarContent stays mounted while closed and the active tab is + // persisted per-agent, so gating on the tab alone would silently clear + // unread state for agents whose last-used tab was Messages. useEffect(() => { - if (mediaActiveTab === "messages") markMessagesRead(); - }, [mediaActiveTab, markMessagesRead]); + if (mediaPanelOpen && mediaActiveTab === "messages") markMessagesRead(); + }, [mediaPanelOpen, mediaActiveTab, markMessagesRead]); const focusedAgentHasStream = focusedAgent?.hasStream ?? false; const focusedAgentStreamUrl = focusedAgentId @@ -715,9 +719,9 @@ export function AgentsView({ data-testid="toggle-media-sidebar" > - {unseenMediaCount > 0 ? ( + {unseenMediaCount + unreadMessageCount > 0 ? ( - {unseenMediaCount} + {unseenMediaCount + unreadMessageCount} ) : null} diff --git a/apps/web/src/components/app/message-timeline.tsx b/apps/web/src/components/app/message-timeline.tsx index 2aca8c69..6bb0b0f6 100644 --- a/apps/web/src/components/app/message-timeline.tsx +++ b/apps/web/src/components/app/message-timeline.tsx @@ -23,7 +23,12 @@ export function MessageTimeline({ · {new Date(m.createdAt).toLocaleTimeString()} {!m.delivered && ( - · not delivered + + · not delivered + )}
diff --git a/apps/web/src/components/app/messages-panel.tsx b/apps/web/src/components/app/messages-panel.tsx index 29168659..88878f2d 100644 --- a/apps/web/src/components/app/messages-panel.tsx +++ b/apps/web/src/components/app/messages-panel.tsx @@ -84,7 +84,7 @@ export function MessagesPanel({ } return ( -
+
{threads.map((thread) => (
@@ -112,7 +112,12 @@ export function MessagesPanel({ · {relativeTime(m.createdAt)} {!m.delivered && ( - · not delivered + + · not delivered + )}
From 79e822521a6b0a5aa2f0c14410582f90d8a22aef Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 10:17:20 -0600 Subject: [PATCH 16/21] =?UTF-8?q?refactor(messages):=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20split=20hooks,=20unify=20bubble=20UX,=20improve?= =?UTF-8?q?=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split useAgentMessages into three focused hooks (messages list, unread count, mark-read mutation). Unify MessageTimeline and MessageBubble into a single exported component used in both sidebar and history. Add collapsible agent-grouped threads with framer-motion animation and persisted collapse state. Add generic Bot fallback icon for unknown agent types. Replace non-reactive queryClient.getQueryData with reactive useQuery+select for agent type enrichment. Delete unused plan doc. Co-Authored-By: Claude Opus 4.6 --- .../components/app/agent-history-detail.tsx | 90 +- .../src/components/app/agent-type-icon.tsx | 23 +- apps/web/src/components/app/agents-view.tsx | 9 +- .../src/components/app/message-timeline.tsx | 41 - .../web/src/components/app/messages-panel.tsx | 230 ++- apps/web/src/hooks/use-agent-messages.ts | 69 +- apps/web/src/hooks/use-media-sidebar-state.ts | 2 + apps/web/src/lib/store.ts | 36 + .../plans/2026-06-30-agent-messages.md | 1287 ----------------- e2e/agent-messages.spec.ts | 2 +- 10 files changed, 378 insertions(+), 1411 deletions(-) delete mode 100644 apps/web/src/components/app/message-timeline.tsx delete mode 100644 docs/superpowers/plans/2026-06-30-agent-messages.md diff --git a/apps/web/src/components/app/agent-history-detail.tsx b/apps/web/src/components/app/agent-history-detail.tsx index 14d0e209..7f49f35e 100644 --- a/apps/web/src/components/app/agent-history-detail.tsx +++ b/apps/web/src/components/app/agent-history-detail.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; -import { ArrowLeft, Search } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowLeft, ChevronDown, ChevronRight, Search } from "lucide-react"; import { Bar, BarChart, XAxis, YAxis } from "recharts"; import { @@ -34,9 +35,92 @@ import { EventTimeline, FeedbackTimeline, } from "@/components/app/agent-history-timeline"; -import { MessageTimeline } from "@/components/app/message-timeline"; +import { + MessageBubble, + groupByParticipant, + type Thread, +} from "@/components/app/messages-panel"; import { type AgentMessage } from "@/hooks/use-agent-messages"; +// ── History thread group ──────────────────────────────────────────── + +function HistoryThreadGroup({ + thread, + agentId, +}: { + thread: Thread; + agentId: string; +}): JSX.Element { + const [expanded, setExpanded] = useState(true); + + return ( +
+ + + {expanded && ( + +
+ {thread.messages.map((m) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function HistoryMessages({ + messages, + agentId, +}: { + messages: AgentMessage[]; + agentId: string; +}): JSX.Element { + const threads = useMemo( + () => groupByParticipant(messages, agentId), + [messages, agentId] + ); + + return ( +
+ {threads.map((thread) => ( + + ))} +
+ ); +} + // ── Helpers ───────────────────────────────────────────────────────── function shortModelName(model: string): string { @@ -251,7 +335,7 @@ function DetailTabs({ )} {tab === "messages" && messages.length > 0 && ( - + )} {tab === "messages" && messages.length === 0 && (

diff --git a/apps/web/src/components/app/agent-type-icon.tsx b/apps/web/src/components/app/agent-type-icon.tsx index 15095775..140ec108 100644 --- a/apps/web/src/components/app/agent-type-icon.tsx +++ b/apps/web/src/components/app/agent-type-icon.tsx @@ -1,4 +1,4 @@ -import { Terminal as TerminalIcon } from "lucide-react"; +import { Bot, Terminal as TerminalIcon } from "lucide-react"; import { siClaude, siCursor } from "simple-icons"; import { cn } from "@/lib/utils"; @@ -38,9 +38,12 @@ function normalizeAgentType( if (type === "terminal") { return "terminal"; } - if (type === "codex" || !type) { + if (type === "codex") { return "codex"; } + if (!type) { + return "unknown"; + } return "unknown"; } @@ -59,7 +62,9 @@ export function AgentTypeIcon({ ? "Cursor" : normalizedType === "terminal" ? "Terminal" - : "Codex"; + : normalizedType === "codex" + ? "Codex" + : "Agent"; const statusClass = eventType ? eventColorClass[eventType] : ""; const baseClass = statusClass ? "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-300" @@ -94,6 +99,18 @@ export function AgentTypeIcon({ ); } + if (normalizedType === "unknown") { + return ( + + + ); + } + const logoPath = normalizedType === "claude" ? siClaude.path diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 9156d97c..c9d3dba3 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -65,7 +65,10 @@ import { type IdeType } from "@/lib/ide-types"; import { type CenterTab } from "@/lib/store"; import { cn } from "@/lib/utils"; import { useAgentActions } from "@/hooks/use-agent-actions"; -import { useAgentMessages } from "@/hooks/use-agent-messages"; +import { + useAgentUnreadCount, + useMarkMessagesRead, +} from "@/hooks/use-agent-messages"; import { useAgents } from "@/hooks/use-agents"; import { useMedia } from "@/hooks/use-media"; import { useMediaSidebarState } from "@/hooks/use-media-sidebar-state"; @@ -330,8 +333,8 @@ export function AgentsView({ refreshMedia, } = useMedia(focusedAgentId, mediaPanelOpen); - const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = - useAgentMessages(focusedAgentId); + const unreadMessageCount = useAgentUnreadCount(focusedAgentId); + const markMessagesRead = useMarkMessagesRead(focusedAgentId); // Only mark read when the sidebar is actually open on the Messages tab. // MediaSidebarContent stays mounted while closed and the active tab is diff --git a/apps/web/src/components/app/message-timeline.tsx b/apps/web/src/components/app/message-timeline.tsx deleted file mode 100644 index 6bb0b0f6..00000000 --- a/apps/web/src/components/app/message-timeline.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { type AgentMessage } from "@/hooks/use-agent-messages"; -import { cn } from "@/lib/utils"; - -export function MessageTimeline({ - messages, -}: { - messages: AgentMessage[]; -}): JSX.Element { - return ( -

- {messages.map((m) => ( -
-
- {m.senderName} - - - {m.recipientName} - - · - {new Date(m.createdAt).toLocaleTimeString()} - {!m.delivered && ( - - · not delivered - - )} -
-
- {m.content} -
-
- ))} -
- ); -} diff --git a/apps/web/src/components/app/messages-panel.tsx b/apps/web/src/components/app/messages-panel.tsx index 88878f2d..df8113fb 100644 --- a/apps/web/src/components/app/messages-panel.tsx +++ b/apps/web/src/components/app/messages-panel.tsx @@ -1,21 +1,38 @@ -import { useMemo } from "react"; -import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; +import { useMemo, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { AnimatePresence, motion } from "framer-motion"; +import { + MessageSquare, + ArrowUpRight, + ArrowDownLeft, + ChevronDown, + ChevronRight, + Copy, + Check, +} from "lucide-react"; +import { AgentTypeIcon } from "@/components/app/agent-type-icon"; +import type { Agent } from "@/components/app/types"; import { useAgentMessages, type AgentMessage, } from "@/hooks/use-agent-messages"; +import { useCopyText } from "@/hooks/use-copy"; +import { messageGroupsCollapsedAtomFamily } from "@/lib/store"; import { cn } from "@/lib/utils"; -type Thread = { +export type Thread = { otherId: string; otherName: string; + otherType?: string; messages: AgentMessage[]; }; -function groupByParticipant( +export function groupByParticipant( messages: AgentMessage[], - agentId: string + agentId: string, + agentMap?: Map ): Thread[] { const threads = new Map(); for (const m of messages) { @@ -26,7 +43,13 @@ function groupByParticipant( if (existing) { existing.messages.push(m); } else { - threads.set(otherId, { otherId, otherName, messages: [m] }); + const otherAgent = agentMap?.get(otherId); + threads.set(otherId, { + otherId, + otherName, + otherType: otherAgent?.type ?? undefined, + messages: [m], + }); } } return Array.from(threads.values()); @@ -41,16 +64,158 @@ function relativeTime(iso: string): string { return `${Math.round(secs / 86400)}d ago`; } +export function MessageBubble({ + message, + isSent, +}: { + message: AgentMessage; + isSent: boolean; +}): JSX.Element { + const [copied, copyText] = useCopyText(); + + return ( +
+
+
{message.content}
+
+ {isSent ? ( + + ) : ( + + )} + {relativeTime(message.createdAt)} + {!message.delivered && ( + + · not delivered + + )} + +
+
+
+ ); +} + +function ThreadGroup({ + thread, + agentId, + expanded, + onToggle, +}: { + thread: Thread; + agentId: string; + expanded: boolean; + onToggle: () => void; +}): JSX.Element { + return ( +
+ + + {expanded && ( + +
+ {thread.messages.map((m) => ( + + ))} +
+
+ )} +
+
+ ); +} + export function MessagesPanel({ agentId, }: { agentId: string | null; }): JSX.Element { const { messages, isLoading } = useAgentMessages(agentId); + const [collapsedIds, setCollapsedIds] = useAtom( + messageGroupsCollapsedAtomFamily(agentId ?? "") + ); + + const { data: agentMap } = useQuery>({ + queryKey: ["agents"], + select: (agents) => new Map(agents.map((a) => [a.id, a])), + }); const threads = useMemo( - () => (agentId ? groupByParticipant(messages, agentId) : []), - [messages, agentId] + () => (agentId ? groupByParticipant(messages, agentId, agentMap) : []), + [messages, agentId, agentMap] + ); + + const collapsedSet = useMemo(() => new Set(collapsedIds), [collapsedIds]); + + const toggleThread = useCallback( + (otherId: string) => { + setCollapsedIds((prev) => + prev.includes(otherId) + ? prev.filter((id) => id !== otherId) + : [...prev, otherId] + ); + }, + [setCollapsedIds] ); if (!agentId) { @@ -86,48 +251,13 @@ export function MessagesPanel({ return (
{threads.map((thread) => ( -
-
- {thread.otherName} -
-
- {thread.messages.map((m) => { - const isSent = m.senderAgentId === agentId; - return ( -
-
- {isSent ? ( - - ) : ( - - )} - {isSent ? "Sent" : "Received"} - · - {relativeTime(m.createdAt)} - {!m.delivered && ( - - · not delivered - - )} -
-
- {m.content} -
-
- ); - })} -
-
+ toggleThread(thread.otherId)} + /> ))}
); diff --git a/apps/web/src/hooks/use-agent-messages.ts b/apps/web/src/hooks/use-agent-messages.ts index a7ce2488..eefe984c 100644 --- a/apps/web/src/hooks/use-agent-messages.ts +++ b/apps/web/src/hooks/use-agent-messages.ts @@ -14,24 +14,47 @@ export type AgentMessage = { createdAt: string; }; -export function useAgentMessages(agentId: string | null): { +type MessagesPayload = { messages: AgentMessage[]; unreadCount: number; - markRead: () => void; +}; + +function messagesQueryKey(agentId: string | null) { + return ["messages", agentId]; +} + +export function useAgentMessages(agentId: string | null): { + messages: AgentMessage[]; isLoading: boolean; } { - const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: messagesQueryKey(agentId), + queryFn: async () => { + const payload = await api( + `/api/v1/agents/${agentId}/messages` + ); + return { + messages: payload.messages ?? [], + unreadCount: payload.unreadCount ?? 0, + }; + }, + enabled: !!agentId, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); + + return { messages: data?.messages ?? [], isLoading }; +} - const { data, isLoading } = useQuery<{ - messages: AgentMessage[]; - unreadCount: number; - }>({ - queryKey: ["messages", agentId], +export function useAgentUnreadCount(agentId: string | null): number { + const { data } = useQuery({ + queryKey: messagesQueryKey(agentId), queryFn: async () => { - const payload = await api<{ - messages: AgentMessage[]; - unreadCount: number; - }>(`/api/v1/agents/${agentId}/messages`); + const payload = await api( + `/api/v1/agents/${agentId}/messages` + ); return { messages: payload.messages ?? [], unreadCount: payload.unreadCount ?? 0, @@ -44,28 +67,28 @@ export function useAgentMessages(agentId: string | null): { refetchOnReconnect: true, }); - const messages = data?.messages ?? []; - // Server-derived (uses the partial unread index) so the badge stays accurate - // even though the message list is capped. - const unreadCount = data?.unreadCount ?? 0; + return data?.unreadCount ?? 0; +} + +export function useMarkMessagesRead(agentId: string | null): () => void { + const queryClient = useQueryClient(); - const markMutation = useMutation({ + const { mutate } = useMutation({ mutationFn: async () => { if (!agentId) return; await api(`/api/v1/agents/${agentId}/messages/read`, { method: "POST" }); }, onSuccess: () => { void queryClient.invalidateQueries({ - queryKey: ["messages", agentId], + queryKey: messagesQueryKey(agentId), exact: true, }); }, }); - const { mutate: markMutate } = markMutation; - const markRead = useCallback(() => { - if (agentId && unreadCount > 0) markMutate(); - }, [agentId, unreadCount, markMutate]); + const unreadCount = useAgentUnreadCount(agentId); - return { messages, unreadCount, markRead, isLoading }; + return useCallback(() => { + if (agentId && unreadCount > 0) mutate(); + }, [agentId, unreadCount, mutate]); } diff --git a/apps/web/src/hooks/use-media-sidebar-state.ts b/apps/web/src/hooks/use-media-sidebar-state.ts index 09682a54..ac1af592 100644 --- a/apps/web/src/hooks/use-media-sidebar-state.ts +++ b/apps/web/src/hooks/use-media-sidebar-state.ts @@ -9,6 +9,7 @@ import { reconcileDiffViewStateStorage, reconcileSplitPaneStateStorage, reconcileReviewDraftStorage, + reconcileMessageGroupsStateStorage, type MediaSidebarTab, } from "@/lib/store"; @@ -100,6 +101,7 @@ export function useMediaSidebarState({ reconcileDiffViewStateStorage(agentIds as string[]); reconcileSplitPaneStateStorage(agentIds as string[]); reconcileReviewDraftStorage(agentIds as string[]); + reconcileMessageGroupsStateStorage(agentIds as string[]); }, [agentIds]); useEffect(() => { diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index dd3a7d00..3abb03fa 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -327,3 +327,39 @@ export function reconcileSplitPaneStateStorage( keysToDelete.forEach((key) => window.localStorage.removeItem(key)); } + +// --------------------------------------------------------------------------- +// Message group collapsed state — per-agent set of collapsed thread IDs +// --------------------------------------------------------------------------- + +export const MESSAGE_GROUPS_STATE_STORAGE_PREFIX = + "dispatch:messageGroupsState:"; + +export const messageGroupsCollapsedAtomFamily = atomFamily((agentId: string) => + atomWithLocalStorage( + `${MESSAGE_GROUPS_STATE_STORAGE_PREFIX}${agentId}`, + [] + ) +); + +export function reconcileMessageGroupsStateStorage( + agentIds: Iterable +): void { + if (typeof window === "undefined") return; + + const liveAgentIds = new Set(agentIds); + const keysToDelete: string[] = []; + + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (!key?.startsWith(MESSAGE_GROUPS_STATE_STORAGE_PREFIX)) continue; + + const agentId = key + .slice(MESSAGE_GROUPS_STATE_STORAGE_PREFIX.length) + .trim(); + if (!agentId || liveAgentIds.has(agentId)) continue; + keysToDelete.push(key); + } + + keysToDelete.forEach((key) => window.localStorage.removeItem(key)); +} diff --git a/docs/superpowers/plans/2026-06-30-agent-messages.md b/docs/superpowers/plans/2026-06-30-agent-messages.md deleted file mode 100644 index be7936fc..00000000 --- a/docs/superpowers/plans/2026-06-30-agent-messages.md +++ /dev/null @@ -1,1287 +0,0 @@ -# Agent Messages Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Persist agent-to-agent messages and surface them in a per-agent sidebar "Messages" tab and a "Messages" tab in the History detail view. - -**Architecture:** Messages become persisted artifacts (like pins/brain/feedback). The existing `dispatch_send_message` handler keeps its ephemeral tmux delivery unchanged, but additionally writes a row to a new `agent_messages` table and publishes a `message.created` SSE event. The web app reads messages via new REST endpoints and renders them in two places, subscribing to SSE for live updates. - -**Tech Stack:** Fastify + PostgreSQL (`pg`) backend, `node-pg-migrate` migrations, React + Vite + TanStack Query frontend, Vitest (unit), Playwright (E2E). Package manager is `pnpm`; the server's `pnpm` scripts are Bun-based and auto-run `prepare:runtime-assets` (which re-embeds migration SQL) before check/test/build. - -## Global Constraints - -- Use `pnpm` for all commands (never `npm`). -- Same-repo-only send rule is **unchanged** — do not lift the cross-repo restriction in `sendMessage`. The schema stores repo roots so cross-repo can be enabled later without a migration. -- This feature is **view-only** in the UI — no composing/replying from the browser. -- Backend imports use relative paths with `.js` extensions (e.g. `../messages/store.js`). -- New migrations are picked up automatically by `prepare:runtime-assets`; no manual codegen step. Migrations run on server start (and via `repo_dev_up`). -- Follow existing patterns: DB query modules live under `apps/server/src//`; camelCase is exposed at the API boundary. -- Before marking complete: `pnpm run check`, `pnpm run finalize:web` (web changed), `pnpm run test` (backend changed), `pnpm run test:e2e`. - ---- - -### Task 1: `agent_messages` table + `MessageStore` query module - -**Files:** - -- Create: `apps/server/src/db/migrations/0029_agent-messages.sql` -- Create: `apps/server/src/messages/store.ts` -- Test: `apps/server/test/message-store.test.ts` - -**Interfaces:** - -- Produces: - - `type StoredMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; senderRepoRoot: string | null; recipientRepoRoot: string | null; createdAt: string }` - - `class MessageStore { constructor(pool: Pool); insertMessage(input: InsertMessageInput): Promise; listForAgent(agentId: string): Promise; countUnreadForAgent(agentId: string): Promise; markReadForAgent(agentId: string): Promise }` - - `type InsertMessageInput = Omit` - -- [ ] **Step 1: Write the migration SQL** - -Create `apps/server/src/db/migrations/0029_agent-messages.sql`: - -```sql --- Agent-to-agent messages: durable record of dispatch_send_message deliveries. --- Delivery itself stays ephemeral (tmux injection); this table is for viewing. - -CREATE TABLE IF NOT EXISTS agent_messages ( - id uuid PRIMARY KEY, - sender_agent_id text NOT NULL, - recipient_agent_id text NOT NULL, - sender_name text NOT NULL, - recipient_name text NOT NULL, - content text NOT NULL, - delivered boolean NOT NULL DEFAULT false, - read_at timestamptz, - -- Stored even though sender/recipient repo roots are equal today (same-repo - -- send rule). Present so cross-repo messaging becomes a config flip, not a - -- migration. - sender_repo_root text, - recipient_repo_root text, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS agent_messages_sender_created_idx - ON agent_messages (sender_agent_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS agent_messages_recipient_created_idx - ON agent_messages (recipient_agent_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS agent_messages_recipient_unread_idx - ON agent_messages (recipient_agent_id) WHERE read_at IS NULL; -``` - -- [ ] **Step 2: Write the failing test** - -Create `apps/server/test/message-store.test.ts` (modeled on `test/brain-store-queries.test.ts`): - -```ts -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import type { Pool } from "pg"; - -import { MessageStore } from "../src/messages/store.js"; -import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; - -let pool: Pool; -let store: MessageStore; - -const REPO = "/repo/msg-test"; -const A = "agt_msg_a"; -const B = "agt_msg_b"; - -beforeAll(async () => { - pool = await setupTestDb(); - await runTestMigrations(); - store = new MessageStore(pool); -}); - -afterAll(async () => { - await teardownTestDb(); -}); - -describe("MessageStore", () => { - it("inserts and lists messages for both participants", async () => { - const inserted = await store.insertMessage({ - senderAgentId: A, - recipientAgentId: B, - senderName: "Alice", - recipientName: "Bob", - content: "hello bob", - delivered: true, - senderRepoRoot: REPO, - recipientRepoRoot: REPO, - }); - expect(inserted.id).toBeTruthy(); - expect(inserted.readAt).toBeNull(); - expect(inserted.delivered).toBe(true); - - const forSender = await store.listForAgent(A); - const forRecipient = await store.listForAgent(B); - expect(forSender.map((m) => m.content)).toContain("hello bob"); - expect(forRecipient.map((m) => m.content)).toContain("hello bob"); - }); - - it("counts and clears unread for the recipient only", async () => { - await store.insertMessage({ - senderAgentId: B, - recipientAgentId: A, - senderName: "Bob", - recipientName: "Alice", - content: "hi alice", - delivered: true, - senderRepoRoot: REPO, - recipientRepoRoot: REPO, - }); - - // A received one message ("hi alice") -> unread 1. A sent one -> not counted. - expect(await store.countUnreadForAgent(A)).toBe(1); - - const cleared = await store.markReadForAgent(A); - expect(cleared).toBe(1); - expect(await store.countUnreadForAgent(A)).toBe(0); - // Marking A read must not touch B's unread. - expect(await store.countUnreadForAgent(B)).toBe(1); - }); -}); -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `cd apps/server && pnpm exec vitest run test/message-store.test.ts` -Expected: FAIL — `Cannot find module '../src/messages/store.js'` (and/or table does not exist). - -- [ ] **Step 4: Implement `MessageStore`** - -Create `apps/server/src/messages/store.ts`: - -```ts -import { randomUUID } from "node:crypto"; -import type { Pool } from "pg"; - -export type StoredMessage = { - id: string; - senderAgentId: string; - recipientAgentId: string; - senderName: string; - recipientName: string; - content: string; - delivered: boolean; - readAt: string | null; - senderRepoRoot: string | null; - recipientRepoRoot: string | null; - createdAt: string; -}; - -export type InsertMessageInput = Omit< - StoredMessage, - "id" | "readAt" | "createdAt" ->; - -type Row = { - id: string; - sender_agent_id: string; - recipient_agent_id: string; - sender_name: string; - recipient_name: string; - content: string; - delivered: boolean; - read_at: Date | null; - sender_repo_root: string | null; - recipient_repo_root: string | null; - created_at: Date; -}; - -function toStoredMessage(row: Row): StoredMessage { - return { - id: row.id, - senderAgentId: row.sender_agent_id, - recipientAgentId: row.recipient_agent_id, - senderName: row.sender_name, - recipientName: row.recipient_name, - content: row.content, - delivered: row.delivered, - readAt: row.read_at ? row.read_at.toISOString() : null, - senderRepoRoot: row.sender_repo_root, - recipientRepoRoot: row.recipient_repo_root, - createdAt: row.created_at.toISOString(), - }; -} - -export class MessageStore { - constructor(private readonly pool: Pool) {} - - async insertMessage(input: InsertMessageInput): Promise { - const result = await this.pool.query( - `INSERT INTO agent_messages - (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, - content, delivered, sender_repo_root, recipient_repo_root) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *`, - [ - randomUUID(), - input.senderAgentId, - input.recipientAgentId, - input.senderName, - input.recipientName, - input.content, - input.delivered, - input.senderRepoRoot, - input.recipientRepoRoot, - ] - ); - return toStoredMessage(result.rows[0]); - } - - async listForAgent(agentId: string): Promise { - const result = await this.pool.query( - `SELECT * FROM agent_messages - WHERE sender_agent_id = $1 OR recipient_agent_id = $1 - ORDER BY created_at ASC`, - [agentId] - ); - return result.rows.map(toStoredMessage); - } - - async countUnreadForAgent(agentId: string): Promise { - const result = await this.pool.query<{ count: string }>( - `SELECT COUNT(*)::text AS count FROM agent_messages - WHERE recipient_agent_id = $1 AND read_at IS NULL`, - [agentId] - ); - return Number(result.rows[0].count); - } - - async markReadForAgent(agentId: string): Promise { - const result = await this.pool.query( - `UPDATE agent_messages SET read_at = now() - WHERE recipient_agent_id = $1 AND read_at IS NULL`, - [agentId] - ); - return result.rowCount ?? 0; - } -} -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `cd apps/server && pnpm exec vitest run test/message-store.test.ts` -Expected: PASS (both tests). `prepare:runtime-assets` re-embeds the new migration automatically when the test DB migrates. - -- [ ] **Step 6: Commit** - -```bash -git add apps/server/src/db/migrations/0029_agent-messages.sql apps/server/src/messages/store.ts apps/server/test/message-store.test.ts -git commit -m "feat(messages): add agent_messages table and MessageStore" -``` - ---- - -### Task 2: Persist + broadcast in the `sendMessage` handler - -**Files:** - -- Modify: `apps/server/src/server/mcp-handlers.ts` (the `sendMessage` method, ~lines 847–947) -- Test: `apps/server/test/mcp-handlers.test.ts` (extend the existing `dispatch_send_message` / `sendMessage` coverage) - -**Interfaces:** - -- Consumes: `MessageStore` from Task 1; existing `pool`, `publishUiEvent`, `appLog`, `sendAgentPrompt`, `resolveRepoRoot` already in the handler factory scope. -- Produces: a persisted row per send + a `{ type: "message.created"; senderAgentId; recipientAgentId }` UI event. - -- [ ] **Step 1: Add the import** - -At the top of `apps/server/src/server/mcp-handlers.ts`, add with the other imports: - -```ts -import { MessageStore } from "../messages/store.js"; -``` - -- [ ] **Step 2: Rewrite the delivery/return block of `sendMessage`** - -Replace the current try/catch + return at the end of `sendMessage` (the block from `const envelope = ...` return, currently ~lines 920–946) with: - -```ts -const envelope = JSON.stringify({ - from: sender.name, - senderId: agentId, - message: input.message, - replyTarget: agentId, -}); -const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nReply with dispatch_send_message using the replyTarget above.`; - -// Deliver first: a persistence failure must never block delivery. -let delivered = false; -let deliveryError: unknown = null; -try { - await sendAgentPrompt(target.id, prompt, { swallowFailure: false }); - delivered = true; -} catch (err) { - deliveryError = err; - appLog.error( - { err, senderId: agentId, targetId: target.id }, - "dispatch_send_message: tmux delivery failed" - ); -} - -// Record the message (including failed deliveries) so it is viewable. -const messageStore = new MessageStore(pool); -await messageStore - .insertMessage({ - senderAgentId: agentId, - recipientAgentId: target.id, - senderName: sender.name, - recipientName: target.name, - content: input.message, - delivered, - senderRepoRoot, - // Same repo today (send rule); stored for future cross-repo support. - recipientRepoRoot: senderRepoRoot, - }) - .catch((err) => - appLog.error( - { err, senderId: agentId, targetId: target.id }, - "dispatch_send_message: failed to persist message" - ) - ); - -publishUiEvent({ - type: "message.created", - senderAgentId: agentId, - recipientAgentId: target.id, -}); - -if (!delivered) { - throw deliveryError instanceof Error - ? deliveryError - : new Error(`Failed to deliver message to "${target.name}".`); -} - -appLog.info( - { senderId: agentId, targetId: target.id }, - "dispatch_send_message: delivered" -); -return { - delivered: true, - targetAgentId: target.id, - targetAgentName: target.name, -}; -``` - -- [ ] **Step 3: Add a failing test for persistence** - -In `apps/server/test/mcp-handlers.test.ts`, find the existing test(s) exercising the `sendMessage` handler (search the file for `sendMessage` / `dispatch_send_message`). Reuse that harness (same handler-construction helper and mocked `agentManager`/`sendAgentPrompt`/`publishUiEvent`) to add: - -```ts -it("persists a row and emits message.created on send", async () => { - // Arrange the harness so two running agents share a repo root and - // sendAgentPrompt resolves (see the existing sendMessage test for setup). - // publishUiEvent is a vi.fn() captured by the harness. - - await handlers.sendMessage("agt_sender", { - target: "agt_receiver", - message: "ping", - senderRepoRoot: "/repo", - }); - - const rows = await pool.query( - "SELECT sender_agent_id, recipient_agent_id, content, delivered FROM agent_messages WHERE content = $1", - ["ping"] - ); - expect(rows.rows).toHaveLength(1); - expect(rows.rows[0].delivered).toBe(true); - - expect(publishUiEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: "message.created" }) - ); -}); -``` - -Note: if the existing `mcp-handlers.test.ts` harness does not already provide a real `pool` (it uses heavy mocks), wire the handler's `pool` to a `setupTestDb()` pool + `runTestMigrations()` in this test's `beforeAll` (as `test/message-store.test.ts` does) so the row is queryable. If integrating a real DB into that mock-heavy file proves noisy, place this test in a new `apps/server/test/send-message-persistence.test.ts` that constructs the handlers with a real pool and stubs only `agentManager`, `sendAgentPrompt`, and `publishUiEvent`. - -- [ ] **Step 4: Run the test to verify it fails, then passes** - -Run: `cd apps/server && pnpm exec vitest run test/mcp-handlers.test.ts` -Expected: FAILS before Step 2's change is in place (no row / no event); PASSES after. - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/server/mcp-handlers.ts apps/server/test/mcp-handlers.test.ts -git commit -m "feat(messages): persist and broadcast messages on send" -``` - ---- - -### Task 3: REST endpoints for reading + marking messages read - -**Files:** - -- Create: `apps/server/src/routes/messages.ts` -- Modify: `apps/server/src/server.ts` (register the new routes near `registerMediaRoutes`, ~line 572) -- Test: `apps/server/test/messages-routes.test.ts` - -**Interfaces:** - -- Consumes: `MessageStore` (Task 1); Fastify `app`, `pool`, `agentManager`, `publishUiEvent` (as passed to `registerMediaRoutes`). -- Produces: - - `GET /api/v1/agents/:id/messages` → `{ messages: StoredMessage[] }` - - `POST /api/v1/agents/:id/messages/read` → `{ ok: true; updated: number }`, and publishes `{ type: "message.read"; agentId }`. - - `registerMessagesRoutes(app, deps)` - -- [ ] **Step 1: Write the failing test** - -Create `apps/server/test/messages-routes.test.ts`. Model the app bootstrap on `test/media-routes.test.ts` (use `test/helpers/inject-app.ts` if it provides an app factory). Minimal shape: - -```ts -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import Fastify, { type FastifyInstance } from "fastify"; -import type { Pool } from "pg"; - -import { registerMessagesRoutes } from "../src/routes/messages.js"; -import { MessageStore } from "../src/messages/store.js"; -import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; - -let app: FastifyInstance; -let pool: Pool; -const A = "agt_route_a"; -const B = "agt_route_b"; - -beforeAll(async () => { - pool = await setupTestDb(); - await runTestMigrations(); - // Seed agents so the :id existence check passes. - await pool.query( - "INSERT INTO agents (id, name, type, status, cwd) VALUES ($1,$2,'claude','running','/repo'), ($3,$4,'claude','running','/repo') ON CONFLICT (id) DO NOTHING", - [A, "Alice", B, "Bob"] - ); - const store = new MessageStore(pool); - await store.insertMessage({ - senderAgentId: A, - recipientAgentId: B, - senderName: "Alice", - recipientName: "Bob", - content: "hi", - delivered: true, - senderRepoRoot: "/repo", - recipientRepoRoot: "/repo", - }); - - app = Fastify(); - await registerMessagesRoutes(app, { - pool, - agentManager: { getAgent: async (id: string) => ({ id }) } as never, - publishUiEvent: () => {}, - }); - await app.ready(); -}); - -afterAll(async () => { - await app.close(); - await teardownTestDb(); -}); - -describe("messages routes", () => { - it("lists messages for an agent", async () => { - const res = await app.inject({ - method: "GET", - url: `/api/v1/agents/${B}/messages`, - }); - expect(res.statusCode).toBe(200); - const body = res.json() as { messages: Array<{ content: string }> }; - expect(body.messages.map((m) => m.content)).toContain("hi"); - }); - - it("marks messages read", async () => { - const res = await app.inject({ - method: "POST", - url: `/api/v1/agents/${B}/messages/read`, - }); - expect(res.statusCode).toBe(200); - expect((res.json() as { updated: number }).updated).toBe(1); - }); - - it("404s for unknown agent", async () => { - const res = await app.inject({ - method: "GET", - url: `/api/v1/agents/agt_missing/messages`, - }); - expect(res.statusCode).toBe(404); - }); -}); -``` - -(If `test/helpers/inject-app.ts` exposes a shared app builder used by `media-routes.test.ts`, prefer it over hand-building Fastify — match the sibling test's style.) - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `cd apps/server && pnpm exec vitest run test/messages-routes.test.ts` -Expected: FAIL — `Cannot find module '../src/routes/messages.js'`. - -- [ ] **Step 3: Implement the routes** - -Create `apps/server/src/routes/messages.ts`: - -```ts -import type { FastifyInstance } from "fastify"; -import type { Pool } from "pg"; - -import type { AgentManager } from "../agents/manager.js"; -import { MessageStore } from "../messages/store.js"; - -type MessagesRouteDeps = { - pool: Pool; - agentManager: AgentManager; - publishUiEvent: (event: unknown) => void; -}; - -export async function registerMessagesRoutes( - app: FastifyInstance, - deps: MessagesRouteDeps -): Promise { - const store = new MessageStore(deps.pool); - - app.get("/api/v1/agents/:id/messages", async (request, reply) => { - const id = (request.params as { id?: string }).id ?? ""; - const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ - id, - ]); - if (exists.rows.length === 0) { - return reply.code(404).send({ error: "Agent not found." }); - } - const messages = await store.listForAgent(id); - return { messages }; - }); - - app.post("/api/v1/agents/:id/messages/read", async (request, reply) => { - const id = (request.params as { id?: string }).id ?? ""; - const exists = await deps.pool.query("SELECT 1 FROM agents WHERE id = $1", [ - id, - ]); - if (exists.rows.length === 0) { - return reply.code(404).send({ error: "Agent not found." }); - } - const updated = await store.markReadForAgent(id); - if (updated > 0) { - deps.publishUiEvent({ type: "message.read", agentId: id }); - } - return { ok: true, updated }; - }); -} -``` - -- [ ] **Step 4: Register the routes in `server.ts`** - -In `apps/server/src/server.ts`, add the import alongside the other route imports (near line 107): - -```ts -import { registerMessagesRoutes } from "./routes/messages.js"; -``` - -And register it right after the `registerMediaRoutes(...)` call (after ~line 578): - -```ts -await registerMessagesRoutes(app, { - pool, - agentManager, - publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), -}); -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `cd apps/server && pnpm exec vitest run test/messages-routes.test.ts` -Expected: PASS (all three cases). - -- [ ] **Step 6: Commit** - -```bash -git add apps/server/src/routes/messages.ts apps/server/src/server.ts apps/server/test/messages-routes.test.ts -git commit -m "feat(messages): add read + mark-read REST endpoints" -``` - ---- - -### Task 4: Frontend data layer — `useAgentMessages` hook + SSE wiring - -**Files:** - -- Create: `apps/web/src/hooks/use-agent-messages.ts` -- Modify: `apps/web/src/hooks/use-sse.ts` (add `message.created` and `message.read` to the `UiEvent` union + handlers) - -**Interfaces:** - -- Produces: - - `type AgentMessage = { id: string; senderAgentId: string; recipientAgentId: string; senderName: string; recipientName: string; content: string; delivered: boolean; readAt: string | null; createdAt: string }` - - `useAgentMessages(agentId: string | null): { messages: AgentMessage[]; unreadCount: number; markRead: () => void }` - -- [ ] **Step 1: Add the SSE event types** - -In `apps/web/src/hooks/use-sse.ts`, add two members to the `UiEvent` union (after the `brain.changed` entry, ~line 42): - -```ts - | { type: "message.created"; senderAgentId: string; recipientAgentId: string } - | { type: "message.read"; agentId: string } -``` - -- [ ] **Step 2: Handle the events** - -In `use-sse.ts`, inside `handleSSEMessage`, add after the `brain.changed` block (~line 192): - -```ts -if (payload.type === "message.created") { - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.senderAgentId], - exact: true, - }); - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.recipientAgentId], - exact: true, - }); - return; -} - -if (payload.type === "message.read") { - void queryClient.invalidateQueries({ - queryKey: ["messages", payload.agentId], - exact: true, - }); - return; -} -``` - -- [ ] **Step 3: Write the hook** - -Create `apps/web/src/hooks/use-agent-messages.ts`: - -```ts -import { useCallback, useMemo } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api } from "@/lib/api"; - -export type AgentMessage = { - id: string; - senderAgentId: string; - recipientAgentId: string; - senderName: string; - recipientName: string; - content: string; - delivered: boolean; - readAt: string | null; - createdAt: string; -}; - -export function useAgentMessages(agentId: string | null) { - const queryClient = useQueryClient(); - - const { data: messages = [] } = useQuery({ - queryKey: ["messages", agentId], - queryFn: async () => { - const payload = await api<{ messages: AgentMessage[] }>( - `/api/v1/agents/${agentId}/messages` - ); - return payload.messages ?? []; - }, - enabled: !!agentId, - staleTime: 0, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, - }); - - const unreadCount = useMemo( - () => - messages.filter( - (m) => m.recipientAgentId === agentId && m.readAt === null - ).length, - [messages, agentId] - ); - - const markMutation = useMutation({ - mutationFn: async () => { - if (!agentId) return; - await api(`/api/v1/agents/${agentId}/messages/read`, { method: "POST" }); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ["messages", agentId], - exact: true, - }); - }, - }); - - const markRead = useCallback(() => { - if (agentId && unreadCount > 0) markMutation.mutate(); - }, [agentId, unreadCount, markMutation]); - - return { messages, unreadCount, markRead }; -} -``` - -Note: confirm `api()` accepts a second `RequestInit`-style argument for `{ method: "POST" }` by checking `apps/web/src/lib/api.ts`. If its signature differs (e.g. `api.post(url)`), use that form instead — match the existing call sites (e.g. the media "seen" POST or notifications ack). - -- [ ] **Step 4: Type-check** - -Run: `pnpm run check` -Expected: PASS (no type errors from the new union members or hook). - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/hooks/use-agent-messages.ts apps/web/src/hooks/use-sse.ts -git commit -m "feat(messages): add useAgentMessages hook and SSE wiring" -``` - ---- - -### Task 5: `MessagesPanel` component (per-agent conversation threads) - -**Files:** - -- Create: `apps/web/src/components/app/messages-panel.tsx` - -**Interfaces:** - -- Consumes: `AgentMessage`, `useAgentMessages` (Task 4). -- Produces: `MessagesPanel({ agentId }: { agentId: string | null }): JSX.Element` - -- [ ] **Step 1: Implement the panel** - -Create `apps/web/src/components/app/messages-panel.tsx` (grouping + empty/loading states modeled on `brain-tab-content.tsx`): - -```tsx -import { useMemo } from "react"; -import { MessageSquare, ArrowUpRight, ArrowDownLeft } from "lucide-react"; - -import { - useAgentMessages, - type AgentMessage, -} from "@/hooks/use-agent-messages"; -import { cn } from "@/lib/utils"; - -type Thread = { - otherId: string; - otherName: string; - messages: AgentMessage[]; -}; - -function groupByParticipant( - messages: AgentMessage[], - agentId: string -): Thread[] { - const threads = new Map(); - for (const m of messages) { - const isSent = m.senderAgentId === agentId; - const otherId = isSent ? m.recipientAgentId : m.senderAgentId; - const otherName = isSent ? m.recipientName : m.senderName; - const existing = threads.get(otherId); - if (existing) { - existing.messages.push(m); - } else { - threads.set(otherId, { otherId, otherName, messages: [m] }); - } - } - return Array.from(threads.values()); -} - -function relativeTime(iso: string): string { - const then = new Date(iso).getTime(); - const secs = Math.round((Date.now() - then) / 1000); - if (secs < 60) return `${secs}s ago`; - if (secs < 3600) return `${Math.round(secs / 60)}m ago`; - if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; - return `${Math.round(secs / 86400)}d ago`; -} - -export function MessagesPanel({ - agentId, -}: { - agentId: string | null; -}): JSX.Element { - const { messages } = useAgentMessages(agentId); - - const threads = useMemo( - () => (agentId ? groupByParticipant(messages, agentId) : []), - [messages, agentId] - ); - - if (!agentId) { - return ( -
-
- -
No agent selected.
-
-
- ); - } - - if (messages.length === 0) { - return ( -
-
- -
This agent has no messages yet.
-
-
- ); - } - - return ( -
- {threads.map((thread) => ( -
-
- {thread.otherName} -
-
- {thread.messages.map((m) => { - const isSent = m.senderAgentId === agentId; - return ( -
-
- {isSent ? ( - - ) : ( - - )} - {isSent ? "Sent" : "Received"} - · - {relativeTime(m.createdAt)} - {!m.delivered && ( - · not delivered - )} -
-
- {m.content} -
-
- ); - })} -
-
- ))} -
- ); -} -``` - -Note: `Date.now()`/`new Date()` are fine in browser code — this is the web app, not a workflow script. - -- [ ] **Step 2: Type-check** - -Run: `pnpm run check` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/app/messages-panel.tsx -git commit -m "feat(messages): add MessagesPanel with conversation threads" -``` - ---- - -### Task 6: Wire the Messages tab into the sidebar - -**Files:** - -- Modify: `apps/web/src/lib/store.ts` (extend `MediaSidebarTab`, ~line 102) -- Modify: `apps/web/src/components/app/media-sidebar.tsx` (add tab button + mounted panel; extend props) -- Modify: `apps/web/src/components/app/agents-view.tsx` (compute unread count, pass props, mark-read on tab open) - -**Interfaces:** - -- Consumes: `MessagesPanel` (Task 5), `useAgentMessages` (Task 4). -- Produces: a fourth sidebar tab `"messages"` with an unread badge. - -- [ ] **Step 1: Extend the tab union** - -In `apps/web/src/lib/store.ts` line 102: - -```ts -export type MediaSidebarTab = "pins" | "media" | "brain" | "messages"; -``` - -- [ ] **Step 2: Add the tab button + panel in `media-sidebar.tsx`** - -Add `unreadMessageCount: number` to the `MediaSidebarContentProps` signature (the destructured params at ~line 388 and the `& { unseenMediaCount: number }` intersection) — extend to `& { unseenMediaCount: number; unreadMessageCount: number }`, and destructure `unreadMessageCount`. - -Add the import at the top: - -```tsx -import { MessagesPanel } from "@/components/app/messages-panel"; -``` - -Add a new tab button after the Brain button (after ~line 472), following the exact pattern of the Media button (destructive badge): - -```tsx - -``` - -Add the mounted panel after the Brain panel `
` (after ~line 552): - -```tsx -
- -
-``` - -- [ ] **Step 3: Wire data + mark-read in `agents-view.tsx`** - -In `apps/web/src/components/app/agents-view.tsx`: - -- Import the hook near the other hook imports: - -```tsx -import { useAgentMessages } from "@/hooks/use-agent-messages"; -``` - -- Call it alongside `useMedia(...)` (~line 230) using the same focused agent id: - -```tsx -const { unreadCount: unreadMessageCount, markRead: markMessagesRead } = - useAgentMessages(focusedAgentId); -``` - -- Mark messages read when the Messages tab becomes active. Add an effect near the sidebar state (after `useMediaSidebarState(...)`, ~line 161): - -```tsx -useEffect(() => { - if (mediaActiveTab === "messages") markMessagesRead(); -}, [mediaActiveTab, markMessagesRead]); -``` - -Use whatever the local variable for the active tab is called from `useMediaSidebarState` (inspect the destructure — it is the `activeTab` value passed to `MediaSidebarContent`). If `useEffect` is not yet imported in this file, add it to the React import. - -- Pass `unreadMessageCount` to **both** `MediaSidebarContent` usages (the two render sites at ~line 704 and the header/props at ~666 already pass `unseenMediaCount`). Add: - -```tsx -unreadMessageCount = { unreadMessageCount }; -``` - -- [ ] **Step 4: Verify build + type-check** - -Run: `pnpm run check && pnpm run finalize:web` -Expected: PASS (type check + production build). - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/store.ts apps/web/src/components/app/media-sidebar.tsx apps/web/src/components/app/agents-view.tsx -git commit -m "feat(messages): add Messages tab to the agent sidebar" -``` - ---- - -### Task 7: History detail — backend query + Messages tab - -**Files:** - -- Modify: `apps/server/src/routes/activity.ts` (add a `messages` query to `GET /api/v1/history/agents/:id`, ~lines 582–678) -- Modify: `apps/web/src/hooks/use-agent-history.ts` (add `messages` to `HistoryAgentDetail`) -- Modify: `apps/web/src/components/app/agent-history-detail.tsx` (add `"messages"` to `DetailTab`, tab button, panel) -- Create: `apps/web/src/components/app/message-timeline.tsx` -- Test: `apps/server/test/activity-routes.test.ts` (extend the history-detail coverage) - -**Interfaces:** - -- Consumes: `agent_messages` table (Task 1); `AgentMessage` type (Task 4). -- Produces: `HistoryAgentDetail.messages: AgentMessage[]`; a `MessageTimeline` component. - -- [ ] **Step 1: Add the messages query to the history detail endpoint** - -In `apps/server/src/routes/activity.ts`, add a sixth query to the `Promise.all([...])` in `GET /api/v1/history/agents/:id` (after the `feedbackResult` query, ~line 661): - -```ts - deps.pool.query<{ - id: string; - senderAgentId: string; - recipientAgentId: string; - senderName: string; - recipientName: string; - content: string; - delivered: boolean; - readAt: string | null; - createdAt: string; - }>( - `SELECT id, - sender_agent_id AS "senderAgentId", - recipient_agent_id AS "recipientAgentId", - sender_name AS "senderName", - recipient_name AS "recipientName", - content, delivered, - read_at AS "readAt", - created_at AS "createdAt" - FROM agent_messages - WHERE sender_agent_id = $1 OR recipient_agent_id = $1 - ORDER BY created_at ASC - LIMIT 500`, - [id] - ), -``` - -Update the destructure to capture it: - -```ts - const [ - eventsResult, - tokenResult, - tokenByModelResult, - mediaResult, - feedbackResult, - messagesResult, - ] = await Promise.all([ -``` - -And add to the returned object (after `feedback: feedbackResult.rows,`): - -```ts - messages: messagesResult.rows, -``` - -- [ ] **Step 2: Add a failing backend test** - -In `apps/server/test/activity-routes.test.ts`, find the existing test for `GET /api/v1/history/agents/:id`. Seed a message for the agent under test and assert it comes back: - -```ts -it("includes agent messages in the history detail", async () => { - // Seed a message where the history agent (agt_history) is the sender. - await pool.query( - `INSERT INTO agent_messages - (id, sender_agent_id, recipient_agent_id, sender_name, recipient_name, content, delivered) - VALUES (gen_random_uuid(), $1, 'agt_other', 'Hist', 'Other', 'history msg', true)`, - ["agt_history"] - ); - const res = await app.inject({ - method: "GET", - url: `/api/v1/history/agents/agt_history`, - }); - expect(res.statusCode).toBe(200); - const body = res.json() as { messages: Array<{ content: string }> }; - expect(body.messages.map((m) => m.content)).toContain("history msg"); -}); -``` - -Use the agent id that the existing detail test already seeds (adjust `agt_history` / `agt_other` to match that file's fixtures). `gen_random_uuid()` requires `pgcrypto`; if the test DB lacks it, insert an explicit uuid string instead. - -- [ ] **Step 3: Run backend tests (fail → pass)** - -Run: `cd apps/server && pnpm exec vitest run test/activity-routes.test.ts` -Expected: FAILS before Step 1's change (no `messages` key), PASSES after. - -- [ ] **Step 4: Extend the frontend detail type** - -In `apps/web/src/hooks/use-agent-history.ts`, import the message type and add it to `HistoryAgentDetail`: - -```ts -import { type AgentMessage } from "@/hooks/use-agent-messages"; -``` - -Add to the `HistoryAgentDetail` type (after `feedback: HistoryFeedbackItem[];`): - -```ts - messages: AgentMessage[]; -``` - -- [ ] **Step 5: Create the `MessageTimeline` component** - -Create `apps/web/src/components/app/message-timeline.tsx`: - -```tsx -import { type AgentMessage } from "@/hooks/use-agent-messages"; -import { cn } from "@/lib/utils"; - -export function MessageTimeline({ - messages, -}: { - messages: AgentMessage[]; -}): JSX.Element { - return ( -
- {messages.map((m) => ( -
-
- {m.senderName} - - - {m.recipientName} - - · - {new Date(m.createdAt).toLocaleTimeString()} - {!m.delivered && ( - · not delivered - )} -
-
- {m.content} -
-
- ))} -
- ); -} -``` - -- [ ] **Step 6: Add the Messages tab to the detail view** - -In `apps/web/src/components/app/agent-history-detail.tsx`: - -- Import the timeline + type near the top: - -```tsx -import { MessageTimeline } from "@/components/app/message-timeline"; -import { type AgentMessage } from "@/hooks/use-agent-messages"; -``` - -- Extend the `DetailTab` type (line 97): - -```tsx -type DetailTab = "events" | "media" | "pins" | "feedback" | "messages"; -``` - -- Add `messages` to the `DetailTabs` props (the destructure + type block at ~lines 99–113): - -```tsx - messages, -``` - -```tsx - messages: AgentMessage[]; -``` - -- Add to the `tabs` array (after the `feedback` entry, ~line 139): - -```tsx - { key: "messages", label: "Messages", count: messages.length }, -``` - -- Add the panel in the content area (after the `feedback` block, ~line 246): - -```tsx -{ - tab === "messages" && messages.length > 0 && ( - - ); -} -{ - tab === "messages" && messages.length === 0 && ( -

- No messages recorded. -

- ); -} -``` - -- Find where `DetailTabs` is rendered by `AgentHistoryDetail` (below line 269, using `data.feedback` etc.) and pass the new prop: - -```tsx - messages={data.messages} -``` - -- [ ] **Step 7: Verify build + type-check** - -Run: `pnpm run check && pnpm run finalize:web` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add apps/server/src/routes/activity.ts apps/server/test/activity-routes.test.ts apps/web/src/hooks/use-agent-history.ts apps/web/src/components/app/agent-history-detail.tsx apps/web/src/components/app/message-timeline.tsx -git commit -m "feat(messages): surface messages in History detail view" -``` - ---- - -### Task 8: End-to-end test + final verification - -**Files:** - -- Create: `e2e/agent-messages.spec.ts` (match the existing e2e spec conventions in `e2e/`) - -**Interfaces:** - -- Consumes: the full stack from Tasks 1–7. - -- [ ] **Step 1: Inspect existing e2e patterns** - -Read one existing spec under `e2e/` (e.g. how it launches agents, selects one, and opens the sidebar) so the new spec matches helpers, fixtures, and readiness signals. Per repo rules: use `waitUntil: "domcontentloaded"` and wait on concrete UI signals — never `networkidle`. - -- [ ] **Step 2: Write the E2E spec** - -Create `e2e/agent-messages.spec.ts`. The flow: - -1. Start from the isolated e2e stack (the suite provisions its own DB + server). -2. Create/launch two agents in the same repo (reuse the suite's agent-creation helper). -3. Trigger a message from agent A to agent B (via the MCP tool path the suite uses for agent actions, or by seeding through the API the suite exposes). -4. Select agent B, open the sidebar, click the **Messages** tab (`data-testid="message-item"` becomes visible), assert the message content is shown and the unread badge appears then clears after opening. -5. Navigate to `/activity/history/`, open the **Messages** tab, assert `data-testid="history-message"` shows the content. -6. Capture a screenshot and publish it via the `dispatch_share` MCP tool (do not leave it local). - -Use the assertions/selectors the sibling specs use; the two `data-testid`s added in Tasks 5 and 7 (`message-item`, `history-message`) are the anchors. - -- [ ] **Step 3: Run the E2E suite** - -Run: `pnpm run test:e2e` -Expected: PASS, including the new spec. (The suite spins up its own isolated DB + server; safe alongside other agents.) - -- [ ] **Step 4: Full pre-completion checks** - -Run, in order, and fix any failures: - -```bash -pnpm run check -pnpm run finalize:web -pnpm run test -pnpm run test:e2e -``` - -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add e2e/agent-messages.spec.ts -git commit -m "test(messages): e2e coverage for sidebar + history messages" -``` - ---- - -## Notes for the implementer - -- **Ordering matters:** Tasks 1→3 are backend and independently testable; 4→7 are frontend and depend on the endpoints from 1–3; Task 8 validates the whole stack. Do them in order. -- **Same-repo rule is intentional.** Do not "fix" the repo-root filter in `sendMessage` — cross-repo is explicitly out of scope. The `sender_repo_root`/`recipient_repo_root` columns exist so that lifting the rule later needs no migration. -- **Delivery-first is intentional.** In Task 2, injection happens before the DB write and the write is wrapped in `.catch` so a persistence failure never stops an agent from receiving its message. -- **`api()` signature:** Task 4 assumes `api(url, init)` supports POST. Verify against `apps/web/src/lib/api.ts` and existing POST call sites (media "seen", notifications ack) before finalizing the mutation. diff --git a/e2e/agent-messages.spec.ts b/e2e/agent-messages.spec.ts index 359d0f07..bb26c521 100644 --- a/e2e/agent-messages.spec.ts +++ b/e2e/agent-messages.spec.ts @@ -108,7 +108,7 @@ test.describe("Agent messages", () => { await expect(historyMessagesTabButton).toBeVisible(); await historyMessagesTabButton.click(); - const historyMessages = page.getByTestId("history-message"); + const historyMessages = page.getByTestId("message-item"); await expect( historyMessages.filter({ hasText: receivedContent }) ).toBeVisible(); From 9f305c77d0e8a984cf63c7eb7d01b09ebf075741 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 12:08:39 -0600 Subject: [PATCH 17/21] fix(messages): address persona review round 1 findings Fix history query to return newest 500 messages (subquery pattern), resolve recipientRepoRoot from target agent's cwd, add tab overflow scrolling for sidebar and history tabs, improve copy button touch target. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/routes/activity.ts | 11 +++++++---- apps/server/src/server/mcp-handlers.ts | 6 ++++-- apps/web/src/components/app/agent-history-detail.tsx | 4 ++-- apps/web/src/components/app/media-sidebar.tsx | 10 +++++----- apps/web/src/components/app/messages-panel.tsx | 3 ++- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/server/src/routes/activity.ts b/apps/server/src/routes/activity.ts index f887b579..7ea00c9e 100644 --- a/apps/server/src/routes/activity.ts +++ b/apps/server/src/routes/activity.ts @@ -679,10 +679,13 @@ export async function registerActivityRoutes( content, delivered, read_at AS "readAt", created_at AS "createdAt" - FROM agent_messages - WHERE sender_agent_id = $1 OR recipient_agent_id = $1 - ORDER BY created_at ASC - LIMIT 500`, + FROM ( + SELECT * FROM agent_messages + WHERE sender_agent_id = $1 OR recipient_agent_id = $1 + ORDER BY created_at DESC + LIMIT 500 + ) recent + ORDER BY created_at ASC`, [id] ), ]); diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 9ff8e8d9..cf681b5b 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -557,6 +557,9 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { // Persistence must never block delivery, so a failed insert is swallowed // and logged. Only announce message.created when the row actually landed, // otherwise the UI would refetch and find nothing. + const recipientRepoRoot = await resolveRepoRoot(target.cwd).catch( + () => null + ); const messageStore = new MessageStore(pool); const persisted = await messageStore .insertMessage({ @@ -567,8 +570,7 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { content: input.message, delivered, senderRepoRoot, - // Same repo today (send rule); stored for future cross-repo support. - recipientRepoRoot: senderRepoRoot, + recipientRepoRoot, }) .then(() => true) .catch((err) => { diff --git a/apps/web/src/components/app/agent-history-detail.tsx b/apps/web/src/components/app/agent-history-detail.tsx index 7f49f35e..ec169c73 100644 --- a/apps/web/src/components/app/agent-history-detail.tsx +++ b/apps/web/src/components/app/agent-history-detail.tsx @@ -231,13 +231,13 @@ function DetailTabs({ return ( <>
-
+
{tabs.map(({ key, label, count }) => ( -
-
- -
Date: Fri, 10 Jul 2026 15:27:34 -0600 Subject: [PATCH 19/21] fix(db): renumber agent-messages migration to 0030 Resolves duplicate prefix conflict with 0029_reviews.sql from main. Co-Authored-By: Claude Opus 4.6 --- .../{0029_agent-messages.sql => 0030_agent-messages.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/server/src/db/migrations/{0029_agent-messages.sql => 0030_agent-messages.sql} (100%) diff --git a/apps/server/src/db/migrations/0029_agent-messages.sql b/apps/server/src/db/migrations/0030_agent-messages.sql similarity index 100% rename from apps/server/src/db/migrations/0029_agent-messages.sql rename to apps/server/src/db/migrations/0030_agent-messages.sql From d035c0bc78fc268d5175afafcee6af86596f6c0c Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 15:33:13 -0600 Subject: [PATCH 20/21] test: remove brain-sidebar E2E tests Brain tab was removed from the sidebar in this PR. Co-Authored-By: Claude Opus 4.6 --- e2e/brain-sidebar.spec.ts | 297 -------------------------------------- 1 file changed, 297 deletions(-) delete mode 100644 e2e/brain-sidebar.spec.ts diff --git a/e2e/brain-sidebar.spec.ts b/e2e/brain-sidebar.spec.ts deleted file mode 100644 index 7011317f..00000000 --- a/e2e/brain-sidebar.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; -import { Pool } from "pg"; - -import { - cleanupE2EAgents, - clickAgentRow, - createAgentViaAPI, - loadApp, -} from "./helpers"; - -const AUTH_HEADER = { - Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, -}; - -const REPO_ROOT = "/tmp/e2e-brain-test"; - -async function seedBrainDataViaDB(agentId: string): Promise { - const connectionString = process.env.DATABASE_URL; - if (!connectionString) { - throw new Error("DATABASE_URL is required to seed brain data."); - } - - const pool = new Pool({ connectionString, max: 1 }); - try { - const client = await pool.connect(); - try { - await client.query("BEGIN"); - - await client.query( - `UPDATE agents SET git_context = $2::jsonb WHERE id = $1`, - [agentId, JSON.stringify({ repoRoot: REPO_ROOT, branch: "main" })] - ); - - await client.query( - `INSERT INTO brain_objects (repo_root, collection, name, value, revision, created_by_agent_id, updated_by_agent_id) - VALUES ($1, 'config', 'settings', '{"theme":"dark"}'::jsonb, 1, $2, $2), - ($1, 'state', 'cursor', '{"pos":42}'::jsonb, 1, $2, $2)`, - [REPO_ROOT, agentId] - ); - - await client.query( - `INSERT INTO brain_lists (repo_root, collection, name, revision, created_by_agent_id, updated_by_agent_id) - VALUES ($1, 'state', 'backlog', 1, $2, $2)`, - [REPO_ROOT, agentId] - ); - await client.query( - `INSERT INTO brain_list_items (repo_root, collection, name, item_index, value) - VALUES ($1, 'state', 'backlog', 0, '{"id":"a"}'::jsonb), - ($1, 'state', 'backlog', 1, '{"id":"b"}'::jsonb)`, - [REPO_ROOT] - ); - - await client.query( - `INSERT INTO brain_events (id, repo_root, collection, kind, subject, tags, value, agent_id) - VALUES (gen_random_uuid(), $1, 'reviews', 'assessment', 'ux-review', ARRAY['noise'], '{"score":0.8}'::jsonb, $2), - (gen_random_uuid(), $1, 'reviews', 'decision', NULL, ARRAY[]::text[], '{"action":"approve"}'::jsonb, $2)`, - [REPO_ROOT, agentId] - ); - - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } finally { - await pool.end(); - } -} - -async function cleanupBrainDataViaDB(): Promise { - const connectionString = process.env.DATABASE_URL; - if (!connectionString) return; - - const pool = new Pool({ connectionString, max: 1 }); - try { - await pool.query("DELETE FROM brain_list_items WHERE repo_root = $1", [ - REPO_ROOT, - ]); - await pool.query("DELETE FROM brain_lists WHERE repo_root = $1", [ - REPO_ROOT, - ]); - await pool.query("DELETE FROM brain_objects WHERE repo_root = $1", [ - REPO_ROOT, - ]); - await pool.query("DELETE FROM brain_events WHERE repo_root = $1", [ - REPO_ROOT, - ]); - } finally { - await pool.end(); - } -} - -async function openMediaSidebarForAgent( - page: Page, - agent: { id: string; name: string } -) { - await clickAgentRow(page, agent.id); - const toggle = page.getByTestId("toggle-media-sidebar"); - await expect(toggle).toBeVisible({ timeout: 10_000 }); - await toggle.click(); -} - -test.describe("Brain sidebar tab", () => { - test.afterAll(async ({ request }) => { - await cleanupBrainDataViaDB(); - await cleanupE2EAgents(request); - }); - - test("shows brain data in sidebar tab", async ({ page, request }) => { - const agent = await createAgentViaAPI(request, { - name: `e2e-agent-brain-${Date.now()}`, - cwd: "/tmp", - }); - - await seedBrainDataViaDB(agent.id); - - await loadApp(page); - await openMediaSidebarForAgent(page, agent); - - const mediaSidebar = page.getByTestId("media-sidebar"); - await expect(mediaSidebar).toBeVisible(); - - await mediaSidebar.getByRole("button", { name: "Brain" }).click(); - - await expect(mediaSidebar.getByText("Objects")).toBeVisible({ - timeout: 10_000, - }); - await expect(mediaSidebar.getByText("Lists")).toBeVisible(); - await expect(mediaSidebar.getByText("Events")).toBeVisible(); - - await expect(mediaSidebar.getByText("settings")).toBeVisible(); - await expect(mediaSidebar.getByText("cursor")).toBeVisible(); - - await expect(mediaSidebar.getByText("backlog")).toBeVisible(); - await expect(mediaSidebar.getByText("2 items")).toBeVisible(); - - await expect(mediaSidebar.getByText("assessment")).toBeVisible(); - await expect(mediaSidebar.getByText("decision")).toBeVisible(); - await expect(mediaSidebar.getByText("ux-review")).toBeVisible(); - await expect(mediaSidebar.getByText("noise")).toBeVisible(); - }); - - test("shows empty state when agent has no brain data", async ({ - page, - request, - }) => { - const agent = await createAgentViaAPI(request, { - name: `e2e-agent-brain-empty-${Date.now()}`, - cwd: "/tmp", - }); - - await loadApp(page); - await openMediaSidebarForAgent(page, agent); - - const mediaSidebar = page.getByTestId("media-sidebar"); - await mediaSidebar.getByRole("button", { name: "Brain" }).click(); - - await expect( - mediaSidebar.getByText(/brain context available|brain data yet/i) - ).toBeVisible({ timeout: 10_000 }); - }); -}); - -test.describe("Brain API", () => { - let agentId: string; - - test.beforeAll(async ({ request }) => { - const agent = await createAgentViaAPI(request, { - name: `e2e-agent-brain-api-${Date.now()}`, - cwd: "/tmp", - }); - agentId = agent.id; - await seedBrainDataViaDB(agentId); - }); - - test.afterAll(async ({ request }) => { - await cleanupBrainDataViaDB(); - await cleanupE2EAgents(request); - }); - - test("GET /api/v1/brain/projects lists projects", async ({ request }) => { - const res = await request.get("/api/v1/brain/projects", { - headers: AUTH_HEADER, - }); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as Array<{ - repoRoot: string; - objectCount: number; - }>; - const project = body.find((p) => p.repoRoot === REPO_ROOT); - expect(project).toBeDefined(); - expect(project!.objectCount).toBeGreaterThanOrEqual(2); - }); - - test("GET /api/v1/brain/collections returns summaries", async ({ - request, - }) => { - const res = await request.get( - `/api/v1/brain/collections?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as Array<{ collection: string }>; - const collections = body.map((c) => c.collection); - expect(collections).toContain("config"); - expect(collections).toContain("state"); - expect(collections).toContain("reviews"); - }); - - test("GET /api/v1/brain/collections returns 400 without repoRoot", async ({ - request, - }) => { - const res = await request.get("/api/v1/brain/collections", { - headers: AUTH_HEADER, - }); - expect(res.status()).toBe(400); - }); - - test("GET /api/v1/brain/objects lists objects", async ({ request }) => { - const res = await request.get( - `/api/v1/brain/objects?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as Array<{ name: string }>; - expect(body.length).toBeGreaterThanOrEqual(2); - }); - - test("GET /api/v1/brain/objects/:collection/:name gets a single object", async ({ - request, - }) => { - const res = await request.get( - `/api/v1/brain/objects/config/settings?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as { name: string; value: unknown }; - expect(body.name).toBe("settings"); - expect(body.value).toEqual({ theme: "dark" }); - }); - - test("GET /api/v1/brain/lists lists with item counts", async ({ - request, - }) => { - const res = await request.get( - `/api/v1/brain/lists?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as Array<{ - name: string; - itemCount: number; - }>; - const backlog = body.find((l) => l.name === "backlog"); - expect(backlog).toBeDefined(); - expect(backlog!.itemCount).toBe(2); - }); - - test("GET /api/v1/brain/events lists events", async ({ request }) => { - const res = await request.get( - `/api/v1/brain/events?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as Array<{ kind: string }>; - expect(body.length).toBeGreaterThanOrEqual(2); - }); - - test("GET /api/v1/brain/agent-activity/:agentId returns activity", async ({ - request, - }) => { - const res = await request.get( - `/api/v1/brain/agent-activity/${agentId}?repoRoot=${encodeURIComponent(REPO_ROOT)}`, - { headers: AUTH_HEADER } - ); - expect(res.ok()).toBeTruthy(); - const body = (await res.json()) as { - objects: Array<{ name: string }>; - lists: Array<{ name: string }>; - events: Array<{ kind: string }>; - }; - expect(body.objects.length).toBeGreaterThanOrEqual(2); - expect(body.lists.length).toBeGreaterThanOrEqual(1); - expect(body.events.length).toBeGreaterThanOrEqual(2); - }); - - test("GET /api/v1/brain/agent-activity/:agentId returns 400 without repoRoot", async ({ - request, - }) => { - const res = await request.get(`/api/v1/brain/agent-activity/${agentId}`, { - headers: AUTH_HEADER, - }); - expect(res.status()).toBe(400); - }); -}); From eabe2c87dc5e0cfecc8962ab7a145ad54a6215b1 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 15:33:52 -0600 Subject: [PATCH 21/21] test(e2e): increase split-pane reload timeout to reduce flakiness The jotai atom reads localStorage asynchronously after hydration; 5s was sometimes too short in CI. Co-Authored-By: Claude Opus 4.6 --- e2e/split-pane.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/split-pane.spec.ts b/e2e/split-pane.spec.ts index cae1e20e..74472637 100644 --- a/e2e/split-pane.spec.ts +++ b/e2e/split-pane.spec.ts @@ -12,6 +12,9 @@ async function waitForAppShell( .getByText(agentName) .first() .waitFor({ state: "visible" }); + // Wait for the agent to actually be focused (URL-routed) so per-agent + // atoms like splitPaneState are active. + await page.getByTestId("current-session-name").waitFor({ state: "attached" }); } /** Seed split-pane state directly in localStorage — deterministic, avoids