- 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>
73 lines
2.9 KiB
TypeScript
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');
|
|
});
|
|
});
|