@orboto/mcp 0.195.1 → 0.198.0

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.
Files changed (43) hide show
  1. package/dist/event-bridge.js +5 -0
  2. package/dist/event-bridge.test.js +34 -0
  3. package/dist/http-transport.js +6 -3
  4. package/dist/manifest-size.test.js +11 -5
  5. package/dist/orboto-client.js +4 -4
  6. package/dist/server.js +10 -0
  7. package/dist/tool-docs.js +1 -1
  8. package/dist/tools/agent-coordination.js +33 -32
  9. package/dist/tools/agent-coordination.test.js +87 -6
  10. package/dist/tools/agent-message-work.js +22 -0
  11. package/dist/tools/agent-message-work.test.js +33 -0
  12. package/dist/tools/agent-messages.js +32 -15
  13. package/dist/tools/agent-presence-schema.js +40 -0
  14. package/dist/tools/agent-session-scope.js +8 -0
  15. package/dist/tools/claim.js +22 -19
  16. package/dist/tools/claim.test.js +7 -5
  17. package/dist/tools/get-ticket.js +3 -0
  18. package/dist/tools/git-rotate-token.js +61 -0
  19. package/dist/tools/git-rotate-token.test.js +87 -0
  20. package/dist/tools/list-git-app-installations.js +1 -10
  21. package/dist/tools/list-tickets.js +4 -0
  22. package/dist/tools/project-readiness-schema.js +16 -0
  23. package/dist/tools/project-readiness.js +16 -0
  24. package/dist/tools/project-readiness.test.js +19 -0
  25. package/dist/tools/raci.js +4 -2
  26. package/dist/tools/sentry.js +85 -0
  27. package/dist/tools/sentry.test.js +107 -0
  28. package/dist/tools/session-start.js +20 -8
  29. package/dist/tools/session-start.test.js +44 -0
  30. package/dist/tools/shared.js +9 -0
  31. package/dist/tools/spec-check.js +15 -0
  32. package/dist/tools/spec-check.test.js +23 -0
  33. package/dist/tools/spec-readiness.test.js +85 -0
  34. package/dist/tools/spec-schemas.js +34 -0
  35. package/dist/tools/ticket-writes.js +27 -22
  36. package/dist/tools/ticket-writes.test.js +3 -4
  37. package/dist/tools/update-project.js +19 -12
  38. package/dist/tools/update-project.test.js +13 -4
  39. package/dist/tools/work-next.js +1 -1
  40. package/dist/tools/work-sessions-next.test.js +23 -0
  41. package/dist/tools/work-sessions.js +30 -9
  42. package/dist/tools/work-sessions.test.js +14 -0
  43. package/package.json +1 -1
@@ -67,6 +67,10 @@ export class EventBridge {
67
67
  }
68
68
  catch { /* ignore */ } });
69
69
  }
70
+ /** ORB-2132 - pin the instance token once the transport session id is known; applies to the next (re)connect. */
71
+ setInstanceToken(token) {
72
+ this.opts = { ...this.opts, instanceToken: token };
73
+ }
70
74
  /** Open the SSE stream and start forwarding. Resolves immediately;
71
75
  * the actual forwarding happens on the returned background loop. */
72
76
  start() {
@@ -114,6 +118,7 @@ export class EventBridge {
114
118
  headers: {
115
119
  Authorization: `Bearer ${bearer}`,
116
120
  Accept: 'text/event-stream',
121
+ ...(this.opts.instanceToken ? { 'x-orboto-agent-session': this.opts.instanceToken } : {}),
117
122
  },
118
123
  signal: this.abort.signal,
119
124
  });
@@ -144,3 +144,37 @@ describe('EventBridge.dispatch', () => {
144
144
  expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(2);
145
145
  });
146
146
  });
147
+ describe('ORB-2132 - the bridge identifies its tool session', () => {
148
+ async function connectOnce(opts) {
149
+ const fetchFn = vi.fn().mockResolvedValue(mockSseResponse([]));
150
+ const bridge = new EventBridge({
151
+ baseUrl: 'https://orboto.example.com',
152
+ apiKey: 'orb_x',
153
+ ...(opts.instanceToken ? { instanceToken: opts.instanceToken } : {}),
154
+ mcp: mockMcp(),
155
+ subscriptions: new Set(),
156
+ fetchFn: fetchFn,
157
+ log: () => { },
158
+ });
159
+ if (opts.pin)
160
+ bridge.setInstanceToken(opts.pin);
161
+ bridge.start();
162
+ await new Promise((r) => setTimeout(r, 30));
163
+ bridge.close();
164
+ expect(fetchFn).toHaveBeenCalledTimes(1);
165
+ const [url, init] = fetchFn.mock.calls[0];
166
+ expect(url).toBe('https://orboto.example.com/sse/mcp-events');
167
+ return init.headers;
168
+ }
169
+ it('sends x-orboto-agent-session with the instance token given at construction', async () => {
170
+ expect(await connectOnce({ instanceToken: 'mcp-session-1' })).toMatchObject({
171
+ Authorization: 'Bearer orb_x', 'x-orboto-agent-session': 'mcp-session-1',
172
+ });
173
+ });
174
+ it('sends the token pinned after construction, once the transport session id is known', async () => {
175
+ expect((await connectOnce({ pin: 'mcp-session-2' }))['x-orboto-agent-session']).toBe('mcp-session-2');
176
+ });
177
+ it('omits the header when no instance token is known', async () => {
178
+ expect(await connectOnce({})).not.toHaveProperty('x-orboto-agent-session');
179
+ });
180
+ });
@@ -11,6 +11,7 @@ import { buildOrbotoMcpServer } from './server.js';
11
11
  import { resolveToolset } from './toolset.js';
