@1agh/maude 0.45.1 → 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,251 @@
1
+ // Pure unit tests for client/panels/acp-elicitation.js — the schema parser +
2
+ // content builder ElicitationPrompt.jsx renders from. Fixture shapes mirror
3
+ // the EXACT wire output of `@agentclientprotocol/claude-agent-acp`'s
4
+ // `askUserQuestionsToCreateRequest` (confirmed on disk — see the file's own
5
+ // header comment), not a guessed/simplified schema.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import {
10
+ buildElicitationContent,
11
+ looksLikeSecretRequest,
12
+ parseElicitationSchema,
13
+ } from '../client/panels/acp-elicitation.js';
14
+
15
+ // A real single-question AskUserQuestion-shaped schema (single() branch of
16
+ // askUserQuestionsToCreateRequest — message carries the question, the field
17
+ // itself has no description).
18
+ const SINGLE_SELECT_SCHEMA = {
19
+ type: 'object',
20
+ properties: {
21
+ question_0: {
22
+ type: 'string',
23
+ title: 'Which color?',
24
+ oneOf: [
25
+ { const: 'Red', title: 'Red', description: 'Warm' },
26
+ { const: 'Blue', title: 'Blue' },
27
+ ],
28
+ },
29
+ question_0_custom: {
30
+ type: 'string',
31
+ title: 'Other',
32
+ description: 'Type your own answer instead of choosing an option above (optional).',
33
+ },
34
+ },
35
+ };
36
+
37
+ // A real multi-question schema — index 1 is multi-select (array + items.anyOf).
38
+ const MULTI_QUESTION_SCHEMA = {
39
+ type: 'object',
40
+ properties: {
41
+ question_0: {
42
+ type: 'string',
43
+ description: 'Pick one',
44
+ oneOf: [
45
+ { const: 'A', title: 'A' },
46
+ { const: 'B', title: 'B' },
47
+ ],
48
+ },
49
+ question_0_custom: { type: 'string', title: 'Other' },
50
+ question_1: {
51
+ type: 'array',
52
+ description: 'Pick many',
53
+ items: {
54
+ anyOf: [
55
+ { const: 'X', title: 'X' },
56
+ { const: 'Y', title: 'Y' },
57
+ ],
58
+ },
59
+ },
60
+ question_1_custom: { type: 'string', title: 'Other' },
61
+ },
62
+ };
63
+
64
+ describe('parseElicitationSchema', () => {
65
+ test('single-select (oneOf): one question, custom field paired not top-level', () => {
66
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
67
+ expect(qs.length).toBe(1);
68
+ expect(qs[0]).toMatchObject({
69
+ id: 'question_0',
70
+ kind: 'single',
71
+ customFieldId: 'question_0_custom',
72
+ required: false,
73
+ });
74
+ expect(qs[0].options).toEqual([
75
+ { value: 'Red', label: 'Red', description: 'Warm', preview: null },
76
+ { value: 'Blue', label: 'Blue', description: null, preview: null },
77
+ ]);
78
+ });
79
+
80
+ test('multi-select (array+items.anyOf) alongside a single-select sibling', () => {
81
+ const qs = parseElicitationSchema(MULTI_QUESTION_SCHEMA);
82
+ expect(qs.map((q) => q.id)).toEqual(['question_0', 'question_1']);
83
+ expect(qs[0].kind).toBe('single');
84
+ expect(qs[1].kind).toBe('multi');
85
+ expect(qs[1].options).toEqual([
86
+ { value: 'X', label: 'X', description: null, preview: null },
87
+ { value: 'Y', label: 'Y', description: null, preview: null },
88
+ ]);
89
+ expect(qs[1].customFieldId).toBe('question_1_custom');
90
+ });
91
+
92
+ test('a bare enum (untitled strings) renders with value===label', () => {
93
+ const qs = parseElicitationSchema({
94
+ type: 'object',
95
+ properties: { color: { type: 'string', enum: ['red', 'blue'] } },
96
+ });
97
+ expect(qs[0].options).toEqual([
98
+ { value: 'red', label: 'red', description: null, preview: null },
99
+ { value: 'blue', label: 'blue', description: null, preview: null },
100
+ ]);
101
+ });
102
+
103
+ test('an enum option carrying the AskUserQuestion preview _meta convention surfaces it as `preview`', () => {
104
+ const qs = parseElicitationSchema({
105
+ type: 'object',
106
+ properties: {
107
+ question_0: {
108
+ type: 'string',
109
+ oneOf: [
110
+ {
111
+ const: 'Option A',
112
+ title: 'Option A',
113
+ _meta: { '_claude/askUserQuestionOption': { preview: 'const x = 1' } },
114
+ },
115
+ { const: 'Option B', title: 'Option B' },
116
+ ],
117
+ },
118
+ },
119
+ });
120
+ expect(qs[0].options[0].preview).toBe('const x = 1');
121
+ expect(qs[0].options[1].preview).toBeNull();
122
+ });
123
+
124
+ test('a plain string/number/boolean property with no enum falls back to a text question', () => {
125
+ const qs = parseElicitationSchema({
126
+ type: 'object',
127
+ properties: { note: { type: 'string', title: 'Note' }, age: { type: 'number' } },
128
+ });
129
+ expect(qs.map((q) => q.kind)).toEqual(['text', 'text']);
130
+ });
131
+
132
+ test('schema.required marks a question required', () => {
133
+ const qs = parseElicitationSchema({
134
+ type: 'object',
135
+ properties: { note: { type: 'string' } },
136
+ required: ['note'],
137
+ });
138
+ expect(qs[0].required).toBe(true);
139
+ });
140
+
141
+ test('a "_custom"-suffixed field with NO base sibling renders as its own question', () => {
142
+ const qs = parseElicitationSchema({
143
+ type: 'object',
144
+ properties: { foo_custom: { type: 'string' } },
145
+ });
146
+ expect(qs.map((q) => q.id)).toEqual(['foo_custom']);
147
+ });
148
+
149
+ test('malformed/missing schema tolerance — never throws, returns []', () => {
150
+ expect(parseElicitationSchema(null)).toEqual([]);
151
+ expect(parseElicitationSchema(undefined)).toEqual([]);
152
+ expect(parseElicitationSchema({})).toEqual([]);
153
+ expect(parseElicitationSchema({ properties: null })).toEqual([]);
154
+ expect(parseElicitationSchema({ properties: 'not-an-object' })).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe('buildElicitationContent', () => {
159
+ test('a selected single-select option folds into content[id]', () => {
160
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
161
+ const content = buildElicitationContent(qs, { question_0: 'Red' });
162
+ expect(content).toEqual({ question_0: 'Red' });
163
+ });
164
+
165
+ test('a non-empty custom answer overrides the selection (matches applyAskElicitationResponse read order)', () => {
166
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
167
+ const content = buildElicitationContent(qs, {
168
+ question_0: 'Red',
169
+ question_0_custom: ' Something else ',
170
+ });
171
+ expect(content).toEqual({ question_0_custom: 'Something else' });
172
+ });
173
+
174
+ test('a blank/whitespace-only custom answer does NOT override the selection', () => {
175
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
176
+ const content = buildElicitationContent(qs, { question_0: 'Blue', question_0_custom: ' ' });
177
+ expect(content).toEqual({ question_0: 'Blue' });
178
+ });
179
+
180
+ test('multi-select folds a non-empty array; an empty selection is omitted', () => {
181
+ const qs = parseElicitationSchema(MULTI_QUESTION_SCHEMA);
182
+ const content = buildElicitationContent(qs, { question_1: ['X', 'Y'] });
183
+ expect(content).toEqual({ question_1: ['X', 'Y'] });
184
+ });
185
+
186
+ test('multi-select: a custom answer is ADDED to the picks (via the base field), not routed through customFieldId', () => {
187
+ const qs = parseElicitationSchema(MULTI_QUESTION_SCHEMA);
188
+ const content = buildElicitationContent(qs, {
189
+ question_1: ['X', 'Y'],
190
+ question_1_custom: ' Z ',
191
+ });
192
+ // Combined into the BASE field's array — applyAskElicitationResponse only
193
+ // overrides when content[customFieldId] itself is non-empty, so leaving
194
+ // that key absent and appending to content[question_1] instead means the
195
+ // adapter's own join() sees the pick AND the custom text.
196
+ expect(content).toEqual({ question_1: ['X', 'Y', 'Z'] });
197
+ expect(content.question_1_custom).toBeUndefined();
198
+ });
199
+
200
+ test('multi-select: a custom answer with NO picks still sends as a single-entry array', () => {
201
+ const qs = parseElicitationSchema(MULTI_QUESTION_SCHEMA);
202
+ const content = buildElicitationContent(qs, { question_1_custom: 'Just this' });
203
+ expect(content).toEqual({ question_1: ['Just this'] });
204
+ });
205
+
206
+ test('an unanswered, non-required question is omitted entirely — never fabricates a value', () => {
207
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
208
+ expect(buildElicitationContent(qs, {})).toEqual({});
209
+ expect(buildElicitationContent([], { anything: 'x' })).toEqual({});
210
+ });
211
+
212
+ test('tolerates a missing/malformed answers object', () => {
213
+ const qs = parseElicitationSchema(SINGLE_SELECT_SCHEMA);
214
+ expect(buildElicitationContent(qs, null)).toEqual({});
215
+ expect(buildElicitationContent(qs, undefined)).toEqual({});
216
+ });
217
+ });
218
+
219
+ describe('looksLikeSecretRequest', () => {
220
+ test('matches common credential-shaped phrasing, case-insensitively', () => {
221
+ expect(looksLikeSecretRequest('Enter your API key')).toBe(true);
222
+ expect(looksLikeSecretRequest('what is your PASSWORD')).toBe(true);
223
+ expect(looksLikeSecretRequest('Paste your auth token here')).toBe(true);
224
+ expect(looksLikeSecretRequest('Provide your OAuth token')).toBe(true);
225
+ });
226
+
227
+ test('checks all provided texts, not just the first', () => {
228
+ expect(looksLikeSecretRequest('Which color do you like?', 'a secret preference')).toBe(true);
229
+ expect(looksLikeSecretRequest(null, 'Enter your secret key', undefined)).toBe(true);
230
+ });
231
+
232
+ test('an ordinary question does not trip the heuristic', () => {
233
+ expect(looksLikeSecretRequest('Which color do you like?')).toBe(false);
234
+ expect(looksLikeSecretRequest('What layout do you prefer?')).toBe(false);
235
+ });
236
+
237
+ test('tolerates no/malformed input', () => {
238
+ expect(looksLikeSecretRequest()).toBe(false);
239
+ expect(looksLikeSecretRequest(null, undefined, 42)).toBe(false);
240
+ });
241
+
242
+ test("parseElicitationSchema tags each question's own secretShaped flag from its title/description", () => {
243
+ const qs = parseElicitationSchema({
244
+ type: 'object',
245
+ properties: {
246
+ question_0: { type: 'string', description: 'Enter your password to continue' },
247
+ },
248
+ });
249
+ expect(qs[0].secretShaped).toBe(true);
250
+ });
251
+ });
@@ -0,0 +1,77 @@
1
+ // SECURITY (ethical-hacker finding, retroactive review of Milestone B) —
2
+ // pure unit tests for PermissionPrompt.jsx's default-option selection. The
3
+ // bridge-side backend logic (resolvePermission's optionId validation) was
4
+ // already correct and covered by test/acp-permission.test.ts; the bug this
5
+ // covers lived entirely in the CLIENT affordance — which option Enter/the
6
+ // visually-primary button resolves to — a seam no backend test could see.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { pickDefaultAllow, pickDefaultReject } from '../client/panels/PermissionPrompt.jsx';
11
+
12
+ describe('pickDefaultAllow', () => {
13
+ test('prefers allow_once over allow_always, regardless of array order', () => {
14
+ // Matches the REAL adapter's own ordering for a routine tool call —
15
+ // allow_always listed BEFORE allow_once — the exact shape that made the
16
+ // original `.find()` grab the wrong one.
17
+ const options = [
18
+ { optionId: 'allow-always', kind: 'allow_always', name: 'Allow always' },
19
+ { optionId: 'allow-once', kind: 'allow_once', name: 'Allow once' },
20
+ { optionId: 'reject-once', kind: 'reject_once', name: 'Reject' },
21
+ ];
22
+ expect(pickDefaultAllow(options)?.optionId).toBe('allow-once');
23
+ });
24
+
25
+ test('ExitPlanMode-shaped option set: prefers the once-only "manually approve edits" over auto/acceptEdits', () => {
26
+ // Mirrors the REAL adapter's ExitPlanMode option ordering exactly (see
27
+ // DDR-179 + the ethical-hacker finding): auto/acceptEdits (both
28
+ // allow_always) listed BEFORE the once-only "default" option.
29
+ const options = [
30
+ { optionId: 'auto', kind: 'allow_always', name: 'Yes, and use auto mode' },
31
+ { optionId: 'acceptEdits', kind: 'allow_always', name: 'Yes, and auto-accept edits' },
32
+ { optionId: 'default', kind: 'allow_once', name: 'Yes, manually approve edits' },
33
+ { optionId: 'plan', kind: 'reject_once', name: 'No, keep planning' },
34
+ ];
35
+ expect(pickDefaultAllow(options)?.optionId).toBe('default');
36
+ });
37
+
38
+ test('falls back to allow_always only when no once-only option exists', () => {
39
+ const options = [
40
+ { optionId: 'allow-always', kind: 'allow_always', name: 'Allow always' },
41
+ { optionId: 'reject-once', kind: 'reject_once', name: 'Reject' },
42
+ ];
43
+ expect(pickDefaultAllow(options)?.optionId).toBe('allow-always');
44
+ });
45
+
46
+ test('no allow-shaped option at all → null, not a reject option', () => {
47
+ const options = [{ optionId: 'reject-once', kind: 'reject_once', name: 'Reject' }];
48
+ expect(pickDefaultAllow(options)).toBeNull();
49
+ });
50
+
51
+ test('tolerates missing/malformed input — never throws', () => {
52
+ expect(pickDefaultAllow(undefined)).toBeNull();
53
+ expect(pickDefaultAllow(null)).toBeNull();
54
+ expect(
55
+ pickDefaultAllow([null, undefined, { kind: 'allow_once', optionId: 'x' }])?.optionId
56
+ ).toBe('x');
57
+ });
58
+ });
59
+
60
+ describe('pickDefaultReject', () => {
61
+ test('finds a reject_once/reject_always option regardless of position', () => {
62
+ const options = [
63
+ { optionId: 'allow-once', kind: 'allow_once', name: 'Allow once' },
64
+ { optionId: 'reject-once', kind: 'reject_once', name: 'Reject' },
65
+ ];
66
+ expect(pickDefaultReject(options)?.optionId).toBe('reject-once');
67
+ });
68
+
69
+ test('no reject-shaped option → null', () => {
70
+ expect(pickDefaultReject([{ optionId: 'allow-once', kind: 'allow_once' }])).toBeNull();
71
+ });
72
+
73
+ test('tolerates missing/malformed input — never throws', () => {
74
+ expect(pickDefaultReject(undefined)).toBeNull();
75
+ expect(pickDefaultReject(null)).toBeNull();
76
+ });
77
+ });
@@ -0,0 +1,262 @@
1
+ // Proves the permission approve/deny gate end-to-end (Milestone B, retires
2
+ // DDR-125 F2's blanket auto-approve) against a mock ACP agent that calls back
3
+ // into the client via a REAL `session/request_permission` request (fixtures/
4
+ // mock-acp-agent-permission.mjs) — no real `claude` needed. Covers:
5
+ // • requestPermission is forwarded via onPermissionRequest (+ onPermission
6
+ // transparency) and stays pending until resolvePermission is called.
7
+ // • A valid decision (an offered optionId, or 'cancelled') resolves it.
8
+ // • An UNOFFERED decision fails closed to 'cancelled' (DDR-125 F1 posture).
9
+ // • Timeout defaults to 'cancelled' (deny), never allow.
10
+ // • A turn-cancel denies every pending request instead of leaving it hung.
11
+ // • The Acp manager relays permission-request/-response frames end to end.
12
+
13
+ import { afterEach, describe, expect, test } from 'bun:test';
14
+ import { join } from 'node:path';
15
+
16
+ import type { ServerWebSocket } from 'bun';
17
+
18
+ import { AcpBridge, MAX_PENDING_PERMISSIONS } from '../acp/bridge.ts';
19
+ import { createAcp } from '../acp/index.ts';
20
+ import type { Context } from '../context.ts';
21
+ import type { WsData } from '../ws.ts';
22
+
23
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
24
+ const FIXTURE_FLOOD = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission-flood.mjs');
25
+ const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
26
+
27
+ function useMockAgent(fixture = FIXTURE) {
28
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = fixture;
29
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
30
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
35
+ });
36
+
37
+ async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
38
+ const start = performance.now();
39
+ for (;;) {
40
+ const v = fn();
41
+ if (v !== undefined) return v;
42
+ if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
43
+ await new Promise((r) => setTimeout(r, 25));
44
+ }
45
+ }
46
+
47
+ function fakeWs(id: string) {
48
+ const frames: Array<Record<string, unknown>> = [];
49
+ const ws = {
50
+ data: { id } as WsData,
51
+ send: (raw: string) => {
52
+ frames.push(JSON.parse(raw));
53
+ },
54
+ } as unknown as ServerWebSocket<WsData>;
55
+ return { ws, frames };
56
+ }
57
+
58
+ describe('AcpBridge — permission approve/deny (Task B1)', () => {
59
+ test('a request is forwarded via onPermissionRequest + onPermission, stays pending, and a valid decision resolves the turn', async () => {
60
+ useMockAgent();
61
+ const requests: Array<{ id: string; toolCallTitle?: string; optionIds: string[] }> = [];
62
+ const transparency: unknown[] = [];
63
+ const bridge = new AcpBridge({
64
+ repoRoot: process.cwd(),
65
+ onUpdate: () => {},
66
+ onPermission: (req) => transparency.push(req),
67
+ onPermissionRequest: (id, req) =>
68
+ requests.push({
69
+ id,
70
+ toolCallTitle: req.toolCall?.title,
71
+ optionIds: req.options.map((o) => o.optionId),
72
+ }),
73
+ });
74
+ try {
75
+ const promptPromise = bridge.prompt('hi', 'c1');
76
+ const req = await until(() => (requests.length ? requests[0] : undefined));
77
+ expect(req.toolCallTitle).toBe('Write file');
78
+ expect(req.optionIds).toEqual(['allow-once', 'allow-always', 'reject-once']);
79
+ expect(transparency.length).toBe(1); // transparency fires unconditionally too
80
+
81
+ bridge.resolvePermission(req.id, 'allow-once');
82
+ await promptPromise;
83
+ } finally {
84
+ await bridge.stop();
85
+ }
86
+ }, 15000);
87
+
88
+ test('an unoffered decision fails closed to cancelled — never forwards an arbitrary optionId', async () => {
89
+ useMockAgent();
90
+ const updates: unknown[] = [];
91
+ const requests: Array<{ id: string }> = [];
92
+ const bridge = new AcpBridge({
93
+ repoRoot: process.cwd(),
94
+ onUpdate: (u) => updates.push(u),
95
+ onPermissionRequest: (id) => requests.push({ id }),
96
+ });
97
+ try {
98
+ const promptPromise = bridge.prompt('hi', 'c1');
99
+ const req = await until(() => (requests.length ? requests[0] : undefined));
100
+ // Not one of ['allow-once','allow-always','reject-once'] — must deny, not select.
101
+ bridge.resolvePermission(req.id, 'some-optionId-never-offered');
102
+ await promptPromise;
103
+ const text = updates.map((u) => u?.content?.text ?? '').join('');
104
+ expect(text).toContain('"outcome":"cancelled"');
105
+ expect(text).not.toContain('some-optionId-never-offered');
106
+ } finally {
107
+ await bridge.stop();
108
+ }
109
+ }, 15000);
110
+
111
+ test('resolvePermission("cancelled") denies explicitly', async () => {
112
+ useMockAgent();
113
+ const updates: unknown[] = [];
114
+ const requests: Array<{ id: string }> = [];
115
+ const bridge = new AcpBridge({
116
+ repoRoot: process.cwd(),
117
+ onUpdate: (u) => updates.push(u),
118
+ onPermissionRequest: (id) => requests.push({ id }),
119
+ });
120
+ try {
121
+ const promptPromise = bridge.prompt('hi', 'c1');
122
+ const req = await until(() => (requests.length ? requests[0] : undefined));
123
+ bridge.resolvePermission(req.id, 'cancelled');
124
+ await promptPromise;
125
+ const text = updates.map((u) => u?.content?.text ?? '').join('');
126
+ expect(text).toContain('"outcome":"cancelled"');
127
+ } finally {
128
+ await bridge.stop();
129
+ }
130
+ }, 15000);
131
+
132
+ test('a stale/unknown id is a silent no-op — never throws', async () => {
133
+ useMockAgent();
134
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
135
+ try {
136
+ expect(() => bridge.resolvePermission('does-not-exist', 'allow-once')).not.toThrow();
137
+ } finally {
138
+ await bridge.stop();
139
+ }
140
+ }, 15000);
141
+
142
+ test('timeout defaults to cancelled (deny), never allow', async () => {
143
+ useMockAgent();
144
+ const updates: unknown[] = [];
145
+ const bridge = new AcpBridge({
146
+ repoRoot: process.cwd(),
147
+ onUpdate: (u) => updates.push(u),
148
+ permissionTimeoutMs: 150, // short override — see AcpBridgeOptions.permissionTimeoutMs (tests only)
149
+ });
150
+ try {
151
+ await bridge.prompt('hi', 'c1'); // never calls resolvePermission — must resolve itself
152
+ const text = updates.map((u) => u?.content?.text ?? '').join('');
153
+ expect(text).toContain('"outcome":"cancelled"');
154
+ } finally {
155
+ await bridge.stop();
156
+ }
157
+ }, 15000);
158
+
159
+ test('a turn-cancel denies every pending permission request instead of hanging it forever', async () => {
160
+ useMockAgent();
161
+ const requests: Array<{ id: string }> = [];
162
+ const bridge = new AcpBridge({
163
+ repoRoot: process.cwd(),
164
+ onUpdate: () => {},
165
+ onPermissionRequest: (id) => requests.push({ id }),
166
+ permissionTimeoutMs: 60000, // long — proves cancel() settles it, not the timeout racing in
167
+ });
168
+ try {
169
+ const promptPromise = bridge.prompt('hi', 'c1');
170
+ await until(() => (requests.length ? true : undefined));
171
+ await bridge.cancel(); // must deny the pending request, not leave it hanging
172
+ await promptPromise; // resolves because the mock's requestPermission() call unblocked
173
+ } finally {
174
+ await bridge.stop();
175
+ }
176
+ }, 15000);
177
+ });
178
+
179
+ describe('Acp manager — permission-request/-response frames (Task B2)', () => {
180
+ test('a permission-request frame is relayed to the client and a valid permission-response resolves it', async () => {
181
+ useMockAgent();
182
+ const ctx = {
183
+ paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter' },
184
+ } as unknown as Context;
185
+ const acp = createAcp(ctx);
186
+
187
+ const a = fakeWs('perm-ws-a');
188
+ try {
189
+ acp.onOpen(a.ws);
190
+ acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
191
+ const reqFrame = await until(() => a.frames.find((f) => f.t === 'permission-request'));
192
+ expect(reqFrame.toolCall).toMatchObject({ title: 'Write file' });
193
+ const options = reqFrame.options as Array<{ optionId: string }>;
194
+ expect(options.map((o) => o.optionId)).toEqual(['allow-once', 'allow-always', 'reject-once']);
195
+
196
+ acp.onMessage(
197
+ a.ws,
198
+ JSON.stringify({ t: 'permission-response', id: reqFrame.id, decision: 'allow-once' })
199
+ );
200
+ await until(() => a.frames.find((f) => f.t === 'turn-end'));
201
+ } finally {
202
+ acp.onClose(a.ws);
203
+ await new Promise((r) => setTimeout(r, 50));
204
+ }
205
+ }, 20000);
206
+
207
+ test('a permission-response with an unknown id is a harmless no-op — the turn still settles via timeout', async () => {
208
+ useMockAgent();
209
+ const ctx = {
210
+ paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-2' },
211
+ } as unknown as Context;
212
+ const acp = createAcp(ctx);
213
+
214
+ const a = fakeWs('perm-ws-b');
215
+ try {
216
+ acp.onOpen(a.ws);
217
+ acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
218
+ await until(() => a.frames.find((f) => f.t === 'permission-request'));
219
+
220
+ // Bogus id — must not throw / crash the manager.
221
+ expect(() =>
222
+ acp.onMessage(
223
+ a.ws,
224
+ JSON.stringify({ t: 'permission-response', id: 'not-a-real-id', decision: 'allow-once' })
225
+ )
226
+ ).not.toThrow();
227
+ } finally {
228
+ acp.onClose(a.ws);
229
+ await new Promise((r) => setTimeout(r, 50));
230
+ }
231
+ }, 20000);
232
+ });
233
+
234
+ // SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
235
+ // can legitimately issue several tool calls back to back, so "one per tool
236
+ // call" was never a real ceiling on `pendingPermissions`. Prove the cap: a
237
+ // flood of concurrent requests only ever keeps MAX_PENDING_PERMISSIONS of
238
+ // them live at once — the rest are denied immediately rather than queued.
239
+ describe('AcpBridge — permission flood cap', () => {
240
+ test(`a burst of concurrent permission requests only forwards ${MAX_PENDING_PERMISSIONS} to the client — the rest deny immediately`, async () => {
241
+ useMockAgent(FIXTURE_FLOOD);
242
+ const requests: Array<{ id: string }> = [];
243
+ const bridge = new AcpBridge({
244
+ repoRoot: process.cwd(),
245
+ onUpdate: () => {},
246
+ onPermissionRequest: (id) => requests.push({ id }),
247
+ });
248
+ try {
249
+ const promptPromise = bridge.prompt('hi', 'c1');
250
+ // Denying is synchronous inside requestPermission once the cap trips,
251
+ // so there's nothing further to await once every request has had a
252
+ // chance to arrive.
253
+ await new Promise((r) => setTimeout(r, 300));
254
+ expect(requests.length).toBeLessThanOrEqual(MAX_PENDING_PERMISSIONS);
255
+ expect(requests.length).toBeGreaterThan(0);
256
+ for (const req of requests) bridge.resolvePermission(req.id, 'cancelled');
257
+ await promptPromise;
258
+ } finally {
259
+ await bridge.stop();
260
+ }
261
+ }, 15000);
262
+ });
@@ -0,0 +1,76 @@
1
+ // Pure unit tests for client/panels/ToolGroup.jsx's grouping/summary helpers
2
+ // (Task C1 — collapsed consecutive-tool-call groups).
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { groupToolCalls, summarizeGroup } from '../client/panels/ToolGroup.jsx';
7
+
8
+ describe('groupToolCalls', () => {
9
+ test('a lone tool-call stays a single item, not a group', () => {
10
+ const parts = [{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' }];
11
+ expect(groupToolCalls(parts)).toEqual([{ type: 'single', part: parts[0] }]);
12
+ });
13
+
14
+ test('2+ CONSECUTIVE tool-calls fold into one group', () => {
15
+ const parts = [
16
+ { type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
17
+ { type: 'tool-call', toolCallId: '2', toolName: 'Run command' },
18
+ { type: 'tool-call', toolCallId: '3', toolName: 'Write file' },
19
+ ];
20
+ const grouped = groupToolCalls(parts);
21
+ expect(grouped).toEqual([{ type: 'tool-group', parts }]);
22
+ });
23
+
24
+ test('text/reasoning parts break a run into separate groups', () => {
25
+ const parts = [
26
+ { type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
27
+ { type: 'tool-call', toolCallId: '2', toolName: 'Write file' },
28
+ { type: 'text', text: 'done reading and writing' },
29
+ { type: 'tool-call', toolCallId: '3', toolName: 'Run command' },
30
+ ];
31
+ const grouped = groupToolCalls(parts);
32
+ expect(grouped).toEqual([
33
+ { type: 'tool-group', parts: [parts[0], parts[1]] },
34
+ { type: 'single', part: parts[2] },
35
+ { type: 'single', part: parts[3] }, // lone tool-call after the break — not a group
36
+ ]);
37
+ });
38
+
39
+ test('a mix of text and reasoning parts each stay single, in order', () => {
40
+ const parts = [
41
+ { type: 'text', text: 'thinking about it' },
42
+ { type: 'reasoning', text: '...' },
43
+ { type: 'text', text: 'here is my answer' },
44
+ ];
45
+ expect(groupToolCalls(parts)).toEqual(parts.map((part) => ({ type: 'single', part })));
46
+ });
47
+
48
+ test('tolerates an empty/missing parts array', () => {
49
+ expect(groupToolCalls([])).toEqual([]);
50
+ expect(groupToolCalls(undefined)).toEqual([]);
51
+ expect(groupToolCalls(null)).toEqual([]);
52
+ });
53
+ });
54
+
55
+ describe('summarizeGroup', () => {
56
+ test('all entries sharing one title collapse to "Ran N × <title>"', () => {
57
+ const parts = [{ toolName: 'Read file' }, { toolName: 'Read file' }, { toolName: 'Read file' }];
58
+ expect(summarizeGroup(parts)).toBe('Ran 3 × Read file');
59
+ });
60
+
61
+ test('mixed titles list the distinct ones, capped at 3 with an ellipsis', () => {
62
+ const parts = [
63
+ { toolName: 'Read file' },
64
+ { toolName: 'Run command' },
65
+ { toolName: 'Web search' },
66
+ { toolName: 'Write file' },
67
+ { toolName: 'Read file' }, // duplicate — must not inflate the unique count
68
+ ];
69
+ const summary = summarizeGroup(parts);
70
+ expect(summary).toBe('Ran 5 tools — Read file, Run command, Web search…');
71
+ });
72
+
73
+ test('a missing toolName falls back to the generic label, not undefined', () => {
74
+ expect(summarizeGroup([{}, {}])).toBe('Ran 2 × tool');
75
+ });
76
+ });