agenthub/tests/message-receipts.test.ts
chahinebrini 3f1fb76a84 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>
2026-07-12 00:56:16 +02:00

73 lines
2.9 KiB
TypeScript

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');
});
});