12
12
  import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
13
13
  import { EventBridge } from './event-bridge.js';
14
+ import { mcpInstanceToken } from './tools/shared.js';
14
15
  /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
15
16
  * call builds a short-lived OrbotoClient bound to the request's token so the
16
17
  * api scopes every read/write to that identity. */
@@ -167,12 +168,13 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
167
168
  export function createHttpServer({ baseUrl, sessionStore }) {
168
169
  const sessions = new Map();
169
170
  const store = sessionStore ?? createApiSessionStore(baseUrl);
170
- async function buildSessionCore(tokenHolder, userAgentSuffix, toolset) {
171
+ async function buildSessionCore(tokenHolder, userAgentSuffix, toolset, sessionId) {
171
172
  const tokenProvider = holderTokenProvider(tokenHolder);
172
173
  const sessionClient = new OrbotoClient({ baseUrl, tokenProvider, userAgentSuffix });
173
174
  const subscriptions = new Set();
174
175
  const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
175
- const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
176
+ const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions,
177
+ ...(sessionId ? { instanceToken: mcpInstanceToken(undefined, { sessionId }) } : {}) });
176
178
  return { sessionClient, subscriptions, mcp, bridge };
177
179
  }
178
180
  /** ORB-1576 - resolve a bearer's owner identity via the status preflight;
@@ -188,7 +190,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
188
190
  }
189
191
  }
190
192
  async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
191
- const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
193
+ const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset, chosenSessionId);
192
194
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
193
195
  transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
194
196
  await mcp.connect(transport);
@@ -373,6 +375,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
373
375
  sessions.set(sid, {
374
376
  transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
375
377
  });
378
+ bridge.setInstanceToken(mcpInstanceToken(undefined, { sessionId: sid }));
376
379
  bridge.start();
377
380
  void store
378
381
  .register(token, { sessionId: sid, clientInfo, userAgent })
@@ -24,12 +24,18 @@ afterEach(() => { vi.restoreAllMocks(); });
24
24
  * edits - a description essay creeping back in is exactly what these
25
25
  * must catch, so the headroom is deliberately tighter than the old
26
26
  * 20 %. Shrink-only: raising either number is a conscious decision to
27
- * grow every session's connect cost.
27
+ * grow every session's connect cost. ORB-2132 raised the full ceiling by
28
+ * 2,000 chars because the agent_presence output schema now carries kind,
29
+ * actsAs, projects and connections (measured full = 165,759 chars).
30
+ * ORB-2136 raised all three: session_start and agent_heartbeat carry the
31
+ * session scope schema, agent_notify the toSessionRef, and orboto_messages
32
+ * renders sender and receiver identities in its output schema (measured
33
+ * curated = 38,640, full = 168,029 chars, minimal = 3,140 tokens).
28
34
  *
29
35
  * @see ORB-1805, ORB-1669, ORB-1910
30
36
  */
31
- const CURATED_MAX_CHARS = 37_500;
32
- const FULL_MAX_CHARS = 165_000;
37
+ const CURATED_MAX_CHARS = 39_000;
38
+ const FULL_MAX_CHARS = 169_000;
33
39
  /**
34
40
  * ORB-1805 - the estimator the ticket measured the failure with
35
41
  * (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
@@ -45,12 +51,12 @@ const estTokens = (chars) => Math.round(chars / CHARS_PER_TOKEN);
45
51
  * left for the conversation. 3,000 was the ticket's number; 3,100 since
46
52
  * ORB-1669 added the annotations block to every tool (+688 chars).
47
53
  */
48
- const MINIMAL_MAX_TOKENS = 3_100;
54
+ const MINIMAL_MAX_TOKENS = 3_200;
49
55
  /**
50
56
  * The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
51
57
  * block has its own budget, enforced in instructions-budget.test.ts).
52
58
  */
