feat(agenthub): TSK-0120 — split messaging from /activity into a /messages conversation view

- MessageSchema: status enum unread|delivered|read|acked + replyTo (additive, RW-compatible)
- messageService: listInbox transitions unread->delivered on fetch (agent-scoped);
  markMessageDelivered (idempotent, never downgrades); ackMessage(cwd,id,by?)
- routes: GET /messages HTML branch -> renderMessagesHtml; GET /messages/:id;
  POST /messages/:id/ack (mirror of /read, emits message/updated status=acked)
- server/messages.ts: 2-column conversation view (list grouped by pair + unread badge,
  thread bubbles aligned by ?as=, replyTo indentation, TSK pill, live via /events)
- activity.ts: drop message rows (tasks-only hard separation)
- ui-shared: HeaderPage +messages + nav link
- CLI: message ack <id> [--by], message reply <parentId> --from --text [--task];
  remoteClient ackMessage + getMessage
- tests: +message-receipts.test.ts, server.test.ts activity/messages/receipt-chain

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-12 00:56:16 +02:00
parent 7922eeb8e8
commit 3f1fb76a84
11 changed files with 524 additions and 27 deletions

View File

@ -1,4 +1,5 @@
import { createMessage, listInbox, markMessageRead } from '../../core/services/messageService.js';
import { createMessage, listInbox, markMessageRead, ackMessage, getMessage } from '../../core/services/messageService.js';
import type { Message } from '../../core/schema.js';
export function messageSend(
cwd: string,
@ -30,3 +31,30 @@ export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: b
for (const m of msgs) markMessageRead(cwd, m.id);
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`);
}
export function messageAck(cwd: string, id: string, by?: string): void {
const m = ackMessage(cwd, id, by);
console.log(`AgentHub: Message acked ${m.id} (${m.from}${m.to})${by ? ` by ${by}` : ''}`);
}
/**
* Reply to a message: loads the parent, sends a new message back to the parent's
* sender (to = parent.from), links it via replyTo, and inherits the parent's
* taskId unless one is given.
*/
export function messageReply(
cwd: string,
parentId: string,
opts: { from: string; text: string; taskId?: string },
): Message {
const { message: parent } = getMessage(cwd, parentId);
const m = createMessage(cwd, {
from: opts.from,
to: parent.from,
text: opts.text,
taskId: opts.taskId ?? parent.taskId,
replyTo: parentId,
});
console.log(`AgentHub: Reply sent ${m.id} (${m.from}${m.to}) ↩ ${parentId}`);
return m;
}

View File

@ -5,7 +5,7 @@ import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './co
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList, messageRead, inboxMarkRead } from './commands/message.js';
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
import { agentSetup, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js';
@ -493,6 +493,45 @@ export function createProgram(cwd: string): Command {
messageRead(projectCwd, id);
}
});
messageCmd
.command('ack <id>')
.description('Acknowledge a message (strongest read-receipt: you actioned it)')
.option('--by <agent>', 'Agent acknowledging the message')
.action(async (id: string, options: { by?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.ackMessage(serverUrl, id, options.by);
console.log(`AgentHub: Message acked ${m.id} (${m.from}${m.to})${options.by ? ` by ${options.by}` : ''}`);
});
} else {
messageAck(projectCwd, id, options.by);
}
});
messageCmd
.command('reply <parentId>')
.description('Reply to a message: sends back to its sender, links via replyTo, inherits its task')
.requiredOption('--from <agent>', 'Sender agent name')
.requiredOption('--text <text>', 'Reply text')
.option('--task <id>', 'Related task ID (defaults to the parent message\'s task)')
.action(async (parentId: string, options: { from: string; text: string; task?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const { message: parent } = await remoteClient.getMessage(serverUrl, parentId);
const m = await remoteClient.sendMessage(serverUrl, {
from: options.from,
to: parent.from,
text: options.text,
taskId: options.task ?? parent.taskId,
replyTo: parentId,
});
console.log(`AgentHub: Reply sent ${m.id} (${m.from}${m.to}) ↩ ${parentId}`);
});
} else {
messageReply(projectCwd, parentId, { from: options.from, text: options.text, taskId: options.task });
}
});
messageCmd
.command('send <to> <text>')
.description('Send a direct message to another agent')

View File

@ -137,6 +137,14 @@ export const remoteClient = {
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
},
async ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
},
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
},
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
},

View File

@ -84,7 +84,17 @@ export const MessageSchema = z.object({
to: z.string().min(1),
text: z.string().min(1),
taskId: z.string().optional(),
status: z.enum(['unread', 'read']).default('unread'),
/**
* Read-receipt lifecycle:
* unread created, never fetched by the recipient
* delivered surfaced to the recipient's inbox (fetched), not yet opened
* read recipient marked it read
* acked recipient explicitly acknowledged/actioned it
* Additive + backward-compatible: old records ('unread'|'read') stay valid.
*/
status: z.enum(['unread', 'delivered', 'read', 'acked']).default('unread'),
/** For threaded replies: the id of the parent message this answers. */
replyTo: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});

View File

@ -34,6 +34,7 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
to: options.to,
text: options.text,
taskId: options.taskId,
replyTo: options.replyTo,
status: 'unread',
createdAt: now,
updatedAt: now,
@ -70,24 +71,46 @@ export interface InboxMessage {
createdAt: string;
}
/** Messages addressed to `agent`, newest first. */
/**
* Messages addressed to `agent`, newest first.
*
* Read-receipt side effect: any still-`unread` message that passes the filter is
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
* filtering and BEFORE returning, so the returned rows reflect the new status.
* This is agent-scoped only the architect-wide `listMessages` never mutates.
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation
* and then marks read, so the delivered intermediate is invisible there no
* regress and no spin.)
*/
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
const recipients = messageRecipientAliases(agent);
return all
const filtered = all
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread')
.map((m) => ({
.filter((m) => !opts.unreadOnly || m.status === 'unread');
return filtered.map((m) => {
let status = m.status ?? 'unread';
if (status === 'unread') {
try {
markMessageDelivered(cwd, m.id);
status = 'delivered';
} catch {
/* best-effort: leave as unread if the file can't be updated */
}
}
return {
id: m.id,
from: m.fromAgent ?? '',
to: m.toAgent ?? '',
text: m.content,
taskId: m.taskId,
status: m.status ?? 'unread',
status,
createdAt: m.createdAt,
}));
};
});
}
/** All messages (architect-visible view), newest first. */
@ -113,6 +136,26 @@ export function getMessage(cwd: string, id: string): { message: Message; body: s
return { message: MessageSchema.parse(frontmatter), body, filePath };
}
/**
* Transition a message from `unread` to `delivered` (the recipient has been
* shown it). Idempotent: a message that is already delivered/read/acked is left
* untouched this never downgrades a stronger receipt.
*/
export function markMessageDelivered(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
if (message.status !== 'unread') return message;
const updated: Message = { ...message, status: 'delivered', updatedAt: new Date().toISOString() };
writeEntity(filePath, updated, body);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}
/** Mark a message as read. */
export function markMessageRead(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
@ -127,3 +170,22 @@ export function markMessageRead(cwd: string, id: string): Message {
return updated;
}
/**
* Acknowledge a message the recipient has explicitly actioned it (the strongest
* receipt). Optionally records who acked in the body trailer.
*/
export function ackMessage(cwd: string, id: string, by?: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
const updated: Message = { ...message, status: 'acked', updatedAt: new Date().toISOString() };
const newBody = by ? `${body}\n\n_acked by ${by}_` : body;
writeEntity(filePath, updated, newBody);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}

View File

@ -1,5 +1,4 @@
import { loadConfig } from '../core/config.js';
import { listMessages } from '../core/services/messageService.js';
import { listTasks } from '../core/services/taskService.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { IndexEntry } from '../core/index.js';
@ -36,18 +35,10 @@ function snippet(text: string, max = 120): string {
export function renderActivityHtml(cwd: string): string {
const config = loadConfig(cwd);
const tasks = listTasks(cwd);
const messages = listMessages(cwd);
const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
// Messaging lives on its own /messages page now — /activity is tasks-only.
const activityRows = [
...messages.map((m) => ({
at: m.createdAt,
html: `<article class="activity-row">
<span class="kind kind-message">message</span>
<div class="activity-main"><span class="id">${escapeHtml(m.id)}</span> ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}</div>
<span class="state">${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : '<span class="unread-dot" aria-hidden="true"></span>unread'}</span>
</article>`,
})),
...tasks.map((t) => ({
at: t.updatedAt || t.createdAt,
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
@ -95,10 +86,8 @@ export function renderActivityHtml(cwd: string): string {
.activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
.activity-row:first-child { border-top:0;padding-top:0; }
.kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; }
.kind-message { color:var(--accent);border-color:rgba(88,166,255,.32);background:rgba(88,166,255,.08); }
.kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); }
.activity-main,.title { min-width:0;overflow-wrap:anywhere; }
.unread-dot { display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:4px; }
.task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
.task-row:first-child { border-top:0;padding-top:0; }
.task-row:hover,.activity-row:hover { color:var(--text); }

221
src/server/messages.ts Normal file
View File

@ -0,0 +1,221 @@
import { loadConfig } from '../core/config.js';
import { listMessages, getMessage } from '../core/services/messageService.js';
import { getRoster } from '../core/services/rosterService.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { Message } from '../core/schema.js';
function ago(iso: string): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function snippet(text: string, max = 88): string {
const clean = (text ?? '').replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return `${clean.slice(0, max - 1)}...`;
}
/** Stable, order-independent key for the conversation between two agents. */
function convoKey(a: string, b: string): string {
return [a, b].map((s) => s.toLowerCase()).sort().join('__');
}
interface Convo {
key: string;
a: string;
b: string;
messages: Message[]; // oldest → newest
lastAt: string;
unread: number; // messages addressed to the viewer not yet read/acked
}
/**
* The /messages conversation view: messaging split cleanly out of /activity.
* Two columns left the conversation list grouped by {from,to} pair, right the
* selected thread rendered as bubbles aligned by the viewer (`?as=<agent>`,
* default `architect`). Live via its own EventSource('/events'), reloading on
* message events. Reply indentation is driven by `replyTo`.
*/
export function renderMessagesHtml(cwd: string, asAgent?: string, selectedKey?: string): string {
const config = loadConfig(cwd);
const viewer = (asAgent && asAgent.trim()) || 'architect';
// Load full messages (frontmatter has replyTo + status); fall back to the
// lightweight index entry when a file can't be read.
const entries = listMessages(cwd);
const messages: Message[] = entries.map((e) => {
try {
return getMessage(cwd, e.id).message;
} catch {
return {
id: e.id,
from: e.from,
to: e.to,
text: e.text,
taskId: e.taskId,
status: (e.status as Message['status']) ?? 'unread',
createdAt: e.createdAt,
updatedAt: e.createdAt,
} as Message;
}
});
// Group into conversations.
const convos = new Map<string, Convo>();
for (const m of messages) {
const key = convoKey(m.from, m.to);
let c = convos.get(key);
if (!c) {
c = { key, a: m.from, b: m.to, messages: [], lastAt: m.createdAt, unread: 0 };
convos.set(key, c);
}
c.messages.push(m);
if (m.createdAt > c.lastAt) c.lastAt = m.createdAt;
const toViewer = m.to.toLowerCase() === viewer.toLowerCase();
if (toViewer && (m.status === 'unread' || m.status === 'delivered')) c.unread += 1;
}
const convoList = [...convos.values()].sort((x, y) => y.lastAt.localeCompare(x.lastAt));
for (const c of convoList) c.messages.sort((x, y) => x.createdAt.localeCompare(y.createdAt));
const selected = convoList.find((c) => c.key === selectedKey) ?? convoList[0];
const convoRows = convoList.length
? convoList
.map((c) => {
const last = c.messages[c.messages.length - 1];
const active = selected && c.key === selected.key;
return `<a class="convo${active ? ' active' : ''}" href="/messages?as=${encodeURIComponent(viewer)}&c=${encodeURIComponent(c.key)}">
<span class="pair">${agentAvatar(c.a, { size: 26 })}${agentAvatar(c.b, { size: 26 })}</span>
<span class="convo-main">
<span class="convo-names">${escapeHtml(c.a)} &harr; ${escapeHtml(c.b)}</span>
<span class="convo-snippet">${escapeHtml(snippet(last?.text ?? ''))}</span>
</span>
<span class="convo-meta">
${c.unread ? `<span class="badge">${c.unread}</span>` : ''}
<span class="when">${escapeHtml(ago(c.lastAt))}</span>
</span>
</a>`;
})
.join('')
: '<div class="empty">No conversations yet.</div>';
const statusLabel = (s: string) => (s === 'acked' ? 'acked' : s === 'read' ? 'read' : s === 'delivered' ? 'delivered' : 'sent');
const thread = selected
? selected.messages
.map((m) => {
const mine = m.from.toLowerCase() === viewer.toLowerCase();
const isReply = !!m.replyTo;
return `<div class="bubble-row ${mine ? 'sent' : 'recv'}${isReply ? ' reply' : ''}">
<div class="bubble">
<div class="bubble-head">${agentAvatar(m.from, { size: 20 })}<span class="from">${escapeHtml(m.from)}</span><span class="id">${escapeHtml(m.id)}</span>${m.taskId ? `<span class="task-pill">${escapeHtml(m.taskId)}</span>` : ''}</div>
<div class="bubble-text">${escapeHtml(m.text)}</div>
<div class="bubble-foot"><span class="when">${escapeHtml(ago(m.createdAt))}</span><span class="rstat" data-status="${escapeHtml(m.status)}">${escapeHtml(statusLabel(m.status))}</span></div>
</div>
</div>`;
})
.join('')
: '<div class="empty">Pick a conversation.</div>';
const roster = getRoster(cwd).map((r) => r.name);
const switchNames = ['architect', ...roster.filter((n) => n.toLowerCase() !== 'architect')];
const switcher = switchNames
.map(
(n) =>
`<a class="as-link${n.toLowerCase() === viewer.toLowerCase() ? ' active' : ''}" href="/messages?as=${encodeURIComponent(n)}${selected ? `&c=${encodeURIComponent(selected.key)}` : ''}">${escapeHtml(n)}</a>`,
)
.join('');
const headerTitle = selected ? `${escapeHtml(selected.a)} &harr; ${escapeHtml(selected.b)}` : 'Messages';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>AgentHub Messages</title>
<style>
${designTokensCss()}
${appHeaderCss()}
body { padding: 96px 20px 32px; }
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(280px,.9fr) minmax(0,1.6fr);gap:12px;align-items:start; }
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;min-width:0; }
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
h1 { font-size:16px;margin:0; }
.id,.when { color:var(--muted);font:11px/1.4 var(--font-mono); }
/* conversation list */
.convo-list { display:grid;gap:6px; }
.convo { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;text-decoration:none;color:inherit;border:1px solid transparent;border-radius:8px;padding:8px; }
.convo:hover { background:var(--raised); }
.convo.active { background:var(--raised);border-color:var(--border); }
.pair { display:inline-flex; }
.pair .agent-avatar:nth-child(2) { margin-left:-8px;box-shadow:0 0 0 2px var(--surface); }
.convo-main { min-width:0;display:grid;gap:2px; }
.convo-names { font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.convo-snippet { color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.convo-meta { display:flex;flex-direction:column;align-items:flex-end;gap:4px; }
.badge { background:var(--accent);color:#fff;border-radius:999px;font:10px/1 var(--font-mono);padding:3px 6px;min-width:16px;text-align:center; }
/* switcher */
.switch { display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:10px;color:var(--muted);font-size:11px; }
.as-link { text-decoration:none;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:2px 9px;font:11px/1.4 var(--font-mono); }
.as-link.active { color:var(--text);border-color:var(--accent);background:rgba(88,166,255,.10); }
/* thread */
.thread { display:grid;gap:8px;max-height:66vh;overflow-y:auto; }
.bubble-row { display:flex; }
.bubble-row.sent { justify-content:flex-end; }
.bubble-row.recv { justify-content:flex-start; }
.bubble-row.reply .bubble { margin-left:28px; }
.bubble-row.sent.reply .bubble { margin-left:0;margin-right:28px; }
.bubble { max-width:76%;background:var(--raised);border:1px solid var(--border);border-radius:12px;padding:8px 11px;min-width:0; }
.bubble-row.sent .bubble { background:rgba(88,166,255,.12);border-color:rgba(88,166,255,.32); }
.bubble-head { display:flex;align-items:center;gap:7px;margin-bottom:4px; }
.from { font-size:12px;font-weight:600; }
.task-pill { color:var(--status-review);border:1px solid rgba(210,153,34,.4);border-radius:999px;padding:0 6px;font:10px/1.5 var(--font-mono); }
.bubble-text { overflow-wrap:anywhere;font-size:13px;line-height:1.5; }
.bubble-foot { display:flex;gap:8px;align-items:center;margin-top:5px; }
.rstat { font:10px/1.4 var(--font-mono);color:var(--muted); }
.rstat[data-status="read"] { color:var(--accent); }
.rstat[data-status="acked"] { color:var(--green); }
.empty { color:var(--muted);font-size:12px;padding:8px; }
@media (max-width:860px){ main{grid-template-columns:1fr} .thread{max-height:none} }
</style>
</head>
<body>
${appHeader(config.projectName, 'messages')}
<main>
<section class="panel">
<div class="panel-head"><h1>Conversations</h1><span class="when">${convoList.length}</span></div>
<div class="convo-list">${convoRows}</div>
</section>
<section class="panel">
<div class="switch"><span>View as</span>${switcher}</div>
<div class="panel-head"><h1>${headerTitle}</h1></div>
<div class="thread">${thread}</div>
</section>
</main>
${taskModalHtml()}
${appHeaderJs()}
<script>
(function(){
if(!('EventSource' in window)) return;
var t=null;
try {
var s=new EventSource('/events');
s.onmessage=function(ev){
try { var p=JSON.parse(ev.data); if(p&&p.type==='message'){ if(t)clearTimeout(t); t=setTimeout(function(){ location.reload(); }, 400); } } catch(_){}
};
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
} catch(_){}
})();
</script>
</body>
</html>`;
}

