@1agh/maude 0.55.0 → 0.57.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.
Files changed (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,135 @@
1
+ // The sentence a person reads after pressing Connect.
2
+ //
3
+ // It used to be computed once, from the ATTACH RESPONSE, at the instant the
4
+ // confirm dialog closed — and never again. The attach response reports an
5
+ // intention (`{ syncing: true }` means `runtime.start()` did not throw and one
6
+ // local canvas qualified), so the sentence claimed success before a socket
7
+ // existed and then sat there through every outcome, including total failure.
8
+ //
9
+ // These cases are one per row of the plan's states/copy table, plus the two
10
+ // degradations that must never take the note away entirely: no live payload,
11
+ // and a payload from a server that predates the counts.
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import { connectOutcomeNote } from '../client/panels/CloudBar.jsx';
16
+ import type { SyncStatusLike } from '../sync/presentation.ts';
17
+
18
+ const live = (over: SyncStatusLike): SyncStatusLike => ({
19
+ state: 'online',
20
+ queuedOps: 0,
21
+ lastSyncAt: null,
22
+ offlineSince: null,
23
+ flash: null,
24
+ updatedAt: 0,
25
+ ...over,
26
+ });
27
+
28
+ const started = { syncing: true, canvases: 75 };
29
+
30
+ describe('with a live payload — the sentence moves', () => {
31
+ test('connecting: nothing has settled yet, and it does not pretend otherwise', () => {
32
+ const n = connectOutcomeNote(
33
+ 'alligators',
34
+ started,
35
+ live({ state: 'connecting', docs: { synced: 0, pending: 75, rejected: 0 } })
36
+ );
37
+ expect(n.text).toContain('Connecting to alligators');
38
+ expect(n.text).not.toContain('Synced');
39
+ });
40
+
41
+ test('syncing: real progress, drawn from settled handshakes', () => {
42
+ const n = connectOutcomeNote(
43
+ 'alligators',
44
+ started,
45
+ live({ docs: { synced: 40, pending: 35, rejected: 0 } })
46
+ );
47
+ expect(n.text).toContain('40 of 75');
48
+ });
49
+
50
+ test('synced: names what arrived and tells the person to go open it', () => {
51
+ const n = connectOutcomeNote(
52
+ 'alligators',
53
+ started,
54
+ live({
55
+ docs: { synced: 75, pending: 0, rejected: 0 },
56
+ pulled: { names: ['ui-welcome'], count: 3 },
57
+ })
58
+ );
59
+ expect(n.text).toContain('Synced with alligators');
60
+ expect(n.text).toContain('3 came down');
61
+ // Deliberately not an imperative to open it: a pulled canvas is hub-authored
62
+ // TSX, and "open this" inside our own green success sentence is the
63
+ // strongest endorsement we could give content we do not vouch for.
64
+ expect(n.title).toContain('new to this machine');
65
+ expect(n.title).not.toContain('Open one');
66
+ });
67
+
68
+ test('refused: the state that cannot fix itself says so, and says what to do', () => {
69
+ // The reported shape, at its worst: the attach response says syncing, the
70
+ // socket is up, and every single document is going nowhere.
71
+ const n = connectOutcomeNote(
72
+ 'alligators',
73
+ started,
74
+ live({ state: 'online', docs: { synced: 0, pending: 0, rejected: 75 } })
75
+ );
76
+ expect(n.text).toContain('refused');
77
+ expect(n.title).toContain('Reconnect');
78
+ });
79
+
80
+ test('unreachable: queued, not lost — and explicitly nothing to do', () => {
81
+ const n = connectOutcomeNote(
82
+ 'alligators',
83
+ started,
84
+ live({ state: 'offline', queuedOps: 2, docs: { synced: 75, pending: 0, rejected: 0 } })
85
+ );
86
+ expect(n.text).toContain('Not reachable');
87
+ expect(n.text).toContain('queued');
88
+ expect(n.title).toContain('resumes by itself');
89
+ });
90
+
91
+ test('the live payload beats the attach response, not the other way round', () => {
92
+ // `sync.syncing` is true here and the link is in fact dead. Whichever wins
93
+ // decides whether this whole change did anything.
94
+ const n = connectOutcomeNote('alligators', started, live({ state: 'offline' }));
95
+ expect(n.text).toContain('Not reachable');
96
+ });
97
+ });
98
+
99
+ describe('without a live payload — degrade, never vanish', () => {
100
+ test('first render falls back to the attach response, phrased as in-flight', () => {
101
+ const n = connectOutcomeNote('alligators', started, undefined);
102
+ expect(n.text).toContain('Connecting to alligators');
103
+ expect(n.text).toContain('75 canvases');
104
+ });
105
+
106
+ test('an unlinked live payload does not swallow the note', () => {
107
+ // `syncPresentation` returns null for `{linked:false}`; the note must fall
108
+ // through to the attach response rather than render nothing at all.
109
+ const n = connectOutcomeNote('alligators', started, { linked: false });
110
+ expect(n.text).toContain('alligators');
111
+ });
112
+
113
+ test('nothing syncable keeps its own copy', () => {
114
+ const n = connectOutcomeNote('alligators', {
115
+ syncing: false,
116
+ reason: 'nothing-syncable',
117
+ detail: 'No canvases in this project are syncable yet.',
118
+ });
119
+ expect(n.text).toContain('nothing to sync yet');
120
+ });
121
+
122
+ test('a failed start reports the reason it was given', () => {
123
+ const n = connectOutcomeNote('alligators', {
124
+ syncing: false,
125
+ reason: 'error',
126
+ detail: 'Syncing could not start: hub refused the handshake',
127
+ });
128
+ expect(n.text).toContain('hub refused the handshake');
129
+ });
130
+
131
+ test('a missing project name never renders as undefined', () => {
132
+ const n = connectOutcomeNote(null, started, undefined);
133
+ expect(n.text).toContain('the workspace');
134
+ });
135
+ });
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import {
13
+ connectOutcomeNote,
13
14
  isDisplayableUrl,
14
15
  localIdentityHint,
15
16
  parseDeepLink,
@@ -322,6 +323,48 @@ describe('localIdentityHint — only an exact, corroborated agreement is silent'
322
323
  });
323
324
  });
