@1agh/maude 0.58.1 → 0.58.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.
@@ -24,6 +24,8 @@
24
24
  // file outside the design root. That is not policy filtering, it is the
25
25
  // difference between writing your project and writing your filesystem.
26
26
 
27
+ import { type CanvasGroupLike, resolveCanvasBodyRel } from './canvas-path.ts';
28
+
27
29
  /** One document as the hub reports it. */
28
30
  export interface RemoteDoc {
29
31
  name: string;
@@ -147,31 +149,66 @@ export function slugFromDocName(docName: string): string | null {
147
149
  export interface PullTarget {
148
150
  slug: string;
149
151
  docName: string;
150
- /** Absolute path the body will be written to, flat under the design root. */
152
+ /** Absolute path the body will be written to. */
151
153
  bodyAbs: string;
154
+ /** True when the path came from the document's own `syncMeta.path` and was
155
+ * accepted — false when it was absent or refused and the fallback applied. */
156
+ fromPath: boolean;
157
+ }
158
+
159
+ export interface PullTargetOptions {
160
+ /** Design root, relative to the repo root. Rule 7 of the path check. */
161
+ designRel?: string;
162
+ /** Declared canvas groups — the fallback places the body inside one. */
163
+ canvasGroups?: readonly CanvasGroupLike[];
164
+ /** Fresh-link relaxation — see `validateCanvasPath`. */
165
+ allowUndeclaredGroup?: boolean;
166
+ /**
167
+ * The document's own `syncMeta.path`, if it is already known.
168
+ *
169
+ * USUALLY NULL AT THIS POINT, and that is not an oversight. The listing
170
+ * (`GET /api/documents`) carries names and byte counts only — the path lives
171
+ * INSIDE the document, so it cannot be resolved until that document has
172
+ * synced. The runtime therefore calls this to get a provisional target, then
173
+ * `resolvePulledTarget` again per document once its doc is populated (see
174
+ * sync/index.ts). Tests pass it directly, which is the whole reason it is a
175
+ * parameter rather than a fetch.
176
+ */
177
+ pathFor?: (docName: string, slug: string) => string | null | undefined;
178
+ /** See `resolvePulledTarget`. */
179
+ realpath?: (p: string) => string;
180
+ /** Called with (slug, reason) when a PRESENT path is refused. */
181
+ onRefused?: (slug: string, reason: string) => void;
152
182
  }
153
183
 
154
184
  /**
155
185
  * Where a hub-only document lands on disk.
156
186
  *
157
- * FLAT, directly under the design root — the same convention the hub applies in
158
- * the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
159
- * reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
160
- * without guessing, and guessing wrong scatters files into directories the user
161
- * never made. A flat file is trivially moved; an invented tree is not. Moving it
162
- * later does not break sync — both paths slug to the same document.
187
+ * It used to be FLAT, directly under the design root, because a slug is lossy —
188
+ * `ui-card` cannot be un-flattened into `ui/Card.tsx` without guessing, and
189
+ * guessing wrong scatters files into directories the user never made. The
190
+ * comment here said a flat file "is trivially moved"; the hub's twin said a
191
+ * desktop peer "will move it on its next sync". Neither was a mechanism, and
192
+ * nothing moved it — and a file at the design root is inside no canvas group,
193
+ * so the tree never listed it and `scanCanvases` never synced it onward.
194
+ *
195
+ * So the path now travels with the document (`syncMeta.path`) and is checked
196
+ * rather than trusted — see `sync/canvas-path.ts` for the eight rules and for
197
+ * why rule 7 is the one that makes it safe. Absent or refused, the fallback
198
+ * still applies, but inside a canvas group.
163
199
  *
164
- * Returns only targets that resolve INSIDE the design root. A document name is
165
- * hub-controlled input, and this is the last point before a create.
200
+ * Returns only targets that resolve INSIDE the design root. A document name and
201
+ * a document's path are both hub-controlled input, and this is the last point
202
+ * before a create — so the containment check stays even for a validated path.
166
203
  */
167
204
  export function pullTargets(
168
205
  hubOnly: readonly RemoteDoc[],
169
206
  designRoot: string,
170
207
  join: (...parts: string[]) => string,
171
208
  resolve: (p: string) => string,
172
- sep: string
209
+ sep: string,
210
+ opts: PullTargetOptions = {}
173
211
  ): PullTarget[] {
174
- const rootResolved = resolve(designRoot);
175
212
  const out: PullTarget[] = [];
176
213
  // One slug, one target. `slugFromDocName` lowercases, so a hub advertising
177
214
  // `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
@@ -182,10 +219,80 @@ export function pullTargets(
182
219
  const slug = slugFromDocName(doc.name);
183
220
  if (!slug || seen.has(slug)) continue;
184
221
  seen.add(slug);
185
- const bodyAbs = join(designRoot, `${slug}.tsx`);
186
- const target = resolve(bodyAbs);
187
- if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
188
- out.push({ slug, docName: doc.name, bodyAbs });
222
+ const target = resolvePulledTarget({
223
+ slug,
224
+ path: opts.pathFor?.(doc.name, slug) ?? null,
225
+ designRoot,
226
+ designRel: opts.designRel,
227
+ canvasGroups: opts.canvasGroups,
228
+ allowUndeclaredGroup: opts.allowUndeclaredGroup,
229
+ join,
230
+ resolve,
231
+ sep,
232
+ realpath: opts.realpath,
233
+ onRefused: opts.onRefused ? (reason) => opts.onRefused?.(slug, reason) : undefined,
234
+ });
235
+ if (!target) continue;
236
+ out.push({ slug, docName: doc.name, ...target });
189
237
  }
190
238
  return out;
191
239
  }
240
+
241
+ /**
242
+ * One document's body path — the whole receiver-side decision, in one call.
243
+ *
244
+ * Separate from `pullTargets` because the runtime needs it TWICE: once from the
245
+ * listing (where no path is known yet, so every target is a fallback) and again
246
+ * per document once that document has synced and its `syncMeta.path` is
247
+ * readable. Both hops go through the same function, so "where does this canvas
248
+ * go" cannot have two answers.
249
+ *
250
+ * Returns null when even the containment check refuses — the only case in which
251
+ * a canvas is dropped rather than degraded.
252
+ */
253
+ export function resolvePulledTarget(args: {
254
+ slug: string;
255
+ path: unknown;
256
+ designRoot: string;
257
+ designRel?: string;
258
+ canvasGroups?: readonly CanvasGroupLike[];
259
+ allowUndeclaredGroup?: boolean;
260
+ join: (...parts: string[]) => string;
261
+ resolve: (p: string) => string;
262
+ sep: string;
263
+ /**
264
+ * Resolve symlinks on the deepest EXISTING ancestor of a path.
265
+ *
266
+ * `resolve()` is purely lexical — it never follows a symlink — and the
267
+ * receivers create parent directories with `mkdirSync(recursive: true)`, which
268
+ * happily traverses one that already exists. While the only reachable target
269
+ * was `<designRoot>/<slug>.tsx` that only mattered for a symlinked design root
270
+ * (operator-controlled); now the sender picks the directory, so any symlink
271
+ * committed anywhere under a canvas group is a write-outside primitive.
272
+ * Injected rather than imported because this module is dependency-free.
273
+ */
274
+ realpath?: (p: string) => string;
275
+ onRefused?: (reason: string) => void;
276
+ }): { bodyAbs: string; fromPath: boolean } | null {
277
+ const { rel, fromPath } = resolveCanvasBodyRel({
278
+ path: args.path,
279
+ slug: args.slug,
280
+ designRel: args.designRel,
281
+ canvasGroups: args.canvasGroups,
282
+ allowUndeclaredGroup: args.allowUndeclaredGroup,
283
+ onRefused: args.onRefused,
284
+ });
285
+ const bodyAbs = args.join(args.designRoot, rel);
286
+ // Belt and braces at a create. The validator already refuses everything that
287
+ // could escape lexically; this catches whatever a platform's own `resolve`
288
+ // makes of a string neither of us anticipated.
289
+ const rootResolved = args.resolve(args.designRoot);
290
+ const target = args.resolve(bodyAbs);
291
+ if (target !== rootResolved && !target.startsWith(rootResolved + args.sep)) return null;
292
+ if (args.realpath) {
293
+ const realRoot = args.realpath(rootResolved);
294
+ const realTarget = args.realpath(target);
295
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + args.sep)) return null;
296
+ }
297
+ return { bodyAbs, fromPath };
298
+ }
@@ -28,6 +28,7 @@ import type { WsData } from '../ws.ts';
28
28
 
