@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.
- package/dist/event-bridge.js +5 -0
- package/dist/event-bridge.test.js +34 -0
- package/dist/http-transport.js +6 -3
- package/dist/manifest-size.test.js +11 -5
- package/dist/orboto-client.js +4 -4
- package/dist/server.js +10 -0
- package/dist/tool-docs.js +1 -1
- package/dist/tools/agent-coordination.js +33 -32
- package/dist/tools/agent-coordination.test.js +87 -6
- package/dist/tools/agent-message-work.js +22 -0
- package/dist/tools/agent-message-work.test.js +33 -0
- package/dist/tools/agent-messages.js +32 -15
- package/dist/tools/agent-presence-schema.js +40 -0
- package/dist/tools/agent-session-scope.js +8 -0
- package/dist/tools/claim.js +22 -19
- package/dist/tools/claim.test.js +7 -5
- package/dist/tools/get-ticket.js +3 -0
- package/dist/tools/git-rotate-token.js +61 -0
- package/dist/tools/git-rotate-token.test.js +87 -0
- package/dist/tools/list-git-app-installations.js +1 -10
- package/dist/tools/list-tickets.js +4 -0
- package/dist/tools/project-readiness-schema.js +16 -0
- package/dist/tools/project-readiness.js +16 -0
- package/dist/tools/project-readiness.test.js +19 -0
- package/dist/tools/raci.js +4 -2
- package/dist/tools/sentry.js +85 -0
- package/dist/tools/sentry.test.js +107 -0
- package/dist/tools/session-start.js +20 -8
- package/dist/tools/session-start.test.js +44 -0
- package/dist/tools/shared.js +9 -0
- package/dist/tools/spec-check.js +15 -0
- package/dist/tools/spec-check.test.js +23 -0
- package/dist/tools/spec-readiness.test.js +85 -0
- package/dist/tools/spec-schemas.js +34 -0
- package/dist/tools/ticket-writes.js +27 -22
- package/dist/tools/ticket-writes.test.js +3 -4
- package/dist/tools/update-project.js +19 -12
- package/dist/tools/update-project.test.js +13 -4
- package/dist/tools/work-next.js +1 -1
- package/dist/tools/work-sessions-next.test.js +23 -0
- package/dist/tools/work-sessions.js +30 -9
- package/dist/tools/work-sessions.test.js +14 -0
- package/package.json +1 -1
package/dist/event-bridge.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/http-transport.js
CHANGED
|
@@ -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 =
|
|
32
|
-
const FULL_MAX_CHARS =
|
|
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 =
|
|
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 =
|
|
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',
|
package/dist/orboto-client.js
CHANGED
|
@@ -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
|
|
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
|
|
41
|
-
inputSchema: z.object({
|
|
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(
|
|
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
|
|
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 ${
|
|
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/
|
|
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: '
|
|
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
|
+
});
|