324
325
 
326
+ describe('connectOutcomeNote — what the rail says after Connect', () => {
327
+ test('no live payload yet: an in-flight state, not a claimed result', () => {
328
+ // This used to assert "Syncing with alligators — 12 canvases." and a hover
329
+ // pointing at the status bar. Both were wrong: the attach response reports
330
+ // that `runtime.start()` did not throw, which is an INTENTION, and the slot
331
+ // it sent people to was itself keying off `state` alone. See DDR-214 — the
332
+ // sentence now derives from the live payload, and this is only the fallback
333
+ // for the first render (and for a server too old to send one.)
334
+ const n = connectOutcomeNote('alligators', { syncing: true, canvases: 12 });
335
+ expect(n.text).toBe('Connecting to alligators — 12 canvases…');
336
+ expect(n.title).toContain('updates as they settle');
337
+ expect(n.text).not.toContain('hub sync');
338
+ expect(connectOutcomeNote('alligators', { syncing: true, canvases: 1 }).text).toContain(
339
+ '1 canvas…'
340
+ );
341
+ });
342
+
343
+ test('linked but nothing syncable says so — and never claims a restart would help', () => {
344
+ const n = connectOutcomeNote('alligators', {
345
+ syncing: false,
346
+ canvases: 0,
347
+ reason: 'nothing-syncable',
348
+ detail: 'no canvases found under .design.',
349
+ });
350
+ expect(n.text).toBe('Connected to alligators — nothing to sync yet.');
351
+ expect(n.title).toContain('no canvases found');
352
+ expect(n.text).not.toMatch(/restart/i);
353
+ });
354
+
355
+ test('any other refusal repeats the server’s own reason', () => {
356
+ const n = connectOutcomeNote('alligators', {
357
+ syncing: false,
358
+ canvases: 0,
359
+ reason: 'no-credential',
360
+ detail: 'No sign-in for this workspace is stored on this machine yet.',
361
+ });
362
+ expect(n.text).toContain('No sign-in for this workspace');
363
+ // Never the old sentence: there is no studio server a person can see.
364
+ expect(n.text).not.toContain('studio server');
365
+ });
366
+ });
367
+
325
368
  describe('attachCode — the maude:// lane end to end (stubbed)', () => {
326
369
  test('a valid code links: hub credential stored, linkedHub written token-free', async () => {
327
370
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
@@ -375,6 +418,65 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
375
418
  expect(r.status).toBe(200);
376
419
  });
377
420
 
421
+ test('linking STARTS syncing — the outcome is reported, not homework', async () => {
422
+ // The bug this closes: the panel used to answer a successful Connect with
423
+ // "restart the studio server to start syncing" — a task, naming a thing
424
+ // the desktop user cannot see. The attach lane now cycles the runtime and
425
+ // reports what happened.
426
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
427
+ const designRoot = join(scratch, '.design-sync');
428
+ const { mkdirSync } = await import('node:fs');
429
+ mkdirSync(designRoot, { recursive: true });
430
+ writeFileSync(join(designRoot, 'config.json'), '{}\n');
431
+
432
+ const handed: unknown[] = [];
433
+ const apiEndpoints = createCloudEndpoints({
434
+ paths: { repoRoot: scratch, designRoot },
435
+ syncControl: {
436
+ restart: async (linkedHub) => {
437
+ handed.push(linkedHub);
438
+ return { syncing: true, canvases: 4 };
439
+ },
440
+ },
441
+ });
442
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
443
+ expect(r.status).toBe(200);
444
+ expect((r.json as any).sync).toEqual({ syncing: true, canvases: 4 });
445
+ expect((r.json as any).note).toBe('Linked — syncing 4 canvases.');
446
+
447
+ // The supervisor is handed the value BY VALUE — the same object written to
448
+ // config.json — so nothing that touches that committed file in between can
449
+ // steer the socket.
450
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
451
+ expect(handed).toEqual([cfg.linkedHub]);
452
+ });
453
+
454
+ test('a TSX opt-out survives a re-link — only the restrictive direction carries', async () => {
455
+ // `syncTsx: false` means "do not upload canvas bodies to this hub"
456
+ // (DDR-072/DDR-079). Re-linking used to overwrite linkedHub wholesale,
457
+ // which silently re-enabled it — harmless while nothing synced until a
458
+ // restart, a real disclosure now that Connect starts syncing immediately.
459
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
460
+ const designRoot = join(scratch, '.design-opt-out');
461
+ const { mkdirSync } = await import('node:fs');
462
+ mkdirSync(designRoot, { recursive: true });
463
+ writeFileSync(
464
+ join(designRoot, 'config.json'),
465
+ JSON.stringify({ linkedHub: { url: 'https://old.example', linkedAt: 1, syncTsx: false } })
466
+ );
467
+
468
+ const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
469
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
470
+ expect(r.status).toBe(200);
471
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
472
+ expect(cfg.linkedHub.syncTsx).toBe(false);
473
+ expect(cfg.linkedHub.url).toContain('127.0.0.1');
474
+ // No supervisor wired (a plain embedder): still linked, and the note says
475
+ // what is left to do instead of claiming success.
476
+ expect((r.json as any).sync.syncing).toBe(false);
477
+ expect((r.json as any).note).toMatch(/Restart Maude/);
478
+ });
479
+
378
480
  test('a malformed or dead code never reaches the network', async () => {
379
481
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
380
482
  const apiEndpoints = createCloudEndpoints({
@@ -64,8 +64,11 @@ describe('the cloud shell offers nothing it cannot honour', () => {
64
64
  // `=== null`, not falsiness. `cloud` is undefined until `/_config` answers,
65
65
  // and treating unknown as "not cloud" mounted this bar for one frame in
66
66
  // every cloud tab — long enough to fire the one request the cell refuses.
67
- expect(APP).toContain('{cloud === null ? <CloudBar /> : null}');
68
- expect(APP).not.toContain('{cloud ? null : <CloudBar />}');
67
+ // Matched on the GUARD rather than the whole element, so adding a prop to
68
+ // CloudBar (DDR-214 drilled the live sync payload in) does not read as a
69
+ // regression in a rule that is entirely about `=== null`.
70
+ expect(APP).toMatch(/\{cloud === null \? <CloudBar[^>]*\/> : null\}/);
71
+ expect(APP).not.toMatch(/\{cloud \? null : <CloudBar/);
69
72
  });
70
73
 
71
74
  test('the local export queue does not hydrate before the shell is known', () => {
@@ -36,14 +36,31 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
36
36
  expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
37
37
  // Right host, wrong port is still cross-origin.
38
38
  expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
39
- // Right host:port, wrong scheme is still cross-origin.
40
- expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
41
39
  });
42
40
 
43
41
  test('allows the legit same-origin shell Origin', () => {
44
42
  expect(sameOriginWrite(post(SELF))).toBe(true);
45
43
  });
46
44
 
45
+ test('a matching host on a different SCHEME is same-origin — TLS terminates at the cell proxy', () => {
46
+ // The second half of the inspector-edits RCA. In a cell the browser sends
47
+ // `https://<host>` but the studio serves plaintext and computes `req.url` as
48
+ // `http://<host>`, so a scheme-strict compare 403'd every legitimate shell
49
+ // write. Host+port still pins it; a cross-site page has a different host.
50
+ const cell = 'https://alligators.cloud.maude.sh';
51
+ const cellReq = (origin?: string) =>
52
+ new Request('http://alligators.cloud.maude.sh/_api/edit-css', {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json', ...(origin ? { origin } : {}) },
55
+ body: '{}',
56
+ });
57
+ expect(sameOriginWrite(cellReq(cell))).toBe(true); // https Origin ↔ http req.url, same host
58
+ // But a genuinely different host is still rejected, scheme notwithstanding.
59
+ expect(sameOriginWrite(cellReq('https://evil.cloud.maude.sh'))).toBe(false);
60
+ // And the port still discriminates when present.
61
+ expect(sameOriginWrite(post('https://localhost:4400'))).toBe(false);
62
+ });
63
+
47
64
  test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
48
65
  expect(sameOriginWrite(post())).toBe(true);
49
66
  });
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bun
2
+ // A mock ACP agent that streams SEVERAL updates with a gap between them, so a
3
+ // test can close the socket MID-turn and assert the turn still completes
4
+ // (feature-acp-write-path-scope Addendum, Task 8 — bridges outlive their socket).
5
+ //
6
+ // The pacing is the whole point: `mock-acp-agent-permission.mjs` and friends
7
+ // settle in one tick, which leaves no window in which "mid-turn" is a real
8
+ // state. Six chunks at ~120 ms give a test room to observe the first one, yank
9
+ // the socket, and still have the rest arrive with nobody attached.
10
+
11
+ import { Readable, Writable } from 'node:stream';
12
+
13
+ import * as acp from '@agentclientprotocol/sdk';
14
+
15
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
16
+
17
+ const CHUNKS = Number(process.env.MAUDE_TEST_SLOW_CHUNKS || 6);
18
+ const GAP_MS = Number(process.env.MAUDE_TEST_SLOW_GAP_MS || 120);
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ let n = 0;
22
+
23
+ acp
24
+ .agent({ name: 'mock-acp-agent-slow' })
25
+ .onRequest('initialize', () => ({
26
+ protocolVersion: acp.PROTOCOL_VERSION,
27
+ agentCapabilities: { loadSession: false },
28
+ }))
29
+ .onRequest('session/new', () => ({ sessionId: `mock-slow-session-${++n}` }))
30
+ .onRequest('session/prompt', async (ctx) => {
31
+ for (let i = 0; i < CHUNKS; i++) {
32
+ await ctx.client.notify('session/update', {
33
+ sessionId: ctx.params.sessionId,
34
+ update: {
35
+ sessionUpdate: 'agent_message_chunk',
36
+ // Numbered so a test can tell WHICH chunks it received, not just how
37
+ // many — the difference between "no duplicates" and "no gaps".
38
+ content: { type: 'text', text: `chunk-${i}\n` },
39
+ },
40
+ });
41
+ await sleep(GAP_MS);
42
+ }
43
+ return { stopReason: 'end_turn' };
44
+ })
45
+ .connect(stream);
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bun
2
+ // feature-acp-write-path-scope Task 1 — the RECORDED SHAPE of a write tool's
3
+ // permission request, replayed as a mock ACP agent.
4
+ //
5
+ // This is not an invented shape. It reproduces, field for field, what
6
+ // `@agentclientprotocol/claude-agent-acp@0.57.0` actually puts on the wire for
7
+ // `Write`/`Edit`/`NotebookEdit`, read out of its own `dist/` on 2026-08-07:
8
+ //
9
+ // • `dist/acp-agent.js:2270-2286` builds the permission request's `toolCall`
10
+ // as `{ toolCallId, rawInput, ...toolInfoFromToolUse(name, input, id) }` —
11
+ // note there is NO tool NAME anywhere in that object.
12
+ // • `dist/tools.js` `case "Write"` / `case "Edit"` set
13
+ // `locations: input?.file_path ? [{ path: input.file_path }] : []` — the
14
+ // model's string VERBATIM. No normalization, no absolutization, despite the
15
+ // ACP schema (`types.gen.d.ts:568-572`) describing the field as "The
16
+ // absolute file path being accessed or modified".
17
+ // • `NotebookEdit` has NO case at all — it falls through to `case "Other"`,
18
+ // which emits no `locations` whatsoever. Its target lives only on
19
+ // `rawInput.notebook_path`.
20
+ // • `dist/acp-agent.js:3808-3829` (`toolCallNotification`) is the ONLY carrier
21
+ // of the tool name: `_meta.claudeCode.toolName` on the streamed `tool_call`.
22
+ // • `requestPermissionFromClient` (`:2134-2141`) AWAITS `ensureToolCallEmitted`
23
+ // before issuing the request, so that notification is guaranteed to be on
24
+ // the wire FIRST. The bridge's gate depends on exactly that ordering, so the
25
+ // fixture reproduces it rather than assuming it.
26
+ //
27
+ // Driven by env so one fixture covers every case:
28
+ // MAUDE_TEST_WRITE_PATH — the `file_path` the "model" emitted (required)
29
+ // MAUDE_TEST_WRITE_TOOL — Write (default) | Edit | NotebookEdit | Read
30
+ // MAUDE_TEST_OMIT_TOOL_CALL=1 — skip the `tool_call` notification, i.e.
31
+ // simulate an adapter that reorders or drops it. The gate must fail
32
+ // CLOSED here (prompt), never auto-approve.
33
+
34
+ import { Readable, Writable } from 'node:stream';
35
+
36
+ import * as acp from '@agentclientprotocol/sdk';
37
+
38
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
39
+
40
+ const TOOL = process.env.MAUDE_TEST_WRITE_TOOL || 'Write';
41
+ const FILE_PATH = process.env.MAUDE_TEST_WRITE_PATH || '/tmp/unset';
42
+ const OMIT_TOOL_CALL = process.env.MAUDE_TEST_OMIT_TOOL_CALL === '1';
43
+ const TOOL_CALL_ID = 'tc-write-1';
44
+
45
+ // Mirrors `toolInfoFromToolUse`'s per-tool branches. NotebookEdit deliberately
46
+ // yields NO `locations` key — that is the adapter's real behaviour, and the case
47
+ // that makes the rawInput fallback mandatory rather than decorative.
48
+ function toolInfo() {
49
+ if (TOOL === 'NotebookEdit') {
50
+ return { title: 'NotebookEdit', kind: 'other', content: [] };
51
+ }
52
+ if (TOOL === 'Read') {
53
+ return {
54
+ title: `Read ${FILE_PATH}`,
55
+ kind: 'read',
56
+ content: [],
57
+ locations: [{ path: FILE_PATH, line: 1 }],
58
+ };
59
+ }
60
+ return {
61
+ title: `${TOOL} ${FILE_PATH}`,
62
+ kind: 'edit',
63
+ content: [],
64
+ locations: [{ path: FILE_PATH }],
65
+ };
66
+ }
67
+
68
+ function rawInput() {
69
+ if (TOOL === 'NotebookEdit') return { notebook_path: FILE_PATH, new_source: 'x' };
70
+ if (TOOL === 'Write') return { file_path: FILE_PATH, content: 'x' };
71
+ if (TOOL === 'Read') return { file_path: FILE_PATH };
72
+ return { file_path: FILE_PATH, old_string: 'a', new_string: 'b' };
73
+ }
74
+
75
+ acp
76
+ .agent({ name: 'mock-acp-agent-write' })
77
+ .onRequest('initialize', () => ({
78
+ protocolVersion: acp.PROTOCOL_VERSION,
79
+ agentCapabilities: { loadSession: false },
80
+ }))
81
+ .onRequest('session/new', () => ({ sessionId: 'mock-write-session-1' }))
82
+ .onRequest('session/prompt', async (ctx) => {
83
+ // (1) The `tool_call` notification — the tool NAME's only carrier.
84
+ if (!OMIT_TOOL_CALL) {
85
+ await ctx.client.notify('session/update', {
86
+ sessionId: ctx.params.sessionId,
87
+ update: {
88
+ _meta: { claudeCode: { toolName: TOOL } },
89
+ toolCallId: TOOL_CALL_ID,
90
+ sessionUpdate: 'tool_call',
91
+ rawInput: rawInput(),
92
+ status: 'pending',
93
+ ...toolInfo(),
94
+ },
95
+ });
96
+ }
97
+ // (2) THEN the permission request, with the adapter's real option set for a
98
+ // routine tool call (`acp-agent.js:2271-2278`) — note `allow_always` is
99
+ // listed FIRST, which is what Decision D's filter has to contend with.
100
+ const outcome = await ctx.client.request(acp.methods.client.session.requestPermission, {
101
+ sessionId: ctx.params.sessionId,
102
+ toolCall: { toolCallId: TOOL_CALL_ID, rawInput: rawInput(), ...toolInfo() },
103
+ options: [
104
+ { optionId: 'allow_always', name: 'Always Allow', kind: 'allow_always' },
105
+ { optionId: 'allow', name: 'Allow', kind: 'allow_once' },
106
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' },
107
+ ],
108
+ });
109
+ await ctx.client.notify('session/update', {
110
+ sessionId: ctx.params.sessionId,
111
+ update: {
112
+ sessionUpdate: 'agent_message_chunk',
113
+ content: { type: 'text', text: `outcome=${JSON.stringify(outcome.outcome)}` },
114
+ },
115
+ });
116
+ return { stopReason: 'end_turn' };
117
+ })
118
+ .connect(stream);