53
- const CURATED_SCHEMA_MAX_TOKENS = 10_450;
59
+ const CURATED_SCHEMA_MAX_TOKENS = 10_800;
54
60
  async function measuredManifest(toolset) {
55
61
  const server = await buildOrbotoMcpServer({
56
62
  baseUrl: 'https://orboto.example.com',
@@ -80,14 +80,14 @@ export class OrbotoClient {
80
80
  }
81
81
  /** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
82
82
  async get(path, options = {}) {
83
- const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal });
83
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal, ...(options.instanceToken ? { headers: { 'x-orboto-agent-session': options.instanceToken } } : {}) });
84
84
  return (await res.json());
85
85
  }
86
- /** POST a JSON body. */
87
- async post(path, body) {
86
+ /** POST a JSON body. `instanceToken` sends `x-orboto-agent-session` so the api attributes the call to this MCP session (ORB-2136). */
87
+ async post(path, body, options = {}) {
88
88
  const res = await this.authedFetch(this.fullUrl(path), {
89
89
  method: 'POST',
90
- headers: { 'Content-Type': 'application/json' },
90
+ headers: { 'Content-Type': 'application/json', ...(options.instanceToken ? { 'x-orboto-agent-session': options.instanceToken } : {}) },
91
91
  body: JSON.stringify(body),
92
92
  });
93
93
  if (res.status === 204)
package/dist/server.js CHANGED
@@ -23,9 +23,11 @@ import { apiCallToolConfig, makeApiCallHandler } from './tools/api-call.js';
23
23
  import { listAgentInstructionsToolConfig, makeListAgentInstructionsHandler, createAgentInstructionToolConfig, makeCreateAgentInstructionHandler, updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler, resetAgentInstructionToolConfig, makeResetAgentInstructionHandler, deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler, } from './tools/agent-instructions.js';
24
24
  import { agentHeartbeatToolConfig, makeAgentHeartbeatHandler, agentPresenceToolConfig, makeAgentPresenceHandler, agentNotifyToolConfig, makeAgentNotifyHandler, } from './tools/agent-coordination.js';
25
25
  import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/agent-coordination.js';
26
+ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
26
27
  import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
27
28
  import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
28
29
  import { getProjectToolConfig, makeGetProjectHandler } from './tools/get-project.js';
30
+ import { projectReadinessToolConfig, makeProjectReadinessHandler } from './tools/project-readiness.js';
29
31
  import { getProjectPrimerToolConfig, makeGetProjectPrimerHandler } from './tools/get-project-primer.js';
30
32
  import { listTicketsToolConfig, makeListTicketsHandler } from './tools/list-tickets.js';
31
33
  import { criticalPathToolConfig, makeCriticalPathHandler } from './tools/critical-path.js';
@@ -39,6 +41,7 @@ import { searchToolConfig, makeSearchHandler } from './tools/search.js';
39
41
  import { queryToolConfig, makeQueryHandler } from './tools/query.js';
40
42
  import { customerReportToolConfig, makeCustomerReportHandler } from './tools/customer-report.js';
41
43
  import { requirementsSpecToolConfig, makeRequirementsSpecHandler } from './tools/requirements-spec.js';
44
+ import { specCheckToolConfig, makeSpecCheckHandler } from './tools/spec-check.js';
42
45
  import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, } from './tools/wiki.js';
43
46
  import { personalFactListToolConfig, makePersonalFactListHandler, personalFactAddToolConfig, makePersonalFactAddHandler, personalFactUpdateToolConfig, makePersonalFactUpdateHandler, personalFactDeleteToolConfig, makePersonalFactDeleteHandler, } from './tools/personal-facts.js';
44
47
  import { listDocSpacesToolConfig, makeListDocSpacesHandler, getDocToolConfig, makeGetDocHandler, createDocSpaceToolConfig, makeCreateDocSpaceHandler, updateDocSpaceToolConfig, makeUpdateDocSpaceHandler, deleteDocSpaceToolConfig, makeDeleteDocSpaceHandler, listDocsInSpaceToolConfig, makeListDocsInSpaceHandler, createDocToolConfig, makeCreateDocHandler, updateDocToolConfig, makeUpdateDocHandler, deleteDocToolConfig, makeDeleteDocHandler, moveDocToolConfig, makeMoveDocHandler, duplicateDocSpaceToolConfig, makeDuplicateDocSpaceHandler, resolveDocSmartLinksToolConfig, makeResolveDocSmartLinksHandler, } from './tools/docs.js';
@@ -53,6 +56,8 @@ import { listCrossProjectLinksToolConfig, makeListCrossProjectLinksHandler, addC
53
56
  import { getTimerToolConfig, makeGetTimerHandler } from './tools/get-timer.js';
54
57
  import { getChecklistsToolConfig, makeGetChecklistsHandler } from './tools/get-checklists.js';
55
58
  import { listGitAppInstallationsToolConfig, makeListGitAppInstallationsHandler } from './tools/list-git-app-installations.js';
59
+ import { gitRotateTokenToolConfig, makeGitRotateTokenHandler } from './tools/git-rotate-token.js';
60
+ import { sentryToolConfig, makeSentryHandler } from './tools/sentry.js';
56
61
  import { createTicketToolConfig, makeCreateTicketHandler, updateTicketToolConfig, makeUpdateTicketHandler, moveTicketToolConfig, makeMoveTicketHandler, closeTicketToolConfig, makeCloseTicketHandler, deleteTicketToolConfig, makeDeleteTicketHandler, commentToolConfig, makeCommentHandler, updateCommentToolConfig, makeUpdateCommentHandler, deleteCommentToolConfig, makeDeleteCommentHandler, assignToolConfig, makeAssignHandler, unassignToolConfig, makeUnassignHandler, labelTicketToolConfig, makeLabelTicketHandler, unlabelTicketToolConfig, makeUnlabelTicketHandler, setMilestoneToolConfig, makeSetMilestoneHandler, addTicketDependencyToolConfig, makeAddTicketDependencyHandler, removeTicketDependencyToolConfig, makeRemoveTicketDependencyHandler, listTicketDependenciesToolConfig, makeListTicketDependenciesHandler, } from './tools/ticket-writes.js';
57
62
  import { timerStartToolConfig, makeTimerStartHandler, timerStopToolConfig, makeTimerStopHandler, logTimeToolConfig, makeLogTimeHandler, listTimeEntriesToolConfig, makeListTimeEntriesHandler, editTimeEntryToolConfig, makeEditTimeEntryHandler, deleteTimeEntryToolConfig, makeDeleteTimeEntryHandler, } from './tools/time-writes.js';
58
63
  import { listTicketSchedulesToolConfig, makeListTicketSchedulesHandler, scheduleTicketSessionToolConfig, makeScheduleTicketSessionHandler, cancelTicketSessionToolConfig, makeCancelTicketSessionHandler, } from './tools/ticket-schedules.js';
@@ -191,6 +196,7 @@ export async function buildOrbotoMcpServer(opts) {
191
196
  reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
192
197
  reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
193
198
  reg('orboto_agent_notify', agentNotifyToolConfig, makeAgentNotifyHandler(client));
199
+ reg('orboto_message_work', agentMessageWorkToolConfig, makeAgentMessageWorkHandler(client));
194
200
  reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
195
201
  reg('orboto_agent_broadcast', agentBroadcastToolConfig, makeAgentBroadcastHandler(client));
196
202
  reg('orboto_list_projects', listProjectsToolConfig, makeListProjectsHandler(client));
@@ -209,6 +215,7 @@ export async function buildOrbotoMcpServer(opts) {
209
215
  reg('orboto_query', queryToolConfig, makeQueryHandler(client));
210
216
  reg('orboto_customer_report', customerReportToolConfig, makeCustomerReportHandler(client));
211
217
  reg('orboto_requirements_spec', requirementsSpecToolConfig, makeRequirementsSpecHandler(client));
218
+ reg('orboto_spec_check', specCheckToolConfig, makeSpecCheckHandler(client));
212
219
  reg('orboto_list_doc_spaces', listDocSpacesToolConfig, makeListDocSpacesHandler(client));
213
220
  reg('orboto_get_doc', getDocToolConfig, makeGetDocHandler(client));
214
221
  reg('orboto_search_docs', searchDocsToolConfig, makeSearchDocsHandler(client));
@@ -261,6 +268,8 @@ export async function buildOrbotoMcpServer(opts) {
261
268
  reg('orboto_personal_fact_delete', personalFactDeleteToolConfig, makePersonalFactDeleteHandler(client));
262
269
  reg('orboto_get_timer', getTimerToolConfig, makeGetTimerHandler(client));
263
270
  reg('orboto_list_git_app_installations', listGitAppInstallationsToolConfig, makeListGitAppInstallationsHandler(client));
271
+ reg('orboto_git_rotate_token', gitRotateTokenToolConfig, makeGitRotateTokenHandler(client));
272
+ reg('orboto_sentry', sentryToolConfig, makeSentryHandler(client));
264
273
  reg('orboto_create_ticket', createTicketToolConfig, makeCreateTicketHandler(client));
265
274
  reg('orboto_update_ticket', updateTicketToolConfig, makeUpdateTicketHandler(client));
266
275
  reg('orboto_move_ticket', moveTicketToolConfig, makeMoveTicketHandler(client));
@@ -336,6 +345,7 @@ export async function buildOrbotoMcpServer(opts) {
336
345
  reg('orboto_attach_to_ticket', attachToTicketToolConfig, makeAttachToTicketHandler(client));
337
346
  reg('orboto_set_parent', setParentToolConfig, makeSetParentHandler(client));
338
347
  reg('orboto_update_project', updateProjectToolConfig, makeUpdateProjectHandler(client));
348
+ reg('orboto_project_readiness', projectReadinessToolConfig, makeProjectReadinessHandler(client));
339
349
  reg('orboto_create_project', createProjectToolConfig, makeCreateProjectHandler(client));
340
350
  reg('orboto_archive_project', archiveProjectToolConfig, makeArchiveProjectHandler(client));
341
351
  reg('orboto_check_similar', checkSimilarToolConfig, makeCheckSimilarHandler(client));
package/dist/tool-docs.js CHANGED
@@ -13,7 +13,7 @@ export const SUMMARY_MAX_CHARS = 220;
13
13
  */
14
14
  const SUMMARY_OVERRIDES = {
15
15
  orboto_create_full_backup: 'Start a full-workspace backup job (all projects, users, config, attachments); returns the job id to poll via orboto_list_backups.',
16
- orboto_get_ticket: 'Fetch one ticket by key (ORB-42) or UUID: full detail incl. description, status, assignees, checklists, dependencies and comments.',
16
+ orboto_get_ticket: 'Fetch one ticket by key or UUID with description, status, assignees, checklists and comments.',
17
17
  orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets (scope, actors, functional + non-functional requirements).',
18
18
  orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
19
19
  orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
@@ -4,12 +4,15 @@
4
4
  * @see ORB-704, ORB-706
5
5
  */
6
6
  import { z } from 'zod';
7
+ import { AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
7
8
  import { mcpInstanceToken } from './shared.js';
9
+ import { AgentSessionScopeSchema } from './agent-session-scope.js';
8
10
  export const agentHeartbeatToolConfig = {
9
11
  title: 'Agent heartbeat (Multi-Agent Coordination)',
10
- 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.',
12
+ 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.',
11
13
  inputSchema: z.object({
12
14
  sessionToken: z.string().nullable().optional(),
15
+ scope: AgentSessionScopeSchema.optional(),
13
16
  status: z.enum(['idle', 'working', 'blocked']).optional(),
14
17
  workingOnTicketId: z.string().uuid().nullable().optional(),
15
18
  capabilities: z.array(z.string()).optional(),
@@ -23,49 +26,44 @@ export const agentHeartbeatToolConfig = {
23
26
  outputSchema: z.object({
24
27
  sessionToken: z.string(),
25
28
  sessionId: z.string().uuid(),
29
+ scope: AgentSessionScopeSchema.nullable(),
26
30
  }).shape,
27
31
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
28
32
  };
29
33
  export function makeAgentHeartbeatHandler(client) {
30
- return async (args) => {
31
- const res = await client.post('/v1/agent/heartbeat', args);
34
+ return async (args, extra) => {
35
+ const res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
36
+ const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
32
37
  return {
33
- content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}…` }],
34
- structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId },
38
+ content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}… session ${res.sessionId.slice(0, 8)}${scope}` }],
39
+ structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null },
35
40
  };
36
41
  };
37
42
  }
38
43
  export const agentPresenceToolConfig = {
39
44
  title: 'Workspace agent presence',
40
- description: 'Returns currently-active agent sessions in the workspace. Active = heartbeat within the last 90 s. Super-admins see every agent; regular users see only their own sessions (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t step on a parallel run.',
41
- inputSchema: z.object({}).shape,
45
+ description: 'Returns all active orboto agent instances, including lane workers/reviewers, inbox and MCP connections and registered runners. Session freshness is 90 seconds; lanes retain their 300-second heartbeat grace. Unexpired work leases keep their owning instance visible. Ticket metadata and claims retain ordinary ticket visibility. Rows include `kind` (agent or human, classified by the credential: agent API key, OAuth grant, lane, external token or bot account; `isBot` is its alias), `actsAs` (the human account an AI client acts under), `projects` (memberships plus lane projects), `connections` (type + label per credential or attachment, `live_events` for the MCP event bridge), owner, lane and stable sessionId; separate instances of one account remain separate. Callers with admin:system:read see every agent; other users see only their own sessions (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t step on a parallel run. Filters `projectKey` (rows whose `projects` contain the key) and `kind` (agent | human) narrow the list client-side, the same way `orboto agents --project KEY --bots|--humans` does.',
46
+ inputSchema: z.object({
47
+ projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
48
+ kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
49
+ }).shape,
42
50
  outputSchema: z.object({
43
- sessions: z.array(z.object({
44
- userId: z.string().uuid(),
45
- userEmail: z.string(),
46
- userFullName: z.string().nullable(),
47
- sessionId: z.string().uuid(),
48
- status: z.string(),
49
- workingOnTicket: z.object({
50
- id: z.string().uuid(),
51
- key: z.string().nullable(),
52
- title: z.string(),
53
- projectKey: z.string().nullable(),
54
- }).nullable(),
55
- capabilities: z.array(z.string()),
56
- clientInfo: z.record(z.string(), z.string()),
57
- lastSeenAt: z.string(),
58
- createdAt: z.string(),
59
- })),
51
+ sessions: z.array(AgentInventoryEntrySchema),
60
52
  }).shape,
61
53
  annotations: { readOnlyHint: true, idempotentHint: true },
62
54
  };
55
+ /** Client-side view filter (ORB-2135); the inventory route stays unfiltered and the chat mirror applies the same rule. */
56
+ export function filterPresenceRows(rows, args) {
57
+ const key = args.projectKey?.trim().toUpperCase();
58
+ return rows.filter((row) => (!args.kind || row.kind === args.kind) && (!key || row.projects.some((p) => p.key.toUpperCase() === key)));
59
+ }
63
60
  export function makeAgentPresenceHandler(client) {
64
- return async () => {
65
- const sessions = await client.get('/v1/agent/presence');
61
+ return async (args = {}) => {
62
+ const all = await client.get('/v1/agent/inventory');
63
+ const sessions = filterPresenceRows(all, args);
66
64
  const lines = [];
67
65
  if (sessions.length === 0) {
68
- lines.push('No active agent sessions in the workspace.');
66
+ lines.push(all.length === 0 ? 'No active agent sessions in the workspace.' : `No active agent sessions match the filter (${all.length} active in the workspace).`);
69
67
  }
70
68
  else {
71
69
  lines.push(`${sessions.length} active session(s):`);
@@ -75,7 +73,7 @@ export function makeAgentPresenceHandler(client) {
75
73
  const work = s.workingOnTicket
76
74
  ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
77
75
  : '';
78
- lines.push(`- ${name} (${runtime}) - ${s.status}${work}`);
76
+ lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${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(', ')}` : ''}`);
79
77
  }
80
78
  }
81
79
  return {
@@ -86,7 +84,7 @@ export function makeAgentPresenceHandler(client) {
86
84
  }
87
85
  export const agentNotifyToolConfig = {
88
86
  title: 'Notify another agent / user',
89
- description: 'Send a fire-and-forget message to a user (bot or human) by email; it lands in their inbox (orboto_messages) and, for humans, in-app. kind: info | request (answer expected) | complete (sub-task done) | error. payload: free-form JSON; threadId links a reply to the message it answers.',
87
+ description: 'Send a fire-and-forget message to a user (bot or human) by email; it lands in their inbox (orboto_messages) and, for humans, in-app. kind: info | request (answer expected) | complete (sub-task done) | error. payload: free-form 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 (ORB-2136).',
90
88
  inputSchema: z.object({
91
89
  targetEmail: z.string().email(),
92
90
  kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
@@ -95,10 +93,12 @@ export const agentNotifyToolConfig = {
95
93
  threadId: z.string().uuid().optional(),
96
94
  project: z.string().min(1).max(64).optional().describe('Project key or UUID: scope the message to the recipient session working that project.'),
97
95
  senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref for self-echo exclusion; defaults to this MCP session.'),
96
+ toSessionRef: z.string().min(1).max(200).optional(),
98
97
  }).shape,
99
98
  outputSchema: z.object({
100
99
  ok: z.literal(true),
101
100
  messageId: z.string().uuid(),
101
+ toSessionId: z.string().uuid().nullable(),
102
102
  }).shape,
103
103
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
104
104
  };
@@ -129,10 +129,11 @@ export function makeAgentBroadcastHandler(client) {
129
129
  export function makeAgentNotifyHandler(client) {
130
130
  return async (args, extra) => {
131
131
  const senderRef = mcpInstanceToken(args.senderRef, extra);
132
- const res = await client.post('/v1/agent/notify', { ...args, senderRef });
132
+ const res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
133
+ const target = res.toSessionId ? `${args.targetEmail} session ${res.toSessionId.slice(0, 8)}` : args.targetEmail;
133
134
  return {
134
- content: [{ type: 'text', text: `notified ${args.targetEmail} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
135
- structuredContent: { ok: true, messageId: res.messageId },
135
+ content: [{ type: 'text', text: `notified ${target} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
136
+ structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null },
136
137
  };
137
138
  };
138
139
  }
@@ -43,16 +43,16 @@ describe('orboto_agent_heartbeat', () => {
43
43
  });
44
44
  });
45
45
  describe('orboto_agent_presence', () => {
46
- it('GETs /v1/agent/presence and renders one line per session', async () => {
46
+ it('GETs /v1/agent/inventory and renders one line per session', async () => {
47
47
  stub([{
48
48
  json: [
49
49
  {
50
50
  userId: '00000000-0000-0000-0000-000000000001',
51
51
  userEmail: 'alice@x.test',
52
- userFullName: 'Alice',
52
+ userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [], workSessions: [],
53
53
  sessionId: '00000000-0000-0000-0000-000000000010',
54
54
  status: 'working',
55
- workingOnTicket: { id: 't1', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
55
+ workingOnTicket: { id: '00000000-0000-0000-0000-000000000020', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
56
56
  capabilities: ['writes-tickets'],
57
57
  clientInfo: { name: 'claude-code' },
58
58
  lastSeenAt: new Date().toISOString(),
@@ -61,7 +61,8 @@ describe('orboto_agent_presence', () => {
61
61
  {
62
62
  userId: '00000000-0000-0000-0000-000000000002',
63
63
  userEmail: 'bot@x.test',
64
- userFullName: null,
64
+ userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' },
65
+ autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'dispatcher' }], workSessions: [],
65
66
  sessionId: '00000000-0000-0000-0000-000000000011',
66
67
  status: 'idle',
67
68
  workingOnTicket: null,
@@ -76,8 +77,9 @@ describe('orboto_agent_presence', () => {
76
77
  const result = await handler();
77
78
  const text = result.content[0].text;
78
79
  expect(text).toContain('2 active session(s)');
79
- expect(text).toContain('Alice (claude-code) - working · working on [ORB] ORB-42');
80
- expect(text).toContain('bot@x.test (dispatcher-daemon) - idle');
80
+ expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
81
+ expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle; owner: Alice');
82
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/inventory');
81
83
  expect(result.structuredContent.sessions).toHaveLength(2);
82
84
  });
83
85
  it('handles an empty workspace gracefully', async () => {
@@ -86,6 +88,38 @@ describe('orboto_agent_presence', () => {
86
88
  const result = await handler();
87
89
  expect(result.content[0].text).toBe('No active agent sessions in the workspace.');
88
90
  });
91
+ const presenceRows = () => [
92
+ { userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'laptop' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000010', status: 'working', workingOnTicket: null, capabilities: [], clientInfo: { name: 'claude-code' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
93
+ { 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() },
94
+ { 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() },
95
+ ];
96
+ it('filters by projectKey (case-insensitive, projects only) and kind on the client side (ORB-2135)', async () => {
97
+ const handler = makeAgentPresenceHandler(client);
98
+ const ids = async (args) => {
99
+ stub([{ json: presenceRows() }]);
100
+ const result = await handler(args);
101
+ expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/inventory');
102
+ return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
103
+ };
104
+ expect(await ids({})).toEqual(['10', '11', '12']);
105
+ expect(await ids({ kind: 'agent' })).toEqual(['11', '12']);
106
+ expect(await ids({ kind: 'human' })).toEqual(['10']);
107
+ expect(await ids({ projectKey: 'acme' })).toEqual(['11']);
108
+ expect(await ids({ projectKey: 'ORB', kind: 'human' })).toEqual(['10']);
109
+ expect(await ids({ projectKey: 'NONE' })).toEqual([]);
110
+ });
111
+ it('says when a filter matched nothing but the workspace is not empty', async () => {
112
+ stub([{ json: presenceRows() }]);
113
+ const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
114
+ expect(result.content[0].text).toBe('No active agent sessions match the filter (3 active in the workspace).');
115
+ });
116
+ it('renders acts-as, lane, projects and connection types on each line', async () => {
117
+ stub([{ json: presenceRows() }]);
118
+ const text = (await makeAgentPresenceHandler(client)({ kind: 'agent' })).content[0].text;
119
+ expect(text).toContain('2 active session(s)');
120
+ expect(text).toContain('owner: Alice; lane: worker-1; projects: ACME, ORB; connections: lane, live_events');
121
+ expect(text).toContain('owner: acts as Alice; connections: oauth');
122
+ });
89
123
  });
90
124
  describe('orboto_agent_notify', () => {
91
125
  it('POSTs to /v1/agent/notify with the body the user supplied', async () => {
@@ -152,3 +186,50 @@ describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
152
186
  expect(urls[1]).not.toContain('excludeRef');
153
187
  });
154
188
  });
189
+ describe('ORB-2136 - session addressing', () => {
190
+ it('agent_notify forwards toSessionRef and sends this MCP session as x-orboto-agent-session', async () => {
191
+ const seen = [];
192
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
193
+ seen.push({ body: JSON.parse(init?.body ?? '{}'), headers: (init?.headers ?? {}) });
194
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: 'cbb52195-0000-4000-8000-000000000000' }), text: async () => '' };
195
+ });
196
+ const result = await makeAgentNotifyHandler(client)({ targetEmail: 'bob@example.com', subject: 'hi', toSessionRef: 'cbb52195' }, { sessionId: 'abc123' });
197
+ expect(seen[0].body).toMatchObject({ toSessionRef: 'cbb52195', senderRef: 'mcp-abc123' });
198
+ expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
199
+ expect(result.content[0].text).toContain('bob@example.com session cbb52195');
200
+ expect(result.structuredContent).toMatchObject({ toSessionId: 'cbb52195-0000-4000-8000-000000000000' });
201
+ });
202
+ it('orboto_messages identifies this session on the fetch and renders sender and receiver identities', async () => {
203
+ const seen = [];
204
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
205
+ seen.push({ url: String(url), headers: (init?.headers ?? {}) });
206
+ return {
207
+ ok: true, status: 200, statusText: 'OK',
208
+ json: async () => ({ sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{
209
+ id: 'm1', fromUserId: 'u1', toUserId: 'u2', fromSessionId: 's1', toSessionId: 's2',
210
+ from: { userId: 'u1', email: 'spec@orboto.io', sessionId: 's1', role: 'spec', label: 'spec@orboto.io (spec, s1)' },
211
+ to: { userId: 'u2', email: 'claude@orboto.io', sessionId: 's2', role: 'integrator', label: 'claude@orboto.io (integrator, s2)' },
212
+ kind: 'request', subject: 'ticket-ready:ORB-1', payload: null, threadId: null, projectKey: 'ORB', createdAt: 'now', deliveredAt: null, readAt: null,
213
+ }] }),
214
+ text: async () => '',
215
+ };
216
+ });
217
+ const result = await makeAgentMessagesHandler(client)({}, { sessionId: 'abc123' });
218
+ expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
219
+ const text = result.content[0].text;
220
+ expect(text).toContain('from spec@orboto.io (spec, s1) to claude@orboto.io (integrator, s2)');
221
+ expect(text).toContain('This session: cbb52195 (ref mcp-abc123)');
222
+ expect(result.structuredContent).toMatchObject({ sessionId: 'cbb52195-0000-4000-8000-000000000000', messages: [{ from: { role: 'spec' }, to: { sessionId: 's2' } }] });
223
+ });
224
+ it('agent_heartbeat carries the scope and the session header', async () => {
225
+ const seen = [];
226
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
227
+ seen.push({ body: JSON.parse(init?.body ?? '{}'), headers: (init?.headers ?? {}) });
228
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ sessionToken: 'mcp-abc123', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: { role: 'integrator', projectKeys: ['ORB'] } }), text: async () => '' };
229
+ });
230
+ const result = await makeAgentHeartbeatHandler(client)({ scope: { role: 'integrator', projectKeys: ['orb'] } }, { sessionId: 'abc123' });
231
+ expect(seen[0].body).toMatchObject({ scope: { role: 'integrator', projectKeys: ['orb'] } });
232
+ expect(seen[0].headers['x-orboto-agent-session']).toBe('mcp-abc123');
233
+ expect(result.structuredContent).toMatchObject({ scope: { role: 'integrator', projectKeys: ['ORB'] } });
234
+ });
235
+ });
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { mcpInstanceToken } from './shared.js';
3
+ export const agentMessageWorkToolConfig = {
4
+ title: 'Inspect or explicitly handle message work',
5
+ description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search). ACK is a separate read receipt and finishes nothing.',
6
+ inputSchema: z.object({ messageId: z.string().uuid().optional(), mutation: z.record(z.unknown()).optional(),
7
+ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(25), openOnly: z.boolean().default(true) }).shape,
8
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
9
+ };
10
+ export function makeAgentMessageWorkHandler(client) {
11
+ return async (args, extra) => {
12
+ if (args.mutation && !args.messageId)
13
+ throw new Error('messageId is required for a mutation');
14
+ const q = new URLSearchParams({ limit: String(args.limit ?? 25), openOnly: String(args.openOnly ?? true) });
15
+ if (args.cursor)
16
+ q.set('cursor', args.cursor);
17
+ const result = args.mutation
18
+ ? await client.post(`/v1/agent/messages/${args.messageId}/work`, { ...args.mutation, instanceToken: mcpInstanceToken(undefined, extra) })
19
+ : await client.get(`${args.messageId ? `/v1/agent/messages/${args.messageId}` : '/v1/agent/message-work'}?${q}`, { instanceToken: mcpInstanceToken(undefined, extra) });
20
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
21
+ };
22
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './agent-message-work.js';
4
+ import { makeAgentMessagesHandler } from './agent-messages.js';
5
+ describe('message work MCP parity', () => {
6
+ it('carries no second copy of the shared mutation schema', () => {
7
+ const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-work.ts', import.meta.url), 'utf8');
8
+ const local = readFileSync(new URL('./agent-message-work.ts', import.meta.url), 'utf8');
9
+ const stepBlock = shared.slice(shared.indexOf('export const MessageWorkStepSchema'), shared.indexOf('export const MessageWorkMutationSchema'));
10
+ expect(local).not.toContain(stepBlock.replaceAll('export const', 'const'));
11
+ expect(local).not.toContain('MessageWorkStepSchema');
12
+ expect(agentMessageWorkToolConfig.description).toContain('orboto_api_search');
13
+ });
14
+ it('uses each MCP connection identity and reads exact IDs without ACK', async () => {
15
+ const get = vi.fn().mockResolvedValueOnce({ message: { id: 'message' } }).mockResolvedValue({ messages: [] });
16
+ const post = vi.fn().mockResolvedValue({ state: 'in_work' });
17
+ const client = { get, post };
18
+ const handler = makeAgentMessageWorkHandler(client);
19
+ await handler({ messageId: 'message' }, { sessionId: 'a' });
20
+ expect(get).toHaveBeenCalledWith('/v1/agent/messages/message?limit=25&openOnly=true', { instanceToken: 'mcp-a' });
21
+ expect(post).not.toHaveBeenCalled();
22
+ await handler({ messageId: 'message', mutation: { action: 'claim', summary: 'Explicit acceptance', evidence: [], leaseSeconds: 900 } }, { sessionId: 'a' });
23
+ expect(post).toHaveBeenCalledWith('/v1/agent/messages/message/work', expect.objectContaining({ instanceToken: 'mcp-a' }));
24
+ });
25
+ it('ACK is a plain read receipt - no evidence travels with it', async () => {
26
+ const get = vi.fn().mockResolvedValue({ messages: [] });
27
+ const post = vi.fn().mockResolvedValue({ acked: 1 });
28
+ const client = { get, post };
29
+ await makeAgentMessagesHandler(client)({ ackIds: ['message'] }, { sessionId: 'b' });
30
+ expect(post).toHaveBeenCalledWith('/v1/agent/messages/ack', { ids: ['message'], instanceToken: 'mcp-b' });
31
+ expect(JSON.stringify(post.mock.calls)).not.toContain('evidence');
32
+ });
33
+ });