View File

@ -4,7 +4,7 @@ import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
@ -16,6 +16,7 @@ import { renderBoardHtml } from './board.js';
import { renderTeamHtml } from './team.js';
import { renderArchiveHtml } from './archive.js';
import { renderDecisionsHtml } from './decisions.js';
import { renderMessagesHtml } from './messages.js';
import { renderTaskDetailHtml } from './taskDetail.js';
import { eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js';
@ -426,9 +427,14 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
// agent's inbox.
app.get('/messages', async (request) => {
const { agent, unread } = request.query as { agent?: string; unread?: string };
app.get('/messages', async (request, reply) => {
const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string };
// JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged.
if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' });
// Browser navigation → the /messages conversation view.
if (wantsHtml(request)) {
return reply.type('text/html; charset=utf-8').send(renderMessagesHtml(cwd, as, c));
}
return listMessages(cwd);
});
app.post('/messages', async (request, reply) => {
@ -450,6 +456,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
);
return message;
});
// Single message (frontmatter + body) — used by `message reply` to load the
// parent it answers. JSON only.
app.get('/messages/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const { message, body } = getMessage(cwd, id);
return { message, body };
} catch {
return notFound(reply, 'Message');
}
});
app.post('/messages/:id/read', async (request, reply) => {
const { id } = request.params as { id: string };
try {
@ -465,6 +482,21 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
}
});
app.post('/messages/:id/ack', async (request, reply) => {
const { id } = request.params as { id: string };
const { by } = (request.body ?? {}) as { by?: string };
try {
const message = ackMessage(cwd, id, by);
// Ack receipt: mirror of /read so the sender's stream shows the strongest state.
emitChange(
{ type: 'message', action: 'updated', id: message.id, status: 'acked', title: `${message.from}${message.to}`, assignedTo: message.to },
message.updatedAt,
);
return message;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
}
});
// ─── Memory ──────────────────────────────────────────────────────────────
app.get('/memory', async () => listMemory(cwd));

