@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.
Files changed (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. 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
+ });