mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
- codeman agent post: accept text that starts with "-" (markdown from a file), --json and -h stay options. - DELETE /api/sessions/:id on a persisted-only row drops the inbox a detach kept. - inbox --wait: wait.ended is exit 3 only when the session list no longer has the session; a restart or detach ending the wait is exit 2 (wait again). - A read whose client hung up during ?wait= marks nothing seen. - InboxPostSchema bound is 4x the byte cap, so every oversize message gets the store's "post a file path" answer. - Store: restore sorts oldest first; the TTL sweep checks every message, not only the first. - Docs: admins can act as any session of the receiver's owner (now tested for admin and non-admin); a peek with ?wait= does not block; exit codes. - Tests: partial PUT /api/settings keeps persistence on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
/**
|
|
* @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('sweeps an expired message that is not first (clock stepped back)', () => {
|
|
const late = post('posted first, stamped later');
|
|
now -= 1000; // the clock steps back
|
|
const early = post('posted second, stamped earlier');
|
|
now += MESSAGE_TTL_MS + 1000 - (late.createdAt - early.createdAt) / 2;
|
|
// `early` is past the TTL, `late` is not, and `early` sits behind `late`.
|
|
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
|
|
});
|
|
|
|
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('restores oldest first whatever the file order, so the caps keep the oldest', () => {
|
|
const msg = (id: string, createdAt: number) => ({ id, from: null, text: 'x', createdAt, seenAt: null });
|
|
const restored = new AgentInbox(() => now);
|
|
restored.restore({ version: 1, inboxes: { s: [msg('new', now - 10), msg('old', now - 20)] } });
|
|
expect(restored.read('s', { receiver: false }).messages.map((m) => m.id)).toEqual(['old', 'new']);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|