@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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1697 - the central response budget.
|
|
3
|
+
*
|
|
4
|
+
* The contract under test, in the order it matters:
|
|
5
|
+
* 1. a result inside its budget is returned byte-identical;
|
|
6
|
+
* 2. an over-budget result is CUT, and the cut is explicit;
|
|
7
|
+
* 3. the cut preserves the payload SHAPE (keys stay, arrays stay
|
|
8
|
+
* arrays, identifier-shaped strings stay whole) - 7 tools declare an
|
|
9
|
+
* outputSchema the SDK validates the payload against;
|
|
10
|
+
* 4. nothing is lost: the omitted remainder is fetchable via the handle;
|
|
11
|
+
* 5. text and structured content are budgeted as separate halves, since
|
|
12
|
+
* no known client charges for both.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
15
|
+
import { applyResponseBudget, budgetFor, budgetEnabled, measureHalves, measureResult, readPayload, resetPayloadStore, resolvePath, DEFAULT_BUDGET_CHARS, MAX_HANDLES, HANDLE_TTL_MS, storePayload, PROTECT_TEXT_META, } from './response-budget.js';
|
|
16
|
+
import { makeResponseExpandHandler } from './tools/response-expand.js';
|
|
17
|
+
const TOOL = 'orboto_test_tool';
|
|
18
|
+
function result(structured, text = 'text block') {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: 'text', text }],
|
|
21
|
+
...(structured === undefined ? {} : { structuredContent: structured }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** A truncation block, if the budget wrote one. */
|
|
25
|
+
function truncationOf(r) {
|
|
26
|
+
const sc = r.structuredContent;
|
|
27
|
+
return sc?.__truncation;
|
|
28
|
+
}
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
resetPayloadStore();
|
|
31
|
+
delete process.env.ORBOTO_MCP_RESPONSE_BUDGET;
|
|
32
|
+
delete process.env.ORBOTO_MCP_RESPONSE_BUDGET_CHARS;
|
|
33
|
+
});
|
|
34
|
+
describe('budget configuration', () => {
|
|
35
|
+
it('defaults to 4k and honours per-tool overrides', () => {
|
|
36
|
+
expect(budgetFor('orboto_whatever', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
37
|
+
// A content read is deliberately higher.
|
|
38
|
+
expect(budgetFor('orboto_get_doc', {})).toBeGreaterThan(DEFAULT_BUDGET_CHARS);
|
|
39
|
+
// ORB-1818 - session_start lost its 48k exemption: its default answer
|
|
40
|
+
// carries a rules INDEX plus a hash, not the rule text, so it lives on
|
|
41
|
+
// the plain default like every other tool.
|
|
42
|
+
expect(budgetFor('orboto_session_start', {})).toBe(DEFAULT_BUDGET_CHARS);
|
|
43
|
+
});
|
|
44
|
+
it('reads a custom default from the env, per-tool overrides still win', () => {
|
|
45
|
+
expect(budgetFor('orboto_whatever', { ORBOTO_MCP_RESPONSE_BUDGET_CHARS: '9000' })).toBe(9000);
|
|
46
|
+
expect(budgetFor('orboto_get_doc', { ORBOTO_MCP_RESPONSE_BUDGET_CHARS: '9000' }))
|
|
47
|
+
.toBe(budgetFor('orboto_get_doc', {}));
|
|
48
|
+
});
|
|
49
|
+
it('ignores a nonsense env value rather than dropping the cap to zero', () => {
|
|
50
|
+
expect(budgetFor('orboto_whatever', { ORBOTO_MCP_RESPONSE_BUDGET_CHARS: 'lots' })).toBe(DEFAULT_BUDGET_CHARS);
|
|
51
|
+
expect(budgetFor('orboto_whatever', { ORBOTO_MCP_RESPONSE_BUDGET_CHARS: '-5' })).toBe(DEFAULT_BUDGET_CHARS);
|
|
52
|
+
});
|
|
53
|
+
it('can be switched off entirely', () => {
|
|
54
|
+
expect(budgetEnabled({ ORBOTO_MCP_RESPONSE_BUDGET: 'off' })).toBe(false);
|
|
55
|
+
expect(budgetEnabled({})).toBe(true);
|
|
56
|
+
const big = result({ blob: 'x'.repeat(50_000) });
|
|
57
|
+
const out = applyResponseBudget(TOOL, big, { ORBOTO_MCP_RESPONSE_BUDGET: 'off' });
|
|
58
|
+
expect(out.truncatedChars).toBe(0);
|
|
59
|
+
expect(truncationOf(out.result)).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('measurement', () => {
|
|
63
|
+
it('measures the two halves separately and prices the heavier one', () => {
|
|
64
|
+
const r = result({ a: 'x'.repeat(1000) }, 'y'.repeat(300));
|
|
65
|
+
const { textChars, structuredChars } = measureHalves(r);
|
|
66
|
+
expect(textChars).toBe(300);
|
|
67
|
+
expect(structuredChars).toBeGreaterThan(1000);
|
|
68
|
+
// Not the sum: no known client pays for both halves.
|
|
69
|
+
expect(measureResult(r)).toBe(structuredChars);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('under budget', () => {
|
|
73
|
+
it('returns the result untouched', () => {
|
|
74
|
+
const r = result({ key: 'ORB-1697', title: 'small' });
|
|
75
|
+
const out = applyResponseBudget(TOOL, r);
|
|
76
|
+
expect(out.result).toBe(r);
|
|
77
|
+
expect(out.truncatedChars).toBe(0);
|
|
78
|
+
expect(out.handle).toBeUndefined();
|
|
79
|
+
expect(out.responseChars).toBe(measureResult(r));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('over budget', () => {
|
|
83
|
+
it('cuts the payload and reports the numbers the call log records', () => {
|
|
84
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
85
|
+
expect(out.originalChars).toBeGreaterThan(30_000);
|
|
86
|
+
expect(out.responseChars).toBeLessThanOrEqual(budgetFor(TOOL));
|
|
87
|
+
expect(out.truncatedChars).toBeGreaterThan(0);
|
|
88
|
+
expect(out.handle).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
it('never truncates silently - the block names the handle, the paths and the way back', () => {
|
|
91
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
92
|
+
const block = truncationOf(out.result);
|
|
93
|
+
expect(block.handle).toBe(out.handle);
|
|
94
|
+
expect(block.budgetChars).toBe(budgetFor(TOOL));
|
|
95
|
+
expect(block.omittedChars).toBeGreaterThan(0);
|
|
96
|
+
expect(String(block.howToGetTheRest)).toContain('orboto_response_expand');
|
|
97
|
+
expect(block.omitted).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'description', kind: 'string' })]));
|
|
98
|
+
// The text half carries the notice too, for clients that ignore
|
|
99
|
+
// structuredContent entirely.
|
|
100
|
+
const text = out.result.content[0].text;
|
|
101
|
+
expect(text).toContain('orboto_response_expand');
|
|
102
|
+
expect(text).toContain(out.handle);
|
|
103
|
+
});
|
|
104
|
+
it('marks the cut inside the value, so a reader cannot mistake it for the whole', () => {
|
|
105
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
106
|
+
const sc = out.result.structuredContent;
|
|
107
|
+
expect(sc.description.length).toBeLessThan(30_000);
|
|
108
|
+
expect(sc.description).toContain('truncated');
|
|
109
|
+
expect(sc.description).toContain('orboto_response_expand');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('shape preservation (outputSchema tools must still validate)', () => {
|
|
113
|
+
it('keeps every key, keeps arrays as arrays, keeps types', () => {
|
|
114
|
+
const structured = {
|
|
115
|
+
id: 'cd42b836-47b8-485a-aea7-ad09789b9bc6',
|
|
116
|
+
key: 'ORB-1697',
|
|
117
|
+
count: 42,
|
|
118
|
+
flag: true,
|
|
119
|
+
nothing: null,
|
|
120
|
+
description: 'd'.repeat(20_000),
|
|
121
|
+
comments: Array.from({ length: 40 }, (_, i) => ({ id: `c${i}`, body: 'b'.repeat(400) })),
|
|
122
|
+
};
|
|
123
|
+
const out = applyResponseBudget(TOOL, result(structured));
|
|
124
|
+
const sc = out.result.structuredContent;
|
|
125
|
+
for (const key of Object.keys(structured))
|
|
126
|
+
expect(sc).toHaveProperty(key);
|
|
127
|
+
expect(Array.isArray(sc.comments)).toBe(true);
|
|
128
|
+
expect(typeof sc.description).toBe('string');
|
|
129
|
+
expect(sc.count).toBe(42);
|
|
130
|
+
expect(sc.flag).toBe(true);
|
|
131
|
+
expect(sc.nothing).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
it('never cuts identifier-shaped strings in half', () => {
|
|
134
|
+
const uuid = 'cd42b836-47b8-485a-aea7-ad09789b9bc6';
|
|
135
|
+
const url = 'https://orboto.example.com/projects/904c8036/tickets/3dab8c3c';
|
|
136
|
+
const out = applyResponseBudget(TOOL, result({ id: uuid, url, blob: 'x'.repeat(40_000) }));
|
|
137
|
+
const sc = out.result.structuredContent;
|
|
138
|
+
expect(sc.id).toBe(uuid);
|
|
139
|
+
expect(sc.url).toBe(url);
|
|
140
|
+
});
|
|
141
|
+
it('drops array items from the tail and says how many', () => {
|
|
142
|
+
const items = Array.from({ length: 60 }, (_, i) => ({ key: `ORB-${i}`, title: 't'.repeat(200) }));
|
|
143
|
+
const out = applyResponseBudget(TOOL, result({ items }));
|
|
144
|
+
const sc = out.result.structuredContent;
|
|
145
|
+
expect(sc.items.length).toBeLessThan(60);
|
|
146
|
+
expect(sc.items.length).toBeGreaterThan(0);
|
|
147
|
+
// Head-first: the first row survives, the tail is what goes.
|
|
148
|
+
expect(sc.items[0].key).toBe('ORB-0');
|
|
149
|
+
const entry = truncationOf(out.result).omitted
|
|
150
|
+
.find((e) => e.path === 'items');
|
|
151
|
+
expect(entry).toMatchObject({ kind: 'array' });
|
|
152
|
+
expect(entry.omittedItems).toBeGreaterThan(0);
|
|
153
|
+
expect(entry.keptItems + entry.omittedItems).toBe(60);
|
|
154
|
+
});
|
|
155
|
+
it('leaves non-text content parts (images, resources) alone', () => {
|
|
156
|
+
const r = {
|
|
157
|
+
content: [
|
|
158
|
+
{ type: 'text', text: 't'.repeat(20_000) },
|
|
159
|
+
{ type: 'image', data: 'AAAA', mimeType: 'image/png' },
|
|
160
|
+
],
|
|
161
|
+
structuredContent: { blob: 'x'.repeat(20_000) },
|
|
162
|
+
};
|
|
163
|
+
const out = applyResponseBudget(TOOL, r);
|
|
164
|
+
expect(out.result.content.some((p) => p.type === 'image')).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
it('handles a result with no structuredContent at all', () => {
|
|
167
|
+
const out = applyResponseBudget(TOOL, { content: [{ type: 'text', text: 'q'.repeat(20_000) }] });
|
|
168
|
+
const text = out.result.content[0].text;
|
|
169
|
+
expect(text.length).toBeLessThanOrEqual(budgetFor(TOOL));
|
|
170
|
+
expect(text).toContain('orboto_response_expand');
|
|
171
|
+
expect(out.truncatedChars).toBeGreaterThan(0);
|
|
172
|
+
});
|
|
173
|
+
it('cuts the text half on a line boundary', () => {
|
|
174
|
+
const lines = Array.from({ length: 900 }, (_, i) => `line-${i}-padding-padding-padding`);
|
|
175
|
+
const out = applyResponseBudget(TOOL, result(undefined, lines.join('\n')));
|
|
176
|
+
const text = out.result.content[0].text;
|
|
177
|
+
const kept = text.split('\n\n[Response truncated')[0].split('\n').filter(Boolean);
|
|
178
|
+
for (const line of kept)
|
|
179
|
+
expect(lines).toContain(line);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('the floor case - a payload made only of uncuttable leaves', () => {
|
|
183
|
+
it('reports atFloor instead of looping or mangling identifiers', () => {
|
|
184
|
+
// 500 short strings: every one is below MIN_STRING_CUT, so there is
|
|
185
|
+
// nothing safe to cut. The budget must give up loudly, not spin.
|
|
186
|
+
const structured = Object.fromEntries(Array.from({ length: 500 }, (_, i) => [`field_${i}`, `v${i}`.padEnd(90, 'x')]));
|
|
187
|
+
const out = applyResponseBudget(TOOL, result(structured));
|
|
188
|
+
expect(out.originalChars).toBeGreaterThan(budgetFor(TOOL));
|
|
189
|
+
const block = truncationOf(out.result);
|
|
190
|
+
expect(block.atFloor).toBe(true);
|
|
191
|
+
// Every field survived intact - a cut that cannot be made safely is
|
|
192
|
+
// not made at all.
|
|
193
|
+
const sc = out.result.structuredContent;
|
|
194
|
+
expect(Object.keys(sc)).toHaveLength(501); // 500 fields + __truncation
|
|
195
|
+
expect(sc.field_0).toBe(structured.field_0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('nothing is lost - orboto_response_expand', () => {
|
|
199
|
+
const expand = makeResponseExpandHandler();
|
|
200
|
+
it('lists what was omitted when called with the handle alone', async () => {
|
|
201
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
202
|
+
const res = await expand({ handle: out.handle });
|
|
203
|
+
const sc = res.structuredContent;
|
|
204
|
+
expect(res.isError).toBeUndefined();
|
|
205
|
+
expect(sc.toolName).toBe(TOOL);
|
|
206
|
+
expect(sc.omitted.map((o) => o.path)).toContain('description');
|
|
207
|
+
});
|
|
208
|
+
it('returns the FULL original value, chunked, following nextCursor to the end', async () => {
|
|
209
|
+
const original = 'd'.repeat(30_000);
|
|
210
|
+
const out = applyResponseBudget(TOOL, result({ description: original }));
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
let assembled = '';
|
|
213
|
+
for (let guard = 0; guard < 50; guard += 1) {
|
|
214
|
+
const res = await expand({ handle: out.handle, path: 'description', cursor });
|
|
215
|
+
const sc = res.structuredContent;
|
|
216
|
+
assembled += sc.chunk;
|
|
217
|
+
expect(sc.totalChars).toBe(original.length);
|
|
218
|
+
if (sc.nextCursor === null)
|
|
219
|
+
break;
|
|
220
|
+
cursor = sc.nextCursor;
|
|
221
|
+
}
|
|
222
|
+
expect(assembled).toBe(original);
|
|
223
|
+
});
|
|
224
|
+
it('serves a nested path and the text half', async () => {
|
|
225
|
+
const out = applyResponseBudget('orboto_session_start', {
|
|
226
|
+
content: [{ type: 'text', text: 'T'.repeat(60_000) }],
|
|
227
|
+
structuredContent: { ticketBundle: { primer: { markdown: 'M'.repeat(60_000) } } },
|
|
228
|
+
});
|
|
229
|
+
const nested = await expand({ handle: out.handle, path: 'ticketBundle.primer.markdown' });
|
|
230
|
+
expect(nested.structuredContent.totalChars).toBe(60_000);
|
|
231
|
+
const text = await expand({ handle: out.handle, path: '$text' });
|
|
232
|
+
expect(text.structuredContent.totalChars).toBe(60_000);
|
|
233
|
+
});
|
|
234
|
+
it('is an explicit error - never a wrong chunk - for an expired handle or a bad path', async () => {
|
|
235
|
+
const gone = await expand({ handle: 'deadbeef' });
|
|
236
|
+
expect(gone.isError).toBe(true);
|
|
237
|
+
expect(gone.content[0].text).toContain('Re-run the original tool');
|
|
238
|
+
const out = applyResponseBudget(TOOL, result({ description: 'd'.repeat(30_000) }));
|
|
239
|
+
const badPath = await expand({ handle: out.handle, path: 'nope.not.here' });
|
|
240
|
+
expect(badPath.isError).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
it('resolves array indices in a path', () => {
|
|
243
|
+
const root = { comments: [{ body: 'first' }, { body: 'second' }] };
|
|
244
|
+
expect(resolvePath(root, 'comments[1].body')).toBe('second');
|
|
245
|
+
expect(resolvePath(root, 'comments[9].body')).toBeUndefined();
|
|
246
|
+
expect(resolvePath(root, '')).toBe(root);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe('the handle store is bounded', () => {
|
|
250
|
+
it('evicts the oldest beyond MAX_HANDLES', () => {
|
|
251
|
+
const first = storePayload(TOOL, { text: 'a' });
|
|
252
|
+
for (let i = 0; i < MAX_HANDLES + 2; i += 1)
|
|
253
|
+
storePayload(TOOL, { text: `x${i}` });
|
|
254
|
+
expect(readPayload(first)).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
it('expires handles after the TTL', () => {
|
|
257
|
+
const t0 = 1_000_000;
|
|
258
|
+
const handle = storePayload(TOOL, { text: 'a' }, t0);
|
|
259
|
+
expect(readPayload(handle, t0 + 1000)).not.toBeNull();
|
|
260
|
+
expect(readPayload(handle, t0 + HANDLE_TTL_MS + 1)).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe('protected paths - a mandatory rule is never cut (ORB-1697)', () => {
|
|
264
|
+
it('cuts the primer, not the rules, on an over-budget session_start', () => {
|
|
265
|
+
// The real shape: rules are the LARGEST string, so the default
|
|
266
|
+
// largest-first rule would have cut exactly the wrong thing. Measured
|
|
267
|
+
// on 2026-08-09 before this guard existed: 2.917 characters gone from
|
|
268
|
+
// the binding rules of a cold session_start({ ticketKey }).
|
|
269
|
+
const rules = 'R'.repeat(24_000);
|
|
270
|
+
const out = applyResponseBudget('orboto_session_start', result({
|
|
271
|
+
rules,
|
|
272
|
+
ticketBundle: { primer: { markdown: 'P'.repeat(30_000) } },
|
|
273
|
+
}));
|
|
274
|
+
const sc = out.result.structuredContent;
|
|
275
|
+
expect(sc.rules).toBe(rules);
|
|
276
|
+
expect(sc.rules).not.toContain('truncated');
|
|
277
|
+
expect(sc.ticketBundle.primer.markdown.length).toBeLessThan(30_000);
|
|
278
|
+
const cutPaths = truncationOf(out.result).omitted.map((o) => o.path);
|
|
279
|
+
expect(cutPaths).toContain('ticketBundle.primer.markdown');
|
|
280
|
+
expect(cutPaths).not.toContain('rules');
|
|
281
|
+
});
|
|
282
|
+
it('keeps the rules whole even when nothing else can be cut, and says so', () => {
|
|
283
|
+
const rules = 'R'.repeat(60_000);
|
|
284
|
+
const out = applyResponseBudget('orboto_session_start', result({ rules }));
|
|
285
|
+
const sc = out.result.structuredContent;
|
|
286
|
+
expect(sc.rules).toBe(rules);
|
|
287
|
+
expect(truncationOf(out.result).atFloor).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
it('protects the subtree, not just the exact key', () => {
|
|
290
|
+
const out = applyResponseBudget('orboto_session_start', result({
|
|
291
|
+
rules: 'short rules',
|
|
292
|
+
// A hypothetical nested shape under a protected root stays protected.
|
|
293
|
+
ticketBundle: { note: 'N'.repeat(80_000) },
|
|
294
|
+
}));
|
|
295
|
+
const sc = out.result.structuredContent;
|
|
296
|
+
// ticketBundle is NOT protected, so this one does get cut - the guard is
|
|
297
|
+
// path-scoped, not a blanket exemption.
|
|
298
|
+
expect(sc.ticketBundle.note.length).toBeLessThan(80_000);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
/**
|
|
302
|
+
* ORB-1818 - the text half needs the same protection the structured half
|
|
303
|
+
* has, for the one answer whose entire payload is the binding rules.
|
|
304
|
+
*/
|
|
305
|
+
describe('per-call text protection (ORB-1818)', () => {
|
|
306
|
+
it('leaves BOTH halves whole, strips the marker, and says nothing was omitted', () => {
|
|
307
|
+
const rules = 'R'.repeat(40_000);
|
|
308
|
+
const marked = {
|
|
309
|
+
_meta: { [PROTECT_TEXT_META]: true },
|
|
310
|
+
content: [{ type: 'text', text: rules }],
|
|
311
|
+
structuredContent: { rules },
|
|
312
|
+
};
|
|
313
|
+
const out = applyResponseBudget('orboto_session_start', marked);
|
|
314
|
+
expect(out.truncatedChars).toBe(0);
|
|
315
|
+
expect(out.result.structuredContent.rules).toBe(rules);
|
|
316
|
+
const text = out.result.content[0].text;
|
|
317
|
+
expect(text).toContain(rules);
|
|
318
|
+
// The floor notice must not claim a truncation that did not happen.
|
|
319
|
+
expect(text).not.toContain('Response truncated');
|
|
320
|
+
expect(text).toContain('NOTHING was omitted');
|
|
321
|
+
// The marker is transport-internal - it never reaches the client.
|
|
322
|
+
expect(out.result._meta).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
it('protects the text half only - unprotected structured leaves are still cut', () => {
|
|
325
|
+
const marked = {
|
|
326
|
+
_meta: { [PROTECT_TEXT_META]: true },
|
|
327
|
+
content: [{ type: 'text', text: 'short text' }],
|
|
328
|
+
structuredContent: { rules: 'R'.repeat(20_000), primer: 'P'.repeat(20_000) },
|
|
329
|
+
};
|
|
330
|
+
const out = applyResponseBudget('orboto_session_start', marked);
|
|
331
|
+
const sc = out.result.structuredContent;
|
|
332
|
+
expect(sc.rules).toBe('R'.repeat(20_000));
|
|
333
|
+
expect(sc.primer.length).toBeLessThan(20_000);
|
|
334
|
+
expect(out.result.content[0].text).toContain('Response truncated');
|
|
335
|
+
});
|
|
336
|
+
it('an unmarked result still has its text half cut', () => {
|
|
337
|
+
const out = applyResponseBudget('orboto_session_start', {
|
|
338
|
+
content: [{ type: 'text', text: 'T'.repeat(40_000) }],
|
|
339
|
+
structuredContent: { rules: 'R'.repeat(40_000) },
|
|
340
|
+
});
|
|
341
|
+
expect(out.result.content[0].text.length).toBeLessThan(40_000);
|
|
342
|
+
});
|
|
343
|
+
});
|