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:
parent
7922eeb8e8
commit
3f1fb76a84
@ -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;
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}`);
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
221
src/server/messages.ts
Normal 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)} ↔ ${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)} ↔ ${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>`;
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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">
|
||||
|
||||
72
tests/message-receipts.test.ts
Normal file
72
tests/message-receipts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user