mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
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:
1 parent
002de32a08
commit
c6dfaa2cac
34 files changed
+2308
-83
No files matched your search
@@ -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)!;
|
||||
|
||||
Reference in new issue
Block a user