@orboto/mcp 0.225.0 → 0.226.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-start.test.js +2 -2
- package/dist/project-scope.js +20 -4
- package/dist/project-scope.test.js +24 -4
- package/dist/session-origin.test.js +4 -0
- package/dist/tools/agent-coordination.js +42 -12
- package/dist/tools/agent-coordination.test.js +82 -2
- package/dist/tools/session-start.js +2 -1
- package/package.json +1 -1
|
@@ -67,7 +67,7 @@ describe('ORB-2584 - the channel needs a channel-enabled start', () => {
|
|
|
67
67
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
68
68
|
const path = new URL(url.toString()).pathname;
|
|
69
69
|
const map = {
|
|
70
|
-
'/v1/agent/heartbeat': { sessionToken: 'mcp-x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null, wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' }, pid: 4100, startedAt: null, forPid: 5200, expiredAt: 'now' }, notes },
|
|
70
|
+
'/v1/agent/heartbeat': { sessionToken: 'mcp-x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null, wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' }, pid: 4100, startedAt: null, forPid: 5200, expiredAt: 'now' }, scopeContinued: { scope: { role: 'lead' }, pid: 4100, startedAt: null, forPid: 5200, conversation: 'conv-1', continuedAt: 'now' }, notes },
|
|
71
71
|
'/users/me': { email: 'dev@example.invalid', fullName: 'Dev' },
|
|
72
72
|
'/agent-instructions': { instructions: 'rules', rulesHash: 'h' },
|
|
73
73
|
};
|
|
@@ -78,6 +78,6 @@ describe('ORB-2584 - the channel needs a channel-enabled start', () => {
|
|
|
78
78
|
const text = res.content[0].text;
|
|
79
79
|
for (const note of notes)
|
|
80
80
|
expect(text).toContain(note);
|
|
81
|
-
expect(res.structuredContent).toMatchObject({ session: { wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' } } } });
|
|
81
|
+
expect(res.structuredContent).toMatchObject({ session: { wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' } }, scopeContinued: { conversation: 'conv-1' } } });
|
|
82
82
|
});
|
|
83
83
|
});
|
package/dist/project-scope.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { rememberDeclaredScope } from './session-scope-memory.js';
|
|
2
4
|
/** Must stay equal to ProjectKeyEnv in cli/internal/cmd/project_scope.go (drift test). */
|
|
3
5
|
export const PROJECT_KEY_ENV = 'ORBOTO_PROJECT_KEY';
|
|
@@ -19,15 +21,29 @@ export function envProjectScope(raw = process.env.ORBOTO_PROJECT_KEY, role = pro
|
|
|
19
21
|
export function scopeDeclared(scope) {
|
|
20
22
|
return !!scope && (!!scope.role || (scope.projectKeys?.length ?? 0) > 0 || (scope.ticketKeys?.length ?? 0) > 0);
|
|
21
23
|
}
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
24
|
+
/** ORB-2593 - true while the `orboto claude` supervisor of this session waits for the conversation a bare `--resume` picks: no default scope is declared before it. */
|
|
25
|
+
export function supervisorScopePending(env = process.env) {
|
|
26
|
+
const dir = (env.ORBOTO_CLAUDE_SUPERVISOR_DIR ?? '').trim();
|
|
27
|
+
const launcher = Number((env.ORBOTO_LAUNCHER_PID ?? '').trim());
|
|
28
|
+
if (!dir || !Number.isInteger(launcher) || launcher <= 1)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'));
|
|
32
|
+
return state.pid === launcher && state.scopePending === true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Declares the environment's project on the session row unless the row already carries a scope or the supervisor waits for a picked conversation; answers the row. */
|
|
39
|
+
export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY, role = process.env.ORBOTO_SESSION_ROLE, pending = supervisorScopePending()) {
|
|
24
40
|
const scope = envProjectScope(raw, role);
|
|
25
|
-
if (!scope)
|
|
41
|
+
if (!scope || pending)
|
|
26
42
|
return null;
|
|
27
43
|
const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
|
|
28
44
|
if (current && scopeDeclared(current.scope))
|
|
29
45
|
return current;
|
|
30
|
-
const row = await client.post('/v1/agent/heartbeat', { scope }, { instanceToken }).catch(() => null);
|
|
46
|
+
const row = await client.post('/v1/agent/heartbeat', { scope, scopeDefault: true }, { instanceToken }).catch(() => null);
|
|
31
47
|
if (row && typeof row.sessionId === 'string')
|
|
32
48
|
rememberDeclaredScope(instanceToken, row.scope ?? scope);
|
|
33
49
|
return row;
|
|
@@ -41,7 +41,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
|
|
|
41
41
|
await declareEnvProjectScope(client, 'agent-abc', 'ACME');
|
|
42
42
|
expect(client.calls).toEqual([
|
|
43
43
|
{ method: 'GET', path: '/v1/agent/session', instanceToken: 'agent-abc' },
|
|
44
|
-
{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] } }, instanceToken: 'agent-abc' },
|
|
44
|
+
{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' },
|
|
45
45
|
]);
|
|
46
46
|
expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
47
47
|
});
|
|
@@ -79,7 +79,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
|
|
|
79
79
|
});
|
|
80
80
|
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
|
|
81
81
|
const res = await makeSessionStartHandler(client)({}, { sessionId: 'scope1' });
|
|
82
|
-
expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] } }]);
|
|
82
|
+
expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }]);
|
|
83
83
|
expect(res.content[0].text).toContain('projects ACME');
|
|
84
84
|
expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
85
85
|
});
|
|
@@ -99,7 +99,7 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
|
|
|
99
99
|
expect(envProjectScope(undefined, 'lead')).toBeNull();
|
|
100
100
|
const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
|
|
101
101
|
await declareEnvProjectScope(client, 'agent-control', undefined, 'coordinator');
|
|
102
|
-
expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' } }, instanceToken: 'agent-control' });
|
|
102
|
+
expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' }, scopeDefault: true }, instanceToken: 'agent-control' });
|
|
103
103
|
expect(rememberedScope('agent-control')).toEqual({ role: 'coordinator' });
|
|
104
104
|
});
|
|
105
105
|
it('orboto_session_start without a scope declares the control session the environment names', async () => {
|
|
@@ -124,7 +124,27 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
|
|
|
124
124
|
});
|
|
125
125
|
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
|
|
126
126
|
await makeSessionStartHandler(client)({}, { sessionId: 'control1' });
|
|
127
|
-
expect(beats).toEqual([{}, { scope: { role: 'coordinator' } }]);
|
|
127
|
+
expect(beats).toEqual([{}, { scope: { role: 'coordinator' }, scopeDefault: true }]);
|
|
128
128
|
expect(rememberedScope('mcp-control1')).toEqual({ role: 'coordinator' });
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
|
+
describe('ORB-2593 - a picker-pending orboto claude start', () => {
|
|
132
|
+
it('declares no default scope while the supervisor waits for the picked conversation, and declares it once the pick resolved', async () => {
|
|
133
|
+
const { mkdtempSync, writeFileSync } = await import('node:fs');
|
|
134
|
+
const { tmpdir } = await import('node:os');
|
|
135
|
+
const { join } = await import('node:path');
|
|
136
|
+
const { supervisorScopePending } = await import('./project-scope.js');
|
|
137
|
+
const dir = mkdtempSync(join(tmpdir(), 'orboto-supervisor-'));
|
|
138
|
+
const env = { ORBOTO_CLAUDE_SUPERVISOR_DIR: dir, ORBOTO_LAUNCHER_PID: '4000' };
|
|
139
|
+
writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000, scopePending: true }));
|
|
140
|
+
expect(supervisorScopePending(env)).toBe(true);
|
|
141
|
+
expect(supervisorScopePending({ ...env, ORBOTO_LAUNCHER_PID: '4001' })).toBe(false);
|
|
142
|
+
const pending = fakeClient({ sessionId: 'x', scope: null });
|
|
143
|
+
expect(await declareEnvProjectScope(pending, 'agent-abc', 'ACME', undefined, supervisorScopePending(env))).toBeNull();
|
|
144
|
+
expect(pending.calls).toEqual([]);
|
|
145
|
+
writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000 }));
|
|
146
|
+
const resolved = fakeClient({ sessionId: 'x', scope: null });
|
|
147
|
+
await declareEnvProjectScope(resolved, 'agent-abc', 'ACME', undefined, supervisorScopePending(env));
|
|
148
|
+
expect(resolved.calls.filter((c) => c.method === 'POST')).toEqual([{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' }]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -20,6 +20,10 @@ describe('ORB-2544 - the origin header', () => {
|
|
|
20
20
|
expect(params.get('harness')).toBe('claude-code');
|
|
21
21
|
expect(params.get('client')).toBe('orboto-mcp');
|
|
22
22
|
});
|
|
23
|
+
it('ORB-2593 - the handed origin keeps the launch, the channel server verdict, the conversation and the checkout', () => {
|
|
24
|
+
const params = new URLSearchParams(stdioOrigin({ ORBOTO_ORIGIN: 'pid=99&harness=claude-code&launch=orboto-claude&server=running&conv=conv-1111&checkout=0123456789abcdef' }));
|
|
25
|
+
expect([params.get('launch'), params.get('server'), params.get('conv'), params.get('checkout')]).toEqual(['orboto-claude', 'running', 'conv-1111', '0123456789abcdef']);
|
|
26
|
+
});
|
|
23
27
|
it('ORB-2571 - a stdio proxy names the harness orboto <harness> started and its model, else the harness markers', () => {
|
|
24
28
|
expect(stdioHarness({ ORBOTO_HARNESS: 'kimi', CLAUDECODE: '1' })).toBe('kimi');
|
|
25
29
|
expect(stdioHarness({ ORBOTO_HARNESS: 'antigravity' })).toBe('agy');
|
|
@@ -121,9 +121,13 @@ export function noRecipientText(err) {
|
|
|
121
121
|
return null;
|
|
122
122
|
const p = body.errorParams ?? {};
|
|
123
123
|
const live = (p.liveSessions ?? []).map((s) => `${s.addresses[0] ?? s.email} (${s.email}, ${s.shortId})`);
|
|
124
|
+
const ways = 'a role address in `to` (integrator@KEY), `toSessionRef` with an instance short id, or `queue: true` with `project` to keep it until a session declares the project';
|
|
125
|
+
if (p.reason === 'ambiguous_account') {
|
|
126
|
+
return `not sent: ${p.address ?? 'that account'} has ${live.length} live sessions you may address and a request needs one of them. Sessions: ${live.join('; ')}. Send again with \`toSessionRef\` set to the instance short id of one, or address a role (${ways}).`;
|
|
127
|
+
}
|
|
124
128
|
return `not sent: no live session can receive a request to ${p.address ?? '?'} (${p.reason ?? 'no_recipient'}). `
|
|
125
|
-
+ (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session
|
|
126
|
-
+
|
|
129
|
+
+ (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session answers there. ')
|
|
130
|
+
+ `Ways on: ${ways}.`;
|
|
127
131
|
}
|
|
128
132
|
/** ORB-2542 - the 409 `owner_mismatch` as a tool error naming the owner and both ways on. */
|
|
129
133
|
export function ownerMismatchText(err) {
|
|
@@ -159,14 +163,15 @@ export function reminderTooSoonText(err) {
|
|
|
159
163
|
}
|
|
160
164
|
export const agentNotifyToolConfig = {
|
|
161
165
|
title: 'Notify an agent or user',
|
|
162
|
-
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome).
|
|
166
|
+
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other JSON); threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
|
|
163
167
|
inputSchema: z.object({
|
|
164
168
|
targetEmail: z.string().email().optional(),
|
|
165
169
|
to: z.string().min(3).max(200).optional(),
|
|
166
170
|
queue: z.boolean().optional(),
|
|
167
171
|
kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
|
|
168
172
|
subject: z.string().min(1).max(200),
|
|
169
|
-
outcome: z.string().
|
|
173
|
+
outcome: z.string().optional(),
|
|
174
|
+
body: z.string().optional(),
|
|
170
175
|
payload: z.record(z.string(), z.unknown()).optional(),
|
|
171
176
|
threadId: z.string().uuid().optional(),
|
|
172
177
|
project: z.string().min(1).max(64).optional(),
|
|
@@ -213,17 +218,41 @@ export function makeAgentBroadcastHandler(client) {
|
|
|
213
218
|
};
|
|
214
219
|
};
|
|
215
220
|
}
|
|
216
|
-
|
|
221
|
+
export const NOTIFY_OUTCOME_MIN = 10;
|
|
222
|
+
export const NOTIFY_OUTCOME_MAX = 1000;
|
|
223
|
+
export const NOTIFY_BODY_MAX_BYTES = 900 * 1024;
|
|
224
|
+
/** ORB-2555 - a complete example call for a kind, the answer to every refusal. */
|
|
225
|
+
export function notifyExample(kind, to) {
|
|
226
|
+
const target = to ?? 'integrator@KEY';
|
|
227
|
+
switch (kind) {
|
|
228
|
+
case 'request': return `orboto_agent_notify { to: "${target}", kind: "request", subject: "<one line>", outcome: "<what must be true when it is done>", body: "<details, any length>" }`;
|
|
229
|
+
case 'complete': return `orboto_agent_notify { to: "${target}", kind: "complete", subject: "<one line>", outcome: "<what is now true>", threadId: "<message id answered>" }`;
|
|
230
|
+
case 'error': return `orboto_agent_notify { to: "${target}", kind: "error", subject: "<one line>", body: "<what failed and what you tried>" }`;
|
|
231
|
+
default: return `orboto_agent_notify { to: "${target}", kind: "info", subject: "<one line>", body: "<text>" }`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** ORB-2223 - the message-kind definition this versioned sender enforces; the API only warns. ORB-2555 - every refusal names the flag and carries a complete example. */
|
|
217
235
|
export function notifyDefinitionError(args) {
|
|
218
236
|
const kind = args.kind ?? 'info';
|
|
237
|
+
const example = (text) => `Invalid arguments: ${text} Example: ${notifyExample(kind, args.to)}`;
|
|
219
238
|
if (!args.to && !args.targetEmail)
|
|
220
|
-
return '
|
|
221
|
-
|
|
222
|
-
|
|
239
|
+
return example('name a recipient - `to` (integrator@KEY, an email or a session id) or `targetEmail`.');
|
|
240
|
+
const outcome = args.outcome?.trim() ?? '';
|
|
241
|
+
if ((kind === 'request' || kind === 'complete') && !outcome) {
|
|
242
|
+
return example(`\`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered), ${NOTIFY_OUTCOME_MIN} to ${NOTIFY_OUTCOME_MAX} characters.`);
|
|
223
243
|
}
|
|
224
|
-
if (
|
|
225
|
-
return
|
|
244
|
+
if (outcome && outcome.length < NOTIFY_OUTCOME_MIN)
|
|
245
|
+
return example(`\`outcome\` has ${outcome.length} characters, at least ${NOTIFY_OUTCOME_MIN} are needed.`);
|
|
246
|
+
if (outcome.length > NOTIFY_OUTCOME_MAX) {
|
|
247
|
+
return example(`\`outcome\` has ${outcome.length} characters, at most ${NOTIFY_OUTCOME_MAX} are allowed: keep the outcome to one sentence and put the long text into \`body\` (any length, it lands in payload.body).`);
|
|
226
248
|
}
|
|
249
|
+
if (args.body !== undefined && !args.body.trim())
|
|
250
|
+
return example('`body` is empty - give the text or leave it out.');
|
|
251
|
+
if (args.body !== undefined && args.payload && 'body' in args.payload)
|
|
252
|
+
return example('`body` and `payload.body` are both set - use one of them.');
|
|
253
|
+
const bodyBytes = args.body === undefined ? 0 : Buffer.byteLength(args.body.replace(/[\r\n]+$/, ''));
|
|
254
|
+
if (bodyBytes > NOTIFY_BODY_MAX_BYTES)
|
|
255
|
+
return example(`\`body\` has ${bodyBytes} bytes, at most ${NOTIFY_BODY_MAX_BYTES} (900 KiB) are allowed: split it into several messages or attach a file with the CLI.`);
|
|
227
256
|
return null;
|
|
228
257
|
}
|
|
229
258
|
export function makeAgentNotifyHandler(client) {
|
|
@@ -234,8 +263,9 @@ export function makeAgentNotifyHandler(client) {
|
|
|
234
263
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
235
264
|
let res;
|
|
236
265
|
try {
|
|
237
|
-
const { urgent, ...rest } = args;
|
|
238
|
-
|
|
266
|
+
const { urgent, body, ...rest } = args;
|
|
267
|
+
const payload = body !== undefined ? { ...(rest.payload ?? {}), body: body.replace(/[\r\n]+$/, '') } : rest.payload;
|
|
268
|
+
res = await client.post('/v1/agent/notify', { ...rest, ...(payload ? { payload } : {}), ...(urgent ? { priority: NOTIFY_PRIORITIES[1] } : {}), senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
239
269
|
}
|
|
240
270
|
catch (err) {
|
|
241
271
|
const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) ?? reminderTooSoonText(err) : null;
|
|
@@ -141,11 +141,13 @@ describe('orboto_agent_notify', () => {
|
|
|
141
141
|
const refused = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42' });
|
|
142
142
|
expect(refused.isError).toBe(true);
|
|
143
143
|
expect(refused.content[0].text).toContain('`outcome` is required for a request');
|
|
144
|
-
const noTarget = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42', outcome: 'ORB-42 has a review verdict' });
|
|
145
|
-
expect(noTarget.content[0].text).toContain('`project` (key or UUID) or `toSessionRef`');
|
|
146
144
|
const noOutcome = await handler({ targetEmail: 'bob@example.com', kind: 'complete', subject: 'Done' });
|
|
147
145
|
expect(noOutcome.content[0].text).toContain('`outcome` is required for a complete');
|
|
148
146
|
expect(capturedBody).toHaveLength(0);
|
|
147
|
+
const bareEmail = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42', outcome: 'ORB-42 has a review verdict' });
|
|
148
|
+
expect(bareEmail.isError).toBeUndefined();
|
|
149
|
+
expect(capturedBody).toHaveLength(1);
|
|
150
|
+
capturedBody.length = 0;
|
|
149
151
|
const result = await handler({
|
|
150
152
|
targetEmail: 'bob@example.com',
|
|
151
153
|
kind: 'request',
|
|
@@ -348,6 +350,84 @@ describe('orboto_agent_notify role addresses (ORB-2264)', () => {
|
|
|
348
350
|
expect(agentNotifyToolConfig.description).toContain('integrator@KEY');
|
|
349
351
|
});
|
|
350
352
|
});
|
|
353
|
+
describe('orboto_agent_notify body and refusals (ORB-2555)', () => {
|
|
354
|
+
const sentBodies = () => {
|
|
355
|
+
const bodies = [];
|
|
356
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
357
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
358
|
+
return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account' }) };
|
|
359
|
+
});
|
|
360
|
+
return bodies;
|
|
361
|
+
};
|
|
362
|
+
it('maps body into payload.body next to other payload keys and carries text beyond the outcome cap', async () => {
|
|
363
|
+
const bodies = sentBodies();
|
|
364
|
+
const long = 'detail line\n'.repeat(400);
|
|
365
|
+
await makeAgentNotifyHandler(client)({ to: 'worker@ORB', kind: 'complete', subject: 'report', outcome: 'the report is delivered in full', body: long, payload: { ticketKey: 'ORB-1' } });
|
|
366
|
+
expect(bodies[0].payload).toEqual({ ticketKey: 'ORB-1', body: long.trimEnd() });
|
|
367
|
+
expect(bodies[0].body).toBeUndefined();
|
|
368
|
+
await makeAgentNotifyHandler(client)({ to: 'worker@ORB', subject: 'plain', body: 'text' });
|
|
369
|
+
expect(bodies[1].payload).toEqual({ body: 'text' });
|
|
370
|
+
});
|
|
371
|
+
it('trims trailing newlines of the body and counts its limit in bytes like the CLI', async () => {
|
|
372
|
+
const bodies = sentBodies();
|
|
373
|
+
const handler = makeAgentNotifyHandler(client);
|
|
374
|
+
await handler({ to: 'worker@ORB', subject: 's', body: 'text\r\n\n' });
|
|
375
|
+
expect(bodies[0].payload).toEqual({ body: 'text' });
|
|
376
|
+
const tooBig = await handler({ to: 'worker@ORB', subject: 's', body: 'ä'.repeat(500 * 1024) });
|
|
377
|
+
expect(tooBig.content[0].text).toContain('bytes, at most 921600 (900 KiB)');
|
|
378
|
+
expect(bodies).toHaveLength(1);
|
|
379
|
+
});
|
|
380
|
+
it('refuses an outcome over 1000 characters with the remedy and a complete example, sending nothing', async () => {
|
|
381
|
+
const bodies = sentBodies();
|
|
382
|
+
const result = await makeAgentNotifyHandler(client)({ to: 'integrator@ORB', kind: 'complete', subject: 'report', outcome: 'x'.repeat(1001) });
|
|
383
|
+
expect(result.isError).toBe(true);
|
|
384
|
+
const text = result.content[0].text;
|
|
385
|
+
expect(text).toContain('`outcome` has 1001 characters, at most 1000');
|
|
386
|
+
expect(text).toContain('put the long text into `body`');
|
|
387
|
+
expect(text).toContain('Example: orboto_agent_notify { to: "integrator@ORB", kind: "complete"');
|
|
388
|
+
expect(bodies).toHaveLength(0);
|
|
389
|
+
});
|
|
390
|
+
it('names the missing field with an example per kind', async () => {
|
|
391
|
+
const bodies = sentBodies();
|
|
392
|
+
const handler = makeAgentNotifyHandler(client);
|
|
393
|
+
const text = async (args) => (await handler(args)).content[0].text;
|
|
394
|
+
expect(await text({ to: 'integrator@ORB', kind: 'request', subject: 's' })).toContain('outcome: "<what must be true when it is done>", body: "<details, any length>"');
|
|
395
|
+
expect(await text({ to: 'integrator@ORB', kind: 'error', subject: 's', outcome: 'short' })).toContain('`outcome` has 5 characters, at least 10');
|
|
396
|
+
expect(await text({ to: 'integrator@ORB', subject: 's', body: ' ' })).toContain('`body` is empty');
|
|
397
|
+
expect(await text({ to: 'integrator@ORB', subject: 's', body: 'x', payload: { body: 'y' } })).toContain('`body` and `payload.body` are both set');
|
|
398
|
+
expect(await text({ subject: 's' })).toContain('Example: orboto_agent_notify { to: "integrator@KEY", kind: "info"');
|
|
399
|
+
expect(bodies).toHaveLength(0);
|
|
400
|
+
});
|
|
401
|
+
it('turns the ambiguous account 409 into a tool error listing the sessions and asking for toSessionRef', async () => {
|
|
402
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
403
|
+
ok: false, status: 409, statusText: 'Conflict', json: async () => ({}),
|
|
404
|
+
text: async () => JSON.stringify({
|
|
405
|
+
errorKey: 'errors.agents.no_recipient',
|
|
406
|
+
errorParams: { address: 'claude@orboto.io', reason: 'ambiguous_account', liveSessions: [
|
|
407
|
+
{ shortId: 'f072e1c5', email: 'claude@orboto.io', addresses: ['worker@ORB'] }, { shortId: 'a072e1c5', email: 'claude@orboto.io', addresses: ['review@ORB'] },
|
|
408
|
+
] },
|
|
409
|
+
}),
|
|
410
|
+
}));
|
|
411
|
+
const result = await makeAgentNotifyHandler(client)({ targetEmail: 'claude@orboto.io', kind: 'request', subject: 's', outcome: 'ORB-1 is reviewed with findings' });
|
|
412
|
+
const text = result.content[0].text;
|
|
413
|
+
expect(result.isError).toBe(true);
|
|
414
|
+
expect(text).toContain('has 2 live sessions');
|
|
415
|
+
expect(text).toContain('worker@ORB (claude@orboto.io, f072e1c5)');
|
|
416
|
+
expect(text).toContain('`toSessionRef`');
|
|
417
|
+
expect(text).toContain('integrator@KEY');
|
|
418
|
+
});
|
|
419
|
+
it('names every way on when the account has no live session', async () => {
|
|
420
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
421
|
+
ok: false, status: 409, statusText: 'Conflict', json: async () => ({}),
|
|
422
|
+
text: async () => JSON.stringify({ errorKey: 'errors.agents.no_recipient', errorParams: { address: 'claude@orboto.io', reason: 'no_recipient', project: null, liveSessions: [] } }),
|
|
423
|
+
}));
|
|
424
|
+
const result = await makeAgentNotifyHandler(client)({ targetEmail: 'claude@orboto.io', kind: 'request', subject: 's', outcome: 'ORB-1 is reviewed with findings' });
|
|
425
|
+
const text = result.content[0].text;
|
|
426
|
+
expect(text).toContain('a role address in `to` (integrator@KEY)');
|
|
427
|
+
expect(text).toContain('`toSessionRef`');
|
|
428
|
+
expect(text).toContain('`queue: true` with `project`');
|
|
429
|
+
});
|
|
430
|
+
});
|
|
351
431
|
describe('orboto_agent_notify dedupe and reminders (ORB-2563)', () => {
|
|
352
432
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
353
433
|
it('mirrors the priorities and sender lines of the shared schema', () => {
|
|
@@ -222,7 +222,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
222
222
|
}
|
|
223
223
|
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
|
|
224
224
|
sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
|
|
225
|
-
wake: r.wake, scopeExpired: r.scopeExpired ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
|
|
225
|
+
wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
|
|
226
226
|
} : null);
|
|
227
227
|
const host = localSessionHost(extra);
|
|
228
228
|
let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
|
|
@@ -383,6 +383,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
383
383
|
ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null,
|
|
384
384
|
reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
|
|
385
385
|
...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
|
|
386
|
+
scopeContinued: registration?.scopeContinued ?? null,
|
|
386
387
|
},
|
|
387
388
|
gitHealth: {
|
|
388
389
|
unhealthy: gitHealthWithConnections
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.226.2",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|