feat(agent): per-session agent inbox (discussion #558)

A coordinator can leave text for a worker without typing into its pane.

- Store (src/web/agent-inbox.ts): read until ack by id, seen only for the
  receiver, 16 KiB/message, 512 KiB + 200 messages per inbox, full = 409
  CONFLICT (never drops the oldest), 12 h TTL from each message's createdAt.
- Routes (routes/inbox-routes.ts): POST/GET /api/sessions/:id/inbox
  (?wait= parks while nothing is unseen), POST .../inbox/ack (receiver
  only, 403 otherwise), GET /api/agent-inbox/summary (owner-scoped counts,
  not in toState()). Caller = resolveParentSessionId().
- Inbox waiters are the wait registry's third kind: same caps, ended by
  cancelAll/cancelEverything.
- Opt-in snapshot (agentInboxPersistEnabled, synced, default off):
  agent-inbox.json via dataPath(), 0600 tmp+rename, off deletes it, boot
  prune only after a successful restore. Dropped on delete, kept on detach.
- CLI: codeman agent post | inbox [--wait] | ack, INBOX column in ls.
- waitCapacityResponse/abortOnClientHangUp moved to route-helpers.ts
  unchanged.
- Docs: api-reference, README, CLAUDE.md, architecture-invariants, skill +
  plugin mirror.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-23 12:45:48 +02:00
1 parent 002de32a08
commit c6dfaa2cac
34 files changed
+2308 -83

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
*
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
* through tmp + rename, and a boot with the setting on restores what was pending.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { AgentInbox } from '../src/web/agent-inbox.js';
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
describe('AgentInboxPersistence', () => {
let dir: string;
let file: string;
let inbox: AgentInbox;
let persistence: AgentInboxPersistence;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
file = join(dir, 'agent-inbox.json');
inbox = new AgentInbox();
// Debounce 0 so a test can wait one macrotask instead of sleeping.
persistence = new AgentInboxPersistence(inbox, () => file, 0);
});
afterEach(() => {
persistence.dispose();
rmSync(dir, { recursive: true, force: true });
});
const tick = () => new Promise((r) => setTimeout(r, 5));
it('writes nothing while off (the default)', async () => {
persistence.load(false);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(false);
});
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(existsSync(`${file}.tmp`)).toBe(false);
});
it('switching on writes the current inboxes at once', () => {
persistence.load(false);
inbox.post('s', null, 'posted while off');
persistence.setEnabled(true);
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
});
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(true);
persistence.setEnabled(false);
expect(existsSync(file)).toBe(false);
expect(inbox.pendingCount('s')).toBe(1);
inbox.post('s', null, 'after off');
await tick();
expect(existsSync(file)).toBe(false);
});
it('a boot with the setting on restores pending mail', async () => {
persistence.load(true);
inbox.post('s', 'sender', 'survives');
await tick();
persistence.dispose();
const next = new AgentInbox();
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
expect(nextPersistence.load(true)).toBe(1);
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
nextPersistence.dispose();
});
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
expect(persistence.load(false)).toBe(0);
expect(existsSync(file)).toBe(false);
});
it('an unreadable file is an empty inbox, not a failed boot', () => {
writeFileSync(file, '{not json');
expect(persistence.load(true)).toBe(0);
});
it('flush writes a pending change before shutdown', () => {
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
slow.load(true);
inbox.post('s', null, 'just before shutdown');
expect(existsSync(file)).toBe(false);
slow.flush();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
slow.dispose();
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview How server.ts treats the agent inbox across a session's lifecycle.
*
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
* keep it, because a detached session comes back.
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
* every session state.json still holds. A failed restore leaves all mail alone.
*
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
* like tab-layout-restore-gate.test.ts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { agentInbox } from '../src/web/agent-inbox.js';
type CleanupServer = {
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
finalizeRestoredState(restored: boolean): Promise<void>;
[key: string]: unknown;
};
function bareServer(live: string[] = [], persisted: string[] = []): CleanupServer {
const server = Object.create(WebServer.prototype) as CleanupServer;
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
removeRalphState: () => {},
},
tabLayouts: {
markRestorationComplete: () => {},
markRestorationFailed: () => {},
reconcileAfterRestoration: async () => {},
},
cleanupStaleSessions: async () => 0,
_customModelDisplacedNotified: new Set(),
respawnControllers: new Map(),
respawnTimers: new Map(),
pendingRespawnStarts: new Map(),
runSummaryTrackers: new Map(),
stopTranscriptWatcher: () => {},
persistDeb: { cancelKey: () => {} },
sse: { cleanupSessionBatches: () => {} },
broadcast: () => {},
});
return server;
}
describe('agent inbox server wiring', () => {
beforeEach(() => agentInbox.resetForTests());
afterEach(() => agentInbox.resetForTests());
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
const server = bareServer();
agentInbox.post('detached', null, 'still here after detach');
agentInbox.post('deleted', null, 'gone with the session');
await server._doCleanupSession('detached', false, 'test');
await server._doCleanupSession('deleted', true, 'test');
expect(agentInbox.pendingCount('detached')).toBe(1);
expect(agentInbox.pendingCount('deleted')).toBe(0);
});
it('a failed restore prunes nothing', async () => {
const server = bareServer();
agentInbox.post('not-restored', null, 'm');
await server.finalizeRestoredState(false);
expect(agentInbox.pendingCount('not-restored')).toBe(1);
});
it('a successful restore prunes orphans and keeps live and persisted sessions', async () => {
const server = bareServer(['live'], ['kept-for-recovery']);
for (const id of ['live', 'kept-for-recovery', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(agentInbox.pendingCount('live')).toBe(1);
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
});
+263
View File
@@ -0,0 +1,263 @@
/**
* @fileoverview The agent inbox store (src/web/agent-inbox.ts), pure: no routes, no IO.
*
* Each block pins one rule from discussion #558: read until ack, seen only for the
* receiver, byte and count caps that refuse instead of dropping, a TTL counted from
* each message's own timestamp, and a snapshot that survives malformed input.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
AgentInbox,
MAX_INBOX_BYTES,
MAX_MESSAGE_BYTES,
MAX_MESSAGES_PER_INBOX,
MESSAGE_TTL_MS,
} from '../src/web/agent-inbox.js';
const RX = 'receiver-session';
describe('AgentInbox', () => {
let now: number;
let inbox: AgentInbox;
beforeEach(() => {
now = 1_000_000;
inbox = new AgentInbox(() => now);
});
const post = (text: string, from: string | null = 'sender') => {
const r = inbox.post(RX, from, text);
if (!r.ok) throw new Error(`post refused: ${r.reason}`);
return r.message;
};
describe('read until ack', () => {
it('a read removes nothing; the same messages come back until acked', () => {
const a = post('first');
const b = post('second');
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.pendingCount(RX)).toBe(2);
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([b.id]);
});
it('ack is idempotent: an id that is already gone or never existed is not an error', () => {
const a = post('x');
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.ack(RX, [a.id])).toBe(0);
expect(inbox.ack(RX, ['no-such-id'])).toBe(0);
expect(inbox.ack('no-such-session', [a.id])).toBe(0);
});
it('ack removes only the given ids, never a message posted after the read', () => {
const a = post('read me');
inbox.read(RX, { receiver: true });
const late = post('arrived after the read');
inbox.ack(RX, [a.id]);
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('keeps multi-line text byte for byte', () => {
const text = 'line one\nline two\r\n\ttabbed\n';
post(text);
expect(inbox.read(RX, { receiver: true }).messages[0].text).toBe(text);
});
});
describe('seen belongs to the receiver', () => {
it('a receiver read marks seen; the flag on each message is the state BEFORE this read', () => {
post('m');
const first = inbox.read(RX, { receiver: true });
expect(first.messages[0].seen).toBe(false);
expect(first.unseen).toBe(0);
const second = inbox.read(RX, { receiver: true });
expect(second.messages[0].seen).toBe(true);
});
it('a peek changes nothing: unseen stays unseen', () => {
post('m');
const peek = inbox.read(RX, { receiver: false });
expect(peek.messages[0].seen).toBe(false);
expect(peek.unseen).toBe(1);
expect(inbox.unseenCount(RX)).toBe(1);
expect(inbox.summary().get(RX)).toMatchObject({ pending: 1, unseen: 1 });
});
it('a message posted after a receiver read is unseen again', () => {
post('a');
inbox.read(RX, { receiver: true });
expect(inbox.unseenCount(RX)).toBe(0);
post('b');
expect(inbox.unseenCount(RX)).toBe(1);
});
});
describe('caps refuse, never drop the oldest', () => {
it(`refuses message ${MAX_MESSAGES_PER_INBOX + 1} and keeps the first one`, () => {
const first = post('0');
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) post(String(i));
expect(inbox.post(RX, null, 'one too many')).toEqual({ ok: false, reason: 'inbox-full-count' });
const ids = inbox.read(RX, { receiver: false }).messages.map((m) => m.id);
expect(ids).toHaveLength(MAX_MESSAGES_PER_INBOX);
expect(ids[0]).toBe(first.id);
});
it('counts the per-message cap in UTF-8 bytes, not characters', () => {
// 'é' is 2 bytes: half the byte budget in characters already fills it.
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2)).ok).toBe(true);
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2 + 1))).toEqual({
ok: false,
reason: 'message-too-large',
});
});
it('refuses a post that would cross the per-inbox byte cap', () => {
const chunk = 'x'.repeat(MAX_MESSAGE_BYTES);
const fit = Math.floor(MAX_INBOX_BYTES / MAX_MESSAGE_BYTES);
for (let i = 0; i < fit; i++) post(chunk);
expect(inbox.post(RX, null, 'x'.repeat(MAX_INBOX_BYTES - fit * MAX_MESSAGE_BYTES + 1))).toEqual({
ok: false,
reason: 'inbox-full-bytes',
});
expect(inbox.pendingCount(RX)).toBe(fit);
});
it('an ack makes room again', () => {
const ids: string[] = [];
for (let i = 0; i < MAX_MESSAGES_PER_INBOX; i++) ids.push(post(String(i)).id);
inbox.ack(RX, [ids[0]]);
expect(inbox.post(RX, null, 'now it fits').ok).toBe(true);
});
it('refuses empty text', () => {
expect(inbox.post(RX, null, '')).toEqual({ ok: false, reason: 'empty' });
});
});
describe('TTL from the message timestamp', () => {
it('a message older than the TTL is gone, read or not', () => {
post('old');
now += MESSAGE_TTL_MS / 2;
const fresh = post('fresh');
now += MESSAGE_TTL_MS / 2 + 1;
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([fresh.id]);
});
it('a restore keeps createdAt, so a restart does not restart the clock', () => {
post('m');
const snap = JSON.parse(JSON.stringify(inbox.snapshot()));
now += MESSAGE_TTL_MS - 1000;
const restarted = new AgentInbox(() => now);
expect(restarted.restore(snap)).toBe(1);
now += 1001;
expect(restarted.pendingCount(RX)).toBe(0);
});
it('does not restore a message that expired while the server was down', () => {
post('m');
const snap = inbox.snapshot();
now += MESSAGE_TTL_MS + 1;
expect(new AgentInbox(() => now).restore(snap)).toBe(0);
});
it('expiry fires onChange so the snapshot catches up', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
now += MESSAGE_TTL_MS + 1;
inbox.summary();
expect(changes).toBe(1);
});
});
describe('summary', () => {
it('reports counts and the oldest unseen time, never text', () => {
const a = post('secret one');
now += 10;
post('secret two');
inbox.read(RX, { receiver: true });
now += 10;
const c = post('secret three');
const row = inbox.summary().get(RX);
expect(row).toEqual({ pending: 3, unseen: 1, oldestUnseenAt: c.createdAt });
expect(JSON.stringify(row)).not.toContain('secret');
expect(a.createdAt).toBeLessThan(c.createdAt);
});
it('leaves out sessions with an empty inbox', () => {
const a = post('m');
inbox.ack(RX, [a.id]);
expect(inbox.summary().has(RX)).toBe(false);
});
});
describe('lifecycle', () => {
it('drop discards the inbox and fires onChange', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
inbox.drop(RX);
expect(inbox.pendingCount(RX)).toBe(0);
expect(changes).toBe(1);
});
it('pruneExcept drops only inboxes of sessions not kept', () => {
inbox.post('keep-me', null, 'a');
inbox.post('gone', null, 'b');
expect(inbox.pruneExcept(['keep-me'])).toBe(1);
expect(inbox.pendingCount('keep-me')).toBe(1);
expect(inbox.pendingCount('gone')).toBe(0);
});
});
describe('snapshot / restore', () => {
it('round-trips messages, senders and the seen state', () => {
const a = post('from a session', 'sender-1');
post('from the user', null);
inbox.read(RX, { receiver: true });
const c = post('unseen');
const restored = new AgentInbox(() => now);
expect(restored.restore(JSON.parse(JSON.stringify(inbox.snapshot())))).toBe(3);
const read = restored.read(RX, { receiver: false });
expect(read.messages.map((m) => [m.id, m.from, m.seen])).toEqual([
[a.id, 'sender-1', true],
[expect.any(String), null, true],
[c.id, 'sender', false],
]);
});
it('skips malformed entries and files instead of throwing', () => {
const restored = new AgentInbox(() => now);
expect(restored.restore(null)).toBe(0);
expect(restored.restore({ version: 2, inboxes: {} })).toBe(0);
expect(restored.restore({ version: 1, inboxes: { s: 'nope' } })).toBe(0);
const good = { id: 'g', from: null, text: 'ok', createdAt: now, seenAt: null };
expect(
restored.restore({
version: 1,
inboxes: {
s: [
good,
{ id: 1 },
{ ...good, id: 'big', text: 'x'.repeat(MAX_MESSAGE_BYTES + 1) },
{ ...good, text: '' },
],
},
})
).toBe(1);
});
it('holds the count cap on restore', () => {
const many = Array.from({ length: MAX_MESSAGES_PER_INBOX + 5 }, (_, i) => ({
id: `m${i}`,
from: null,
text: 'x',
createdAt: now,
seenAt: null,
}));
const restored = new AgentInbox(() => now);
expect(restored.restore({ version: 1, inboxes: { s: many } })).toBe(MAX_MESSAGES_PER_INBOX);
});
});
});
+146
View File
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentAck,
agentInboxRead,
agentInterrupt,
agentLs,
agentPost,
agentRead,
agentRm,
agentSend,
@@ -28,10 +31,12 @@ import {
deleteRefusal,
describeFailure,
httpRequest,
inboxCell,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
postTextFromArgs,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
@@ -607,6 +612,147 @@ describe('session id prefixes', () => {
});
});
describe('agent inbox verbs', () => {
const MSG = {
id: 'm-1',
from: OTHER,
text: 'line one\nline two',
createdAt: 1_700_000_000_000 - 90_000,
seen: false,
};
it('ls merges the inbox summary into an INBOX column', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({
sessions: {
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
},
})
: ok([
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
});
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
const deps = fakeDeps(
(o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
: ok([{ id: SELF }, { id: OTHER }]),
true
);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
});
it('ls still lists sessions when the server has no summary route', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
expect(deps.out.join('\n')).not.toContain('INBOX');
});
it('inboxCell', () => {
expect(inboxCell(undefined)).toBe('');
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
});
it('post sends the text as data to the resolved session and never to /input', async () => {
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
expect(deps.calls).toEqual([
expect.objectContaining({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/inbox`,
body: { text: 'line one\nline two' },
}),
]);
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
});
it('post passes a full inbox (409) through as an error', async () => {
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
});
it('post takes ONE argument and refuses an empty one before any request', async () => {
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
const deps = fakeDeps([]);
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
expect(deps.calls[0].query).toEqual({ wait: undefined });
const text = deps.out.join('\n');
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
expect(text).toContain('line one\nline two');
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
});
it('inbox --wait passes the budget and maps timeout to exit 2, a vanished session to exit 3', async () => {
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
const ended = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } })]);
expect(await agentInboxRead(ended, { waitMs: 30_000 })).toBe(EXIT.dead);
});
it('ack posts the ids to this session’s own inbox', async () => {
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${SELF}/inbox/ack`,
body: { ids: ['m-1', 'm-2'] },
});
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
});
it('ack refuses without ids and passes a 403 through', async () => {
const none = fakeDeps([]);
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
expect(none.calls).toEqual([]);
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
});
it('none of the inbox verbs writes to a pane', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
);
await agentPost(deps, { id: OTHER, text: 'x' });
await agentInboxRead(deps, {});
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
await agentAck(deps, { ids: ['m'] });
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
+255
View File
@@ -0,0 +1,255 @@
/**
* @fileoverview Agent inbox routes (src/web/routes/inbox-routes.ts) via app.inject().
*
* The caller is whoever `X-Codeman-Parent-Session` resolves to (live session, same
* owner as the receiver). These tests pin what that decides: who a post is from,
* whether a read marks seen or is a peek, and who may ack. Plus the wait rule (parks
* while nothing is UNSEEN), the full-inbox 409 and the owner-scoped summary.
*
* The routes use the process-wide `agentInbox` and `sessionWaits`; every test resets
* the inbox and cancels its waiters.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerInboxRoutes } from '../../src/web/routes/inbox-routes.js';
import { agentInbox, MAX_MESSAGES_PER_INBOX } from '../../src/web/agent-inbox.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode, type AuthUser } from '../../src/types.js';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
const RX = 'aaaaaaaa-1111-4111-8111-111111111111';
const TX = 'bbbbbbbb-2222-4222-8222-222222222222';
const OTHER = 'cccccccc-3333-4333-8333-333333333333';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
let currentUser: AuthUser | undefined;
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: RX });
for (const id of [TX, OTHER]) ctx.sessions.set(id, createMockSession(id) as never);
app.decorateRequest('authUser', undefined);
app.addHook('onRequest', async (req) => {
req.authUser = currentUser;
});
registerInboxRoutes(app, ctx as never);
// server.ts's envelope hook: an error envelope carries its 4xx.
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && p.success === false && reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const as = (caller?: string) => (caller ? { 'x-codeman-parent-session': caller } : {});
describe('agent inbox routes', () => {
let h: Harness;
const post = (to: string, text: string, caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${to}/inbox`, payload: { text }, headers: as(caller) });
const read = (of: string, caller?: string, query = '') =>
h.app.inject({ method: 'GET', url: `/api/sessions/${of}/inbox${query}`, headers: as(caller) });
const ack = (of: string, ids: string[], caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${of}/inbox/ack`, payload: { ids }, headers: as(caller) });
const data = (res: { body: string }) => JSON.parse(res.body).data;
beforeEach(async () => {
agentInbox.resetForTests();
currentUser = undefined;
delete process.env.CODEMAN_MULTIUSER;
h = await createHarness();
});
afterEach(async () => {
for (const id of [RX, TX, OTHER]) sessionWaits.cancelAll(id);
await h.app.close();
agentInbox.resetForTests();
delete process.env.CODEMAN_MULTIUSER;
});
describe('from', () => {
it('is the resolved caller session (exact id or 8-char prefix)', async () => {
expect(data(await post(RX, 'hi', TX)).message.from).toBe(TX);
expect(data(await post(RX, 'hi', TX.slice(0, 8))).message.from).toBe(TX);
});
it('is null (the user) without a header, for an unknown id, or for a short prefix', async () => {
expect(data(await post(RX, 'a')).message.from).toBeNull();
expect(data(await post(RX, 'b', 'dddddddd-0000-4000-8000-000000000000')).message.from).toBeNull();
expect(data(await post(RX, 'c', TX.slice(0, 7))).message.from).toBeNull();
});
it('a body field cannot set it: the schema rejects unknown keys', async () => {
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${RX}/inbox`,
payload: { text: 'x', from: TX },
});
expect(res.statusCode).toBe(400);
});
it('multi-user: a session of another owner never resolves as the sender', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'admin' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(OTHER) as unknown as { owner: string }).owner = 'alice';
expect(data(await post(RX, 'x', TX)).message.from).toBeNull();
expect(data(await post(RX, 'y', OTHER)).message.from).toBe(OTHER);
});
});
describe('post', () => {
it('keeps multi-line text as data', async () => {
await post(RX, 'one\ntwo\n', TX);
expect(data(await read(RX, RX)).messages[0].text).toBe('one\ntwo\n');
});
it('a full inbox is 409 CONFLICT with a clear message, and the oldest stays', async () => {
const first = data(await post(RX, 'first')).message.id;
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) agentInbox.post(RX, null, String(i));
const res = await post(RX, 'one too many');
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body)).toMatchObject({ errorCode: 'CONFLICT' });
expect(JSON.parse(res.body).error).toMatch(/full .*Nothing was stored/);
expect(data(await read(RX)).messages[0].id).toBe(first);
});
it('a message over the byte cap is 400', async () => {
expect((await post(RX, 'é'.repeat(8193))).statusCode).toBe(400);
});
it('404 for an unknown session', async () => {
expect((await post('nope', 'x')).statusCode).toBe(404);
});
});
describe('read', () => {
it('only the receiver marks seen; a coordinator read is a peek', async () => {
await post(RX, 'task', TX);
const peek = data(await read(RX, TX));
expect(peek).toMatchObject({ receiver: false, pending: 1, unseen: 1 });
expect(data(await read(RX)).unseen).toBe(1);
const own = data(await read(RX, RX));
expect(own).toMatchObject({ receiver: true, pending: 1, unseen: 0 });
expect(own.messages[0].seen).toBe(false);
expect(data(await read(RX, RX)).messages[0].seen).toBe(true);
});
it('?wait= parks while nothing is unseen, even with a seen-but-unacked message', async () => {
await post(RX, 'already seen', TX);
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
expect(sessionWaits.inboxWaiterCount(RX, true)).toBe(1);
await post(RX, 'new', TX);
const res = data(await parked);
expect(res.wait).toMatchObject({ posted: true, timedOut: false });
expect(res.messages.map((m: { text: string; seen: boolean }) => [m.text, m.seen])).toEqual([
['already seen', true],
['new', false],
]);
});
it('?wait= answers at once when something is unseen', async () => {
await post(RX, 'm', TX);
const res = data(await read(RX, RX, '?wait=60000'));
expect(res.wait).toMatchObject({ immediate: true, waitedMs: 0, timeoutMs: 60000 });
});
it('?wait= runs out as a 200 with timedOut, clamped to the wait floor', async () => {
const res = await read(RX, RX, '?wait=1');
expect(res.statusCode).toBe(200);
expect(data(res).wait).toMatchObject({ timedOut: true, timeoutMs: 1000 });
expect(res.headers['cache-control']).toBe('no-store');
});
it('?wait= rejects junk', async () => {
expect((await read(RX, RX, '?wait=-5')).statusCode).toBe(400);
expect((await read(RX, RX, '?wait=abc')).statusCode).toBe(400);
});
it('a parked read on a session that goes away answers ended, with an empty inbox', async () => {
await post(RX, 'x');
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
// What server.ts does on delete: drop the inbox, then cancel the waiters.
agentInbox.drop(RX);
sessionWaits.cancelAll(RX);
const res = data(await parked);
expect(res.wait).toMatchObject({ ended: true });
expect(res.messages).toEqual([]);
});
});
describe('ack', () => {
it('removes by id for the receiver, idempotently', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
expect(data(await ack(RX, [id], RX))).toMatchObject({ removed: 1, pending: 0 });
const again = await ack(RX, [id, 'never-existed'], RX);
expect(again.statusCode).toBe(200);
expect(data(again)).toMatchObject({ removed: 0, pending: 0 });
});
it('is 403 for anyone but the receiver, and removes nothing', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
for (const caller of [TX, undefined]) {
const res = await ack(RX, [id], caller);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
}
expect(agentInbox.pendingCount(RX)).toBe(1);
});
it('rejects an empty id list', async () => {
expect((await ack(RX, [], RX)).statusCode).toBe(400);
});
});
describe('summary', () => {
it('reports counts and parked per session, never text', async () => {
await post(RX, 'secret', TX);
await post(TX, 'other secret', RX);
await read(TX, TX);
const parked = read(OTHER, OTHER, '?wait=5000');
// A coordinator peeking with ?wait= is not the receiver: not "parked".
const peeking = read(RX, TX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(res.body).not.toContain('secret');
expect(data(res).sessions).toEqual({
[RX]: { pending: 1, unseen: 1, oldestUnseenAt: expect.any(Number), parked: false },
[TX]: { pending: 1, unseen: 0, oldestUnseenAt: null, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
});
sessionWaits.cancelAll(OTHER);
sessionWaits.cancelAll(RX);
await Promise.all([parked, peeking]);
});
it('multi-user: lists only sessions the caller can see', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'user' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
agentInbox.post(RX, null, 'mine');
agentInbox.post(TX, null, 'bob’s');
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(Object.keys(data(res).sessions)).toEqual([RX]);
});
});
});
+1
View File
@@ -88,6 +88,7 @@ describe('isSensitivePath', () => {
['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`],
['codeman intent profiles', `${HOME}/.codeman/intents.json`],
['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`],
['codeman agent inbox snapshot', `${HOME}/.codeman/agent-inbox.json`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
+63
View File
@@ -1088,3 +1088,66 @@ describe('SessionWaitRegistry: teardown and leak prevention', () => {
expect(reg.totalWaiterCount()).toBe(0);
});
});
describe('inbox waiters', () => {
it('resolve with posted:true on notifyInbox, and only for that session', async () => {
const reg = new SessionWaitRegistry();
const a = reg.waitForInbox('a', { timeoutMs: 5_000 });
const b = reg.waitForInbox('b', { timeoutMs: 50 });
expect(reg.notifyInbox('a')).toBe(1);
await expect(a).resolves.toMatchObject({ posted: true, timedOut: false, ended: false });
await expect(b).resolves.toMatchObject({ posted: false, timedOut: true });
expect(reg.totalWaiterCount()).toBe(0);
});
it('count toward the per-session, per-owner and total caps like the other kinds', async () => {
const reg = new SessionWaitRegistry({ maxWaitersPerSession: 2, maxWaitersPerOwner: 3, maxWaitersTotal: 4 });
const pending = [
reg.waitForSignal('s', { until: ['stop'], timeoutMs: 5_000, owner: 'alice' }),
reg.waitForInbox('s', { timeoutMs: 5_000, owner: 'alice' }),
];
expect(() => reg.assertCapacity('s', 'alice')).toThrow(WaitCapacityError);
expect(() => reg.waitForInbox('s', { timeoutMs: 5_000 })).toThrow(/scope: session/);
pending.push(reg.waitForInbox('t', { timeoutMs: 5_000, owner: 'alice' }));
expect(() => reg.waitForInbox('u', { timeoutMs: 5_000, owner: 'alice' })).toThrow(/scope: owner/);
pending.push(reg.waitForInbox('u', { timeoutMs: 5_000, owner: 'bob' }));
expect(() => reg.waitForInbox('v', { timeoutMs: 5_000 })).toThrow(/scope: total/);
expect(reg.waiterCount('s')).toBe(2);
reg.stop();
await Promise.all(pending);
expect(reg.ownerWaiterCount('alice')).toBe(0);
});
it('counts receiver waiters separately (the summary’s parked)', () => {
const reg = new SessionWaitRegistry();
void reg.waitForInbox('s', { timeoutMs: 5_000, receiver: false });
expect(reg.inboxWaiterCount('s')).toBe(1);
expect(reg.inboxWaiterCount('s', true)).toBe(0);
void reg.waitForInbox('s', { timeoutMs: 5_000, receiver: true });
expect(reg.inboxWaiterCount('s', true)).toBe(1);
reg.stop();
});
it('cancelAll ends them with ended:true, and an abort frees the slot at once', async () => {
const reg = new SessionWaitRegistry();
const ended = reg.waitForInbox('s', { timeoutMs: 5_000 });
expect(reg.cancelAll('s')).toBe(1);
await expect(ended).resolves.toMatchObject({ ended: true, aborted: false, posted: false });
const controller = new AbortController();
const aborted = reg.waitForInbox('s', { timeoutMs: 5_000, abortSignal: controller.signal });
expect(reg.inboxWaiterCount('s')).toBe(1);
controller.abort();
await expect(aborted).resolves.toMatchObject({ ended: true, aborted: true });
expect(reg.inboxWaiterCount('s')).toBe(0);
});
it('after stop() a new inbox wait resolves at once with ended:true', async () => {
const reg = new SessionWaitRegistry();
const parked = reg.waitForInbox('s', { timeoutMs: 5_000 });
reg.stop();
await expect(parked).resolves.toMatchObject({ ended: true });
await expect(reg.waitForInbox('s', { timeoutMs: 5_000 })).resolves.toMatchObject({ ended: true, posted: false });
expect(reg.totalWaiterCount()).toBe(0);
});
});
+5
View File
@@ -45,7 +45,12 @@ describe('tab layout restore gate', () => {
};
cleanupStaleSessions(): Promise<number>;
finalizeRestoredState(restored: boolean): Promise<void>;
sessions: Map<string, unknown>;
store: { getSessions(): Record<string, unknown> };
};
// Read by the agent-inbox prune at the end of a successful restore.
server.sessions = new Map();
server.store = { getSessions: () => ({}) };
server.tabLayouts = {
markRestorationComplete: () => order.push('complete'),
markRestorationFailed: () => order.push('failed'),