@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.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1093 - session-start composes 4 reads into a re-orientation
|
|
3
|
+
* digest. Assert it hits the right endpoints and folds the rules +
|
|
4
|
+
* in-progress work + timer into the output.
|
|
5
|
+
*
|
|
6
|
+
* ORB-1607 - also covers the rules-hash ack (per-connection cache) and
|
|
7
|
+
* the optional `ticketKey` one-shot bundle.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
11
|
+
import { makeSessionStartHandler } from './session-start.js';
|
|
12
|
+
import { makeResponseExpandHandler } from './response-expand.js';
|
|
13
|
+
import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
|
|
14
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
15
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
16
|
+
function stubByPath(map) {
|
|
17
|
+
const calls = [];
|
|
18
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
19
|
+
const u = new URL(url.toString());
|
|
20
|
+
calls.push(u.pathname + u.search);
|
|
21
|
+
const key = Object.keys(map).find((k) => (u.pathname + u.search).startsWith(k));
|
|
22
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
|
|
23
|
+
});
|
|
24
|
+
return calls;
|
|
25
|
+
}
|
|
26
|
+
/** Like `stubByPath`, but a given path can also 404 (OrbotoApiError). */
|
|
27
|
+
function stubByPathWithFailures(map, notFound) {
|
|
28
|
+
const calls = [];
|
|
29
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
30
|
+
const u = new URL(url.toString());
|
|
31
|
+
const pathAndSearch = u.pathname + u.search;
|
|
32
|
+
calls.push(pathAndSearch);
|
|
33
|
+
if (notFound.some((p) => pathAndSearch.startsWith(p))) {
|
|
34
|
+
return { ok: false, status: 404, statusText: 'Not Found', json: async () => ({ error: 'not_found' }), text: async () => 'not found' };
|
|
35
|
+
}
|
|
36
|
+
const key = Object.keys(map).find((k) => pathAndSearch.startsWith(k));
|
|
37
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
|
|
38
|
+
});
|
|
39
|
+
return calls;
|
|
40
|
+
}
|
|
41
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
42
|
+
describe('orboto_session_start (ORB-1093)', () => {
|
|
43
|
+
it('composes identity + rules + in-progress tickets + timer', async () => {
|
|
44
|
+
const calls = stubByPath({
|
|
45
|
+
'/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
|
|
46
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
|
|
47
|
+
'/agent-instructions': { instructions: 'claim -> commit -> close' },
|
|
48
|
+
'/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
|
|
49
|
+
});
|
|
50
|
+
const res = await makeSessionStartHandler(client)();
|
|
51
|
+
const text = res.content[0].text;
|
|
52
|
+
expect(text).toContain('claim -> commit -> close');
|
|
53
|
+
expect(text).toContain('ORB-42');
|
|
54
|
+
expect(text).toContain('Running on ORB-42');
|
|
55
|
+
expect(calls.some((c) => c.startsWith('/agent-instructions'))).toBe(true);
|
|
56
|
+
expect(calls.some((c) => c.startsWith('/time/timer'))).toBe(true);
|
|
57
|
+
// ORB-1330 - the briefing must only ask for OPEN work; DONE tickets
|
|
58
|
+
// must never reach the "in-progress" section. Assert the status
|
|
59
|
+
// filter is on the wire so a future refactor can't silently widen it.
|
|
60
|
+
const assignedCall = calls.find((c) => c.startsWith('/users/me/assigned-tickets'));
|
|
61
|
+
expect(assignedCall).toBeDefined();
|
|
62
|
+
expect(assignedCall).toContain('statuses=IN_PROGRESS,IN_REVIEW');
|
|
63
|
+
});
|
|
64
|
+
it('handles the empty case (no tickets, no timer) without throwing', async () => {
|
|
65
|
+
stubByPath({
|
|
66
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
67
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
68
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
69
|
+
'/time/timer': {},
|
|
70
|
+
});
|
|
71
|
+
const res = await makeSessionStartHandler(client)();
|
|
72
|
+
const text = res.content[0].text;
|
|
73
|
+
expect(text).toContain('No tickets currently assigned');
|
|
74
|
+
expect(text).toContain('No timer running');
|
|
75
|
+
});
|
|
76
|
+
// ORB-1605 - session-start fans out to GET /projects/:id/git-health for
|
|
77
|
+
// every distinct project the caller has open work in, and surfaces a
|
|
78
|
+
// warning when a connection is unhealthy.
|
|
79
|
+
it('warns when a project git connection is unhealthy', async () => {
|
|
80
|
+
stubByPath({
|
|
81
|
+
'/users/me/assigned-tickets': {
|
|
82
|
+
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
83
|
+
},
|
|
84
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
85
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
86
|
+
'/time/timer': {},
|
|
87
|
+
'/projects/proj-1/git-health': {
|
|
88
|
+
connections: [{
|
|
89
|
+
connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
|
|
90
|
+
connected: false, healthy: false, lastEventAt: null, reason: 'connection_inactive',
|
|
91
|
+
}],
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const res = await makeSessionStartHandler(client)();
|
|
95
|
+
const text = res.content[0].text;
|
|
96
|
+
expect(text).toContain('Git connection health - WARNING');
|
|
97
|
+
expect(text).toContain('orboto/orboto');
|
|
98
|
+
expect(text).toContain('connection is deactivated');
|
|
99
|
+
// ORB-1697 - unhealthy connections only; a healthy one is 11 fields
|
|
100
|
+
// the agent never acts on, so it is reduced to a count.
|
|
101
|
+
const structured = res.structuredContent;
|
|
102
|
+
expect(structured.gitHealth.unhealthy).toHaveLength(1);
|
|
103
|
+
expect(structured.gitHealth.unhealthy[0].projectId).toBe('proj-1');
|
|
104
|
+
expect(structured.gitHealth.healthyCount).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
it('omits the git health section when every connection is healthy', async () => {
|
|
107
|
+
stubByPath({
|
|
108
|
+
'/users/me/assigned-tickets': {
|
|
109
|
+
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
110
|
+
},
|
|
111
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
112
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
113
|
+
'/time/timer': {},
|
|
114
|
+
'/projects/proj-1/git-health': {
|
|
115
|
+
connections: [{
|
|
116
|
+
connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
|
|
117
|
+
connected: true, healthy: true, lastEventAt: '2026-07-20T00:00:00Z', reason: null,
|
|
118
|
+
}],
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const res = await makeSessionStartHandler(client)();
|
|
122
|
+
const text = res.content[0].text;
|
|
123
|
+
expect(text).not.toContain('Git connection health - WARNING');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// ORB-1607 - rules-hash ack: the handler remembers the last rulesHash it
|
|
127
|
+
// saw FOR THE LIFETIME OF ONE `makeSessionStartHandler(client)` CLOSURE
|
|
128
|
+
// (mirrors one MCP connection) and passes it back as `knownRulesHash` on
|
|
129
|
+
// every subsequent call.
|
|
130
|
+
describe('orboto_session_start - rules-hash ack (ORB-1607)', () => {
|
|
131
|
+
it('sends no knownRulesHash on the first call, then passes the received hash back on the second', async () => {
|
|
132
|
+
const calls = [];
|
|
133
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
134
|
+
const u = new URL(url.toString());
|
|
135
|
+
calls.push(u.pathname + u.search);
|
|
136
|
+
if (u.pathname === '/agent-instructions') {
|
|
137
|
+
const known = u.searchParams.get('knownRulesHash');
|
|
138
|
+
const body = known === 'abc123def456'
|
|
139
|
+
? { rulesHash: 'abc123def456', rulesUnchanged: true }
|
|
140
|
+
: { instructions: 'claim -> commit -> close', rulesHash: 'abc123def456' };
|
|
141
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
142
|
+
}
|
|
143
|
+
const fallback = {
|
|
144
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
145
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
146
|
+
'/time/timer': {},
|
|
147
|
+
};
|
|
148
|
+
const key = Object.keys(fallback).find((k) => (u.pathname + u.search).startsWith(k));
|
|
149
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? fallback[key] : {}), text: async () => '' };
|
|
150
|
+
});
|
|
151
|
+
const handler = makeSessionStartHandler(client);
|
|
152
|
+
const first = await handler();
|
|
153
|
+
const firstCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
154
|
+
expect(firstCall).toBe('/agent-instructions');
|
|
155
|
+
const firstText = first.content[0].text;
|
|
156
|
+
expect(firstText).toContain('claim -> commit -> close');
|
|
157
|
+
const firstStructured = first.structuredContent;
|
|
158
|
+
expect(firstStructured.rulesHash).toBe('abc123def456');
|
|
159
|
+
expect(firstStructured.rulesUnchanged).toBe(false);
|
|
160
|
+
calls.length = 0;
|
|
161
|
+
const second = await handler();
|
|
162
|
+
const secondCall = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
163
|
+
expect(secondCall).toBe('/agent-instructions?knownRulesHash=abc123def456');
|
|
164
|
+
const secondText = second.content[0].text;
|
|
165
|
+
// ORB-1697 - the ack must not assert that the CALLER still holds the
|
|
166
|
+
// rules (a stdio server outlives /clear and every compaction), and it
|
|
167
|
+
// must name the way to get them back.
|
|
168
|
+
expect(secondText).toContain('Unchanged since this connection last delivered them');
|
|
169
|
+
expect(secondText).toContain('forceRules=true');
|
|
170
|
+
expect(secondText).not.toContain('keep following what you already loaded');
|
|
171
|
+
expect(secondText).not.toContain('claim -> commit -> close');
|
|
172
|
+
const secondStructured = second.structuredContent;
|
|
173
|
+
expect(secondStructured.rulesHash).toBe('abc123def456');
|
|
174
|
+
expect(secondStructured.rulesUnchanged).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
it('a fresh handler (new connection) starts with no cached hash again', async () => {
|
|
177
|
+
const calls = stubByPath({
|
|
178
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
179
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
180
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'hash1' },
|
|
181
|
+
'/time/timer': {},
|
|
182
|
+
});
|
|
183
|
+
await makeSessionStartHandler(client)();
|
|
184
|
+
const call = calls.find((c) => c.startsWith('/agent-instructions'));
|
|
185
|
+
expect(call).toBe('/agent-instructions');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// ORB-1607 - the optional `ticketKey` one-shot bundle: project primer,
|
|
189
|
+
// full ticket (incl. dependencies + checklists), git health, and active
|
|
190
|
+
// sessions folded into the same response.
|
|
191
|
+
describe('orboto_session_start - ticketKey bundle (ORB-1607)', () => {
|
|
192
|
+
const bundleStubs = {
|
|
193
|
+
'/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
|
|
194
|
+
'/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug', status: 'IN_PROGRESS' },
|
|
195
|
+
'/projects/proj-1/tickets/tick-1/dependencies': {
|
|
196
|
+
blockedBy: [{ ticketKey: 'ORB-10', title: 'Prereq', statusName: 'Done', statusCategory: 'done' }],
|
|
197
|
+
blocks: [],
|
|
198
|
+
},
|
|
199
|
+
'/tickets/tick-1/checklists': [{
|
|
200
|
+
title: 'Acceptance', triggersDone: true, progress: { done: 1, total: 2 },
|
|
201
|
+
items: [
|
|
202
|
+
{ content: 'Write tests', effectiveCompleted: true, linkedTicketKey: null, linkedTicketTitle: null, linkedTicketStatusCategory: null },
|
|
203
|
+
{ content: 'Update docs', effectiveCompleted: false, linkedTicketKey: null, linkedTicketTitle: null, linkedTicketStatusCategory: null },
|
|
204
|
+
],
|
|
205
|
+
}],
|
|
206
|
+
'/projects/proj-1/ai-primer': { markdown: '# orboto primer\n\nActive milestones: 2', totalTokens: 42, truncatedSections: [] },
|
|
207
|
+
'/projects/proj-1/git-health': { connections: [] },
|
|
208
|
+
'/v1/agent/presence': [{ userId: 'u2', userEmail: 'other@x.io', userFullName: 'Other Dev', status: 'working', workingOnTicket: { id: 'tick-1', key: 'ORB-42', title: 'Bug' }, lastSeenAt: '2026-07-23T10:00:00Z' }],
|
|
209
|
+
};
|
|
210
|
+
const baseStubs = {
|
|
211
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
212
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
213
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'h1' },
|
|
214
|
+
'/time/timer': {},
|
|
215
|
+
// Re-fetch of the enriched by-id row (mirrors orboto_get_ticket).
|
|
216
|
+
'/projects/proj-1/tickets/tick-1': {
|
|
217
|
+
id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug',
|
|
218
|
+
status: 'IN_PROGRESS', statusName: 'In Progress', priority: 'high', type: 'bug',
|
|
219
|
+
description: 'Something is broken.',
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
it('folds primer + ticket + dependencies + checklists + git health + active sessions into one response', async () => {
|
|
223
|
+
// bundleStubs first - the merged map is matched by `startsWith`, and
|
|
224
|
+
// baseStubs' bare `/projects/proj-1/tickets/tick-1` would otherwise
|
|
225
|
+
// shadow bundleStubs' longer `/projects/proj-1/tickets/tick-1/dependencies`
|
|
226
|
+
// if it came first in iteration order.
|
|
227
|
+
const calls = stubByPath({ ...bundleStubs, ...baseStubs });
|
|
228
|
+
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
|
|
229
|
+
const text = res.content[0].text;
|
|
230
|
+
expect(text).toContain('## Ticket bundle: ORB-42');
|
|
231
|
+
expect(text).toContain('orboto primer');
|
|
232
|
+
expect(text).toContain('[ORB-42] Bug');
|
|
233
|
+
expect(text).toContain('Something is broken.');
|
|
234
|
+
expect(text).toContain('Write tests');
|
|
235
|
+
expect(text).toContain('[ORB-10] Prereq');
|
|
236
|
+
expect(text).toContain('Other Dev');
|
|
237
|
+
const structured = res.structuredContent;
|
|
238
|
+
expect(structured.ticketBundle.ticketKey).toBe('ORB-42');
|
|
239
|
+
expect(structured.ticketBundle.checklists).toHaveLength(1);
|
|
240
|
+
expect(structured.ticketBundle.dependencies.blockedBy).toHaveLength(1);
|
|
241
|
+
expect(structured.ticketBundle.activeSessions).toHaveLength(1);
|
|
242
|
+
// Replaces what would otherwise be several separate calls.
|
|
243
|
+
expect(calls.some((c) => c.startsWith('/projects/proj-1/ai-primer'))).toBe(true);
|
|
244
|
+
expect(calls.some((c) => c.startsWith('/tickets/tick-1/checklists'))).toBe(true);
|
|
245
|
+
expect(calls.some((c) => c.startsWith('/projects/proj-1/tickets/tick-1/dependencies'))).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
it('an unresolvable/unauthorized ticket key surfaces a clean error section instead of throwing', async () => {
|
|
248
|
+
stubByPathWithFailures({ ...baseStubs, '/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' } }, ['/projects/proj-1/tickets/by-key/999']);
|
|
249
|
+
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-999' });
|
|
250
|
+
const text = res.content[0].text;
|
|
251
|
+
expect(text).toContain('## Ticket bundle: ORB-999');
|
|
252
|
+
expect(text).toContain('Could not load this ticket');
|
|
253
|
+
// The rest of the digest (rules, in-progress work, timer) still renders.
|
|
254
|
+
expect(text).toContain('## Working rules');
|
|
255
|
+
expect(text).toContain('## Timer');
|
|
256
|
+
const structured = res.structuredContent;
|
|
257
|
+
expect(structured.ticketBundle.error).toBeTruthy();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
/**
|
|
261
|
+
* ORB-1697 - the response-budget-driven changes to session_start.
|
|
262
|
+
*
|
|
263
|
+
* The digest is the most expensive tool in the workspace (measured 18.620
|
|
264
|
+
* characters average, fired on every session start AND after every
|
|
265
|
+
* compaction), so what it puts in the payload has to earn its place.
|
|
266
|
+
*/
|
|
267
|
+
describe('orboto_session_start - context cost (ORB-1697)', () => {
|
|
268
|
+
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
|
|
269
|
+
const rulesStub = {
|
|
270
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
271
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
272
|
+
'/agent-instructions': { instructions: 'THE FULL RULE TEXT', rulesHash: 'h9' },
|
|
273
|
+
'/time/timer': {},
|
|
274
|
+
};
|
|
275
|
+
it('forceRules re-sends the rules in full even when this connection already delivered them', async () => {
|
|
276
|
+
const calls = stubByPath(rulesStub);
|
|
277
|
+
const handler = makeSessionStartHandler(client);
|
|
278
|
+
await handler(); // primes the per-connection hash
|
|
279
|
+
calls.length = 0;
|
|
280
|
+
const forced = await handler({ forceRules: true });
|
|
281
|
+
// No knownRulesHash means the API cannot answer with an ack.
|
|
282
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions');
|
|
283
|
+
const text = forced.content[0].text;
|
|
284
|
+
expect(text).toContain('THE FULL RULE TEXT');
|
|
285
|
+
expect(text).not.toContain('forceRules=true');
|
|
286
|
+
});
|
|
287
|
+
it('without forceRules the ack path is still used - the saving is not lost', async () => {
|
|
288
|
+
const calls = stubByPath(rulesStub);
|
|
289
|
+
const handler = makeSessionStartHandler(client);
|
|
290
|
+
await handler();
|
|
291
|
+
calls.length = 0;
|
|
292
|
+
await handler();
|
|
293
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toBe('/agent-instructions?knownRulesHash=h9');
|
|
294
|
+
});
|
|
295
|
+
it('a healthy git connection costs a count, not eleven fields', async () => {
|
|
296
|
+
stubByPath({
|
|
297
|
+
'/users/me/assigned-tickets': {
|
|
298
|
+
items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
|
|
299
|
+
},
|
|
300
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
301
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
302
|
+
'/time/timer': {},
|
|
303
|
+
'/projects/proj-1/git-health': {
|
|
304
|
+
connections: [{
|
|
305
|
+
connectionId: 'conn-1', name: 'orboto/orboto', provider: 'github',
|
|
306
|
+
connected: true, healthy: true, lastEventAt: '2026-08-09T00:00:00Z', reason: null,
|
|
307
|
+
outboundReachable: true, inboundDelivering: true, deliveryError: null, lastProbeAt: '2026-08-09T00:00:00Z',
|
|
308
|
+
}],
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const res = await makeSessionStartHandler(client)();
|
|
312
|
+
const structured = res.structuredContent;
|
|
313
|
+
expect(structured.gitHealth.unhealthy).toEqual([]);
|
|
314
|
+
expect(structured.gitHealth.healthyCount).toBe(1);
|
|
315
|
+
// And the connection object itself is nowhere in the payload.
|
|
316
|
+
expect(JSON.stringify(structured)).not.toContain('lastProbeAt');
|
|
317
|
+
});
|
|
318
|
+
it('with a ticketKey, open work in OTHER projects becomes a count instead of 20 rows', async () => {
|
|
319
|
+
stubByPath({
|
|
320
|
+
'/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
|
|
321
|
+
'/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
|
|
322
|
+
'/projects/proj-1/tickets/tick-1': {
|
|
323
|
+
id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug',
|
|
324
|
+
status: 'IN_PROGRESS', statusName: 'In Progress', priority: 'high', type: 'bug',
|
|
325
|
+
},
|
|
326
|
+
// Longest path first - the stub map is matched by startsWith.
|
|
327
|
+
'/users/me/assigned-tickets': {
|
|
328
|
+
items: [
|
|
329
|
+
{ ticketKey: 'ORB-42', title: 'Same project', statusName: 'In Progress', projectId: 'proj-1' },
|
|
330
|
+
{ ticketKey: 'HIVE-818', title: 'Another project entirely', statusName: 'In Progress', projectId: 'proj-2' },
|
|
331
|
+
{ ticketKey: 'HIVE-814', title: 'And another', statusName: 'In Review', projectId: 'proj-2' },
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
335
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
336
|
+
'/time/timer': {},
|
|
337
|
+
'/v1/agent/presence': [],
|
|
338
|
+
});
|
|
339
|
+
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
|
|
340
|
+
const structured = res.structuredContent;
|
|
341
|
+
expect(structured.inProgress.map((t) => t.ticketKey)).toEqual(['ORB-42']);
|
|
342
|
+
expect(structured.inProgressElsewhereCount).toBe(2);
|
|
343
|
+
const text = res.content[0].text;
|
|
344
|
+
// Nothing is hidden: the count is stated, with the way to list them.
|
|
345
|
+
expect(text).toContain('2 open ticket(s) assigned to you in other projects');
|
|
346
|
+
expect(text).toContain('orboto_my_tickets');
|
|
347
|
+
expect(text).not.toContain('Another project entirely');
|
|
348
|
+
});
|
|
349
|
+
it('without a ticketKey the full cross-project list is kept - the scoping is opt-in by context, not a silent filter', async () => {
|
|
350
|
+
stubByPath({
|
|
351
|
+
'/users/me/assigned-tickets': {
|
|
352
|
+
items: [
|
|
353
|
+
{ ticketKey: 'ORB-42', title: 'One', statusName: 'In Progress', projectId: 'proj-1' },
|
|
354
|
+
{ ticketKey: 'HIVE-818', title: 'Two', statusName: 'In Progress', projectId: 'proj-2' },
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
358
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
359
|
+
'/time/timer': {},
|
|
360
|
+
});
|
|
361
|
+
const res = await makeSessionStartHandler(client)();
|
|
362
|
+
const structured = res.structuredContent;
|
|
363
|
+
expect(structured.inProgress.map((t) => t.ticketKey)).toEqual(['ORB-42', 'HIVE-818']);
|
|
364
|
+
expect(structured.inProgressElsewhereCount).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* ORB-1697 - the class the ORB-1697 tests kept tripping over: every
|
|
369
|
+
* optional read here is guarded with `.catch`, which covers a REJECTED
|
|
370
|
+
* request but not a 200 whose body has a different shape (an older
|
|
371
|
+
* instance, a wrapped/paginated response, a field rename). Three separate
|
|
372
|
+
* spots in this file would have thrown and taken the whole digest down
|
|
373
|
+
* over an optional section - at exactly the moment an agent has the least
|
|
374
|
+
* context. One test pins all of them.
|
|
375
|
+
*/
|
|
376
|
+
describe('orboto_session_start - a 200 with an unexpected body never kills the digest (ORB-1697)', () => {
|
|
377
|
+
const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'orb_k' });
|
|
378
|
+
it('renders the digest when every optional endpoint answers 200 with {}', async () => {
|
|
379
|
+
// Only the paths the digest cannot work without return real data; every
|
|
380
|
+
// optional read answers `{}`, which is what the fallthrough produces.
|
|
381
|
+
stubByPath({
|
|
382
|
+
'/projects/by-key/ORB': { id: 'proj-1', key: 'ORB', name: 'orboto' },
|
|
383
|
+
'/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
|
|
384
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
385
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
386
|
+
'/agent-instructions': { instructions: 'rules here' },
|
|
387
|
+
});
|
|
388
|
+
const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
|
|
389
|
+
const text = res.content[0].text;
|
|
390
|
+
expect(res.isError).toBeUndefined();
|
|
391
|
+
expect(text).toContain('## Working rules');
|
|
392
|
+
expect(text).toContain('## Ticket bundle: ORB-42');
|
|
393
|
+
expect(text).toContain('### Dependencies');
|
|
394
|
+
expect(text).toContain('### Checklists');
|
|
395
|
+
const structured = res.structuredContent;
|
|
396
|
+
expect(structured.ticketBundle.dependencies.blockedBy).toEqual([]);
|
|
397
|
+
expect(structured.ticketBundle.dependencies.blocks).toEqual([]);
|
|
398
|
+
expect(structured.ticketBundle.checklists).toEqual([]);
|
|
399
|
+
expect(structured.ticketBundle.activeSessions).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
describe('ORB-1753 - rule targeting passthrough', () => {
|
|
403
|
+
afterEach(() => {
|
|
404
|
+
delete process.env.ORBOTO_AGENT_KIND;
|
|
405
|
+
delete process.env.ORBOTO_MODEL_TIER;
|
|
406
|
+
});
|
|
407
|
+
it('explicit agentKind/modelTier ride the rules fetch; env fills in when absent; explicit beats env', async () => {
|
|
408
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
409
|
+
let calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h1' } });
|
|
410
|
+
let handler = makeSessionStartHandler(client);
|
|
411
|
+
await handler({ agentKind: 'runner', modelTier: 'small' });
|
|
412
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=runner');
|
|
413
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('modelTier=small');
|
|
414
|
+
vi.restoreAllMocks();
|
|
415
|
+
process.env.ORBOTO_AGENT_KIND = 'Coding';
|
|
416
|
+
process.env.ORBOTO_MODEL_TIER = 'frontier';
|
|
417
|
+
calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h2' } });
|
|
418
|
+
handler = makeSessionStartHandler(client);
|
|
419
|
+
await handler({});
|
|
420
|
+
// env applies, lowercase-normalized client-side.
|
|
421
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=coding');
|
|
422
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('modelTier=frontier');
|
|
423
|
+
vi.restoreAllMocks();
|
|
424
|
+
calls = stubByPath({ '/agent-instructions': { instructions: 'RULES', rulesHash: 'h3' } });
|
|
425
|
+
handler = makeSessionStartHandler(client);
|
|
426
|
+
await handler({ agentKind: 'reviewer' });
|
|
427
|
+
// explicit input beats the env default.
|
|
428
|
+
expect(calls.find((c) => c.startsWith('/agent-instructions'))).toContain('agentKind=reviewer');
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
/**
|
|
432
|
+
* ORB-1818 - the rules INDEX. Production measured the last 15 calls of
|
|
433
|
+
* this tool at 21.8k-29.3k characters, 89 % of it the assembled rule
|
|
434
|
+
* text, none ever truncated. The default answer must now fit the plain
|
|
435
|
+
* 4k budget, with the full text one call away and byte-identical.
|
|
436
|
+
*/
|
|
437
|
+
describe('ORB-1818 - rules index instead of the full rule text', () => {
|
|
438
|
+
/** 27 blocks of realistic size - the ORB workspace on 2026-09-03 had
|
|
439
|
+
* 27 enabled blocks and 17.746 characters of rule body. */
|
|
440
|
+
const BLOCK_TITLES = Array.from({ length: 27 }, (_, i) => `Rule block number ${i + 1} - a realistic operator-authored title`);
|
|
441
|
+
const RULES_TEXT = BLOCK_TITLES.map((t, i) => `${t}\n${'x'.repeat(650)}${i}`).join('\n\n');
|
|
442
|
+
const RULES_INDEX = BLOCK_TITLES.map((title) => ({ title, chars: 651 }));
|
|
443
|
+
const rulesFull = { instructions: RULES_TEXT, rulesHash: 'hash1818aaaa', rulesIndex: RULES_INDEX, rulesChars: RULES_TEXT.length };
|
|
444
|
+
function stubDigest(rules) {
|
|
445
|
+
return stubByPath({
|
|
446
|
+
'/users/me/assigned-tickets': {
|
|
447
|
+
items: [
|
|
448
|
+
{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress', projectId: 'proj-1' },
|
|
449
|
+
{ ticketKey: 'ORB-43', title: 'Landed but idle', statusName: 'In Progress', projectId: 'proj-1', landedIdle: true, landedIdleWorkingDays: 3 },
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
|
|
453
|
+
'/agent-instructions': rules,
|
|
454
|
+
'/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-09-03T10:00:00Z' },
|
|
455
|
+
'/projects/proj-1/git-health': { connections: [] },
|
|
456
|
+
'/v1/agent/messages': { messages: [] },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
it('the default answer fits the 4k response budget and carries index + hash + handle, not the text', async () => {
|
|
460
|
+
stubDigest(rulesFull);
|
|
461
|
+
const res = await makeSessionStartHandler(client)();
|
|
462
|
+
const budgeted = applyResponseBudget('orboto_session_start', res, {});
|
|
463
|
+
// The whole point: no truncation, because there is nothing big left.
|
|
464
|
+
expect(budgeted.truncatedChars).toBe(0);
|
|
465
|
+
expect(budgeted.responseChars).toBeLessThan(DEFAULT_BUDGET_CHARS);
|
|
466
|
+
// ...and the tool no longer buys itself a special budget.
|
|
467
|
+
expect(budgetFor('orboto_session_start', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
468
|
+
const structured = res.structuredContent;
|
|
469
|
+
expect(structured.rulesDelivery).toBe('index');
|
|
470
|
+
expect(structured.rules).toBe('');
|
|
471
|
+
// One line per enabled block, in delivery order.
|
|
472
|
+
expect(structured.rulesIndex).toEqual(BLOCK_TITLES);
|
|
473
|
+
expect(structured.rulesChars).toBe(RULES_TEXT.length);
|
|
474
|
+
// The way back must live in the STRUCTURED half too - Claude Code
|
|
475
|
+
// keeps that one and drops the text block.
|
|
476
|
+
expect(structured.rulesHowToRead).toContain('rulesOnly');
|
|
477
|
+
expect(structured.rulesHandle).toBeTruthy();
|
|
478
|
+
// The rest of the digest is unchanged (ORB-1799 landed-idle included).
|
|
479
|
+
expect(structured.inProgress).toHaveLength(2);
|
|
480
|
+
expect(structured.inProgress[1].landedIdle).toBe(true);
|
|
481
|
+
expect(structured.timer?.ticketKey).toBe('ORB-42');
|
|
482
|
+
const text = res.content[0].text;
|
|
483
|
+
expect(text).toContain('27 rule(s) bind you');
|
|
484
|
+
expect(text).toContain('1. Rule block number 1');
|
|
485
|
+
expect(text).toContain('27. Rule block number 27');
|
|
486
|
+
expect(text).not.toContain('xxxxxxxxxx');
|
|
487
|
+
});
|
|
488
|
+
it('the handle expands to the byte-identical rule text', async () => {
|
|
489
|
+
stubDigest(rulesFull);
|
|
490
|
+
const res = await makeSessionStartHandler(client)();
|
|
491
|
+
const { rulesHandle } = res.structuredContent;
|
|
492
|
+
const expand = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules' });
|
|
493
|
+
const chunks = [];
|
|
494
|
+
let cursor = 0;
|
|
495
|
+
let guard = 0;
|
|
496
|
+
while (cursor !== null && guard++ < 20) {
|
|
497
|
+
const page = await makeResponseExpandHandler()({ handle: rulesHandle, path: 'rules', cursor });
|
|
498
|
+
const s = page.structuredContent;
|
|
499
|
+
chunks.push(s.chunk);
|
|
500
|
+
cursor = s.nextCursor;
|
|
501
|
+
}
|
|
502
|
+
expect(expand.structuredContent.totalChars).toBe(RULES_TEXT.length);
|
|
503
|
+
expect(chunks.join('')).toBe(RULES_TEXT);
|
|
504
|
+
});
|
|
505
|
+
it('an acked hash returns neither the text NOR the index', async () => {
|
|
506
|
+
const handler = makeSessionStartHandler(client);
|
|
507
|
+
stubDigest(rulesFull);
|
|
508
|
+
await handler();
|
|
509
|
+
vi.restoreAllMocks();
|
|
510
|
+
stubDigest({ rulesHash: 'hash1818aaaa', rulesUnchanged: true });
|
|
511
|
+
const second = await handler();
|
|
512
|
+
const structured = second.structuredContent;
|
|
513
|
+
expect(structured.rulesDelivery).toBe('ack');
|
|
514
|
+
expect(structured.rulesIndex).toBeUndefined();
|
|
515
|
+
expect(structured.rules).toBe('');
|
|
516
|
+
const text = second.content[0].text;
|
|
517
|
+
expect(text).toContain('Unchanged since this connection last delivered them');
|
|
518
|
+
expect(text).toContain('rulesOnly=true');
|
|
519
|
+
expect(text).not.toContain('Rule block number 1');
|
|
520
|
+
});
|
|
521
|
+
it('forceRules returns the full text inline, and the budget never cuts it', async () => {
|
|
522
|
+
stubDigest(rulesFull);
|
|
523
|
+
const res = await makeSessionStartHandler(client)({ forceRules: true });
|
|
524
|
+
const text = res.content[0].text;
|
|
525
|
+
expect(text).toContain(RULES_TEXT);
|
|
526
|
+
expect(res.structuredContent.rules).toBe(RULES_TEXT);
|
|
527
|
+
expect(res.structuredContent.rulesDelivery).toBe('full');
|
|
528
|
+
const budgeted = applyResponseBudget('orboto_session_start', res, {});
|
|
529
|
+
expect(budgeted.truncatedChars).toBe(0);
|
|
530
|
+
// Both halves survive: the structured one via PROTECTED_PATHS, the
|
|
531
|
+
// text one via the per-call PROTECT_TEXT_META flag...
|
|
532
|
+
expect(budgeted.result.structuredContent.rules).toBe(RULES_TEXT);
|
|
533
|
+
expect(budgeted.result.content[0].text).toContain(RULES_TEXT);
|
|
534
|
+
// ...and no misleading "response truncated" notice is appended.
|
|
535
|
+
expect(budgeted.result.content[0].text).not.toContain('Response truncated');
|
|
536
|
+
// The marker itself never reaches the wire.
|
|
537
|
+
expect(budgeted.result._meta).toBeUndefined();
|
|
538
|
+
});
|
|
539
|
+
it('rulesOnly returns just the rules - no tickets, no timer, no other calls', async () => {
|
|
540
|
+
const calls = stubDigest(rulesFull);
|
|
541
|
+
const res = await makeSessionStartHandler(client)({ rulesOnly: true });
|
|
542
|
+
expect(calls).toEqual(['/agent-instructions']);
|
|
543
|
+
const text = res.content[0].text;
|
|
544
|
+
expect(text).toContain(RULES_TEXT);
|
|
545
|
+
expect(text).not.toContain('## Your in-progress work');
|
|
546
|
+
const structured = res.structuredContent;
|
|
547
|
+
expect(structured.rules).toBe(RULES_TEXT);
|
|
548
|
+
expect(structured.rulesDelivery).toBe('full');
|
|
549
|
+
expect(structured.rulesChars).toBe(RULES_TEXT.length);
|
|
550
|
+
const budgeted = applyResponseBudget('orboto_session_start', res, {});
|
|
551
|
+
expect(budgeted.truncatedChars).toBe(0);
|
|
552
|
+
});
|
|
553
|
+
it('rulesOnly ignores the cached ack - the caller asked because it does not hold them', async () => {
|
|
554
|
+
const handler = makeSessionStartHandler(client);
|
|
555
|
+
stubDigest(rulesFull);
|
|
556
|
+
await handler();
|
|
557
|
+
vi.restoreAllMocks();
|
|
558
|
+
const calls = stubDigest(rulesFull);
|
|
559
|
+
await handler({ rulesOnly: true });
|
|
560
|
+
expect(calls[0]).toBe('/agent-instructions');
|
|
561
|
+
});
|
|
562
|
+
it('an API too old to send an index still delivers the full rules inline', async () => {
|
|
563
|
+
stubDigest({ instructions: RULES_TEXT, rulesHash: 'oldapi' });
|
|
564
|
+
const res = await makeSessionStartHandler(client)();
|
|
565
|
+
expect(res.structuredContent.rulesDelivery).toBe('full');
|
|
566
|
+
expect(res.content[0].text).toContain(RULES_TEXT);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 (gap-cluster 8) - re-parent existing tickets.
|
|
3
|
+
*
|
|
4
|
+
* `orboto_update_ticket` accepts most ticket fields but NOT
|
|
5
|
+
* `parentTicketId` / `parentTicketKey`, mirroring an API choice made
|
|
6
|
+
* during ORB-309: parent assignment was treated as a creation-time
|
|
7
|
+
* decision (set via `orboto_create_ticket`'s `parentTicketKey`) and the
|
|
8
|
+
* patch surface was deliberately narrowed.
|
|
9
|
+
*
|
|
10
|
+
* In practice that left a gap: re-parenting an existing ticket under a
|
|
11
|
+
* new epic (e.g. discovering during cluster-formation that ORB-600
|
|
12
|
+
* belongs under ORB-800 as a sub-ticket) had no MCP-side path and
|
|
13
|
+
* required falling back to the Bash wrapper's raw `patch /projects/...`
|
|
14
|
+
* route - documented in `feedback_mcp_reparent_limitation.md`.
|
|
15
|
+
*
|
|
16
|
+
* Design choice: separate `orboto_set_parent` tool rather than
|
|
17
|
+
* extending `orboto_update_ticket`'s patch shape. Symmetric to
|
|
18
|
+
* `orboto_set_milestone` which exists for the same reason - a
|
|
19
|
+
* dedicated tool with clearer semantics, easier permission gating
|
|
20
|
+
* thinking, and a natural `parentTicketKey: null` path for detaching.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
24
|
+
import { resolveTicketByKey } from './shared.js';
|
|
25
|
+
export const setParentToolConfig = {
|
|
26
|
+
title: 'Set or clear a ticket\'s parent',
|
|
27
|
+
description: 'Re-parent an existing ticket. Pass `parentTicketKey` to make this ticket a sub-ticket of another, or pass `parentTicketKey: null` to detach from any parent (back to top-level). Both tickets must live in the same project. Cycle detection + self-parenting are rejected by the API. Symmetric to `orboto_set_milestone` - use this rather than `orboto_update_ticket` for parent moves.',
|
|
28
|
+
inputSchema: z.object({
|
|
29
|
+
ticketKey: z.string().min(3),
|
|
30
|
+
parentTicketKey: z.string().nullable().describe('New parent ticket key (e.g. "ACME-10"), or null to detach.'),
|
|
31
|
+
}).shape,
|
|
32
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
33
|
+
};
|
|
34
|
+
export function makeSetParentHandler(client) {
|
|
35
|
+
return async ({ ticketKey, parentTicketKey }) => {
|
|
36
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
37
|
+
let parentTicketId = null;
|
|
38
|
+
let parentKey = null;
|
|
39
|
+
if (parentTicketKey !== null && parentTicketKey !== undefined) {
|
|
40
|
+
const parent = await resolveTicketByKey(client, parentTicketKey);
|
|
41
|
+
if (parent.projectId !== ticket.projectId) {
|
|
42
|
+
throw new Error(`Cross-project parenting is not allowed: ${ticket.ticketKey} is in a different project than ${parent.ticketKey}.`);
|
|
43
|
+
}
|
|
44
|
+
if (parent.id === ticket.id) {
|
|
45
|
+
throw new Error(`A ticket cannot be its own parent (${ticket.ticketKey}).`);
|
|
46
|
+
}
|
|
47
|
+
parentTicketId = parent.id;
|
|
48
|
+
parentKey = parent.ticketKey;
|
|
49
|
+
}
|
|
50
|
+
let updated;
|
|
51
|
+
try {
|
|
52
|
+
updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { parentTicketId });
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// Surface the API's own cycle-detection error verbatim if it
|
|
56
|
+
// catches a deeper cycle we couldn't see from one hop above.
|
|
57
|
+
if (err instanceof OrbotoApiError && err.status === 400) {
|
|
58
|
+
throw new Error(`Re-parent rejected by the API: ${err.body || 'cycle or constraint violation'}.`);
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
content: [{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: parentTicketId
|
|
66
|
+
? `Re-parented [${updated.ticketKey}] under [${parentKey}].`
|
|
67
|
+
: `Detached [${updated.ticketKey}] from its parent (now top-level).`,
|
|
68
|
+
}],
|
|
69
|
+
structuredContent: {
|
|
70
|
+
ticketKey: updated.ticketKey,
|
|
71
|
+
parentTicketKey: parentKey,
|
|
72
|
+
parentTicketId,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|