29
29
  const FIXTURE_SLOW = join(import.meta.dir, 'fixtures', 'mock-acp-agent-slow.mjs');
30
30
  const FIXTURE_PERM = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
31
+ const FIXTURE_WEDGED = join(import.meta.dir, 'fixtures', 'mock-acp-agent-wedged.mjs');
31
32
  const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
32
33
 
33
34
  function useMockAgent(fixture: string) {
@@ -333,6 +334,111 @@ describe('bridge lifetime — the attach seq is client-supplied and must be boun
333
334
  }, 30000);
334
335
  });
335
336
 
337
+ describe('cancel() must not hang forever on a non-cooperating agent (issue-82)', () => {
338
+ // The reported bug: "I clicked STOP and nothing happened. Probably stuck."
339
+ // The old cancel() just `await`ed the cooperative ACP `session/cancel`
340
+ // request with no bound — a wedged subprocess (or one that never
341
+ // implemented session/cancel at all, as here) left it, and the pending
342
+ // prompt() call, hanging forever. `bridge.cancel()` below must always
343
+ // return, and it must force the stuck turn to actually end.
344
+ test('cancel() escalates and force-ends the turn when the agent never responds', async () => {
345
+ useMockAgent(FIXTURE_WEDGED);
346
+ const ctx = freshCtx();
347
+ const bridge = new AcpBridge({
348
+ repoRoot: ctx.paths.repoRoot,
349
+ cancelEscalationMs: 200, // real default is 5s; keep the test fast
350
+ onUpdate: () => {},
351
+ });
352
+ try {
353
+ const promptPromise = bridge.prompt('go', 'c1');
354
+ // Let the turn actually reach the wedged conn.prompt() call — the same
355
+ // race a real Stop click lands in — before cancelling.
356
+ await until(() => (bridge.sessionId ? true : undefined));
357
+
358
+ // This is the assertion that matters: without the fix, this call never
359
+ // resolves and the test times out.
360
+ await bridge.cancel();
361
+
362
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
363
+ // The subprocess was actually killed, not just the promise resolved —
364
+ // otherwise a "cancelled" chat could keep silently running/burning
365
+ // tokens in the background.
366
+ expect(bridge.connected).toBe(false);
367
+ } finally {
368
+ await bridge.stop();
369
+ }
370
+ }, 15000);
371
+
372
+ // Ethical-hacker finding on the first cut of this fix: `handlePrompt` has
373
+ // no reentrancy guard, so a duplicate/replayed `prompt` frame for the same
374
+ // chat can start a SECOND, overlapping turn on one bridge. A single shared
375
+ // "pending turn" field would be overwritten by that second turn and then
376
+ // nulled by whichever turn settles FIRST — even an ordinary, uncancelled
377
+ // completion — silently leaving an earlier, genuinely wedged turn
378
+ // permanently unescalatable and reopening this exact bug from a different
379
+ // angle. Pin that a normal turn finishing does not blind cancel() to a
380
+ // still-stuck one that started before it.
381
+ test('an overlapping turn finishing normally does not blind cancel() to an earlier wedged one', async () => {
382
+ useMockAgent(FIXTURE_WEDGED);
383
+ const ctx = freshCtx();
384
+ const bridge = new AcpBridge({
385
+ repoRoot: ctx.paths.repoRoot,
386
+ cancelEscalationMs: 200,
387
+ onUpdate: () => {},
388
+ });
389
+ try {
390
+ // Turn A — the fixture's FIRST session/prompt call, which wedges forever.
391
+ const turnA = bridge.prompt('go', 'c1');
392
+ await until(() => (bridge.sessionId ? true : undefined));
393
+
394
+ // Turn B — a second, overlapping call for the SAME chat while A is
395
+ // still in flight. The fixture resolves every call after the first
396
+ // quickly, so this settles normally with no cancellation involved.
397
+ const turnB = bridge.prompt('go again', 'c1');
398
+ await expect(turnB).resolves.toMatchObject({ stopReason: 'end_turn' });
399
+
400
+ // Turn B's ordinary completion must not have cleared turn A's
401
+ // escalation registration — cancel() must still find and force-end A.
402
+ await bridge.cancel();
403
+ await expect(turnA).rejects.toThrow(/did not respond/);
404
+ expect(bridge.connected).toBe(false);
405
+ } finally {
406
+ await bridge.stop();
407
+ }
408
+ }, 15000);
409
+
410
+ // A second-round ethical-hacker finding on THIS fix: cancel()'s escalation
411
+ // calls this.stop() directly, which does NOT go through index.ts's reap()
412
+ // (the only place that deletes a bridge from the `bridges` map). Without a
413
+ // guard, the SAME now-`stopped` instance would stay live and silently
414
+ // respawn a working-looking subprocess on the next prompt — except its
415
+ // requestPermission/unstable_createElicitation handlers auto-deny forever
416
+ // (the `stopped` check never resets), a silent permission-gate regression
417
+ // with no visible error. `ensureStarted()` must refuse to resurrect it.
418
+ test('after cancel() escalates and self-stops, the bridge refuses to silently respawn', async () => {
419
+ useMockAgent(FIXTURE_WEDGED);
420
+ const ctx = freshCtx();
421
+ const bridge = new AcpBridge({
422
+ repoRoot: ctx.paths.repoRoot,
423
+ cancelEscalationMs: 200,
424
+ onUpdate: () => {},
425
+ });
426
+ try {
427
+ const promptPromise = bridge.prompt('go', 'c1');
428
+ await until(() => (bridge.sessionId ? true : undefined));
429
+ await bridge.cancel(); // escalates → self-stop()
430
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
431
+ expect(bridge.connected).toBe(false);
432
+
433
+ // The assertion that matters: a LOUD, immediate error — never a silent
434
+ // respawn into a permanently permission-denying session.
435
+ await expect(bridge.prompt('again', 'c1')).rejects.toThrow(/stopped and cannot be reused/);
436
+ } finally {
437
+ await bridge.stop();
438
+ }
439
+ }, 15000);
440
+ });
441
+
336
442
  describe('set-mode is attachment-scoped too (ethical-hacker A2)', () => {
337
443
  test('a socket CANNOT flip another chat into bypassPermissions', async () => {
338
444
  // A2 was the one control frame that escaped the attachment-scoping sweep,
@@ -0,0 +1,117 @@
1
+ // The shell's inbound-postMessage comment relays must stay scoped to the
2
+ // ACTIVE canvas.
3
+ //
4
+ // Threat: the origin check at the top of app.jsx's `onMessage` compares against
5
+ // the single shared `canvasOrigin`, so EVERY open canvas iframe passes it —
6
+ // including ones in background tabs. `comment-submit` / `comment-patch` /
7
+ // `comment-delete` relay straight onto the privileged WS, so without a
8
+ // per-message `e.source === activeWin` gate a background canvas can write
9
+ // comments onto a file it names itself, with no user gesture. That mattered
10
+ // little while comments were an inert store; the ACP panel's one-click
11
+ // "Implement N comments" action turns open comments into a feed the agent acts
12
+ // on, which is what promoted the seam to worth-closing.
13
+ //
14
+ // This is a SOURCE-STRUCTURE test, not a behavioural one: `App()` is a
15
+ // 14k-line component that cannot be mounted in isolation, and the twenty other
16
+ // `e.source === activeWin` gates in the same handler have no coverage at all.
17
+ // Asserting on the source keeps the guard cheap and honest about its reach —
18
+ // it catches a gate being deleted, a `file:` pin regressing to the
19
+ // caller-supplied value, and (the last test) a gate that is written but cannot
20
+ // execute. It claims nothing beyond that: it does not prove the relay behaves
21
+ // correctly, only that the control is present and reachable.
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const SRC = readFileSync(join(import.meta.dir, '..', 'client', 'app.jsx'), 'utf8');
28
+
29
+ /** The body of one `else if (m.dgn === '<name>' …)` branch, up to the next one. */
30
+ function branch(name: string): string {
31
+ const start = SRC.indexOf(`m.dgn === '${name}'`);
32
+ expect(start, `branch for ${name} not found in app.jsx`).toBeGreaterThan(-1);
33
+ const rest = SRC.slice(start + name.length);
34
+ const end = rest.indexOf('} else if (m.dgn ===');
35
+ return rest.slice(0, end === -1 ? 2000 : end);
36
+ }
37
+
38
+ describe('canvas → shell comment relays are scoped to the active canvas', () => {
39
+ // The comment relays, plus the three shell-control branches that were
40
+ // ungated alongside them: `shell-shortcut` (reload / panel toggles /
41
+ // Export + Handoff dialogs), and `open-export` / `open-timeline-request`,
42
+ // which reach the same dialogs from the in-canvas toolbar and context menu.
43
+ // All five are actions a user takes INSIDE the canvas in view; none has a
44
+ // reason to be honoured from a background frame.
45
+ for (const name of [
46
+ 'comment-submit',
47
+ 'comment-patch',
48
+ 'comment-delete',
49
+ 'shell-shortcut',
50
+ 'open-export',
51
+ 'open-timeline-request',
52
+ ]) {
53
+ test(`${name} is gated on e.source === activeWin`, () => {
54
+ expect(branch(name)).toContain('e.source === activeWin');
55
+ });
56
+ }
57
+
58
+ test('comment-submit pins the target file to activePath, never the payload', () => {
59
+ const body = branch('comment-submit');
60
+ // The file the comment lands on is the one the user is looking at.
61
+ expect(body).toContain('file: activePath');
62
+ // A canvas-supplied target would let a background iframe pick the victim.
63
+ expect(body).not.toContain('file: p.file');
64
+ });
65
+
66
+ test('comment-patch / comment-delete only accept ids owned by the active canvas', () => {
67
+ // A bare id would otherwise reach any comment in the project — and these
68
+ // two verbs change or remove somebody else's note (ws.ts refuses both for a
69
+ // viewer session for exactly that reason).
70
+ expect(branch('comment-patch')).toContain('ownsActiveComment(m.id)');
71
+ expect(branch('comment-delete')).toContain('ownsActiveComment(m.id)');
72
+ });
73
+
74
+ // The above only prove the gate is WRITTEN. This proves it can RUN.
75
+ //
76
+ // House style declares `const activeWin` block-locally inside each `else if`
77
+ // branch, so a branch that compares against `activeWin` without declaring it
78
+ // reads exactly like a working gate while throwing `ReferenceError` at
79
+ // runtime — fail-closed, but it takes the legitimate path down with it and
80
+ // silently reopens the moment anything introduces an outer binding. That is
81
+ // not hypothetical: the first cut of this fix shipped it, and every
82
+ // string-matching assertion above stayed green.
83
+ test('every activeWin comparison has a declaration, derived from activePath', () => {
84
+ // Line comments are blanked first — the prose in this handler quotes the
85
+ // gate expression, and a comment is not a use.
86
+ const CODE = SRC.split('\n')
87
+ .map((l) => l.replace(/^\s*\/\/.*$/, ''))
88
+ .join('\n');
89
+ const BRANCH_START = /\b(?:else )?if \(m\.dgn ===/g;
90
+ const starts = [...CODE.matchAll(BRANCH_START)].map((m) => m.index ?? 0);
91
+ const undeclared: string[] = [];
92
+
93
+ // Both idioms count: the `===` gate used by the comment relays and the
94
+ // `!== … return` early-exit form five other branches use. Checking only
95
+ // one lets a branch convert to the other and slip the check.
96
+ for (const use of [...CODE.matchAll(/e\.source [!=]== activeWin/g)].map((m) => m.index ?? 0)) {
97
+ // Nearest branch boundary at or before this usage.
98
+ const open = starts.filter((s) => s < use).pop() ?? 0;
99
+ // `= activePath` is the load-bearing half: a declaration that derives the
100
+ // window from the MESSAGE (`iframesRef.current.get(m.file)`) instead of
101
+ // from app state satisfies a bare `const activeWin` check while handing
102
+ // the attacker the very comparison the gate exists to make.
103
+ if (!/const activeWin =\s*\n?\s*activePath/.test(CODE.slice(open, use))) {
104
+ undeclared.push(
105
+ CODE.slice(open, open + 60)
106
+ .split('\n')[0]!
107
+ .trim()
108
+ );
109
+ }
110
+ }
111
+
112
+ expect(
113
+ undeclared,
114
+ `activeWin used without a block-local declaration in: ${undeclared.join(' | ')}`
115
+ ).toEqual([]);
116
+ });
117
+ });
@@ -0,0 +1,155 @@
1
+ // Issue #74 — `_comments/<slug>.json` written DIRECTLY on disk must reach
2
+ // already-open shells over WS.
3
+ //
4
+ // This pins EXISTING behaviour rather than covering a fix. `/design:edit`
5
+ // resolves comments with a plain jq-rewrite of the comments file (edit.md §3's
6
+ // documented recipe), which bypasses api.commentsPatch and so never fires the
7
+ // `onCommentsChanged` re-broadcast in server.ts. The broadcast happens anyway,
8
+ // from collab/index.ts's `fs:any` re-seed ("the sidebar still needs the
9
+ // 'comments' bus emit, so keep that unconditionally") — a non-obvious owner
10
+ // that a reader of server.ts alone would conclude was missing.
11
+ //
12
+ // It is worth locking down because the chat panel's "Implement N comments"
13
+ // quick action (issue #74) is built on it: the agent resolves comments by
14
+ // direct write, and if that stopped reaching open shells the button would keep
15
+ // counting comments that are already done, with nothing else failing.
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { mkdirSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
22
+
23
+ const FILE = '.design/ui/Foo.tsx';
24
+ const SLUG = 'ui-foo';
25
+
26
+ async function openWs(port: number): Promise<WebSocket> {
27
+ const ws = new WebSocket(`ws://localhost:${port}/_ws`);
28
+ await new Promise<void>((resolve, reject) => {
29
+ const t = setTimeout(() => reject(new Error('ws open timeout')), 2000);
30
+ ws.addEventListener('open', () => {
31
+ clearTimeout(t);
32
+ resolve();
33
+ });
34
+ ws.addEventListener('error', (e) => {
35
+ clearTimeout(t);
36
+ reject(e);
37
+ });
38
+ });
39
+ return ws;
40
+ }
41
+
42
+ /** Resolve on the first `comments` frame for `file`, or null after `ms`. */
43
+ function nextCommentsFrame(
44
+ ws: WebSocket,
45
+ file: string,
46
+ ms = 5000
47
+ ): Promise<{ file: string; comments: Array<{ id: string; status: string }> } | null> {
48
+ return new Promise((resolve) => {
49
+ const done = (v: never | null) => {
50
+ clearTimeout(timer);
51
+ ws.removeEventListener('message', onMsg);
52
+ resolve(v);
53
+ };
54
+ const timer = setTimeout(() => done(null), ms);
55
+ const onMsg = (ev: MessageEvent) => {
56
+ try {
57
+ const m = JSON.parse(String(ev.data));
58
+ if (m && m.type === 'comments' && m.file === file) done(m);
59
+ } catch {
60
+ /* non-JSON frame — ignore */
61
+ }
62
+ };
63
+ ws.addEventListener('message', onMsg);
64
+ });
65
+ }
66
+
67
+ function seedCanvas(designRoot: string) {
68
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
69
+ writeFileSync(join(designRoot, 'ui', 'Foo.tsx'), 'export default function P(){return <main/>}\n');
70
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
71
+ }
72
+
73
+ function commentsJson(status: 'open' | 'resolved') {
74
+ return JSON.stringify(
75
+ [
76
+ {
77
+ id: 'c_one',
78
+ file: FILE,
79
+ selector: 'main',
80
+ dom_path: ['main'],
81
+ tag: 'main',
82
+ classes: '',
83
+ bounds: null,
84
+ html_excerpt: '',
85
+ text: 'tighten the padding',
86
+ status,
87
+ created: '2026-01-01T00:00:00.000Z',
88
+ resolved_at: status === 'resolved' ? '2026-01-02T00:00:00.000Z' : null,
89
+ author: 'Test User',
90
+ thread: [],
91
+ mentions: [],
92
+ },
93
+ ],
94
+ null,
95
+ 2
96
+ );
97
+ }
98
+
99
+ describe('issue #74 — comments file-watch re-broadcast', () => {
100
+ test('a direct resolve-write broadcasts the updated list to open shells', async () => {
101
+ const { root, designRoot } = makeSandbox();
102
+ const port = nextPort();
103
+ const proc = await bootServer(root, port);
104
+ try {
105
+ seedCanvas(designRoot);
106
+ const commentsFile = join(designRoot, '_comments', `${SLUG}.json`);
107
+ writeFileSync(commentsFile, commentsJson('open'));
108
+
109
+ const ws = await openWs(port);
110
+ // Clear the fs-watch per-path debounce window (50 ms) so the resolve
111
+ // write below is seen as its own event rather than folded into the seed.
112
+ await Bun.sleep(150);
113
+
114
+ const frame = nextCommentsFrame(ws, FILE);
115
+ // Exactly what edit.md §3's resolve recipe does: rewrite the file in
116
+ // place, never touching the API.
117
+ writeFileSync(commentsFile, commentsJson('resolved'));
118
+
119
+ const got = await frame;
120
+ ws.close();
121
+
122
+ expect(got).not.toBeNull();
123
+ expect(got!.comments).toHaveLength(1);
124
+ expect(got!.comments[0]!.id).toBe('c_one');
125
+ expect(got!.comments[0]!.status).toBe('resolved');
126
+ } finally {
127
+ await killProc(proc);
128
+ }
129
+ }, 20000);
130
+
131
+ test('a comments file for an unknown slug is ignored, not fatal', async () => {
132
+ const { root, designRoot } = makeSandbox();
133
+ const port = nextPort();
134
+ const proc = await bootServer(root, port);
135
+ try {
136
+ seedCanvas(designRoot);
137
+ const ws = await openWs(port);
138
+ await Bun.sleep(150);
139
+
140
+ // No canvas resolves to this slug — the handler must bail quietly and
141
+ // leave the server healthy.
142
+ writeFileSync(
143
+ join(designRoot, '_comments', 'ui-does-not-exist.json'),
144
+ JSON.stringify([], null, 2)
145
+ );
146
+ await Bun.sleep(400);
147
+ ws.close();
148
+
149
+ const health = await fetch(`http://localhost:${port}/_health`);
150
+ expect(health.ok).toBe(true);
151
+ } finally {
152
+ await killProc(proc);
153
+ }
154
+ }, 20000);
155
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+ // A mock ACP agent that WEDGES on the FIRST turn only (issue-82): its
3
+ // `session/prompt` handler never resolves for call #1 — simulating a
4
+ // `claude` subprocess that has hung — and it never implements
5
+ // `session/cancel` at all, so a bridge that only sends the cooperative ACP
6
+ // `session/cancel` request and trusts it will hang forever, exactly like the
7
+ // reported "I clicked STOP and nothing happened" bug.
8
+ //
9
+ // Every call AFTER the first resolves normally and quickly — this lets a
10
+ // test start a second, overlapping `prompt()` on the same bridge/chat (the
11
+ // ethical-hacker's "no reentrancy guard" finding) and confirm that turn's
12
+ // ordinary completion doesn't blind `cancel()` to the still-wedged first one.
13
+
14
+ import { Readable, Writable } from 'node:stream';
15
+
16
+ import * as acp from '@agentclientprotocol/sdk';
17
+
18
+ const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
19
+
20
+ let promptCalls = 0;
21
+
22
+ acp
23
+ .agent({ name: 'mock-acp-agent-wedged' })
24
+ .onRequest('initialize', () => ({
25
+ protocolVersion: acp.PROTOCOL_VERSION,
26
+ agentCapabilities: { loadSession: false },
27
+ }))
28
+ .onRequest('session/new', () => ({ sessionId: 'mock-wedged-session' }))
29
+ .onRequest('session/prompt', () => {
30
+ promptCalls += 1;
31
+ if (promptCalls === 1) return new Promise(() => {}); // never resolves, never rejects
32
+ return Promise.resolve({ stopReason: 'end_turn' });
33
+ })
34
+ // Deliberately NO `session/cancel` handler — an agent that never
35
+ // implemented turn-cancellation at all, the other real-world shape of
36
+ // "Stop does nothing".
37
+ .connect(stream);