@1agh/maude 0.45.0 → 0.45.2
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/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// Proves the elicitation-form gate end-to-end (feature-acp-ask-user-question)
|
|
2
|
+
// against a mock ACP agent that calls back into the client via a REAL
|
|
3
|
+
// `elicitation/create` request (fixtures/mock-acp-agent-elicit.mjs), shaped
|
|
4
|
+
// exactly like the AskUserQuestion → form-elicitation mapping
|
|
5
|
+
// (askUserQuestionsToCreateRequest) — no real `claude` needed. Covers:
|
|
6
|
+
// • the request is forwarded via onElicitationRequest and stays pending.
|
|
7
|
+
// • accept with a selected option resolves with that content.
|
|
8
|
+
// • accept with a custom free-text answer resolves with that content
|
|
9
|
+
// (mirrors applyAskElicitationResponse's override rule — the UI, not the
|
|
10
|
+
// bridge, decides which one to send; the bridge just forwards it).
|
|
11
|
+
// • accept with missing/malformed content fails closed to decline — never
|
|
12
|
+
// fabricates content.
|
|
13
|
+
// • an explicit decline / cancel round-trips as sent.
|
|
14
|
+
// • a malformed/unrecognized action fails closed to decline.
|
|
15
|
+
// • timeout defaults to decline (not cancel — decline lets the turn
|
|
16
|
+
// continue per applyAskElicitationResponse's documented contract).
|
|
17
|
+
// • a turn-cancel declines every pending request instead of hanging it.
|
|
18
|
+
// • a stale/unknown id is a silent no-op.
|
|
19
|
+
// • the Acp manager relays elicitation-request/-response frames end to end.
|
|
20
|
+
|
|
21
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import type { ServerWebSocket } from 'bun';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
AcpBridge,
|
|
28
|
+
elicitationSchemaWithinBounds,
|
|
29
|
+
MAX_ELICITATION_SCHEMA_BYTES,
|
|
30
|
+
MAX_ELICITATION_SCHEMA_PROPERTIES,
|
|
31
|
+
MAX_PENDING_ELICITATIONS,
|
|
32
|
+
} from '../acp/bridge.ts';
|
|
33
|
+
import { createAcp } from '../acp/index.ts';
|
|
34
|
+
import type { Context } from '../context.ts';
|
|
35
|
+
import type { WsData } from '../ws.ts';
|
|
36
|
+
|
|
37
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-elicit.mjs');
|
|
38
|
+
const FIXTURE_URL = join(import.meta.dir, 'fixtures', 'mock-acp-agent-elicit-url.mjs');
|
|
39
|
+
const FIXTURE_FLOOD = join(import.meta.dir, 'fixtures', 'mock-acp-agent-elicit-flood.mjs');
|
|
40
|
+
const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
|
|
41
|
+
|
|
42
|
+
function useMockAgent(fixture = FIXTURE) {
|
|
43
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = fixture;
|
|
44
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
45
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const key of TEST_ENV_KEYS) delete process.env[key];
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
|
|
53
|
+
const start = performance.now();
|
|
54
|
+
for (;;) {
|
|
55
|
+
const v = fn();
|
|
56
|
+
if (v !== undefined) return v;
|
|
57
|
+
if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
|
|
58
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fakeWs(id: string) {
|
|
63
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
64
|
+
const ws = {
|
|
65
|
+
data: { id } as WsData,
|
|
66
|
+
send: (raw: string) => {
|
|
67
|
+
frames.push(JSON.parse(raw));
|
|
68
|
+
},
|
|
69
|
+
} as unknown as ServerWebSocket<WsData>;
|
|
70
|
+
return { ws, frames };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('AcpBridge — elicitation-form gate', () => {
|
|
74
|
+
test('a request is forwarded via onElicitationRequest, stays pending, and accept-with-selection resolves it', async () => {
|
|
75
|
+
useMockAgent();
|
|
76
|
+
const requests: Array<{ id: string; message?: string; mode?: string }> = [];
|
|
77
|
+
const bridge = new AcpBridge({
|
|
78
|
+
repoRoot: process.cwd(),
|
|
79
|
+
onUpdate: () => {},
|
|
80
|
+
onElicitationRequest: (id, req) =>
|
|
81
|
+
requests.push({ id, message: req.message, mode: req.mode }),
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
85
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
86
|
+
expect(req.message).toBe('Which color do you like?');
|
|
87
|
+
expect(req.mode).toBe('form');
|
|
88
|
+
|
|
89
|
+
bridge.resolveElicitation(req.id, { action: 'accept', content: { question_0: 'Red' } });
|
|
90
|
+
await promptPromise;
|
|
91
|
+
} finally {
|
|
92
|
+
await bridge.stop();
|
|
93
|
+
}
|
|
94
|
+
}, 15000);
|
|
95
|
+
|
|
96
|
+
test('accept with a custom free-text answer resolves with that exact content', async () => {
|
|
97
|
+
useMockAgent();
|
|
98
|
+
const updates: unknown[] = [];
|
|
99
|
+
const requests: Array<{ id: string }> = [];
|
|
100
|
+
const bridge = new AcpBridge({
|
|
101
|
+
repoRoot: process.cwd(),
|
|
102
|
+
onUpdate: (u) => updates.push(u),
|
|
103
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
107
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
108
|
+
bridge.resolveElicitation(req.id, {
|
|
109
|
+
action: 'accept',
|
|
110
|
+
content: { question_0_custom: 'Something else' },
|
|
111
|
+
});
|
|
112
|
+
await promptPromise;
|
|
113
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
114
|
+
.map((u) => u?.content?.text ?? '')
|
|
115
|
+
.join('');
|
|
116
|
+
expect(text).toContain('"question_0_custom":"Something else"');
|
|
117
|
+
} finally {
|
|
118
|
+
await bridge.stop();
|
|
119
|
+
}
|
|
120
|
+
}, 15000);
|
|
121
|
+
|
|
122
|
+
test('accept with missing/malformed content fails closed to decline — never fabricates content', async () => {
|
|
123
|
+
useMockAgent();
|
|
124
|
+
const updates: unknown[] = [];
|
|
125
|
+
const requests: Array<{ id: string }> = [];
|
|
126
|
+
const bridge = new AcpBridge({
|
|
127
|
+
repoRoot: process.cwd(),
|
|
128
|
+
onUpdate: (u) => updates.push(u),
|
|
129
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
133
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
134
|
+
// @ts-expect-error — deliberately malformed (accept with no content) to prove the fail-closed path
|
|
135
|
+
bridge.resolveElicitation(req.id, { action: 'accept' });
|
|
136
|
+
await promptPromise;
|
|
137
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
138
|
+
.map((u) => u?.content?.text ?? '')
|
|
139
|
+
.join('');
|
|
140
|
+
expect(text).toContain('"action":"decline"');
|
|
141
|
+
} finally {
|
|
142
|
+
await bridge.stop();
|
|
143
|
+
}
|
|
144
|
+
}, 15000);
|
|
145
|
+
|
|
146
|
+
test('an explicit decline round-trips as sent', async () => {
|
|
147
|
+
useMockAgent();
|
|
148
|
+
const updates: unknown[] = [];
|
|
149
|
+
const requests: Array<{ id: string }> = [];
|
|
150
|
+
const bridge = new AcpBridge({
|
|
151
|
+
repoRoot: process.cwd(),
|
|
152
|
+
onUpdate: (u) => updates.push(u),
|
|
153
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
157
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
158
|
+
bridge.resolveElicitation(req.id, { action: 'decline' });
|
|
159
|
+
await promptPromise;
|
|
160
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
161
|
+
.map((u) => u?.content?.text ?? '')
|
|
162
|
+
.join('');
|
|
163
|
+
expect(text).toContain('"action":"decline"');
|
|
164
|
+
} finally {
|
|
165
|
+
await bridge.stop();
|
|
166
|
+
}
|
|
167
|
+
}, 15000);
|
|
168
|
+
|
|
169
|
+
test('an explicit cancel round-trips as sent (distinct from decline)', async () => {
|
|
170
|
+
useMockAgent();
|
|
171
|
+
const updates: unknown[] = [];
|
|
172
|
+
const requests: Array<{ id: string }> = [];
|
|
173
|
+
const bridge = new AcpBridge({
|
|
174
|
+
repoRoot: process.cwd(),
|
|
175
|
+
onUpdate: (u) => updates.push(u),
|
|
176
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
180
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
181
|
+
bridge.resolveElicitation(req.id, { action: 'cancel' });
|
|
182
|
+
await promptPromise;
|
|
183
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
184
|
+
.map((u) => u?.content?.text ?? '')
|
|
185
|
+
.join('');
|
|
186
|
+
expect(text).toContain('"action":"cancel"');
|
|
187
|
+
} finally {
|
|
188
|
+
await bridge.stop();
|
|
189
|
+
}
|
|
190
|
+
}, 15000);
|
|
191
|
+
|
|
192
|
+
test('a malformed/unrecognized action fails closed to decline', async () => {
|
|
193
|
+
useMockAgent();
|
|
194
|
+
const updates: unknown[] = [];
|
|
195
|
+
const requests: Array<{ id: string }> = [];
|
|
196
|
+
const bridge = new AcpBridge({
|
|
197
|
+
repoRoot: process.cwd(),
|
|
198
|
+
onUpdate: (u) => updates.push(u),
|
|
199
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
203
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
204
|
+
bridge.resolveElicitation(req.id, { action: 'yolo' });
|
|
205
|
+
await promptPromise;
|
|
206
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
207
|
+
.map((u) => u?.content?.text ?? '')
|
|
208
|
+
.join('');
|
|
209
|
+
expect(text).toContain('"action":"decline"');
|
|
210
|
+
} finally {
|
|
211
|
+
await bridge.stop();
|
|
212
|
+
}
|
|
213
|
+
}, 15000);
|
|
214
|
+
|
|
215
|
+
test('a stale/unknown id is a silent no-op — never throws', async () => {
|
|
216
|
+
useMockAgent();
|
|
217
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
218
|
+
try {
|
|
219
|
+
expect(() =>
|
|
220
|
+
bridge.resolveElicitation('does-not-exist', { action: 'accept', content: {} })
|
|
221
|
+
).not.toThrow();
|
|
222
|
+
} finally {
|
|
223
|
+
await bridge.stop();
|
|
224
|
+
}
|
|
225
|
+
}, 15000);
|
|
226
|
+
|
|
227
|
+
test('timeout defaults to decline (never cancel, never accept)', async () => {
|
|
228
|
+
useMockAgent();
|
|
229
|
+
const updates: unknown[] = [];
|
|
230
|
+
const bridge = new AcpBridge({
|
|
231
|
+
repoRoot: process.cwd(),
|
|
232
|
+
onUpdate: (u) => updates.push(u),
|
|
233
|
+
permissionTimeoutMs: 150, // short override — reused for elicitation too (see AcpBridgeOptions doc)
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
await bridge.prompt('hi', 'c1'); // never calls resolveElicitation — must resolve itself
|
|
237
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
238
|
+
.map((u) => u?.content?.text ?? '')
|
|
239
|
+
.join('');
|
|
240
|
+
expect(text).toContain('"action":"decline"');
|
|
241
|
+
} finally {
|
|
242
|
+
await bridge.stop();
|
|
243
|
+
}
|
|
244
|
+
}, 15000);
|
|
245
|
+
|
|
246
|
+
test('a turn-cancel declines every pending elicitation request instead of hanging it forever', async () => {
|
|
247
|
+
useMockAgent();
|
|
248
|
+
const requests: Array<{ id: string }> = [];
|
|
249
|
+
const bridge = new AcpBridge({
|
|
250
|
+
repoRoot: process.cwd(),
|
|
251
|
+
onUpdate: () => {},
|
|
252
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
253
|
+
permissionTimeoutMs: 60000, // long — proves cancel() settles it, not the timeout racing in
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
257
|
+
await until(() => (requests.length ? true : undefined));
|
|
258
|
+
await bridge.cancel(); // must decline the pending request, not leave it hanging
|
|
259
|
+
await promptPromise; // resolves because the mock's elicitation/create call unblocked
|
|
260
|
+
} finally {
|
|
261
|
+
await bridge.stop();
|
|
262
|
+
}
|
|
263
|
+
}, 15000);
|
|
264
|
+
|
|
265
|
+
test('a mode:"url" request is rejected structurally — declined without ever reaching onElicitationRequest', async () => {
|
|
266
|
+
useMockAgent(FIXTURE_URL);
|
|
267
|
+
const updates: unknown[] = [];
|
|
268
|
+
const requests: unknown[] = [];
|
|
269
|
+
const bridge = new AcpBridge({
|
|
270
|
+
repoRoot: process.cwd(),
|
|
271
|
+
onUpdate: (u) => updates.push(u),
|
|
272
|
+
onElicitationRequest: (id, req) => requests.push({ id, req }),
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
await bridge.prompt('hi', 'c1');
|
|
276
|
+
const text = (updates as Array<{ content?: { text?: string } }>)
|
|
277
|
+
.map((u) => u?.content?.text ?? '')
|
|
278
|
+
.join('');
|
|
279
|
+
expect(text).toContain('"action":"decline"');
|
|
280
|
+
// Never even surfaced to the client — only `form` was ever declared, so
|
|
281
|
+
// a non-compliant/malicious sender's url-mode request must not reach
|
|
282
|
+
// the UI at all (ethical-hacker finding — capability negotiation alone
|
|
283
|
+
// isn't a rejection).
|
|
284
|
+
expect(requests.length).toBe(0);
|
|
285
|
+
} finally {
|
|
286
|
+
await bridge.stop();
|
|
287
|
+
}
|
|
288
|
+
}, 15000);
|
|
289
|
+
|
|
290
|
+
test('onElicitationSettled fires on a bridge-initiated timeout — the client can learn its card is dead', async () => {
|
|
291
|
+
useMockAgent();
|
|
292
|
+
const settled: string[] = [];
|
|
293
|
+
const requests: Array<{ id: string }> = [];
|
|
294
|
+
const bridge = new AcpBridge({
|
|
295
|
+
repoRoot: process.cwd(),
|
|
296
|
+
onUpdate: () => {},
|
|
297
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
298
|
+
onElicitationSettled: (id) => settled.push(id),
|
|
299
|
+
permissionTimeoutMs: 150,
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
await bridge.prompt('hi', 'c1');
|
|
303
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
304
|
+
expect(settled).toEqual([req.id]);
|
|
305
|
+
} finally {
|
|
306
|
+
await bridge.stop();
|
|
307
|
+
}
|
|
308
|
+
}, 15000);
|
|
309
|
+
|
|
310
|
+
test('onElicitationSettled also fires for a client-driven resolveElicitation call', async () => {
|
|
311
|
+
useMockAgent();
|
|
312
|
+
const settled: string[] = [];
|
|
313
|
+
const requests: Array<{ id: string }> = [];
|
|
314
|
+
const bridge = new AcpBridge({
|
|
315
|
+
repoRoot: process.cwd(),
|
|
316
|
+
onUpdate: () => {},
|
|
317
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
318
|
+
onElicitationSettled: (id) => settled.push(id),
|
|
319
|
+
});
|
|
320
|
+
try {
|
|
321
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
322
|
+
const req = await until(() => (requests.length ? requests[0] : undefined));
|
|
323
|
+
bridge.resolveElicitation(req.id, { action: 'decline' });
|
|
324
|
+
await promptPromise;
|
|
325
|
+
expect(settled).toEqual([req.id]);
|
|
326
|
+
} finally {
|
|
327
|
+
await bridge.stop();
|
|
328
|
+
}
|
|
329
|
+
}, 15000);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('Acp manager — elicitation-request/-response frames', () => {
|
|
333
|
+
test('an elicitation-request frame is relayed to the client and a valid elicitation-response resolves it', async () => {
|
|
334
|
+
useMockAgent();
|
|
335
|
+
const ctx = {
|
|
336
|
+
paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-elicit' },
|
|
337
|
+
} as unknown as Context;
|
|
338
|
+
const acp = createAcp(ctx);
|
|
339
|
+
|
|
340
|
+
const a = fakeWs('elicit-ws-a');
|
|
341
|
+
try {
|
|
342
|
+
acp.onOpen(a.ws);
|
|
343
|
+
acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
|
|
344
|
+
const reqFrame = await until(() => a.frames.find((f) => f.t === 'elicitation-request'));
|
|
345
|
+
expect(reqFrame.message).toBe('Which color do you like?');
|
|
346
|
+
expect(reqFrame.requestedSchema).toBeTruthy();
|
|
347
|
+
// Forwarded so the client can attribute the request to a specific tool
|
|
348
|
+
// call instead of a blanket "from Claude" label (ethical-hacker finding).
|
|
349
|
+
expect(reqFrame.toolCallId).toBe('tc-elicit-1');
|
|
350
|
+
|
|
351
|
+
acp.onMessage(
|
|
352
|
+
a.ws,
|
|
353
|
+
JSON.stringify({
|
|
354
|
+
t: 'elicitation-response',
|
|
355
|
+
id: reqFrame.id,
|
|
356
|
+
action: 'accept',
|
|
357
|
+
content: { question_0: 'Blue' },
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
await until(() => a.frames.find((f) => f.t === 'turn-end'));
|
|
361
|
+
// A client-driven response also gets the settled-notice frame (harmless
|
|
362
|
+
// for this path — acp-runtime.js's client-side removal is optimistic —
|
|
363
|
+
// but proves the manager relays onElicitationSettled unconditionally).
|
|
364
|
+
const settledFrame = await until(() => a.frames.find((f) => f.t === 'elicitation-resolved'));
|
|
365
|
+
expect(settledFrame.id).toBe(reqFrame.id);
|
|
366
|
+
} finally {
|
|
367
|
+
acp.onClose(a.ws);
|
|
368
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
369
|
+
}
|
|
370
|
+
}, 20000);
|
|
371
|
+
|
|
372
|
+
test('an elicitation-response with an unknown id is a harmless no-op — the turn still settles via timeout', async () => {
|
|
373
|
+
useMockAgent();
|
|
374
|
+
const ctx = {
|
|
375
|
+
paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-elicit-2' },
|
|
376
|
+
} as unknown as Context;
|
|
377
|
+
const acp = createAcp(ctx);
|
|
378
|
+
|
|
379
|
+
const a = fakeWs('elicit-ws-b');
|
|
380
|
+
try {
|
|
381
|
+
acp.onOpen(a.ws);
|
|
382
|
+
acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
|
|
383
|
+
await until(() => a.frames.find((f) => f.t === 'elicitation-request'));
|
|
384
|
+
|
|
385
|
+
// Bogus id — must not throw / crash the manager.
|
|
386
|
+
expect(() =>
|
|
387
|
+
acp.onMessage(
|
|
388
|
+
a.ws,
|
|
389
|
+
JSON.stringify({
|
|
390
|
+
t: 'elicitation-response',
|
|
391
|
+
id: 'not-a-real-id',
|
|
392
|
+
action: 'accept',
|
|
393
|
+
content: {},
|
|
394
|
+
})
|
|
395
|
+
)
|
|
396
|
+
).not.toThrow();
|
|
397
|
+
} finally {
|
|
398
|
+
acp.onClose(a.ws);
|
|
399
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
400
|
+
}
|
|
401
|
+
}, 20000);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// SECURITY (ethical-hacker finding) — a compromised/hostile MCP server can
|
|
405
|
+
// issue elicitation requests directly, with no natural rate limit the way a
|
|
406
|
+
// permission request has (one per tool call). Prove both caps that guard
|
|
407
|
+
// against that: an oversized/too-many-properties schema is rejected before
|
|
408
|
+
// ever reaching the client, and a flood of concurrent requests only ever
|
|
409
|
+
// keeps MAX_PENDING_ELICITATIONS of them live at once — the rest are declined
|
|
410
|
+
// immediately rather than queued.
|
|
411
|
+
describe('elicitationSchemaWithinBounds — pure bound check', () => {
|
|
412
|
+
test('a normal, small schema is within bounds', () => {
|
|
413
|
+
expect(
|
|
414
|
+
elicitationSchemaWithinBounds({
|
|
415
|
+
type: 'object',
|
|
416
|
+
properties: { question_0: { type: 'string', oneOf: [{ const: 'a', title: 'A' }] } },
|
|
417
|
+
})
|
|
418
|
+
).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('a missing/malformed schema is treated as within bounds — nothing to bound', () => {
|
|
422
|
+
expect(elicitationSchemaWithinBounds(undefined)).toBe(true);
|
|
423
|
+
expect(elicitationSchemaWithinBounds(null)).toBe(true);
|
|
424
|
+
expect(elicitationSchemaWithinBounds('not-an-object')).toBe(true);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('too many top-level properties fails the bound', () => {
|
|
428
|
+
const properties: Record<string, unknown> = {};
|
|
429
|
+
for (let i = 0; i < MAX_ELICITATION_SCHEMA_PROPERTIES + 1; i++) {
|
|
430
|
+
properties[`q${i}`] = { type: 'string' };
|
|
431
|
+
}
|
|
432
|
+
expect(elicitationSchemaWithinBounds({ type: 'object', properties })).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('a schema within the property-count limit but exceeding the byte cap still fails', () => {
|
|
436
|
+
const bigDescription = 'x'.repeat(MAX_ELICITATION_SCHEMA_BYTES + 1);
|
|
437
|
+
expect(
|
|
438
|
+
elicitationSchemaWithinBounds({
|
|
439
|
+
type: 'object',
|
|
440
|
+
properties: { question_0: { type: 'string', description: bigDescription } },
|
|
441
|
+
})
|
|
442
|
+
).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('AcpBridge — elicitation flood cap', () => {
|
|
447
|
+
test(`a burst of concurrent elicitation requests only forwards ${MAX_PENDING_ELICITATIONS} to the client — the rest decline immediately`, async () => {
|
|
448
|
+
useMockAgent(FIXTURE_FLOOD);
|
|
449
|
+
const requests: Array<{ id: string }> = [];
|
|
450
|
+
const bridge = new AcpBridge({
|
|
451
|
+
repoRoot: process.cwd(),
|
|
452
|
+
onUpdate: () => {},
|
|
453
|
+
onElicitationRequest: (id) => requests.push({ id }),
|
|
454
|
+
});
|
|
455
|
+
try {
|
|
456
|
+
const promptPromise = bridge.prompt('hi', 'c1');
|
|
457
|
+
// Poll instead of a fixed sleep — a fixed 300ms window flaked under
|
|
458
|
+
// concurrent machine load (subprocess spawn + ACP handshake alone can
|
|
459
|
+
// exceed it well before the flood itself even starts; confirmed via a
|
|
460
|
+
// throwaway diagnostic run: 0 requests at 300ms, exactly
|
|
461
|
+
// MAX_PENDING_ELICITATIONS at 3s). The 8 flood requests fire
|
|
462
|
+
// essentially synchronously from the fixture's own `Promise.all`, and
|
|
463
|
+
// declining an overflow request is synchronous inside
|
|
464
|
+
// `unstable_createElicitation`, so once the FIRST request arrives the
|
|
465
|
+
// rest of the accepted batch is already settled — polling for the cap
|
|
466
|
+
// to be reached is both faster on a fast machine and correct on a slow one.
|
|
467
|
+
await until(() => (requests.length >= MAX_PENDING_ELICITATIONS ? true : undefined));
|
|
468
|
+
expect(requests.length).toBe(MAX_PENDING_ELICITATIONS);
|
|
469
|
+
for (const req of requests) bridge.resolveElicitation(req.id, { action: 'decline' });
|
|
470
|
+
await promptPromise;
|
|
471
|
+
} finally {
|
|
472
|
+
await bridge.stop();
|
|
473
|
+
}
|
|
474
|
+
}, 15000);
|
|
475
|
+
});
|