View File

@ -291,7 +291,7 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
// identically on the non-board pages.
// ─────────────────────────────────────────────────────────────────────────────
export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task';
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task';
/** CSS for the shared header + new-task modal + toasts. Include once per page. */
export function appHeaderCss(): string {
@ -385,6 +385,7 @@ export function appHeader(projectName: string, current: HeaderPage): string {
${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')}
${link('Activity', '/activity', 'activity')}
${link('Messages', '/messages', 'messages')}
${link('Decisions', '/decisions', 'decisions')}
</nav>
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">

View File

@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import {
createMessage,
listInbox,
getMessage,
markMessageDelivered,
markMessageRead,
ackMessage,
} from '../src/core/services/messageService.js';
import { messageReply } from '../src/cli/commands/message.js';
describe('message read-receipts + replies', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-receipt-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('listInbox transitions unread → delivered on fetch (agent-scoped)', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
expect(getMessage(cwd, msg.id).message.status).toBe('unread');
const inbox = listInbox(cwd, 'claude');
expect(inbox[0].status).toBe('delivered');
// Persisted on disk, not just in the returned row.
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
});
it('markMessageDelivered never downgrades a stronger receipt', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
markMessageRead(cwd, msg.id);
const after = markMessageDelivered(cwd, msg.id);
expect(after.status).toBe('read');
});
it('ackMessage transitions to acked (strongest receipt)', () => {
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
const acked = ackMessage(cwd, msg.id, 'claude');
expect(acked.status).toBe('acked');
expect(getMessage(cwd, msg.id).message.status).toBe('acked');
});
it('persists replyTo through create → getMessage', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X' });
const reply = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done', replyTo: parent.id });
expect(reply.replyTo).toBe(parent.id);
expect(getMessage(cwd, reply.id).message.replyTo).toBe(parent.id);
});
it('messageReply routes back to the parent sender and inherits its task', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it' });
expect(reply.to).toBe('claude'); // back to parent.from
expect(reply.replyTo).toBe(parent.id);
expect(reply.taskId).toBe('TSK-0007'); // inherited
});
it('messageReply --task overrides the inherited task', () => {
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it', taskId: 'TSK-0009' });
expect(reply.taskId).toBe('TSK-0009');
});
});

