@orboto/mcp 0.178.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 (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * ORB-912 - doc-spaces CRUD + list-docs-in-space tool tests.
3
+ *
4
+ * Same fetch-stub harness as docs-ai.test.ts. Each test asserts both
5
+ * the outgoing wire shape (URL + method + body) and the structured
6
+ * content the model gets back, because the API's response Zod
7
+ * validator rejects off-shape rows and we want to catch drift at the
8
+ * unit-test layer rather than against a live API.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
12
+ import { makeCreateDocSpaceHandler, makeUpdateDocSpaceHandler, makeDeleteDocSpaceHandler, makeListDocsInSpaceHandler, makeGetDocHandler, makeCreateDocHandler, makeUpdateDocHandler, makeDeleteDocHandler, makeMoveDocHandler, } from './docs.js';
13
+ beforeEach(() => { vi.restoreAllMocks(); });
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ function stubJSON(responses) {
16
+ const calls = [];
17
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
18
+ calls.push({
19
+ url: url.toString(),
20
+ method: init?.method ?? 'GET',
21
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
22
+ });
23
+ const r = responses.shift();
24
+ if (!r)
25
+ throw new Error('unexpected extra fetch');
26
+ return {
27
+ ok: r.ok ?? true,
28
+ status: r.status ?? 200,
29
+ statusText: 'OK',
30
+ json: async () => ('json' in r ? r.json : {}),
31
+ text: async () => '',
32
+ };
33
+ });
34
+ return calls;
35
+ }
36
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
37
+ const PROJECT_SCOPED_SPACE = {
38
+ id: '11111111-1111-1111-1111-111111111111',
39
+ name: 'Backend Runbooks',
40
+ slug: 'backend-runbooks',
41
+ description: 'Ops + on-call notes for the API.',
42
+ icon: '📘',
43
+ type: 'project',
44
+ projectId: '22222222-2222-2222-2222-222222222222',
45
+ isPublic: false,
46
+ };
47
+ describe('orboto_create_doc_space', () => {
48
+ it('POSTs with name + type + projectId and surfaces the new id', async () => {
49
+ const calls = stubJSON([{ status: 201, json: PROJECT_SCOPED_SPACE }]);
50
+ const res = await makeCreateDocSpaceHandler(client)({
51
+ name: 'Backend Runbooks',
52
+ type: 'project',
53
+ projectId: PROJECT_SCOPED_SPACE.projectId,
54
+ icon: '📘',
55
+ });
56
+ expect(calls[0]).toMatchObject({
57
+ method: 'POST',
58
+ url: 'https://orboto.example.com/spaces',
59
+ body: {
60
+ name: 'Backend Runbooks',
61
+ type: 'project',
62
+ projectId: PROJECT_SCOPED_SPACE.projectId,
63
+ icon: '📘',
64
+ },
65
+ });
66
+ expect(res.content[0].text).toContain('Backend Runbooks');
67
+ expect(res.structuredContent).toMatchObject({
68
+ id: PROJECT_SCOPED_SPACE.id,
69
+ name: 'Backend Runbooks',
70
+ type: 'project',
71
+ });
72
+ });
73
+ it('resolves a projectKey to the UUID before POSTing (ORB-1042)', async () => {
74
+ const calls = stubJSON([
75
+ { json: { id: PROJECT_SCOPED_SPACE.projectId, key: 'ORB', name: 'orboto', status: 'active' } }, // by-key resolve
76
+ { status: 201, json: PROJECT_SCOPED_SPACE },
77
+ ]);
78
+ await makeCreateDocSpaceHandler(client)({ name: 'Backend Runbooks', type: 'project', projectKey: 'ORB' });
79
+ expect(calls[0].url).toContain('/projects/by-key/ORB');
80
+ expect(calls[1]).toMatchObject({
81
+ method: 'POST',
82
+ url: 'https://orboto.example.com/spaces',
83
+ body: { name: 'Backend Runbooks', type: 'project', projectId: PROJECT_SCOPED_SPACE.projectId },
84
+ });
85
+ });
86
+ it('errors when type=project and neither projectKey nor projectId is given', async () => {
87
+ stubJSON([]);
88
+ await expect(makeCreateDocSpaceHandler(client)({ name: 'Orphan', type: 'project' })).rejects.toThrow(/projectKey/);
89
+ });
90
+ it('passes through 403 from the API as OrbotoApiError', async () => {
91
+ stubJSON([{ ok: false, status: 403, json: { error: 'Only super-admins can create global spaces' } }]);
92
+ await expect(makeCreateDocSpaceHandler(client)({ name: 'Workspace Wiki', type: 'global' })).rejects.toBeInstanceOf(OrbotoApiError);
93
+ });
94
+ });
95
+ describe('orboto_update_doc_space', () => {
96
+ it('PATCHes only the fields the caller passed', async () => {
97
+ const calls = stubJSON([{ json: { ...PROJECT_SCOPED_SPACE, name: 'API Runbooks' } }]);
98
+ await makeUpdateDocSpaceHandler(client)({
99
+ spaceId: PROJECT_SCOPED_SPACE.id,
100
+ name: 'API Runbooks',
101
+ });
102
+ expect(calls[0]).toMatchObject({
103
+ method: 'PATCH',
104
+ url: `https://orboto.example.com/spaces/${PROJECT_SCOPED_SPACE.id}`,
105
+ body: { name: 'API Runbooks' },
106
+ });
107
+ });
108
+ it('refuses no-op calls before hitting the API', async () => {
109
+ const calls = stubJSON([]);
110
+ await expect(makeUpdateDocSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id })).rejects.toThrow(/at least one field/);
111
+ expect(calls).toHaveLength(0);
112
+ });
113
+ });
114
+ describe('orboto_delete_doc_space', () => {
115
+ it('DELETEs the space id and returns a deleted flag', async () => {
116
+ const calls = stubJSON([{ status: 204, json: undefined }]);
117
+ const res = await makeDeleteDocSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id });
118
+ expect(calls[0]).toMatchObject({
119
+ method: 'DELETE',
120
+ url: `https://orboto.example.com/spaces/${PROJECT_SCOPED_SPACE.id}`,
121
+ });
122
+ expect(res.structuredContent).toMatchObject({ deleted: true });
123
+ });
124
+ it('lets a 403 (system-generated space) bubble up', async () => {
125
+ stubJSON([{ ok: false, status: 403, json: { error: 'This space is auto-generated…' } }]);
126
+ await expect(makeDeleteDocSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id })).rejects.toBeInstanceOf(OrbotoApiError);
127
+ });
128
+ });
129
+ describe('orboto_list_docs_in_space', () => {
130
+ const ROOT = {
131
+ id: 'a1111111-0000-0000-0000-000000000001',
132
+ spaceId: PROJECT_SCOPED_SPACE.id,
133
+ parentDocId: null,
134
+ title: 'Architecture',
135
+ content: '...',
136
+ slug: 'architecture',
137
+ docKey: 'ORB-D1',
138
+ visibility: 'workspace',
139
+ sortOrder: 0,
140
+ icon: null,
141
+ updatedAt: '2026-05-17T13:00:00.000Z',
142
+ };
143
+ const CHILD = {
144
+ ...ROOT,
145
+ id: 'a1111111-0000-0000-0000-000000000002',
146
+ parentDocId: ROOT.id,
147
+ title: 'Queue worker retry semantics',
148
+ sortOrder: 0,
149
+ };
150
+ it('renders an indented tree text + flat structured list', async () => {
151
+ stubJSON([{ json: [CHILD, ROOT] /* deliberately unsorted to verify the local sort */ }]);
152
+ const res = await makeListDocsInSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id });
153
+ const text = res.content[0].text;
154
+ expect(text.indexOf('Architecture')).toBeLessThan(text.indexOf('Queue worker'));
155
+ expect(text).toMatch(/\s\s- .*Queue worker/);
156
+ const docs = res.structuredContent.docs;
157
+ expect(docs).toHaveLength(2);
158
+ });
159
+ it('surfaces the doc key in the tree text + structured rows (ORB-1004)', async () => {
160
+ stubJSON([{ json: [ROOT] }]);
161
+ const res = await makeListDocsInSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id });
162
+ expect(res.content[0].text).toContain('ORB-D1');
163
+ const docs = res.structuredContent.docs;
164
+ expect(docs[0].docKey).toBe('ORB-D1');
165
+ });
166
+ it('handles the empty-space case without rendering an empty list', async () => {
167
+ stubJSON([{ json: [] }]);
168
+ const res = await makeListDocsInSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id });
169
+ expect(res.content[0].text).toContain('No docs');
170
+ expect(res.structuredContent).toMatchObject({ docs: [] });
171
+ });
172
+ });
173
+ const DOC = {
174
+ id: 'd0000000-0000-0000-0000-000000000001',
175
+ spaceId: PROJECT_SCOPED_SPACE.id,
176
+ parentDocId: null,
177
+ title: 'Retry semantics',
178
+ content: '# Retry semantics\n\n200ms × 2^n.',
179
+ slug: 'retry-semantics',
180
+ docKey: 'ORB-D7',
181
+ visibility: 'workspace',
182
+ sortOrder: 0,
183
+ icon: null,
184
+ updatedAt: '2026-05-17T13:30:00.000Z',
185
+ };
186
+ describe('orboto_get_doc (ORB-1004 key support)', () => {
187
+ it('fetches by UUID via /docs/:id', async () => {
188
+ const calls = stubJSON([{ json: DOC }, { json: [] }]);
189
+ const res = await makeGetDocHandler(client)({ docId: DOC.id });
190
+ expect(calls[0].url).toBe(`https://orboto.example.com/docs/${DOC.id}`);
191
+ expect(res.structuredContent.doc.docKey).toBe('ORB-D7');
192
+ });
193
+ it('resolves a doc key via /docs/by-key/:key, then backlinks by UUID', async () => {
194
+ const calls = stubJSON([{ json: DOC }, { json: [] }]);
195
+ const res = await makeGetDocHandler(client)({ docId: 'ORB-D7' });
196
+ expect(calls[0].url).toBe('https://orboto.example.com/docs/by-key/ORB-D7');
197
+ expect(calls[1].url).toBe(`https://orboto.example.com/docs/${DOC.id}/backlinks`);
198
+ expect(res.content[0].text).toContain('Key: ORB-D7');
199
+ });
200
+ });
201
+ describe('orboto_create_doc', () => {
202
+ it('POSTs to /spaces/:id/docs with title + content', async () => {
203
+ const calls = stubJSON([{ status: 201, json: DOC }]);
204
+ const res = await makeCreateDocHandler(client)({
205
+ spaceId: PROJECT_SCOPED_SPACE.id,
206
+ title: 'Retry semantics',
207
+ content: '# Retry semantics\n\n200ms × 2^n.',
208
+ });
209
+ expect(calls[0]).toMatchObject({
210
+ method: 'POST',
211
+ url: `https://orboto.example.com/spaces/${PROJECT_SCOPED_SPACE.id}/docs`,
212
+ body: { title: 'Retry semantics', content: '# Retry semantics\n\n200ms × 2^n.' },
213
+ });
214
+ expect(res.structuredContent).toMatchObject({ id: DOC.id, title: DOC.title });
215
+ });
216
+ it('passes parentDocId + visibility through', async () => {
217
+ const calls = stubJSON([{ status: 201, json: { ...DOC, parentDocId: 'p1' } }]);
218
+ await makeCreateDocHandler(client)({
219
+ spaceId: PROJECT_SCOPED_SPACE.id,
220
+ title: 'Sub-page',
221
+ parentDocId: 'a1111111-0000-0000-0000-000000000099',
222
+ visibility: 'specific',
223
+ });
224
+ expect(calls[0].body).toMatchObject({
225
+ title: 'Sub-page',
226
+ parentDocId: 'a1111111-0000-0000-0000-000000000099',
227
+ visibility: 'specific',
228
+ });
229
+ });
230
+ });
231
+ describe('orboto_update_doc', () => {
232
+ // ORB-1084 - write tools accept the human-readable doc key: the key
233
+ // resolves via /docs/by-key, then the PATCH targets the UUID.
234
+ it('resolves a doc key to the UUID before writing', async () => {
235
+ const calls = stubJSON([
236
+ { json: DOC }, // by-key resolve
237
+ { json: { ...DOC, content: 'appended' } }, // the PATCH
238
+ ]);
239
+ await makeUpdateDocHandler(client)({ docId: 'DOC-4', content: 'appended' });
240
+ expect(calls[0]).toMatchObject({ method: 'GET', url: 'https://orboto.example.com/docs/by-key/DOC-4' });
241
+ expect(calls[1]).toMatchObject({
242
+ method: 'PATCH',
243
+ url: `https://orboto.example.com/docs/${DOC.id}`,
244
+ body: { content: 'appended' },
245
+ });
246
+ });
247
+ it('PATCHes only the content field when only content was supplied', async () => {
248
+ const calls = stubJSON([{ json: { ...DOC, content: 'new body' } }]);
249
+ await makeUpdateDocHandler(client)({ docId: DOC.id, content: 'new body' });
250
+ expect(calls[0]).toMatchObject({
251
+ method: 'PATCH',
252
+ url: `https://orboto.example.com/docs/${DOC.id}`,
253
+ body: { content: 'new body' },
254
+ });
255
+ expect(Object.keys(calls[0].body)).toEqual(['content']);
256
+ });
257
+ it('refuses the no-op call before hitting the API', async () => {
258
+ const calls = stubJSON([]);
259
+ await expect(makeUpdateDocHandler(client)({ docId: DOC.id })).rejects.toThrow(/at least one field/);
260
+ expect(calls).toHaveLength(0);
261
+ });
262
+ });
263
+ describe('orboto_delete_doc', () => {
264
+ it('DELETEs the doc id', async () => {
265
+ const calls = stubJSON([{ status: 204, json: undefined }]);
266
+ const res = await makeDeleteDocHandler(client)({ docId: DOC.id });
267
+ expect(calls[0]).toMatchObject({ method: 'DELETE', url: `https://orboto.example.com/docs/${DOC.id}` });
268
+ expect(res.structuredContent).toMatchObject({ deleted: true });
269
+ });
270
+ it('surfaces a 403 from the system-generated-doc guard', async () => {
271
+ stubJSON([{ ok: false, status: 403, json: { error: 'This doc is auto-managed…' } }]);
272
+ await expect(makeDeleteDocHandler(client)({ docId: DOC.id })).rejects.toBeInstanceOf(OrbotoApiError);
273
+ });
274
+ });
275
+ describe('orboto_move_doc', () => {
276
+ it('POSTs to /docs/:id/move with the supplied fields only', async () => {
277
+ const calls = stubJSON([{ json: { ...DOC, parentDocId: 'newParent', sortOrder: 5 } }]);
278
+ await makeMoveDocHandler(client)({
279
+ docId: DOC.id,
280
+ parentDocId: 'b1111111-0000-0000-0000-000000000001',
281
+ sortOrder: 5,
282
+ });
283
+ expect(calls[0]).toMatchObject({
284
+ method: 'POST',
285
+ url: `https://orboto.example.com/docs/${DOC.id}/move`,
286
+ body: { parentDocId: 'b1111111-0000-0000-0000-000000000001', sortOrder: 5 },
287
+ });
288
+ });
289
+ it('refuses an empty move', async () => {
290
+ const calls = stubJSON([]);
291
+ await expect(makeMoveDocHandler(client)({ docId: DOC.id })).rejects.toThrow(/at least one/);
292
+ expect(calls).toHaveLength(0);
293
+ });
294
+ it('allows reparenting to root (parentDocId=null)', async () => {
295
+ const calls = stubJSON([{ json: { ...DOC, parentDocId: null } }]);
296
+ await makeMoveDocHandler(client)({ docId: DOC.id, parentDocId: null });
297
+ expect(calls[0].body).toEqual({ parentDocId: null });
298
+ });
299
+ });
300
+ import { makeDuplicateDocSpaceHandler, makeResolveDocSmartLinksHandler, } from './docs.js';
301
+ describe('orboto_duplicate_doc_space', () => {
302
+ it('POSTs /spaces/:id/duplicate and surfaces the new space id', async () => {
303
+ const calls = stubJSON([{
304
+ status: 201,
305
+ json: { ...PROJECT_SCOPED_SPACE, id: 'newspace0-0000-0000-0000-000000000001', name: 'Backend Runbooks (copy)', slug: 'backend-runbooks-copy' },
306
+ }]);
307
+ const res = await makeDuplicateDocSpaceHandler(client)({ spaceId: PROJECT_SCOPED_SPACE.id });
308
+ expect(calls[0]).toMatchObject({
309
+ method: 'POST',
310
+ url: `https://orboto.example.com/spaces/${PROJECT_SCOPED_SPACE.id}/duplicate`,
311
+ });
312
+ expect(res.structuredContent).toMatchObject({
313
+ sourceSpaceId: PROJECT_SCOPED_SPACE.id,
314
+ newSpaceId: 'newspace0-0000-0000-0000-000000000001',
315
+ name: 'Backend Runbooks (copy)',
316
+ });
317
+ });
318
+ it('bubbles up a 404 for an unknown source space', async () => {
319
+ stubJSON([{ ok: false, status: 404, json: { error: 'Not found' } }]);
320
+ await expect(makeDuplicateDocSpaceHandler(client)({ spaceId: '00000000-0000-0000-0000-000000000000' })).rejects.toBeInstanceOf(OrbotoApiError);
321
+ });
322
+ });
323
+ describe('orboto_resolve_doc_smart_links', () => {
324
+ it('POSTs the items array and surfaces resolved + unresolved entries', async () => {
325
+ const calls = stubJSON([{
326
+ json: [
327
+ { type: 'ticket', id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', title: 'Auth bug', url: '/p/orb/t/ORB-42', ticketKey: 'ORB-42', projectKey: 'ORB' },
328
+ // Note: the doc UUID below is in the input below but absent from the response - verifies "unresolved" surfacing.
329
+ ],
330
+ }]);
331
+ const res = await makeResolveDocSmartLinksHandler(client)({
332
+ items: [
333
+ { type: 'ticket', id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' },
334
+ { type: 'doc', id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' },
335
+ ],
336
+ });
337
+ expect(calls[0]).toMatchObject({
338
+ method: 'POST',
339
+ url: 'https://orboto.example.com/docs/resolve-links',
340
+ body: {
341
+ items: [
342
+ { type: 'ticket', id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' },
343
+ { type: 'doc', id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' },
344
+ ],
345
+ },
346
+ });
347
+ const text = res.content[0].text;
348
+ expect(text).toContain('Auth bug');
349
+ expect(text).toContain('Unresolved');
350
+ expect(text).toContain('doc:bbbbbbbb');
351
+ expect(res.structuredContent).toMatchObject({
352
+ resolved: [{ type: 'ticket', ticketKey: 'ORB-42' }],
353
+ unresolved: [{ type: 'doc' }],
354
+ });
355
+ });
356
+ it('handles the all-resolved case without an Unresolved section', async () => {
357
+ stubJSON([{
358
+ json: [{ type: 'doc', id: 'd', title: 'Found', url: '/x' }],
359
+ }]);
360
+ const res = await makeResolveDocSmartLinksHandler(client)({
361
+ items: [{ type: 'doc', id: 'd' }],
362
+ });
363
+ expect(res.content[0].text).not.toContain('Unresolved');
364
+ });
365
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ORB-1309 - `orboto_embedding_status`.
3
+ *
4
+ * Operator diagnostic for the embedding pipeline: provider/model/dims, coverage
5
+ * (embedded vs total, and how many are PENDING - never-embedded / stale - per
6
+ * tickets / comments / docs and overall), the circuit-breaker state, and when
7
+ * the last embedding was written. Wraps GET /admin/ai/embedding-status.
8
+ *
9
+ * `orboto_ai_status` only reports whether embeddings are configured; this is the
10
+ * deeper surface for "why is semantic search / duplicate detection / ask-docs
11
+ * stale" - a tripped breaker, a stuck queue, or a provider that stopped
12
+ * responding. Admin:ai:read gated (403 for non-admin callers).
13
+ */
14
+ import { z } from 'zod';
15
+ export const embeddingStatusToolConfig = {
16
+ title: 'Embedding pipeline status (coverage + circuit breaker)',
17
+ description: 'Operator diagnostic for AI search / embeddings. Returns the configured provider/model + vector dimensions, coverage (embedded vs total, and how many are PENDING - never-embedded / stale - per tickets / comments / docs and overall), the circuit-breaker state (tripped + reason + consecutive failures), and when the last embedding was written. Use this to diagnose why semantic search / duplicate detection / ask-docs are stale or empty - e.g. a tripped breaker, a stuck queue, or a provider that stopped responding. Deeper than orboto_ai_status (which only says whether embeddings are configured). Requires admin:ai:read - returns 403 for non-admin callers.',
18
+ inputSchema: z.object({}).shape,
19
+ outputSchema: z.object({
20
+ configured: z.boolean(),
21
+ provider: z.string().nullable(),
22
+ model: z.string().nullable(),
23
+ dimensions: z.number(),
24
+ embedded: z.number(),
25
+ total: z.number(),
26
+ embeddable: z.number(),
27
+ pending: z.number(),
28
+ noContent: z.number(),
29
+ breakerTripped: z.boolean(),
30
+ breakerReason: z.string().nullable(),
31
+ billingGated: z.boolean(),
32
+ billingGateReason: z.string().nullable(),
33
+ allowanceRemainingMicrocents: z.number().nullable(),
34
+ allowanceGrantMicrocents: z.number().nullable(),
35
+ overageMode: z.string().nullable(),
36
+ lastEmbeddedAt: z.string().nullable(),
37
+ stalled: z.boolean(),
38
+ stalledMinutes: z.number().nullable(),
39
+ }).shape,
40
+ annotations: { readOnlyHint: true, idempotentHint: true },
41
+ };
42
+ export function makeEmbeddingStatusHandler(client) {
43
+ return async () => {
44
+ const s = await client.get('/admin/ai/embedding-status');
45
+ const o = s.coverage.overall;
46
+ const lines = [];
47
+ if (!s.configured) {
48
+ lines.push('Embeddings: NOT configured - no embedding provider set in Admin → AI Settings.');
49
+ }
50
+ else {
51
+ lines.push(`Embeddings: ${s.provider} / ${s.model} · ${s.dimensions} dims`);
52
+ lines.push(`Coverage: ${o.embedded} / ${o.embeddable} embeddable embedded · ${o.pending} pending${o.noContent > 0 ? ` · ${o.noContent} with no embeddable content (excluded)` : ''}`);
53
+ lines.push(` tickets ${s.coverage.ticket.embedded}/${s.coverage.ticket.embeddable} · comments ${s.coverage.comment.embedded}/${s.coverage.comment.embeddable} · docs ${s.coverage.doc.embedded}/${s.coverage.doc.embeddable} (embeddable)`);
54
+ lines.push(`Circuit breaker: ${s.breaker.tripped ? `TRIPPED - ${s.breaker.lastTrippedReason ?? 'unknown reason'} (${s.breaker.consecutiveFailures} consecutive failures)` : 'ok'}`);
55
+ // ORB-1715 - a billing gate is the one cause the customer can fix
56
+ // themselves; name it instead of letting it read as a provider fault.
57
+ if (s.billingGate?.gated) {
58
+ lines.push(`BILLING GATE: managed AI is paused for billing${s.billingGate.reason ? ` (${s.billingGate.reason})` : ''} since ${s.billingGate.blockedAt ?? 'unknown'}. The workspace admin resolves this in their orboto account; queued items embed automatically after the unblock.`);
59
+ }
60
+ // ORB-1719 - spend visibility so an agent can warn BEFORE the gate.
61
+ if (s.spend?.allowanceGrantMicrocents != null && s.spend.allowanceConsumedMicrocents != null) {
62
+ const eur = (mc) => (mc / 1_000_000).toFixed(2);
63
+ lines.push(`Included AI this month: ${eur(s.spend.allowanceConsumedMicrocents)} of ${eur(s.spend.allowanceGrantMicrocents)} EUR used${s.spend.allowanceRemainingMicrocents != null ? ` · ${eur(s.spend.allowanceRemainingMicrocents)} EUR left` : ''} · overage ${s.spend.overageMode ?? 'unknown'}`);
64
+ }
65
+ lines.push(`Last embedded: ${s.lastEmbeddedAt ?? 'never'}`);
66
+ if (s.stalled) {
67
+ lines.push('');
68
+ lines.push(`STALLED - ${o.pending} pending, breaker healthy, but nothing has embedded for ${s.stalledMinutes} min. The provider is likely failing transiently (e.g. Ollama down / model unloaded); those errors don't trip the breaker. Check the embedding provider, then re-run the backfill.`);
69
+ }
70
+ else if (o.pending > 0 && !s.breaker.tripped) {
71
+ lines.push('');
72
+ lines.push(`${o.pending} pending with a healthy breaker - trigger a backfill from Admin → AI Settings, or check the embedding worker / provider throughput if the count isn't draining.`);
73
+ }
74
+ }
75
+ return {
76
+ content: [{ type: 'text', text: lines.join('\n') }],
77
+ structuredContent: {
78
+ configured: s.configured,
79
+ provider: s.provider,
80
+ model: s.model,
81
+ dimensions: s.dimensions,
82
+ embedded: o.embedded,
83
+ total: o.total,
84
+ embeddable: o.embeddable,
85
+ pending: o.pending,
86
+ noContent: o.noContent,
87
+ breakerTripped: s.breaker.tripped,
88
+ breakerReason: s.breaker.lastTrippedReason,
89
+ billingGated: s.billingGate?.gated ?? false,
90
+ billingGateReason: s.billingGate?.reason ?? null,
91
+ allowanceRemainingMicrocents: s.spend?.allowanceRemainingMicrocents ?? null,
92
+ allowanceGrantMicrocents: s.spend?.allowanceGrantMicrocents ?? null,
93
+ overageMode: s.spend?.overageMode ?? null,
94
+ lastEmbeddedAt: s.lastEmbeddedAt,
95
+ stalled: s.stalled,
96
+ stalledMinutes: s.stalledMinutes,
97
+ },
98
+ };
99
+ };
100
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ORB-1715 - `orboto_embedding_status` tool mapping test. Pins the consumed
3
+ * response shape (incl. the OCP-D24 billing-gate block) so an api-side field
4
+ * rename breaks the build here instead of silently dropping the diagnostic.
5
+ */
6
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
7
+ import { OrbotoClient } from '../orboto-client.js';
8
+ import { makeEmbeddingStatusHandler } from './embedding-status.js';
9
+ beforeEach(() => { vi.restoreAllMocks(); });
10
+ afterEach(() => { vi.restoreAllMocks(); });
11
+ const BASE_STATUS = {
12
+ configured: true,
13
+ provider: 'orboto',
14
+ model: 'Qwen/Qwen3-Embedding-8B',
15
+ dimensions: 1024,
16
+ coverage: {
17
+ ticket: { embedded: 10, total: 12, embeddable: 11 },
18
+ comment: { embedded: 5, total: 5, embeddable: 5 },
19
+ doc: { embedded: 2, total: 3, embeddable: 3 },
20
+ overall: { embedded: 17, total: 20, embeddable: 19, pending: 2, noContent: 1 },
21
+ },
22
+ breaker: { tripped: false, trippedUntil: null, consecutiveFailures: 0, lastTrippedReason: null },
23
+ billingGate: null,
24
+ spend: null,
25
+ lastEmbeddedAt: '2026-08-12T10:00:00.000Z',
26
+ stalled: false,
27
+ stalledMinutes: null,
28
+ };
29
+ function mockFetch(json) {
30
+ return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
31
+ ok: true, status: 200, statusText: 'OK',
32
+ json: async () => json,
33
+ });
34
+ }
35
+ describe('tools/embedding-status', () => {
36
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
37
+ it('maps the healthy shape (no gate, no trip)', async () => {
38
+ mockFetch(BASE_STATUS);
39
+ const result = await makeEmbeddingStatusHandler(client)();
40
+ expect(result.structuredContent).toMatchObject({
41
+ configured: true, pending: 2, breakerTripped: false,
42
+ billingGated: false, billingGateReason: null,
43
+ });
44
+ const text = result.content[0].text;
45
+ expect(text).toContain('Circuit breaker: ok');
46
+ expect(text).not.toContain('BILLING GATE');
47
+ });
48
+ it('surfaces the billing gate with its reason instead of a provider fault', async () => {
49
+ mockFetch({
50
+ ...BASE_STATUS,
51
+ billingGate: { gated: true, blockedAt: '2026-08-12T06:45:00.000Z', reason: 'allowance_exhausted', source: 'signal', updatedAt: '2026-08-12T06:50:00.000Z' },
52
+ });
53
+ const result = await makeEmbeddingStatusHandler(client)();
54
+ expect(result.structuredContent).toMatchObject({
55
+ billingGated: true, billingGateReason: 'allowance_exhausted',
56
+ });
57
+ const text = result.content[0].text;
58
+ expect(text).toContain('BILLING GATE');
59
+ expect(text).toContain('allowance_exhausted');
60
+ // Contract guarantee: never vendor prose on any surface.
61
+ expect(text).not.toContain('Key is blocked');
62
+ });
63
+ it('surfaces the spend state so agents can warn before the gate (ORB-1719)', async () => {
64
+ mockFetch({
65
+ ...BASE_STATUS,
66
+ spend: {
67
+ month: '2026-08-01', allowanceGrantMicrocents: 4_000_000, allowanceConsumedMicrocents: 3_500_000,
68
+ allowanceRemainingMicrocents: 500_000, overageMode: 'hard_gate', overageCapEurCents: null,
69
+ overageConsumedMicrocents: null, walletBalanceMicrocents: null, walletFunded: true,
70
+ updatedAt: '2026-08-13T09:00:00.000Z',
71
+ },
72
+ });
73
+ const result = await makeEmbeddingStatusHandler(client)();
74
+ expect(result.structuredContent).toMatchObject({
75
+ allowanceRemainingMicrocents: 500_000, allowanceGrantMicrocents: 4_000_000, overageMode: 'hard_gate',
76
+ });
77
+ const text = result.content[0].text;
78
+ expect(text).toContain('3.50 of 4.00 EUR used');
79
+ });
80
+ it('tolerates an older api without the billingGate field', async () => {
81
+ const { billingGate: _omitted, ...legacy } = BASE_STATUS;
82
+ mockFetch(legacy);
83
+ const result = await makeEmbeddingStatusHandler(client)();
84
+ expect(result.structuredContent).toMatchObject({ billingGated: false, billingGateReason: null });
85
+ });
86
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ORB-625 - `orboto_free_busy`. Read a batch of users' near-term capacity
3
+ * (planned + booked + external-busy vs contractual capacity) so an agent can
4
+ * pick an available assignee. Wraps GET /users/free-busy, which is gated by
5
+ * the workspace `free_busy_team_visibility` toggle (disabled → 403 for
6
+ * non-admins) and the per-user opt-out (→ status 'unknown').
7
+ */
8
+ import { z } from 'zod';
9
+ import { OrbotoApiError } from '../orboto-client.js';
10
+ export const freeBusyToolConfig = {
11
+ title: 'User free/busy',
12
+ description: "Look up near-term capacity (free/busy) for one or more users so you can pick an available assignee. Returns, per user, a `status` (available | busy | overcapacity | absent | unknown) and per-bucket entries with plannedMinutes (plan-blocks), bookedMinutes (logged time), capacityMinutes (contractual), onAbsence, and externalBusyMinutes. `unknown` means the user opted out or the workspace hides their data from you. Requires the workspace free/busy toggle to be enabled (or admin rights); otherwise you get a not-permitted result. `userIds` is a list of user UUIDs (get them from orboto_list_users). Optional `from`/`to` (YYYY-MM-DD, default the next 2 weeks) and `granularity` (day | week, default day).",
13
+ inputSchema: z.object({
14
+ userIds: z.array(z.string().uuid()).min(1).max(200).describe('User UUIDs to look up.'),
15
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Range start (YYYY-MM-DD). Default: today.'),
16
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Range end inclusive (YYYY-MM-DD). Default: today + 13 days.'),
17
+ granularity: z.enum(['day', 'week']).optional().describe('Bucket size. Default: day.'),
18
+ }).shape,
19
+ annotations: { readOnlyHint: true, idempotentHint: true },
20
+ };
21
+ export function makeFreeBusyHandler(client) {
22
+ return async (input) => {
23
+ const qs = new URLSearchParams();
24
+ qs.set('userIds', input.userIds.join(','));
25
+ if (input.from)
26
+ qs.set('from', input.from);
27
+ if (input.to)
28
+ qs.set('to', input.to);
29
+ if (input.granularity)
30
+ qs.set('granularity', input.granularity);
31
+ let data;
32
+ try {
33
+ data = await client.get(`/users/free-busy?${qs.toString()}`);
34
+ }
35
+ catch (err) {
36
+ if (err instanceof OrbotoApiError && err.status === 403) {
37
+ return {
38
+ content: [{ type: 'text', text: 'Not permitted: free/busy visibility is disabled for this workspace (admins only).' }],
39
+ structuredContent: { error: 'forbidden' },
40
+ };
41
+ }
42
+ throw err;
43
+ }
44
+ return {
45
+ content: [{ type: 'text', text: `Free/busy:\n${JSON.stringify(data, null, 2)}` }],
46
+ structuredContent: data,
47
+ };
48
+ };
49
+ }
@@ -0,0 +1,46 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeFreeBusyHandler } from './free-busy.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stub(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
9
+ calls.push(url.toString());
10
+ const r = responses.shift();
11
+ if (!r)
12
+ throw new Error(`unexpected extra fetch to ${url}`);
13
+ const status = r.status ?? 200;
14
+ return { ok: status < 400, status, statusText: 'x', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
15
+ });
16
+ return calls;
17
+ }
18
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
19
+ const U1 = '11111111-1111-1111-1111-111111111111';
20
+ const U2 = '22222222-2222-2222-2222-222222222222';
21
+ describe('orboto_free_busy (ORB-625)', () => {
22
+ it('batches the user-ids into a single GET /users/free-busy call', async () => {
23
+ const payload = { users: [{ userId: U1, entries: [], status: 'available' }, { userId: U2, entries: [], status: 'busy' }] };
24
+ const calls = stub([{ json: payload }]);
25
+ const res = await makeFreeBusyHandler(client)({ userIds: [U1, U2] });
26
+ expect(calls).toHaveLength(1);
27
+ expect(calls[0]).toContain('/users/free-busy?');
28
+ expect(calls[0]).toContain(`userIds=${encodeURIComponent(`${U1},${U2}`)}`);
29
+ expect(res.structuredContent.users).toHaveLength(2);
30
+ expect(res.structuredContent.users[0]).toMatchObject({ userId: U1, status: 'available' });
31
+ });
32
+ it('passes the window + granularity through', async () => {
33
+ const calls = stub([{ json: { users: [] } }]);
34
+ await makeFreeBusyHandler(client)({ userIds: [U1], from: '2026-07-01', to: '2026-07-14', granularity: 'week' });
35
+ expect(calls[0]).toContain('from=2026-07-01');
36
+ expect(calls[0]).toContain('to=2026-07-14');
37
+ expect(calls[0]).toContain('granularity=week');
38
+ });
39
+ it('returns a readable not-permitted result on 403 (workspace toggle off)', async () => {
40
+ stub([{ status: 403, json: { error: 'Free/busy availability is disabled for this workspace.' } }]);
41
+ const res = await makeFreeBusyHandler(client)({ userIds: [U1] });
42
+ expect(res.structuredContent).toMatchObject({ error: 'forbidden' });
43
+ const first = res.content[0];
44
+ expect(String(first.text)).toMatch(/not permitted/i);
45
+ });
46
+ });