@orboto/mcp 0.226.2 → 0.228.1
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/index.js +13 -2
- package/dist/manifest-size.test.js +3 -0
- package/dist/project-scope.js +15 -1
- package/dist/project-scope.test.js +9 -9
- package/dist/response-budget.js +1 -1
- package/dist/server.js +5 -0
- package/dist/session-origin.js +23 -3
- package/dist/tools/agent-coordination.js +101 -36
- package/dist/tools/agent-coordination.test.js +62 -22
- package/dist/tools/agent-instructions.js +5 -4
- package/dist/tools/agent-messages-compact.test.js +3 -3
- package/dist/tools/agent-messages.js +24 -4
- package/dist/tools/agent-presence-row.js +22 -0
- package/dist/tools/agent-who.js +18 -18
- package/dist/tools/agent-who.test.js +9 -2
- package/dist/tools/bulk-writes.js +3 -3
- package/dist/tools/checklist-writes.js +2 -2
- package/dist/tools/claim.js +2 -2
- package/dist/tools/doc-attachments.js +4 -4
- package/dist/tools/doc-comments.js +5 -5
- package/dist/tools/doc-export.js +3 -3
- package/dist/tools/doc-revisions.js +3 -3
- package/dist/tools/docs.js +7 -7
- package/dist/tools/get-timer.js +6 -1
- package/dist/tools/identity.js +10 -1
- package/dist/tools/identity.test.js +11 -0
- package/dist/tools/list-git-app-installations.js +1 -1
- package/dist/tools/phase-b-tools.test.js +7 -0
- package/dist/tools/presence-scope.test.js +170 -0
- package/dist/tools/primer-facts.js +15 -15
- package/dist/tools/project-readiness-schema.js +1 -1
- package/dist/tools/read-budget.test.js +161 -0
- package/dist/tools/responsibilities.test.js +1 -1
- package/dist/tools/rule-proposals.js +131 -0
- package/dist/tools/rule-proposals.test.js +82 -0
- package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
- package/dist/tools/session-start.js +52 -16
- package/dist/tools/session-start.test.js +8 -7
- package/dist/tools/shared.js +49 -0
- package/dist/tools/ticket-writes.js +2 -2
- package/dist/tools/wiki.js +2 -2
- package/dist/tools/work-next.js +5 -5
- package/dist/tools/work-sessions.js +15 -15
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -70,16 +70,27 @@ async function main() {
|
|
|
70
70
|
const { mcpProcessInstance } = await import('./tools/shared.js');
|
|
71
71
|
const instanceToken = mcpProcessInstance();
|
|
72
72
|
const { declareEnvProjectScope } = await import('./project-scope.js');
|
|
73
|
-
await declareEnvProjectScope(preflightClient, instanceToken);
|
|
73
|
+
const declared = await declareEnvProjectScope(preflightClient, instanceToken);
|
|
74
74
|
const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
|
|
75
75
|
inbox.start();
|
|
76
|
+
if (declared?.refusal)
|
|
77
|
+
void inbox.deliverNotice('session_exists', declared.refusal);
|
|
76
78
|
const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
|
|
77
79
|
if (outdated)
|
|
78
80
|
void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
|
|
79
81
|
const { readClaudeContext, channelClientInfo } = await import('./claude-context.js');
|
|
82
|
+
const { presenceRefusal } = await import('./tools/shared.js');
|
|
83
|
+
let refusalLogged = false;
|
|
80
84
|
const beat = () => {
|
|
81
85
|
preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined) }, { instanceToken })
|
|
82
|
-
.
|
|
86
|
+
.then(() => { refusalLogged = false; })
|
|
87
|
+
.catch((err) => {
|
|
88
|
+
const refused = presenceRefusal(err);
|
|
89
|
+
if (refused && refusalLogged)
|
|
90
|
+
return;
|
|
91
|
+
refusalLogged = Boolean(refused);
|
|
92
|
+
console.error(`[orboto-mcp] channel heartbeat ${refused ? 'refused' : 'failed'}: ${refused?.message ?? err.message}`);
|
|
93
|
+
});
|
|
83
94
|
};
|
|
84
95
|
beat();
|
|
85
96
|
const beats = setInterval(beat, 60_000);
|
|
@@ -62,6 +62,9 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
62
62
|
* ORB-2563 gave `orboto_agent_notify` `remind` and `urgent` inside the
|
|
63
63
|
* ceiling: the docs `content` parameter description shrank to pay for it
|
|
64
64
|
* (full = 171,194).
|
|
65
|
+
* ORB-2579 added the four rule proposal tools to the full tier after
|
|
66
|
+
* tightening repeated parameter descriptions (doc ids, bulk selectors,
|
|
67
|
+
* work-session leases, primer facts) by 3.0k (full = 169,607).
|
|
65
68
|
*
|
|
66
69
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
67
70
|
*/
|
package/dist/project-scope.js
CHANGED
|
@@ -43,7 +43,21 @@ export async function declareEnvProjectScope(client, instanceToken, raw = proces
|
|
|
43
43
|
const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
|
|
44
44
|
if (current && scopeDeclared(current.scope))
|
|
45
45
|
return current;
|
|
46
|
-
const
|
|
46
|
+
const declaration = { scope, scopeDefault: true, start: true };
|
|
47
|
+
const row = await client.post('/v1/agent/heartbeat', declaration, { instanceToken })
|
|
48
|
+
.catch(async (err) => {
|
|
49
|
+
const { exitedPredecessor, presenceRefusal } = await import('./tools/shared.js');
|
|
50
|
+
const { machineId, shortHost } = await import('./session-origin.js');
|
|
51
|
+
const refused = presenceRefusal(err);
|
|
52
|
+
const dead = exitedPredecessor(refused, { machine: machineId(), host: shortHost() });
|
|
53
|
+
if (dead) {
|
|
54
|
+
const again = await client.post('/v1/agent/heartbeat', { ...declaration, previous: 'end' }, { instanceToken }).catch(() => null);
|
|
55
|
+
if (again)
|
|
56
|
+
console.error(`[orboto-mcp] ${dead.line}`);
|
|
57
|
+
return again;
|
|
58
|
+
}
|
|
59
|
+
return refused ? { refusal: refused.message } : null;
|
|
60
|
+
});
|
|
47
61
|
if (row && typeof row.sessionId === 'string')
|
|
48
62
|
rememberDeclaredScope(instanceToken, row.scope ?? scope);
|
|
49
63
|
return row;
|
|
@@ -18,7 +18,7 @@ function fakeClient(row) {
|
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
beforeEach(() => { _forgetDeclaredScopes(); });
|
|
21
|
+
beforeEach(() => { _forgetDeclaredScopes(); vi.stubEnv(PROJECT_KEY_ENV, ''); vi.stubEnv(SESSION_ROLE_ENV, ''); });
|
|
22
22
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
|
23
23
|
describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
|
|
24
24
|
it('names the same variable the CLI writes into the entry and hands to the session', () => {
|
|
@@ -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'] }, scopeDefault: true }, instanceToken: 'agent-abc' },
|
|
44
|
+
{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }, instanceToken: 'agent-abc' },
|
|
45
45
|
]);
|
|
46
46
|
expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
|
|
47
47
|
});
|
|
@@ -58,7 +58,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
|
|
|
58
58
|
expect(await declareEnvProjectScope(none, 'agent-none', '')).toBeNull();
|
|
59
59
|
expect(none.calls).toEqual([]);
|
|
60
60
|
});
|
|
61
|
-
it('orboto_session_start without a scope
|
|
61
|
+
it('orboto_session_start without a scope sends the environment project as fallbackScope and declares it itself on an instance that ignores it', async () => {
|
|
62
62
|
vi.stubEnv(PROJECT_KEY_ENV, 'acme');
|
|
63
63
|
const beats = [];
|
|
64
64
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
@@ -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'] }, scopeDefault: true }]);
|
|
82
|
+
expect(beats).toEqual([{ start: true, fallbackScope: { role: 'worker', projectKeys: ['ACME'] } }, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: 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,10 +99,10 @@ 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' }, scopeDefault: true }, instanceToken: 'agent-control' });
|
|
102
|
+
expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' }, scopeDefault: true, start: true }, instanceToken: 'agent-control' });
|
|
103
103
|
expect(rememberedScope('agent-control')).toEqual({ role: 'coordinator' });
|
|
104
104
|
});
|
|
105
|
-
it('orboto_session_start without a scope
|
|
105
|
+
it('orboto_session_start without a scope sends the control session the environment names as fallbackScope in its one start beat', async () => {
|
|
106
106
|
vi.stubEnv(SESSION_ROLE_ENV, 'coordinator');
|
|
107
107
|
vi.stubEnv(PROJECT_KEY_ENV, 'ACME');
|
|
108
108
|
const beats = [];
|
|
@@ -112,7 +112,7 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
|
|
|
112
112
|
if (u.pathname === '/v1/agent/heartbeat') {
|
|
113
113
|
const sent = init?.body ? JSON.parse(init.body) : {};
|
|
114
114
|
beats.push(sent);
|
|
115
|
-
body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? null };
|
|
115
|
+
body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? sent.fallbackScope ?? null };
|
|
116
116
|
}
|
|
117
117
|
else if (u.pathname === '/v1/agent/session') {
|
|
118
118
|
body = { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null };
|
|
@@ -124,7 +124,7 @@ 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([{
|
|
127
|
+
expect(beats).toEqual([{ start: true, fallbackScope: { role: 'coordinator' } }]);
|
|
128
128
|
expect(rememberedScope('mcp-control1')).toEqual({ role: 'coordinator' });
|
|
129
129
|
});
|
|
130
130
|
});
|
|
@@ -145,6 +145,6 @@ describe('ORB-2593 - a picker-pending orboto claude start', () => {
|
|
|
145
145
|
writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000 }));
|
|
146
146
|
const resolved = fakeClient({ sessionId: 'x', scope: null });
|
|
147
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' }]);
|
|
148
|
+
expect(resolved.calls.filter((c) => c.method === 'POST')).toEqual([{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }, instanceToken: 'agent-abc' }]);
|
|
149
149
|
});
|
|
150
150
|
});
|
package/dist/response-budget.js
CHANGED
|
@@ -239,7 +239,7 @@ export const TruncationBlockSchema = z.object({
|
|
|
239
239
|
*/
|
|
240
240
|
export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
|
|
241
241
|
.passthrough()
|
|
242
|
-
.describe('
|
|
242
|
+
.describe('Cut: pass handle to orboto_response_expand.');
|
|
243
243
|
function stringMarker(omitted, handle, path) {
|
|
244
244
|
return `... [truncated ${omitted} chars - full value: orboto_response_expand handle="${handle}" path="${path}"]`;
|
|
245
245
|
}
|
package/dist/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { helpToolConfig, makeHelpHandler } from './tools/help.js';
|
|
|
24
24
|
import { apiSearchToolConfig, makeApiSearchHandler } from './tools/api-search.js';
|
|
25
25
|
import { apiCallToolConfig, makeApiCallHandler } from './tools/api-call.js';
|
|
26
26
|
import { listAgentInstructionsToolConfig, makeListAgentInstructionsHandler, createAgentInstructionToolConfig, makeCreateAgentInstructionHandler, updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler, resetAgentInstructionToolConfig, makeResetAgentInstructionHandler, deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler, } from './tools/agent-instructions.js';
|
|
27
|
+
import { ruleProposeToolConfig, makeRuleProposeHandler, ruleProposalsToolConfig, makeRuleProposalsHandler, ruleProposalAcceptToolConfig, makeRuleProposalAcceptHandler, ruleProposalRejectToolConfig, makeRuleProposalRejectHandler, } from './tools/rule-proposals.js';
|
|
27
28
|
import { agentHeartbeatToolConfig, makeAgentHeartbeatHandler, agentPresenceToolConfig, makeAgentPresenceHandler, agentNotifyToolConfig, makeAgentNotifyHandler, } from './tools/agent-coordination.js';
|
|
28
29
|
import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/agent-coordination.js';
|
|
29
30
|
import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
|
|
@@ -200,6 +201,10 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
200
201
|
reg('orboto_update_agent_instruction', updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler(client));
|
|
201
202
|
reg('orboto_reset_agent_instruction', resetAgentInstructionToolConfig, makeResetAgentInstructionHandler(client));
|
|
202
203
|
reg('orboto_delete_agent_instruction', deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler(client));
|
|
204
|
+
reg('orboto_rule_propose', ruleProposeToolConfig, makeRuleProposeHandler(client));
|
|
205
|
+
reg('orboto_rule_proposals', ruleProposalsToolConfig, makeRuleProposalsHandler(client));
|
|
206
|
+
reg('orboto_rule_proposal_accept', ruleProposalAcceptToolConfig, makeRuleProposalAcceptHandler(client));
|
|
207
|
+
reg('orboto_rule_proposal_reject', ruleProposalRejectToolConfig, makeRuleProposalRejectHandler(client));
|
|
203
208
|
reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
|
|
204
209
|
reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
|
|
205
210
|
reg('orboto_who', agentWhoToolConfig, makeAgentWhoHandler(client));
|
package/dist/session-origin.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
10
10
|
import { hostname } from 'node:os';
|
|
11
11
|
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { VERSION } from './version.js';
|
|
13
13
|
const KEYS = [
|
|
14
|
-
['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'],
|
|
14
|
+
['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'], ['checkout', 'checkout'],
|
|
15
15
|
];
|
|
16
16
|
const WRAPPED_HARNESSES = { claude: 'claude-code', 'claude-code': 'claude-code', codex: 'codex', gemini: 'gemini', kimi: 'kimi', agy: 'agy', antigravity: 'agy' };
|
|
17
17
|
/** ORB-2571 - the harness that started a stdio proxy: the one `orboto <harness>` named, else the markers a harness sets. */
|
|
@@ -55,6 +55,26 @@ export function repoBasename(cwd = process.cwd()) {
|
|
|
55
55
|
}
|
|
56
56
|
return basename(cwd) || null;
|
|
57
57
|
}
|
|
58
|
+
/** ORB-2553 - the hashed working tree the directory sits in (a linked worktree is its own), the same value the CLI sends; never a path. */
|
|
59
|
+
export function checkoutHash(cwd = process.cwd()) {
|
|
60
|
+
let root = cwd;
|
|
61
|
+
for (let dir = cwd, i = 0; i < 40; i += 1) {
|
|
62
|
+
if (existsSync(join(dir, '.git'))) {
|
|
63
|
+
root = dir;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const parent = dirname(dir);
|
|
67
|
+
if (parent === dir)
|
|
68
|
+
break;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
let physical = root;
|
|
72
|
+
try {
|
|
73
|
+
physical = realpathSync(root);
|
|
74
|
+
}
|
|
75
|
+
catch { /* an unreadable path hashes as given */ }
|
|
76
|
+
return createHash('sha256').update(physical).digest('hex').slice(0, 16);
|
|
77
|
+
}
|
|
58
78
|
let machine;
|
|
59
79
|
/** A hashed stable machine id (the platform UUID on macOS, /etc/machine-id elsewhere); null when none is readable. */
|
|
60
80
|
export function machineId(read = readMachineId) {
|
|
@@ -87,7 +107,7 @@ export function stdioOrigin(env = process.env) {
|
|
|
87
107
|
return params.toString();
|
|
88
108
|
}
|
|
89
109
|
return formatOrigin({
|
|
90
|
-
host: shortHost(), machineId: machineId(), repo: repoBasename(), pid: process.ppid || null,
|
|
110
|
+
host: shortHost(), machineId: machineId(), repo: repoBasename(), checkout: checkoutHash(), pid: process.ppid || null,
|
|
91
111
|
client: 'orboto-mcp', clientVersion: VERSION,
|
|
92
112
|
harness: stdioHarness(env),
|
|
93
113
|
model: /^[\x21-\x7e]{1,80}$/.test((env.ORBOTO_MODEL ?? '').trim()) ? env.ORBOTO_MODEL.trim() : null,
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* @see ORB-704, ORB-706
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { AGENT_WAKE_LABELS,
|
|
7
|
+
import { AGENT_WAKE_LABELS, AgentInventoryKindSchema, AgentSessionWakeSchema, } from './agent-presence-schema.js';
|
|
8
|
+
import { AgentPresenceRowSchema } from './agent-presence-row.js';
|
|
8
9
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
9
|
-
import { mcpInstanceToken } from './shared.js';
|
|
10
|
-
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
10
|
+
import { mcpInstanceToken, presenceRefusal } from './shared.js';
|
|
11
|
+
import { AgentSessionRoleSchema, AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
11
12
|
export const agentHeartbeatToolConfig = {
|
|
12
13
|
title: 'Agent heartbeat (Multi-Agent Coordination)',
|
|
13
14
|
description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it. `startedBy`: starter session id, same account.',
|
|
@@ -35,7 +36,16 @@ export const agentHeartbeatToolConfig = {
|
|
|
35
36
|
};
|
|
36
37
|
export function makeAgentHeartbeatHandler(client) {
|
|
37
38
|
return async (args, extra) => {
|
|
38
|
-
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const refused = presenceRefusal(err);
|
|
45
|
+
if (!refused)
|
|
46
|
+
throw err;
|
|
47
|
+
return { content: [{ type: 'text', text: `heartbeat refused (ORB-2553) - ${refused.message}` }], isError: true };
|
|
48
|
+
}
|
|
39
49
|
const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
|
|
40
50
|
const notes = (res.notes ?? []).map((n) => `\n${n}`).join('');
|
|
41
51
|
return {
|
|
@@ -54,45 +64,99 @@ export function originText(s) {
|
|
|
54
64
|
const where = [o.host ?? 'unknown host', o.repo, o.pid ? `pid ${o.pid}` : null, o.harness, o.client ? `${o.client}${o.clientVersion ? ` ${o.clientVersion}` : ''}` : null].filter(Boolean).join(' ');
|
|
55
65
|
return ` [${s.state ?? 'live'}] on ${where}${o.startedAt ? ` since ${o.startedAt}` : ''}`;
|
|
56
66
|
}
|
|
67
|
+
/** ORB-2548 - the page an MCP presence read returns by default, sized so four rows fit the 4,000-character budget. */
|
|
68
|
+
export const PRESENCE_PAGE_LIMIT = 4;
|
|
69
|
+
export const PresenceFilterShape = {
|
|
70
|
+
role: AgentSessionRoleSchema.optional(),
|
|
71
|
+
state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
|
|
72
|
+
email: z.string().min(3).max(320).optional(),
|
|
73
|
+
sessionId: z.string().min(8).max(36).optional(),
|
|
74
|
+
kind: AgentInventoryKindSchema.optional(),
|
|
75
|
+
wake: AgentSessionWakeSchema.optional(),
|
|
76
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
77
|
+
cursor: z.string().max(500).optional(),
|
|
78
|
+
};
|
|
79
|
+
export function presenceQuery(args, extra = {}) {
|
|
80
|
+
const qs = new URLSearchParams(extra);
|
|
81
|
+
for (const key of ['project', 'role', 'state', 'email', 'sessionId', 'kind', 'wake', 'cursor', 'for']) {
|
|
82
|
+
const value = args[key]?.trim();
|
|
83
|
+
if (value)
|
|
84
|
+
qs.set(key, value);
|
|
85
|
+
}
|
|
86
|
+
qs.set('limit', String(args.limit ?? PRESENCE_PAGE_LIMIT));
|
|
87
|
+
return qs.toString();
|
|
88
|
+
}
|
|
89
|
+
/** ORB-2548 - the answer for a project filter the API does not know or the caller may not see (its 404). */
|
|
90
|
+
export function unknownProjectText(key) {
|
|
91
|
+
return `Project "${key.trim().toUpperCase()}" is unknown or not visible to you - no session is listed for it.`;
|
|
92
|
+
}
|
|
93
|
+
/** Runs the presence read; a 404 for a project filter becomes null, so the tool answers unknownProjectText instead of a raw error. */
|
|
94
|
+
export async function readPresence(client, path, project) {
|
|
95
|
+
try {
|
|
96
|
+
return await client.get(path);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (project?.trim() && err instanceof OrbotoApiError && err.status === 404)
|
|
100
|
+
return null;
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** The line that ends a page with more rows behind it. */
|
|
105
|
+
export function nextPageLine(nextCursor) {
|
|
106
|
+
return nextCursor ? [`More sessions: call again with cursor "${nextCursor}", or narrow with email, sessionId, project, role, kind, wake or state.`] : [];
|
|
107
|
+
}
|
|
108
|
+
function presenceRow(s) {
|
|
109
|
+
const o = s.origin;
|
|
110
|
+
return {
|
|
111
|
+
sessionId: s.sessionId, userEmail: s.userEmail, userFullName: s.userFullName, kind: s.kind,
|
|
112
|
+
owner: s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : null),
|
|
113
|
+
status: s.status, lane: s.lane?.name ?? null, scope: s.scope ?? null, memberProjects: s.projects.length,
|
|
114
|
+
connections: [...new Set(s.connections.map((c) => c.type))], ...(s.wake ? { wake: s.wake } : {}), ...(s.state ? { state: s.state } : {}),
|
|
115
|
+
origin: o ? { host: o.host, repo: o.repo, pid: o.pid, harness: o.harness, client: o.client } : null,
|
|
116
|
+
workingOn: s.workingOnTicket ? s.workingOnTicket.key ?? s.workingOnTicket.id : null,
|
|
117
|
+
lastSeenAt: String(s.lastSeenAt), ...(s.redacted ? { redacted: true } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function presenceLine(s) {
|
|
121
|
+
const name = s.userFullName ?? s.userEmail;
|
|
122
|
+
const runtime = s.clientInfo.name ?? 'unknown';
|
|
123
|
+
const work = s.workingOnTicket ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})` : '';
|
|
124
|
+
const owner = s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self');
|
|
125
|
+
const declared = s.scope?.projectKeys ?? [];
|
|
126
|
+
const scope = s.scope?.role || declared.length ? `; scope: ${s.scope?.role ?? 'no role'}${declared.length ? ` on ${declared.join(', ')}` : ''}` : '';
|
|
127
|
+
const keys = s.projects.map((p) => p.key);
|
|
128
|
+
const projects = keys.length === 0 ? '' : keys.length <= 8 ? `; projects: ${keys.join(', ')}` : `; member of ${keys.length} projects`;
|
|
129
|
+
const connections = s.connections.length ? `; connections: ${[...new Set(s.connections.map((c) => c.type))].join(', ')}` : '';
|
|
130
|
+
return `- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${owner}${s.lane ? `; lane: ${s.lane.name}` : ''}${scope}${projects}${connections}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`;
|
|
131
|
+
}
|
|
57
132
|
export const agentPresenceToolConfig = {
|
|
58
133
|
title: 'Workspace agent presence',
|
|
59
|
-
description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections
|
|
134
|
+
description: 'Active agent instances. The orboto_who set from GET /v1/agent/who?include=inventory: lane workers and reviewers, inbox, MCP and runner connections. Each row: sessionId, account, kind (agent | human by credential), owner or acts-as, status, lane, declared scope, member project count, connection types, wake (on_call: nothing wakes it), state (live | quiet | orphaned | unknown_origin), origin (host, repo, pid, harness, client), current ticket. Filters run on the server: email, sessionId (id or 8+ character prefix), projectKey (sessions declared on it), role, kind, wake, state; 4 rows a page, nextCursor continues (cursor). A projectKey that is unknown or not visible to you answers so in one line, with no rows. Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those on their projects, a foreign row redacted. The full inventory row: `orboto agents --json`.',
|
|
60
135
|
inputSchema: z.object({
|
|
61
|
-
projectKey: z.string().min(1).max(64).optional()
|
|
62
|
-
|
|
136
|
+
projectKey: z.string().min(1).max(64).optional(),
|
|
137
|
+
...PresenceFilterShape,
|
|
63
138
|
}).shape,
|
|
64
139
|
outputSchema: z.object({
|
|
65
|
-
sessions: z.array(
|
|
140
|
+
sessions: z.array(AgentPresenceRowSchema),
|
|
141
|
+
nextCursor: z.string().nullable(),
|
|
66
142
|
}).shape,
|
|
67
143
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
68
144
|
};
|
|
69
|
-
/** Client-side view filter (ORB-2135); the inventory route stays unfiltered and the chat mirror applies the same rule. */
|
|
70
|
-
export function filterPresenceRows(rows, args) {
|
|
71
|
-
const key = args.projectKey?.trim().toUpperCase();
|
|
72
|
-
return rows.filter((row) => (!args.kind || row.kind === args.kind) && (!key || row.projects.some((p) => p.key.toUpperCase() === key)));
|
|
73
|
-
}
|
|
74
145
|
export function makeAgentPresenceHandler(client) {
|
|
75
146
|
return async (args = {}) => {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const runtime = s.clientInfo.name ?? 'unknown';
|
|
87
|
-
const work = s.workingOnTicket
|
|
88
|
-
? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
|
|
89
|
-
: '';
|
|
90
|
-
lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
147
|
+
const { projectKey, ...filters } = args;
|
|
148
|
+
const project = projectKey ?? filters.project;
|
|
149
|
+
const res = await readPresence(client, `/v1/agent/who?${presenceQuery({ ...filters, project }, { include: 'inventory' })}`, project);
|
|
150
|
+
if (!res)
|
|
151
|
+
return { content: [{ type: 'text', text: unknownProjectText(project) }], structuredContent: { sessions: [], nextCursor: null } };
|
|
152
|
+
const rows = res.inventory ?? [];
|
|
153
|
+
const filtered = Object.entries(args).some(([key, value]) => key !== 'limit' && key !== 'cursor' && value !== undefined && value !== '');
|
|
154
|
+
const lines = rows.length === 0
|
|
155
|
+
? [filtered ? 'No active agent session matches the filter.' : 'No active agent sessions in the workspace.']
|
|
156
|
+
: [`${rows.length} active session(s)${res.nextCursor ? ' on this page' : ''}:`, ...rows.map(presenceLine), ...nextPageLine(res.nextCursor)];
|
|
93
157
|
return {
|
|
94
158
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
95
|
-
structuredContent: { sessions },
|
|
159
|
+
structuredContent: { sessions: rows.map(presenceRow), nextCursor: res.nextCursor ?? null },
|
|
96
160
|
};
|
|
97
161
|
};
|
|
98
162
|
}
|
|
@@ -121,7 +185,7 @@ export function noRecipientText(err) {
|
|
|
121
185
|
return null;
|
|
122
186
|
const p = body.errorParams ?? {};
|
|
123
187
|
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';
|
|
188
|
+
const ways = 'a role address in `to` (integrator@KEY), `toSessionRef` with an instance short id, or `queue: true` with `project` (a role address needs no project) to keep it until a session declares the project or the role';
|
|
125
189
|
if (p.reason === 'ambiguous_account') {
|
|
126
190
|
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
191
|
}
|
|
@@ -163,7 +227,7 @@ export function reminderTooSoonText(err) {
|
|
|
163
227
|
}
|
|
164
228
|
export const agentNotifyToolConfig = {
|
|
165
229
|
title: 'Notify an agent or user',
|
|
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.',
|
|
230
|
+
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; alone (no to, no targetEmail) a session id or short id addresses that session; 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; queued to a role address (worker@KEY) it waits for the first session that declares that role, and a queued request to an account address on the project of the owner address goes to the owner role. 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.',
|
|
167
231
|
inputSchema: z.object({
|
|
168
232
|
targetEmail: z.string().email().optional(),
|
|
169
233
|
to: z.string().min(3).max(200).optional(),
|
|
@@ -235,8 +299,8 @@ export function notifyExample(kind, to) {
|
|
|
235
299
|
export function notifyDefinitionError(args) {
|
|
236
300
|
const kind = args.kind ?? 'info';
|
|
237
301
|
const example = (text) => `Invalid arguments: ${text} Example: ${notifyExample(kind, args.to)}`;
|
|
238
|
-
if (!args.to && !args.targetEmail)
|
|
239
|
-
return example('name a recipient - `to` (integrator@KEY, an email or a session id) or `
|
|
302
|
+
if (!args.to && !args.targetEmail && !args.toSessionRef?.trim())
|
|
303
|
+
return example('name a recipient - `to` (integrator@KEY, an email or a session id), `targetEmail`, or `toSessionRef` (a session id or its short id).');
|
|
240
304
|
const outcome = args.outcome?.trim() ?? '';
|
|
241
305
|
if ((kind === 'request' || kind === 'complete') && !outcome) {
|
|
242
306
|
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.`);
|
|
@@ -275,7 +339,8 @@ export function makeAgentNotifyHandler(client) {
|
|
|
275
339
|
}
|
|
276
340
|
const who = res.ownerHint?.routed ? res.ownerHint.ownerAddress : args.to ?? args.targetEmail ?? '?';
|
|
277
341
|
const recipients = (res.recipients ?? []).map((r) => r.label);
|
|
278
|
-
const
|
|
342
|
+
const sessionOnly = !res.ownerHint?.routed && !args.to && !args.targetEmail;
|
|
343
|
+
const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${sessionOnly ? '' : `${who} `}session ${res.toSessionId.slice(0, 8)}` : who;
|
|
279
344
|
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
280
345
|
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
281
346
|
if (res.deduplicated) {
|
|
@@ -82,7 +82,7 @@ describe('orboto_agent_presence', () => {
|
|
|
82
82
|
expect(text).toContain('2 active session(s)');
|
|
83
83
|
expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
|
|
84
84
|
expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle [orphaned] on build-host sample-repo pid 4242 claude-code orboto-cli 1.2.3; owner: Alice');
|
|
85
|
-
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
|
|
85
|
+
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
|
|
86
86
|
expect(result.structuredContent.sessions).toHaveLength(2);
|
|
87
87
|
});
|
|
88
88
|
it('handles an empty workspace gracefully', async () => {
|
|
@@ -96,32 +96,56 @@ describe('orboto_agent_presence', () => {
|
|
|
96
96
|
{ userId: '00000000-0000-0000-0000-000000000002', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, autonomyPaused: false, lane: { id: '00000000-0000-0000-0000-000000000040', name: 'worker-1', role: 'implementation', paused: false }, projects: [{ id: '00000000-0000-0000-0000-000000000031', key: 'ACME', name: 'Acme' }, { id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'lane', label: 'worker-1' }, { type: 'live_events', label: 'MCP live events' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000011', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: { name: 'pi' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
|
|
97
97
|
{ userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'agent', isBot: true, actsAs: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [{ type: 'oauth', label: 'claude.ai' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000012', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: {}, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
|
|
98
98
|
];
|
|
99
|
-
it('
|
|
99
|
+
it('ORB-2548: sends every filter to the one presence read, projectKey as project, four rows a page by default', async () => {
|
|
100
100
|
const handler = makeAgentPresenceHandler(client);
|
|
101
|
-
const
|
|
102
|
-
stub([{ json: { sessions: [], inventory:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
|
|
101
|
+
const url = async (args) => {
|
|
102
|
+
stub([{ json: { sessions: [], inventory: [] } }]);
|
|
103
|
+
await handler(args);
|
|
104
|
+
return String(vi.mocked(fetch).mock.calls.at(-1)?.[0]);
|
|
106
105
|
};
|
|
107
|
-
expect(await
|
|
108
|
-
expect(await
|
|
109
|
-
expect(await
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
106
|
+
expect(await url({})).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
|
|
107
|
+
expect(await url({ projectKey: 'acme', kind: 'agent' })).toBe('https://orboto.example.com/v1/agent/who?include=inventory&project=acme&kind=agent&limit=4');
|
|
108
|
+
expect(await url({ email: 'w@x.test', sessionId: 'c785946f', role: 'worker', wake: 'on_call', state: 'live', limit: 2, cursor: 'next-1' }))
|
|
109
|
+
.toBe('https://orboto.example.com/v1/agent/who?include=inventory&role=worker&state=live&email=w%40x.test&sessionId=c785946f&wake=on_call&cursor=next-1&limit=2');
|
|
110
|
+
});
|
|
111
|
+
it('ORB-2548: a projectKey that is unknown or not visible answers in one line instead of a raw 404', async () => {
|
|
112
|
+
stub([{ ok: false, status: 404, json: { errorKey: 'errors.agents.message_project_not_found' } }]);
|
|
113
|
+
const result = await makeAgentPresenceHandler(client)({ projectKey: 'nope' });
|
|
114
|
+
expect(result.isError).toBeUndefined();
|
|
115
|
+
expect(result.content[0].text).toBe('Project "NOPE" is unknown or not visible to you - no session is listed for it.');
|
|
116
|
+
expect(result.structuredContent).toEqual({ sessions: [], nextCursor: null });
|
|
117
|
+
stub([{ ok: false, status: 404, json: {} }]);
|
|
118
|
+
await expect(makeAgentPresenceHandler(client)({ email: 'x@y.test' })).rejects.toThrow();
|
|
119
|
+
});
|
|
120
|
+
it('says when a filter matched nothing', async () => {
|
|
121
|
+
stub([{ json: { sessions: [], inventory: [] } }]);
|
|
116
122
|
const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
|
|
117
|
-
expect(result.content[0].text).toBe('No active agent
|
|
123
|
+
expect(result.content[0].text).toBe('No active agent session matches the filter.');
|
|
118
124
|
});
|
|
119
|
-
it('renders acts-as, lane, projects and connection types on each line', async () => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
it('renders acts-as, lane, scope, projects and connection types on each line and returns the compact row', async () => {
|
|
126
|
+
const rows = presenceRows().filter((row) => row.kind === 'agent').map((row, i) => (i === 0 ? { ...row, scope: { role: 'worker', projectKeys: ['ACME'] }, wake: 'channel' } : row));
|
|
127
|
+
stub([{ json: { sessions: [], inventory: rows, nextCursor: 'cursor-2' } }]);
|
|
128
|
+
const result = await makeAgentPresenceHandler(client)({ kind: 'agent' });
|
|
129
|
+
const text = result.content[0].text;
|
|
130
|
+
expect(text).toContain('2 active session(s) on this page');
|
|
131
|
+
expect(text).toContain('owner: Alice; lane: worker-1; scope: worker on ACME; projects: ACME, ORB; connections: lane, live_events; reachable by channel');
|
|
124
132
|
expect(text).toContain('owner: acts as Alice; connections: oauth');
|
|
133
|
+
expect(text).toContain('More sessions: call again with cursor "cursor-2"');
|
|
134
|
+
const structured = result.structuredContent;
|
|
135
|
+
expect(structured.nextCursor).toBe('cursor-2');
|
|
136
|
+
expect(structured.sessions[0]).toEqual({
|
|
137
|
+
sessionId: '00000000-0000-0000-0000-000000000011', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', owner: 'Alice',
|
|
138
|
+
status: 'idle', lane: 'worker-1', scope: { role: 'worker', projectKeys: ['ACME'] }, memberProjects: 2, connections: ['lane', 'live_events'],
|
|
139
|
+
wake: 'channel', origin: null, workingOn: null, lastSeenAt: rows[0].lastSeenAt,
|
|
140
|
+
});
|
|
141
|
+
expect(structured.sessions[1]).toMatchObject({ owner: 'acts as Alice', memberProjects: 0, connections: ['oauth'] });
|
|
142
|
+
});
|
|
143
|
+
it('names the member count instead of listing more than eight projects', async () => {
|
|
144
|
+
const many = Array.from({ length: 99 }, (_, i) => ({ id: '00000000-0000-0000-0000-000000000030', key: `P${i}`, name: `Project ${i}` }));
|
|
145
|
+
stub([{ json: { sessions: [], inventory: [{ ...presenceRows()[1], projects: many }] } }]);
|
|
146
|
+
const text = (await makeAgentPresenceHandler(client)({})).content[0].text;
|
|
147
|
+
expect(text).toContain('; member of 99 projects;');
|
|
148
|
+
expect(text).not.toContain('P98');
|
|
125
149
|
});
|
|
126
150
|
});
|
|
127
151
|
describe('orboto_agent_notify', () => {
|
|
@@ -263,6 +287,22 @@ describe('ORB-2136 - session addressing', () => {
|
|
|
263
287
|
expect(result.content[0].text).toContain('bob@example.com session cbb52195');
|
|
264
288
|
expect(result.structuredContent).toMatchObject({ toSessionId: 'cbb52195-0000-4000-8000-000000000000' });
|
|
265
289
|
});
|
|
290
|
+
it('ORB-2548: toSessionRef alone is a recipient - sent without to or targetEmail, the answer names the session', async () => {
|
|
291
|
+
const seen = [];
|
|
292
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
293
|
+
seen.push(JSON.parse(init?.body ?? '{}'));
|
|
294
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: 'cbb52195-0000-4000-8000-000000000000', routedTo: 'session' }), text: async () => '' };
|
|
295
|
+
});
|
|
296
|
+
const result = await makeAgentNotifyHandler(client)({ subject: 'hi', toSessionRef: 'cbb52195' }, { sessionId: 'abc123' });
|
|
297
|
+
expect(result.isError).toBeUndefined();
|
|
298
|
+
expect(seen[0]).toMatchObject({ toSessionRef: 'cbb52195', subject: 'hi' });
|
|
299
|
+
expect(seen[0]).not.toHaveProperty('to');
|
|
300
|
+
expect(result.content[0].text).toContain('notified session cbb52195 (message');
|
|
301
|
+
vi.restoreAllMocks();
|
|
302
|
+
const none = await makeAgentNotifyHandler(client)({ subject: 'hi' });
|
|
303
|
+
expect(none.isError).toBe(true);
|
|
304
|
+
expect(none.content[0].text).toContain('`toSessionRef` (a session id or its short id)');
|
|
305
|
+
});
|
|
266
306
|
it('orboto_messages identifies this session on the fetch and renders sender and receiver identities', async () => {
|
|
267
307
|
const seen = [];
|
|
268
308
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { sizeBlockResult } from './shared.js';
|
|
9
|
+
const AGENTS_PROPOSE = ' Agents propose shared rules with orboto_rule_propose (403 errors.agent_rules.write_needs_person).';
|
|
9
10
|
const RULE_WRITING_CONTRACT = ' Rule: title = one imperative sentence; body <= 400 chars, Rule / Why / How, no long examples, nothing another block says.';
|
|
10
11
|
function excerptOf(body) {
|
|
11
12
|
const firstLine = body.split('\n', 1)[0] ?? '';
|
|
@@ -74,7 +75,7 @@ export function makeListAgentInstructionsHandler(client) {
|
|
|
74
75
|
export const createAgentInstructionToolConfig = {
|
|
75
76
|
title: 'Add a custom coding-agent rule block',
|
|
76
77
|
description: 'Create a custom rule block at a scope (workspace, customer, project or personal). Workspace reaches every agent (admin:ai:write), customer every project of one customer (customer:write), project one project (project:edit), personal only you. For scope=customer pass customerId; for scope=project pass projectId.'
|
|
77
|
-
+ RULE_WRITING_CONTRACT,
|
|
78
|
+
+ AGENTS_PROPOSE + RULE_WRITING_CONTRACT,
|
|
78
79
|
inputSchema: z.object({
|
|
79
80
|
title: z.string().min(1).max(120),
|
|
80
81
|
body: z.string().min(1).max(8000).describe('The rule text the agent should follow.'),
|
|
@@ -109,7 +110,7 @@ export function makeCreateAgentInstructionHandler(client) {
|
|
|
109
110
|
export const updateAgentInstructionToolConfig = {
|
|
110
111
|
title: 'Edit / toggle / reorder a coding-agent rule block',
|
|
111
112
|
description: 'Patch a rule block: title, body, enabled (toggle a rule on/off), or sortOrder. Works on default AND custom blocks (defaults keep their builtinKey and can be reset later). Needs admin:ai:write.'
|
|
112
|
-
+ RULE_WRITING_CONTRACT,
|
|
113
|
+
+ AGENTS_PROPOSE + RULE_WRITING_CONTRACT,
|
|
113
114
|
inputSchema: z.object({
|
|
114
115
|
id: z.string().uuid(),
|
|
115
116
|
title: z.string().min(1).max(120).optional(),
|
|
@@ -141,7 +142,7 @@ export function makeUpdateAgentInstructionHandler(client) {
|
|
|
141
142
|
}
|
|
142
143
|
export const resetAgentInstructionToolConfig = {
|
|
143
144
|
title: 'Reset a default rule block to its shipped text',
|
|
144
|
-
description: 'Restore a seeded DEFAULT rule block (one with a builtinKey) to the text orboto ships. No-op for custom blocks. Needs admin:ai:write.',
|
|
145
|
+
description: 'Restore a seeded DEFAULT rule block (one with a builtinKey) to the text orboto ships. No-op for custom blocks. Needs admin:ai:write.' + AGENTS_PROPOSE,
|
|
145
146
|
inputSchema: z.object({ id: z.string().uuid() }).shape,
|
|
146
147
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
147
148
|
};
|
|
@@ -153,7 +154,7 @@ export function makeResetAgentInstructionHandler(client) {
|
|
|
153
154
|
}
|
|
154
155
|
export const deleteAgentInstructionToolConfig = {
|
|
155
156
|
title: 'Delete a custom coding-agent rule block',
|
|
156
|
-
description: 'Delete a CUSTOM rule block. Default (builtin) blocks cannot be deleted - disable them via update instead. Needs admin:ai:write.',
|
|
157
|
+
description: 'Delete a CUSTOM rule block. Default (builtin) blocks cannot be deleted - disable them via update instead. Needs admin:ai:write.' + AGENTS_PROPOSE,
|
|
157
158
|
inputSchema: z.object({ id: z.string().uuid() }).shape,
|
|
158
159
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
159
160
|
};
|