Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/db/migrations/054_mailbox_source_meta.sql
Original file line number Diff line number Diff line change
@@ -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
-- `<channel …>` 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;
106 changes: 106 additions & 0 deletions src/lib/__tests__/mailbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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('<channel ')).toBe(true);
expect(native.text).toContain('source="whatsapp"');
expect(native.text).toContain('phone="+5511999"');
expect(native.text.endsWith('whats up genie</channel>')).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');
});
});

// ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions src/lib/brief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ describe('formatBrief', () => {
createdAt: '2026-03-28T14:26:00.000Z',
read: false,
deliveredAt: null,
source: 'agent',
meta: {},
},
{
id: 'msg-2',
Expand All @@ -54,6 +56,8 @@ describe('formatBrief', () => {
createdAt: '2026-03-28T14:30:00.000Z',
read: false,
deliveredAt: null,
source: 'agent',
meta: {},
},
],
taskMessages: [],
Expand Down
124 changes: 124 additions & 0 deletions src/lib/channel-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -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(/^<channel /);
expect(out).toMatch(/source="whatsapp"/);
expect(out).toMatch(/from="\+5511999999999"/);
expect(out).toMatch(/phone="\+5511999999999"/);
expect(out).toMatch(/conversationId="wa-123"/);
expect(out.endsWith('whats up genie</channel>')).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 = '<channel source="whatsapp" from="+55" phone="+55">hello</channel>';
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('<channel source="x">no close')).toBeNull();
expect(parseEnvelope('<channel >missing attrs</channel>')).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 = ' \n<channel source="system">x</channel>';
expect(parseEnvelope(text)?.body).toBe('x');
});
});
Loading
Loading