diff --git a/src/db/migrations/054_mailbox_source_meta.sql b/src/db/migrations/054_mailbox_source_meta.sql new file mode 100644 index 000000000..6ebd41111 --- /dev/null +++ b/src/db/migrations/054_mailbox_source_meta.sql @@ -0,0 +1,28 @@ +-- 054_mailbox_source_meta.sql +-- +-- Foundation for the channel-shaped envelope (PR A in the channels-pivot +-- roadmap). The mailbox row gains two optional, idempotent columns so the +-- delivery layer can carry source attribution and arbitrary metadata +-- (whatsapp phone, telegram chat id, system nudge kind, …) end-to-end: +-- +-- - `source TEXT NOT NULL DEFAULT 'agent'` — origin of the message. The +-- default keeps every pre-existing row (and every legacy `mailbox.send` +-- caller that doesn't pass an opts arg) reading as `'agent'`, which is +-- the back-compat behaviour the renderer expects (plain body, no +-- `` wrap). +-- - `meta JSONB NOT NULL DEFAULT '{}'::jsonb` — free-form k/v map. +-- Persisted verbatim and re-hydrated by the inbox/outbox readers so +-- channel-aware UIs round-trip the data. +-- +-- Indexes are deliberately omitted — every existing inbox/outbox query +-- already filters on `to_worker` / `from_worker`, so no extra index is +-- needed for the foundation. PR C+ may add a `(to_worker, source)` index +-- once it's clear which sources warrant their own hot path. +-- +-- Re-runnable: both ALTER COLUMN clauses use `IF NOT EXISTS`. + +ALTER TABLE mailbox + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'agent'; + +ALTER TABLE mailbox + ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/src/lib/__tests__/mailbox.test.ts b/src/lib/__tests__/mailbox.test.ts index 2e853dec3..a0da70932 100644 --- a/src/lib/__tests__/mailbox.test.ts +++ b/src/lib/__tests__/mailbox.test.ts @@ -177,6 +177,8 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => { createdAt: '2026-01-01T00:00:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }; const native = toNativeInboxMessage(msg); @@ -195,6 +197,8 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => { createdAt: '2026-01-01T00:00:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }; const native = toNativeInboxMessage(msg); @@ -211,11 +215,113 @@ describe.skipIf(!DB_AVAILABLE)('pg', () => { createdAt: '2026-01-01T00:00:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }; const native = toNativeInboxMessage(msg, 'red'); expect(native.color).toBe('red'); }); + + test('agent source keeps plain body and omits source/meta', () => { + const msg = { + id: 'msg-test', + from: 'sender', + to: 'worker', + body: 'plain body', + createdAt: '2026-01-01T00:00:00.000Z', + read: false, + deliveredAt: null, + source: 'agent', + meta: {}, + }; + const native = toNativeInboxMessage(msg); + expect(native.text).toBe('plain body'); + expect(native.source).toBeUndefined(); + expect(native.meta).toBeUndefined(); + }); + + test('non-default source wraps body in channel envelope and carries source/meta', () => { + const msg = { + id: 'msg-test', + from: '+5511999', + to: 'worker', + body: 'whats up genie', + createdAt: '2026-01-01T00:00:00.000Z', + read: false, + deliveredAt: null, + source: 'whatsapp', + meta: { phone: '+5511999', conversationId: 'wa-1' }, + }; + const native = toNativeInboxMessage(msg); + expect(native.text.startsWith('')).toBe(true); + expect(native.source).toBe('whatsapp'); + expect(native.meta).toEqual({ phone: '+5511999', conversationId: 'wa-1' }); + }); + }); + + // ============================================================================ + // Channel envelope: source/meta persistence + // ============================================================================ + + describe('channel envelope source/meta', () => { + test('default source is "agent" when opts omitted (back-compat)', async () => { + const repo = '/tmp/source-default-test'; + const msg = await send(repo, 'sender', 'worker', 'hello'); + expect(msg.source).toBe('agent'); + expect(msg.meta).toEqual({}); + + const messages = await inbox(repo, 'worker'); + const found = messages.find((m) => m.id === msg.id); + expect(found?.source).toBe('agent'); + expect(found?.meta).toEqual({}); + }); + + test('explicit source=whatsapp round-trips through send → inbox → outbox', async () => { + const repo = '/tmp/source-whatsapp-test'; + const meta = { phone: '+5511999999999', conversationId: 'wa-abc' }; + const msg = await send(repo, '+5511999999999', 'worker', 'whats up', { + source: 'whatsapp', + meta, + }); + expect(msg.source).toBe('whatsapp'); + expect(msg.meta).toEqual(meta); + + const inboxRows = await inbox(repo, 'worker'); + const inboxFound = inboxRows.find((m) => m.id === msg.id); + expect(inboxFound?.source).toBe('whatsapp'); + expect(inboxFound?.meta).toEqual(meta); + + const outboxRows = await readOutbox(repo, '+5511999999999'); + const outboxFound = outboxRows.find((m) => m.id === msg.id); + expect(outboxFound?.source).toBe('whatsapp'); + expect(outboxFound?.meta).toEqual(meta); + }); + + test('meta JSONB persists nested values verbatim', async () => { + const repo = '/tmp/source-meta-jsonb-test'; + const meta = { + priority: 3, + urgent: true, + nested: { kind: 'system-nudge', via: 'codex' }, + }; + const msg = await send(repo, 'system', 'worker', 'wake up', { source: 'system', meta }); + const messages = await inbox(repo, 'worker'); + const found = messages.find((m) => m.id === msg.id); + expect(found?.source).toBe('system'); + expect(found?.meta).toEqual(meta); + }); + + test('existing 4-arg callers still work (no opts arg)', async () => { + const repo = '/tmp/source-legacy-test'; + // Compile-time check: this signature must continue to be valid. + const msg = await send(repo, 'a', 'b', 'legacy body'); + expect(msg.body).toBe('legacy body'); + expect(msg.source).toBe('agent'); + }); }); // ============================================================================ diff --git a/src/lib/brief.test.ts b/src/lib/brief.test.ts index a087774b6..56d95bb6e 100644 --- a/src/lib/brief.test.ts +++ b/src/lib/brief.test.ts @@ -45,6 +45,8 @@ describe('formatBrief', () => { createdAt: '2026-03-28T14:26:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }, { id: 'msg-2', @@ -54,6 +56,8 @@ describe('formatBrief', () => { createdAt: '2026-03-28T14:30:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }, ], taskMessages: [], diff --git a/src/lib/channel-envelope.test.ts b/src/lib/channel-envelope.test.ts new file mode 100644 index 000000000..7077b2771 --- /dev/null +++ b/src/lib/channel-envelope.test.ts @@ -0,0 +1,124 @@ +/** + * channel-envelope — Unit tests + * + * Pure module — no PG, no fs. Covers: + * - format/parse round-trip across multiple sources + * - plain-body passthrough when source defaults to 'agent' + * - meta keys with characters that need attribute escaping + * - parseEnvelope returns null for malformed input + * + * Run with: bun test src/lib/channel-envelope.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { formatEnvelope, parseEnvelope } from './channel-envelope.js'; + +describe('formatEnvelope', () => { + test('passes plain body through when source defaults to agent', () => { + const out = formatEnvelope({ body: 'hello peer' }); + expect(out).toBe('hello peer'); + }); + + test('passes plain body through when source is explicitly agent', () => { + const out = formatEnvelope({ source: 'agent', body: 'hi' }); + expect(out).toBe('hi'); + }); + + test('wraps body with channel tag for whatsapp source', () => { + const out = formatEnvelope({ + source: 'whatsapp', + from: '+5511999999999', + meta: { phone: '+5511999999999', conversationId: 'wa-123' }, + body: 'whats up genie', + }); + expect(out).toMatch(/^')).toBe(true); + }); + + test('serialises numeric and boolean meta values as strings', () => { + const out = formatEnvelope({ + source: 'system', + meta: { priority: 3, urgent: true }, + body: 'nudge', + }); + expect(out).toContain('priority="3"'); + expect(out).toContain('urgent="true"'); + }); + + test('escapes embedded quotes inside attribute values', () => { + const out = formatEnvelope({ + source: 'webhook', + meta: { reason: 'said "hi"' }, + body: 'b', + }); + expect(out).toContain('reason="said \\"hi\\""'); + }); + + test('skips invalid meta keys silently', () => { + const out = formatEnvelope({ + source: 'system', + meta: { 'bad key': 'x', good_key: 'y' }, + body: 'b', + }); + expect(out).not.toContain('bad key'); + expect(out).toContain('good_key="y"'); + }); +}); + +describe('parseEnvelope', () => { + test('parses an envelope with source/from/meta and body', () => { + const text = 'hello'; + const parsed = parseEnvelope(text); + expect(parsed).not.toBeNull(); + expect(parsed?.source).toBe('whatsapp'); + expect(parsed?.from).toBe('+55'); + expect(parsed?.meta).toEqual({ phone: '+55' }); + expect(parsed?.body).toBe('hello'); + }); + + test('round-trips arbitrary source values via format → parse', () => { + for (const source of ['whatsapp', 'system', 'telegram', 'webhook']) { + const formatted = formatEnvelope({ + source, + from: 'sender', + meta: { k: 'v' }, + body: `body for ${source}`, + }); + const parsed = parseEnvelope(formatted); + expect(parsed).not.toBeNull(); + expect(parsed?.source).toBe(source); + expect(parsed?.from).toBe('sender'); + expect(parsed?.meta).toEqual({ k: 'v' }); + expect(parsed?.body).toBe(`body for ${source}`); + } + }); + + test('returns null for plain body (no envelope)', () => { + expect(parseEnvelope('hello peer')).toBeNull(); + }); + + test('returns null for malformed input', () => { + expect(parseEnvelope('no close')).toBeNull(); + expect(parseEnvelope('missing attrs')).not.toBeNull(); + expect(parseEnvelope('')).toBeNull(); + }); + + test('round-trips meta values containing escaped quotes', () => { + const formatted = formatEnvelope({ + source: 'webhook', + meta: { reason: 'said "hi"' }, + body: 'b', + }); + const parsed = parseEnvelope(formatted); + expect(parsed?.meta.reason).toBe('said "hi"'); + }); + + test('tolerates leading whitespace before the tag', () => { + const text = ' \nx'; + expect(parseEnvelope(text)?.body).toBe('x'); + }); +}); diff --git a/src/lib/channel-envelope.ts b/src/lib/channel-envelope.ts new file mode 100644 index 000000000..ab33032b7 --- /dev/null +++ b/src/lib/channel-envelope.ts @@ -0,0 +1,123 @@ +/** + * Channel envelope — format/parse `body` wrappers. + * + * The genie mailbox carries optional source attribution (`agent`, `whatsapp`, + * `system`, future external adapters). When a message is delivered into a + * Claude Code native inbox, non-default sources get rendered as a structured + * tag so the receiving agent can react to the origin without parsing free + * text: + * + * body + * + * Pure functions only — no I/O, no PG, no fs. Easy to unit test and reuse + * across the deliver path (PRs C/D), the codex hook (PR B), and external + * channel adapters (PR F+). + */ + +const DEFAULT_SOURCE = 'agent'; + +const TAG_OPEN_RE = /^]*)>([\s\S]*)<\/channel>\s*$/; +const ATTR_RE = /([a-zA-Z_][a-zA-Z0-9_-]*)="((?:\\.|[^"\\])*)"/g; + +export interface FormatEnvelopeInput { + /** Channel source tag. Defaults to `'agent'` (back-compat plain body). */ + source?: string; + /** Sender identifier surfaced as `from="…"` when provided. */ + from?: string; + /** Free-form attribute map; non-string values are stringified. */ + meta?: Record; + /** Message body text — embedded verbatim inside the tag. */ + body: string; +} + +export interface ParsedEnvelope { + source: string; + from?: string; + meta: Record; + body: string; +} + +/** + * Render a channel envelope for `body`. When `source` resolves to the default + * (`'agent'`), the body is returned unchanged — Claude Code peers expect a + * plain string in their native inbox today, and PR A keeps that invariant. + */ +export function formatEnvelope(input: FormatEnvelopeInput): string { + const source = input.source && input.source.length > 0 ? input.source : DEFAULT_SOURCE; + if (source === DEFAULT_SOURCE) return input.body; + + const attrs: string[] = [`source="${escapeAttr(source)}"`]; + if (input.from) attrs.push(`from="${escapeAttr(input.from)}"`); + + if (input.meta) { + for (const [key, value] of Object.entries(input.meta)) { + if (!isValidAttrName(key)) continue; + if (value === undefined || value === null) continue; + attrs.push(`${key}="${escapeAttr(stringifyAttr(value))}"`); + } + } + + return `${input.body}`; +} + +/** + * Best-effort parse of a channel envelope. Returns `null` for input that + * doesn't match the `body` shape — callers + * should treat that as a plain `'agent'` body. + * + * Whitespace tolerance: leading whitespace before `` is also + * tolerated. Bodies are returned verbatim (including any embedded markup) so + * round-tripping with {@link formatEnvelope} is lossless for well-formed + * inputs. + */ +export function parseEnvelope(text: string): ParsedEnvelope | null { + if (typeof text !== 'string') return null; + const trimmed = text.trimStart(); + const match = TAG_OPEN_RE.exec(trimmed); + if (!match) return null; + + const attrBlob = match[1]; + const body = match[2]; + + const attrs: Record = {}; + ATTR_RE.lastIndex = 0; + let attrMatch: RegExpExecArray | null = ATTR_RE.exec(attrBlob); + while (attrMatch !== null) { + const [, name, rawValue] = attrMatch; + attrs[name] = unescapeAttr(rawValue); + attrMatch = ATTR_RE.exec(attrBlob); + } + + const source = attrs.source ?? DEFAULT_SOURCE; + const from = attrs.from; + const meta: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (key === 'source' || key === 'from') continue; + meta[key] = value; + } + + return { source, from, meta, body }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function escapeAttr(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function unescapeAttr(value: string): string { + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + +function isValidAttrName(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name); +} + +function stringifyAttr(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} diff --git a/src/lib/claude-native-teams.test.ts b/src/lib/claude-native-teams.test.ts index 44aeab27a..8a9b3514a 100644 --- a/src/lib/claude-native-teams.test.ts +++ b/src/lib/claude-native-teams.test.ts @@ -458,6 +458,59 @@ describe('writeNativeInbox', () => { expect(content).toHaveLength(1); expect(content[0].text).toBe('Hello'); }); + + test('persists source and meta when supplied', async () => { + await createTestTeamConfig('my-team', [{ agentId: 'engineer@my-team', name: 'engineer' }]); + + const msg: NativeInboxMessage = { + from: '+5511999', + text: 'hi genie', + summary: 'hi genie', + timestamp: '2026-03-24T10:00:00.000Z', + color: 'blue', + read: false, + source: 'whatsapp', + meta: { phone: '+5511999', conversationId: 'wa-abc' }, + }; + + await writeNativeInbox('my-team', 'engineer', msg); + + const sanitized = sanitizeTeamName('my-team'); + const inboxFile = join(tempDir, 'teams', sanitized, 'inboxes', 'engineer.json'); + const content = JSON.parse(await readFile(inboxFile, 'utf-8')); + expect(content[0].source).toBe('whatsapp'); + expect(content[0].meta).toEqual({ phone: '+5511999', conversationId: 'wa-abc' }); + expect(content[0].text).toContain(' { + await createTestTeamConfig('my-team', [{ agentId: 'engineer@my-team', name: 'engineer' }]); + + const msg: NativeInboxMessage = { + from: 'team-lead', + text: 'plain text', + summary: 'plain text', + timestamp: '2026-03-24T10:00:00.000Z', + color: 'blue', + read: false, + }; + + await writeNativeInbox('my-team', 'engineer', msg); + + const sanitized = sanitizeTeamName('my-team'); + const inboxFile = join(tempDir, 'teams', sanitized, 'inboxes', 'engineer.json'); + const content = JSON.parse(await readFile(inboxFile, 'utf-8')); + expect(content[0]).toEqual({ + from: 'team-lead', + text: 'plain text', + summary: 'plain text', + timestamp: '2026-03-24T10:00:00.000Z', + color: 'blue', + read: false, + }); + expect('source' in content[0]).toBe(false); + expect('meta' in content[0]).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/claude-native-teams.ts b/src/lib/claude-native-teams.ts index c9a3e8916..5ae07570b 100644 --- a/src/lib/claude-native-teams.ts +++ b/src/lib/claude-native-teams.ts @@ -65,6 +65,18 @@ export interface NativeInboxMessage { timestamp: string; color: string; read: boolean; + /** + * Source of the message — `'agent'` (peer worker, default), `'whatsapp'`, + * `'system'`, future external adapters, etc. Omitted entries are treated as + * `'agent'` so existing callers stay back-compat. + */ + source?: string; + /** + * Channel envelope metadata — arbitrary key/value pairs propagated as + * attributes when the body is rendered into a `` tag. Persisted + * verbatim so future readers can round-trip the data. + */ + meta?: Record; } // ============================================================================ diff --git a/src/lib/mailbox.ts b/src/lib/mailbox.ts index 4a1390e75..4046d9225 100644 --- a/src/lib/mailbox.ts +++ b/src/lib/mailbox.ts @@ -28,6 +28,7 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { formatEnvelope } from './channel-envelope.js'; import type { NativeInboxMessage } from './claude-native-teams.js'; import { getConnection } from './db.js'; import { endSpan, startSpan } from './emit.js'; @@ -53,6 +54,17 @@ export interface MailboxMessage { read: boolean; /** ISO timestamp when message was delivered to pane (null if pending). */ deliveredAt: string | null; + /** + * Channel source — `'agent'` (peer worker, default), `'whatsapp'`, + * `'system'`, future external adapters. Persisted on the row so readers + * can route/render messages by origin without inspecting the body. + */ + source: string; + /** + * Channel envelope metadata — free-form k/v pairs. Round-trips through PG + * JSONB and is rendered as `` attributes when source !== 'agent'. + */ + meta: Record; } // ============================================================================ @@ -67,6 +79,8 @@ interface MailboxRow { created_at: Date | string; read: boolean; delivered_at: Date | string | null; + source?: string | null; + meta?: Record | null; } function generateMessageId(): string { @@ -87,6 +101,8 @@ function rowToMessage(row: MailboxRow): MailboxMessage { ? row.delivered_at.toISOString() : String(row.delivered_at) : null, + source: typeof row.source === 'string' && row.source.length > 0 ? row.source : 'agent', + meta: row.meta && typeof row.meta === 'object' ? (row.meta as Record) : {}, }; } @@ -99,15 +115,38 @@ function normalizeWorkerIds(worker: string | string[]): string[] { // Public API // ============================================================================ +/** + * Optional channel-envelope attribution for {@link send}. + * + * - `source` — origin tag persisted on the row. Defaults to `'agent'` when + * omitted, which keeps every existing caller back-compat with the + * pre-channel renderer (plain body, no `` wrap). + * - `meta` — arbitrary key/value map stored as JSONB. Round-trips verbatim + * so channel-aware UIs can re-attach attributes (whatsapp phone, telegram + * chat id, system nudge kind, …) on the read side. + */ +export interface SendOptions { + source?: string; + meta?: Record; +} + /** * Write a message to a worker's mailbox. * This persists BEFORE any delivery attempt (DEC-7). * PG trigger auto-fires NOTIFY genie_mailbox_delivery. */ -export async function send(repoPath: string, from: string, to: string, body: string): Promise { +export async function send( + repoPath: string, + from: string, + to: string, + body: string, + opts?: SendOptions, +): Promise { const sql = await getConnection(); const id = generateMessageId(); const now = new Date().toISOString(); + const source = opts?.source && opts.source.length > 0 ? opts.source : 'agent'; + const meta: Record = opts?.meta ?? {}; const span = isWideEmitEnabled() ? startSpan( @@ -120,8 +159,8 @@ export async function send(repoPath: string, from: string, to: string, body: str let outcome: 'delivered' | 'queued' | 'rejected' = 'queued'; try { await sql` - INSERT INTO mailbox (id, from_worker, to_worker, body, repo_path, read, delivered_at, created_at) - VALUES (${id}, ${from}, ${to}, ${body}, ${repoPath}, false, ${null}, ${now}) + INSERT INTO mailbox (id, from_worker, to_worker, body, repo_path, read, delivered_at, created_at, source, meta) + VALUES (${id}, ${from}, ${to}, ${body}, ${repoPath}, false, ${null}, ${now}, ${source}, ${sql.json(meta)}) `; outcome = 'queued'; } catch (err) { @@ -144,6 +183,8 @@ export async function send(repoPath: string, from: string, to: string, body: str createdAt: now, read: false, deliveredAt: null, + source, + meta, }; // Mirror mailbox writes into the PG runtime event log for follow/QA flows. @@ -259,19 +300,52 @@ export async function markRead(messageId: string): Promise { /** * Convert a Genie mailbox message to Claude Code's native inbox format. + * + * For default-source (`'agent'`) messages, the body is passed through + * verbatim and `source`/`meta` are omitted — preserving back-compat with + * peer-to-peer worker delivery (existing JSON inboxes keep the exact same + * shape they had before PR A). + * + * For non-default sources (`'whatsapp'`, `'system'`, future adapters), the + * body is wrapped in a `` envelope so the receiving Claude can + * react to the origin without parsing free text, and `source`/`meta` are + * persisted onto the inbox row so JSON readers round-trip the attribution. */ export function toNativeInboxMessage(msg: MailboxMessage, color = 'blue'): NativeInboxMessage { // Truncate body to create a summary (5-10 words) const words = msg.body.split(/\s+/); const summary = words.slice(0, 8).join(' ') + (words.length > 8 ? '...' : ''); + const source = msg.source && msg.source.length > 0 ? msg.source : 'agent'; + const isDefault = source === 'agent'; + + if (isDefault) { + return { + from: msg.from, + text: msg.body, + summary, + timestamp: msg.createdAt, + color, + read: false, + }; + } + + const meta: Record = {}; + for (const [key, value] of Object.entries(msg.meta ?? {})) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + meta[key] = value; + } + } + return { from: msg.from, - text: msg.body, + text: formatEnvelope({ source, from: msg.from, meta: msg.meta, body: msg.body }), summary, timestamp: msg.createdAt, color, read: false, + source, + meta, }; } diff --git a/src/lib/scheduler-daemon.test.ts b/src/lib/scheduler-daemon.test.ts index 0ca558f54..0a8810d2b 100644 --- a/src/lib/scheduler-daemon.test.ts +++ b/src/lib/scheduler-daemon.test.ts @@ -2158,6 +2158,8 @@ describe('scheduler-daemon', () => { createdAt: '2026-04-17T12:00:00Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, ...overrides, }; } diff --git a/src/lib/unified-log.test.ts b/src/lib/unified-log.test.ts index 58b6df273..3649f63a4 100644 --- a/src/lib/unified-log.test.ts +++ b/src/lib/unified-log.test.ts @@ -142,6 +142,8 @@ describe('inboxMessageToLogEvent', () => { createdAt: '2026-03-20T10:00:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }, 'engineer', 'my-team', @@ -167,6 +169,8 @@ describe('outboxMessageToLogEvent', () => { createdAt: '2026-03-20T11:00:00.000Z', read: false, deliveredAt: null, + source: 'agent', + meta: {}, }, 'engineer', ); diff --git a/src/term-commands/agent/inbox.test.ts b/src/term-commands/agent/inbox.test.ts new file mode 100644 index 000000000..d67a03e9f --- /dev/null +++ b/src/term-commands/agent/inbox.test.ts @@ -0,0 +1,111 @@ +/** + * inbox renderers — unit tests + * + * Pure helpers exposed by `genie agent inbox list`. Verifies: + * - `[]` tag prepended in the human render when source !== 'agent' + * - default-source previews skip the tag (back-compat) + * - JSON enrichment surfaces source + meta verbatim + * + * Run with: bun test src/term-commands/agent/inbox.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { buildInboxEntry, extractSource, renderConversation } from './inbox.js'; + +const baseConv = { + id: 'conv-1', + name: 'whatsapp:+5511999', + type: 'dm', + linkedEntity: null, + linkedEntityId: null, +}; + +describe('extractSource', () => { + test('returns agent for null/undefined message', () => { + expect(extractSource(null)).toBe('agent'); + expect(extractSource(undefined)).toBe('agent'); + }); + + test('returns agent when metadata.source missing', () => { + expect(extractSource({ metadata: {} })).toBe('agent'); + expect(extractSource({})).toBe('agent'); + }); + + test('returns the explicit source value', () => { + expect(extractSource({ metadata: { source: 'whatsapp' } })).toBe('whatsapp'); + expect(extractSource({ metadata: { source: 'system' } })).toBe('system'); + }); + + test('treats non-string source as agent', () => { + expect(extractSource({ metadata: { source: 42 } })).toBe('agent'); + }); +}); + +describe('renderConversation', () => { + test('omits source tag for default agent source', () => { + const lastMsg = { + body: 'hello peer', + senderId: 'engineer', + createdAt: '2026-04-27T13:42:00.000Z', + metadata: {}, + }; + const lines = renderConversation(baseConv, lastMsg); + const previewLine = lines[1]; + expect(previewLine.includes('engineer:')).toBe(true); + expect(previewLine.includes('[agent]')).toBe(false); + expect(previewLine.startsWith(' ')).toBe(true); + }); + + test('prepends [] tag when source is non-default', () => { + const lastMsg = { + body: 'whats up genie', + senderId: 'felipe', + createdAt: '2026-04-27T13:42:00.000Z', + metadata: { source: 'whatsapp', phone: '+5511999' }, + }; + const lines = renderConversation(baseConv, lastMsg); + const previewLine = lines[1]; + expect(previewLine.includes('[whatsapp] ')).toBe(true); + expect(previewLine.includes('felipe:')).toBe(true); + }); + + test('handles missing last message without crashing', () => { + const lines = renderConversation(baseConv, null); + expect(lines.length).toBe(2); // header + trailing blank + }); +}); + +describe('buildInboxEntry', () => { + test('JSON entry surfaces source and meta verbatim', () => { + const conv = { id: 'c1', name: 'wa', type: 'dm' }; + const lastMessage = { + id: 1, + body: 'hi', + senderId: 'felipe', + createdAt: '2026-04-27T13:42:00.000Z', + metadata: { source: 'whatsapp', phone: '+5511999', conversationId: 'wa-abc' }, + }; + const entry = buildInboxEntry(conv, lastMessage); + expect(entry.conversation).toBe(conv); + expect(entry.lastMessage).toBe(lastMessage); + expect(entry.source).toBe('whatsapp'); + expect(entry.meta).toEqual({ + source: 'whatsapp', + phone: '+5511999', + conversationId: 'wa-abc', + }); + }); + + test('JSON entry defaults source to agent when missing', () => { + const entry = buildInboxEntry({ id: 'c2' }, { metadata: {} }); + expect(entry.source).toBe('agent'); + expect(entry.meta).toEqual({}); + }); + + test('handles null last message', () => { + const entry = buildInboxEntry({ id: 'c3' }, null); + expect(entry.lastMessage).toBeNull(); + expect(entry.source).toBe('agent'); + expect(entry.meta).toEqual({}); + }); +}); diff --git a/src/term-commands/agent/inbox.ts b/src/term-commands/agent/inbox.ts index 3e71cdac6..311c17373 100644 --- a/src/term-commands/agent/inbox.ts +++ b/src/term-commands/agent/inbox.ts @@ -15,19 +15,59 @@ async function getTaskService(): Promise { return _taskService; } +/** + * Extract the channel `source` tag from a message-like object. Defaults to + * `'agent'` for messages without metadata or with an empty/non-string source. + * + * Exported for unit tests and future renderers. + */ +// biome-ignore lint/suspicious/noExplicitAny: message metadata shape varies across stores +export function extractSource(msg: any): string { + if (!msg || typeof msg !== 'object') return 'agent'; + const meta = (msg.metadata ?? {}) as Record; + const value = typeof meta.source === 'string' ? meta.source : null; + return value && value.length > 0 ? value : 'agent'; +} + +/** + * Build the human-readable inbox lines for a single conversation/preview pair. + * Returns an array of lines (already padded) rather than printing directly so + * tests can assert against the rendered shape without capturing stdout. + */ // biome-ignore lint/suspicious/noExplicitAny: conversation + message from dynamic import -function printConversation(conv: any, lastMsg: any): void { +export function renderConversation(conv: any, lastMsg: any): string[] { const name = conv.name ?? conv.id; const type = conv.type === 'dm' ? 'DM' : 'Group'; const linked = conv.linkedEntity ? ` [${conv.linkedEntity}:${conv.linkedEntityId}]` : ''; const preview = lastMsg ? truncate(lastMsg.body, 50) : '(no messages)'; const time = lastMsg ? formatTime(lastMsg.createdAt) : ''; + const source = extractSource(lastMsg); + const sourceTag = source !== 'agent' ? `[${source}] ` : ''; - console.log(` ${padRight(name, 30)} ${padRight(type, 6)}${linked}`); + const lines = [` ${padRight(name, 30)} ${padRight(type, 6)}${linked}`]; if (lastMsg) { - console.log(` ${time} ${lastMsg.senderId}: ${preview}`); + lines.push(` ${sourceTag}${time} ${lastMsg.senderId}: ${preview}`); } - console.log(''); + lines.push(''); + return lines; +} + +// biome-ignore lint/suspicious/noExplicitAny: conversation + message from dynamic import +function printConversation(conv: any, lastMsg: any): void { + for (const line of renderConversation(conv, lastMsg)) console.log(line); +} + +/** + * Build an enriched inbox entry per conversation: the conversation row, its + * last message preview (or null), and the channel source/meta surfaced from + * the last message's metadata. Pure — exported for tests so callers can + * verify JSON output without touching stdout. + */ +// biome-ignore lint/suspicious/noExplicitAny: conversation + message from dynamic import +export function buildInboxEntry(conversation: any, lastMessage: any) { + const source = extractSource(lastMessage); + const meta = ((lastMessage?.metadata as Record | undefined) ?? {}) as Record; + return { conversation, lastMessage, source, meta }; } async function handleInbox(agent: string | undefined, options: { json?: boolean }): Promise { @@ -36,12 +76,23 @@ async function handleInbox(agent: string | undefined, options: { json?: boolean const actor: taskServiceTypes.Actor = { actorType: 'local', actorId: resolvedAgent }; const conversations = await ts.listConversations(actor); + // Hydrate the last message for every conversation in one pass so JSON and + // human renders share the same source-of-truth (and source/meta surface + // verbatim in JSON output). + const enriched = await Promise.all( + conversations.map(async (conv) => { + const messages = await ts.getMessages(conv.id, { limit: 1 }); + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; + return buildInboxEntry(conv, lastMessage); + }), + ); + if (options.json) { - console.log(JSON.stringify(conversations, null, 2)); + console.log(JSON.stringify(enriched, null, 2)); return; } - if (conversations.length === 0) { + if (enriched.length === 0) { console.log(`No conversations for "${resolvedAgent}".`); return; } @@ -50,10 +101,8 @@ async function handleInbox(agent: string | undefined, options: { json?: boolean console.log(`INBOX: ${resolvedAgent}`); console.log('─'.repeat(60)); - for (const conv of conversations) { - const messages = await ts.getMessages(conv.id, { limit: 1 }); - const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; - printConversation(conv, lastMsg); + for (const entry of enriched) { + printConversation(entry.conversation, entry.lastMessage); } }