View File

@ -255,7 +255,7 @@ describe('server routes', () => {
expect(res.payload).toContain('1m');
});
it('serves the activity page with recent activity and done archive', async () => {
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
await app.inject({
method: 'PATCH',
@ -273,8 +273,43 @@ describe('server routes', () => {
expect(res.headers['content-type']).toContain('text/html');
expect(res.payload).toContain('Recent Activity');
expect(res.payload).toContain('Done Archive');
expect(res.payload).toContain('MSG-0001');
expect(res.payload).toContain('TSK-0001');
// Hard separation: no message rows leak into /activity anymore.
expect(res.payload).not.toContain('MSG-0001');
expect(res.payload).not.toContain('kind-message');
});
it('serves the /messages conversation view (HTML) with a conversation list', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'claude', to: 'codex', text: 'Please check the board' } });
const res = await app.inject({ method: 'GET', url: '/messages', headers: { accept: 'text/html' } });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.payload).toContain('Conversations');
expect(res.payload).toContain('MSG-0001');
expect(res.payload).toContain('Please check the board');
// JSON contract unchanged: no accept header → JSON list, not HTML.
const json = await app.inject({ method: 'GET', url: '/messages' });
expect(() => JSON.parse(json.payload)).not.toThrow();
});
it('read-receipt chain: create → inbox(delivered) → read → ack', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
// create → unread
const listed = JSON.parse((await app.inject({ method: 'GET', url: '/messages' })).payload) as Array<{ id: string; status: string }>;
expect(listed[0].status).toBe('unread');
// inbox fetch → delivered (agent-scoped side effect)
const inbox = JSON.parse((await app.inject({ method: 'GET', url: '/messages?agent=claude' })).payload) as Array<{ id: string; status: string }>;
expect(inbox[0].status).toBe('delivered');
// read
const read = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/read' })).payload) as { status: string };
expect(read.status).toBe('read');
// ack
const acked = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/ack', payload: { by: 'claude' } })).payload) as { status: string };
expect(acked.status).toBe('acked');
});
it('GET /handoffs returns fromRole and toRole fields', async () => {