macro-agent 0.2.1 → 0.2.3

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.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Tests for the macro-agent side of the cascade diff protocol — shells out
3
+ * to real git on a temp repo, captures the resulting JSON-RPC notifications
4
+ * on a mock connection, and asserts the inline / streaming / error paths.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ import { execSync } from 'child_process';
12
+ import { createHash } from 'crypto';
13
+ import {
14
+ setupCascadeDiffServer,
15
+ type CascadeDiffServerConnection,
16
+ } from '../cascade-diff-server.js';
17
+ import {
18
+ createGitCascadeAdapter,
19
+ type GitCascadeAdapter,
20
+ } from '../../workspace/git-cascade-adapter.js';
21
+
22
+ interface SentNotification {
23
+ method: string;
24
+ params: Record<string, unknown>;
25
+ }
26
+
27
+ interface MockConnection extends CascadeDiffServerConnection {
28
+ sent: SentNotification[];
29
+ handlers: Map<string, (params: unknown) => void | Promise<void>>;
30
+ /** Test-only helper — invoke the registered handler from outside. */
31
+ fire(method: string, params: unknown): Promise<void>;
32
+ }
33
+
34
+ function createMockConnection(): MockConnection {
35
+ const sent: SentNotification[] = [];
36
+ const handlers = new Map<string, (params: unknown) => void | Promise<void>>();
37
+ return {
38
+ sent,
39
+ handlers,
40
+ onNotification(method, handler) {
41
+ handlers.set(method, handler);
42
+ },
43
+ offNotification(method) {
44
+ handlers.delete(method);
45
+ },
46
+ async sendNotification(method, params) {
47
+ sent.push({ method, params });
48
+ },
49
+ async fire(method, params) {
50
+ const h = handlers.get(method);
51
+ if (h) await h(params);
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Build a minimal GitCascadeAdapter stub. Only repoPath + listWorktrees
58
+ * are exercised by the diff server. Cast to GitCascadeAdapter for the
59
+ * interface surface.
60
+ */
61
+ function mkAdapter(opts: {
62
+ repoPath: string;
63
+ worktrees?: Array<{ path: string; currentStream: string }>;
64
+ }): GitCascadeAdapter {
65
+ return {
66
+ get repoPath() {
67
+ return opts.repoPath;
68
+ },
69
+ listWorktrees() {
70
+ return opts.worktrees ?? [];
71
+ },
72
+ } as unknown as GitCascadeAdapter;
73
+ }
74
+
75
+ function shell(cmd: string, cwd: string): string {
76
+ return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
77
+ .toString('utf-8')
78
+ .trim();
79
+ }
80
+
81
+ describe('cascade-diff-server', () => {
82
+ let tempDir: string;
83
+ let repoPath: string;
84
+ let commitA: string;
85
+ let commitB: string;
86
+
87
+ beforeEach(() => {
88
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cascade-diff-server-'));
89
+ repoPath = path.join(tempDir, 'repo');
90
+ fs.mkdirSync(repoPath);
91
+
92
+ shell('git init -q', repoPath);
93
+ shell('git config user.email "test@test.com"', repoPath);
94
+ shell('git config user.name "Test"', repoPath);
95
+ shell('git config commit.gpgsign false', repoPath);
96
+
97
+ fs.writeFileSync(path.join(repoPath, 'a.txt'), 'one\n');
98
+ shell('git add .', repoPath);
99
+ shell('git commit -q -m initial', repoPath);
100
+ commitA = shell('git rev-parse HEAD', repoPath);
101
+
102
+ fs.writeFileSync(path.join(repoPath, 'a.txt'), 'one\ntwo\n');
103
+ fs.writeFileSync(path.join(repoPath, 'b.txt'), 'new file\n');
104
+ shell('git add .', repoPath);
105
+ shell('git commit -q -m second', repoPath);
106
+ commitB = shell('git rev-parse HEAD', repoPath);
107
+ });
108
+
109
+ afterEach(() => {
110
+ if (fs.existsSync(tempDir)) {
111
+ fs.rmSync(tempDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ it('returns an inline response for a small single-commit diff', async () => {
116
+ const conn = createMockConnection();
117
+ const adapter = mkAdapter({ repoPath });
118
+ setupCascadeDiffServer(conn, adapter);
119
+
120
+ await conn.fire('cascade/diff.request', {
121
+ request_id: 'req-1',
122
+ stream_id: 'doesnt-matter',
123
+ head: commitB,
124
+ format: 'unified',
125
+ });
126
+
127
+ expect(conn.sent).toHaveLength(1);
128
+ const note = conn.sent[0];
129
+ expect(note.method).toBe('cascade/diff.response');
130
+ const p = note.params as {
131
+ request_id: string;
132
+ streaming: boolean;
133
+ diff: string;
134
+ files_touched: string[];
135
+ truncated: boolean;
136
+ };
137
+ expect(p.request_id).toBe('req-1');
138
+ expect(p.streaming).toBe(false);
139
+ expect(p.diff).toContain('diff --git a/a.txt');
140
+ expect(p.diff).toContain('diff --git a/b.txt');
141
+ expect(p.files_touched.sort()).toEqual(['a.txt', 'b.txt']);
142
+ expect(p.truncated).toBe(false);
143
+ });
144
+
145
+ it('respects files_only and returns name-only output with empty blob', async () => {
146
+ const conn = createMockConnection();
147
+ const adapter = mkAdapter({ repoPath });
148
+ setupCascadeDiffServer(conn, adapter);
149
+
150
+ await conn.fire('cascade/diff.request', {
151
+ request_id: 'req-files-only',
152
+ stream_id: 'x',
153
+ head: commitB,
154
+ files_only: true,
155
+ format: 'unified',
156
+ });
157
+
158
+ expect(conn.sent).toHaveLength(1);
159
+ const p = conn.sent[0].params as {
160
+ streaming: boolean;
161
+ diff: string;
162
+ files_touched: string[];
163
+ };
164
+ expect(p.streaming).toBe(false);
165
+ expect(p.diff).toBe('');
166
+ expect(p.files_touched.sort()).toEqual(['a.txt', 'b.txt']);
167
+ });
168
+
169
+ it('produces a range diff when base is set', async () => {
170
+ const conn = createMockConnection();
171
+ const adapter = mkAdapter({ repoPath });
172
+ setupCascadeDiffServer(conn, adapter);
173
+
174
+ await conn.fire('cascade/diff.request', {
175
+ request_id: 'req-range',
176
+ stream_id: 'x',
177
+ base: commitA,
178
+ head: commitB,
179
+ format: 'unified',
180
+ });
181
+
182
+ const p = conn.sent[0].params as {
183
+ diff: string;
184
+ files_touched: string[];
185
+ };
186
+ // a.txt went 'one\n' → 'one\ntwo\n' (pure addition, no removed line)
187
+ expect(p.diff).toContain('+two');
188
+ expect(p.diff).toContain('+new file');
189
+ expect(p.files_touched.sort()).toEqual(['a.txt', 'b.txt']);
190
+ });
191
+
192
+ it('prefers a live worktree on the stream over the bare repo', async () => {
193
+ // Create a fake "worktree" path that's a sibling git checkout. For
194
+ // this test we reuse repoPath since we just want to verify the
195
+ // resolver picks the worktree when present.
196
+ const conn = createMockConnection();
197
+ const adapter = mkAdapter({
198
+ repoPath: '/should/not/be/used',
199
+ worktrees: [{ path: repoPath, currentStream: 'stream-1' }],
200
+ });
201
+ setupCascadeDiffServer(conn, adapter);
202
+
203
+ await conn.fire('cascade/diff.request', {
204
+ request_id: 'req-wt',
205
+ stream_id: 'stream-1',
206
+ head: commitB,
207
+ format: 'unified',
208
+ });
209
+
210
+ const note = conn.sent[0];
211
+ expect(note.method).toBe('cascade/diff.response');
212
+ expect((note.params as { error?: unknown }).error).toBeUndefined();
213
+ });
214
+
215
+ it('falls back to repo path when no worktree matches the stream', async () => {
216
+ const conn = createMockConnection();
217
+ const adapter = mkAdapter({
218
+ repoPath,
219
+ worktrees: [{ path: '/elsewhere', currentStream: 'other-stream' }],
220
+ });
221
+ setupCascadeDiffServer(conn, adapter);
222
+
223
+ await conn.fire('cascade/diff.request', {
224
+ request_id: 'req-fallback',
225
+ stream_id: 'stream-not-in-list',
226
+ head: commitB,
227
+ format: 'unified',
228
+ });
229
+
230
+ expect(conn.sent).toHaveLength(1);
231
+ expect((conn.sent[0].params as { error?: unknown }).error).toBeUndefined();
232
+ });
233
+
234
+ it('returns not_found error when no worktree and no repo path exists', async () => {
235
+ const conn = createMockConnection();
236
+ const adapter = mkAdapter({
237
+ repoPath: '/definitely/does/not/exist/xyzzy',
238
+ worktrees: [],
239
+ });
240
+ setupCascadeDiffServer(conn, adapter);
241
+
242
+ await conn.fire('cascade/diff.request', {
243
+ request_id: 'req-404',
244
+ stream_id: 'whatever',
245
+ head: commitB,
246
+ format: 'unified',
247
+ });
248
+
249
+ expect(conn.sent).toHaveLength(1);
250
+ const p = conn.sent[0].params as {
251
+ error?: { code: string; message: string };
252
+ };
253
+ expect(p.error?.code).toBe('not_found');
254
+ });
255
+
256
+ it('returns bad_request error when head is missing', async () => {
257
+ const conn = createMockConnection();
258
+ const adapter = mkAdapter({ repoPath });
259
+ setupCascadeDiffServer(conn, adapter);
260
+
261
+ await conn.fire('cascade/diff.request', {
262
+ request_id: 'req-bad',
263
+ stream_id: 'x',
264
+ // head missing
265
+ });
266
+
267
+ expect(conn.sent).toHaveLength(1);
268
+ const p = conn.sent[0].params as { error?: { code: string } };
269
+ expect(p.error?.code).toBe('bad_request');
270
+ });
271
+
272
+ it('returns internal error when git fails (unknown SHA)', async () => {
273
+ const conn = createMockConnection();
274
+ const adapter = mkAdapter({ repoPath });
275
+ setupCascadeDiffServer(conn, adapter);
276
+
277
+ await conn.fire('cascade/diff.request', {
278
+ request_id: 'req-bad-sha',
279
+ stream_id: 'x',
280
+ head: '0'.repeat(40),
281
+ format: 'unified',
282
+ });
283
+
284
+ expect(conn.sent).toHaveLength(1);
285
+ const p = conn.sent[0].params as { error?: { code: string } };
286
+ expect(p.error?.code).toBe('internal');
287
+ });
288
+
289
+ it('streams large diffs as chunks with correct sha256', async () => {
290
+ // Build a >512 KB diff by writing a big file in commit C.
291
+ const big = 'x'.repeat(600 * 1024) + '\n';
292
+ fs.writeFileSync(path.join(repoPath, 'big.txt'), big);
293
+ shell('git add .', repoPath);
294
+ shell('git commit -q -m big', repoPath);
295
+ const commitC = shell('git rev-parse HEAD', repoPath);
296
+
297
+ const conn = createMockConnection();
298
+ const adapter = mkAdapter({ repoPath });
299
+ setupCascadeDiffServer(conn, adapter);
300
+
301
+ await conn.fire('cascade/diff.request', {
302
+ request_id: 'req-stream',
303
+ stream_id: 'x',
304
+ head: commitC,
305
+ format: 'unified',
306
+ });
307
+
308
+ // Expect one response notification + N chunk notifications.
309
+ expect(conn.sent.length).toBeGreaterThan(1);
310
+ const head = conn.sent[0];
311
+ expect(head.method).toBe('cascade/diff.response');
312
+ const headParams = head.params as {
313
+ streaming: boolean;
314
+ chunk_stream_id: string;
315
+ total_size: number;
316
+ files_touched: string[];
317
+ };
318
+ expect(headParams.streaming).toBe(true);
319
+ expect(headParams.total_size).toBeGreaterThan(512 * 1024);
320
+ expect(headParams.files_touched).toContain('big.txt');
321
+
322
+ const chunks = conn.sent.slice(1);
323
+ for (const c of chunks) expect(c.method).toBe('cascade/diff.chunk');
324
+
325
+ // Reassemble + verify sha against the final chunk's announced hash.
326
+ const parts: Buffer[] = [];
327
+ let finalSha: string | undefined;
328
+ let finalTruncated: boolean | undefined;
329
+ for (const c of chunks) {
330
+ const cp = c.params as {
331
+ chunk_stream_id: string;
332
+ seq: number;
333
+ data: string;
334
+ final?: boolean;
335
+ sha256?: string;
336
+ truncated?: boolean;
337
+ };
338
+ expect(cp.chunk_stream_id).toBe(headParams.chunk_stream_id);
339
+ parts[cp.seq] = Buffer.from(cp.data, 'base64');
340
+ if (cp.final) {
341
+ finalSha = cp.sha256;
342
+ finalTruncated = cp.truncated;
343
+ }
344
+ }
345
+ const reassembled = Buffer.concat(parts);
346
+ expect(reassembled.length).toBe(headParams.total_size);
347
+ expect(createHash('sha256').update(reassembled).digest('hex')).toBe(finalSha);
348
+ expect(finalTruncated).toBe(false);
349
+ }, 30_000);
350
+
351
+ it('cleanup() unregisters the handler', async () => {
352
+ const conn = createMockConnection();
353
+ const adapter = mkAdapter({ repoPath });
354
+ const cleanup = setupCascadeDiffServer(conn, adapter);
355
+ expect(conn.handlers.has('cascade/diff.request')).toBe(true);
356
+ cleanup();
357
+ expect(conn.handlers.has('cascade/diff.request')).toBe(false);
358
+ });
359
+
360
+ it('ignores malformed requests without a request_id', async () => {
361
+ const conn = createMockConnection();
362
+ const adapter = mkAdapter({ repoPath });
363
+ setupCascadeDiffServer(conn, adapter);
364
+
365
+ await conn.fire('cascade/diff.request', { stream_id: 'x', head: commitB });
366
+ expect(conn.sent).toHaveLength(0);
367
+ });
368
+
369
+ // ── Real GitCascadeAdapter integration ─────────────────────────────
370
+ //
371
+ // The tests above use a hand-rolled `mkAdapter` that just satisfies the
372
+ // two surface methods (`repoPath`, `listWorktrees`). This integration
373
+ // test wires the actual `createGitCascadeAdapter` from the workspace
374
+ // layer to verify the diff server doesn't depend on stub-only behavior.
375
+
376
+ describe('with a real GitCascadeAdapter instance', () => {
377
+ let realAdapter: GitCascadeAdapter | null = null;
378
+
379
+ afterEach(() => {
380
+ if (realAdapter) {
381
+ try { realAdapter.close(); } catch { /* nothing-to-close is fine */ }
382
+ realAdapter = null;
383
+ }
384
+ });
385
+
386
+ it('serves diff from the real adapter\'s repoPath when no worktree matches', async () => {
387
+ realAdapter = createGitCascadeAdapter({
388
+ enabled: true,
389
+ repoPath,
390
+ dbPath: path.join(tempDir, 'real-adapter.db'),
391
+ skipRecovery: true,
392
+ });
393
+
394
+ const conn = createMockConnection();
395
+ setupCascadeDiffServer(conn, realAdapter);
396
+
397
+ await conn.fire('cascade/diff.request', {
398
+ request_id: 'req-real-1',
399
+ stream_id: 'no-such-stream', // no worktree → repoPath fallback
400
+ head: commitB,
401
+ format: 'unified',
402
+ });
403
+
404
+ expect(conn.sent).toHaveLength(1);
405
+ const p = conn.sent[0].params as {
406
+ streaming: boolean;
407
+ diff: string;
408
+ files_touched: string[];
409
+ };
410
+ expect(p.streaming).toBe(false);
411
+ expect(p.diff).toContain('diff --git a/a.txt');
412
+ expect(p.files_touched.sort()).toEqual(['a.txt', 'b.txt']);
413
+ });
414
+
415
+ it('exposes repoPath that matches the configured path', () => {
416
+ realAdapter = createGitCascadeAdapter({
417
+ enabled: true,
418
+ repoPath,
419
+ dbPath: path.join(tempDir, 'real-adapter-2.db'),
420
+ skipRecovery: true,
421
+ });
422
+
423
+ // The diff server reads adapter.repoPath as the fallback. Confirm
424
+ // the real adapter exposes the same shape we stub.
425
+ expect(realAdapter.repoPath).toBe(repoPath);
426
+ expect(Array.isArray(realAdapter.listWorktrees())).toBe(true);
427
+ });
428
+ });
429
+ });
430
+
431
+ // Reference vi to keep ESM tree-shake from removing the import; spawn(child_process)
432
+ // in the module under test produces no output we need to mock, but vitest's
433
+ // hooks rely on the namespace being present.
434
+ expect(typeof vi).toBe('object');
@@ -173,6 +173,94 @@ describe("setupMailBridge", () => {
173
173
  });
174
174
  });
175
175
 
176
+ describe("importance derivation", () => {
177
+ const DISPATCHER_ID = "dispatcher:host:1234:abc";
178
+
179
+ it("passes through importance from hub notification params", async () => {
180
+ await setupMailBridge({
181
+ connection: conn,
182
+ inboxAdapter: inbox as any,
183
+ dispatcherAgentId: DISPATCHER_ID,
184
+ });
185
+
186
+ await conn._fire({
187
+ conversation_id: "conv-imp-1",
188
+ turn_id: "turn-imp-1",
189
+ participant_id: "user:admin",
190
+ content_type: "application/json",
191
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-1" } }),
192
+ importance: "high",
193
+ });
194
+
195
+ expect(inbox.send).toHaveBeenCalledOnce();
196
+ const [, , , opts] = inbox.send.mock.calls[0];
197
+ expect(opts?.importance).toBe("high");
198
+ });
199
+
200
+ it("passes through 'urgent' importance for orchestrator recall", async () => {
201
+ await setupMailBridge({
202
+ connection: conn,
203
+ inboxAdapter: inbox as any,
204
+ dispatcherAgentId: DISPATCHER_ID,
205
+ });
206
+
207
+ await conn._fire({
208
+ conversation_id: "conv-imp-2",
209
+ turn_id: "turn-imp-2",
210
+ participant_id: "system:dispatch-orchestrator",
211
+ content_type: "application/json",
212
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-2" } }),
213
+ importance: "urgent",
214
+ });
215
+
216
+ expect(inbox.send).toHaveBeenCalledOnce();
217
+ const [, , , opts] = inbox.send.mock.calls[0];
218
+ expect(opts?.importance).toBe("urgent");
219
+ });
220
+
221
+ it("defaults to 'normal' when importance is missing", async () => {
222
+ await setupMailBridge({
223
+ connection: conn,
224
+ inboxAdapter: inbox as any,
225
+ dispatcherAgentId: DISPATCHER_ID,
226
+ });
227
+
228
+ await conn._fire({
229
+ conversation_id: "conv-imp-3",
230
+ turn_id: "turn-imp-3",
231
+ participant_id: "user:admin",
232
+ content_type: "application/json",
233
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-3" } }),
234
+ // no importance field
235
+ });
236
+
237
+ expect(inbox.send).toHaveBeenCalledOnce();
238
+ const [, , , opts] = inbox.send.mock.calls[0];
239
+ expect(opts?.importance).toBe("normal");
240
+ });
241
+
242
+ it("ignores invalid importance values and falls back to 'normal'", async () => {
243
+ await setupMailBridge({
244
+ connection: conn,
245
+ inboxAdapter: inbox as any,
246
+ dispatcherAgentId: DISPATCHER_ID,
247
+ });
248
+
249
+ await conn._fire({
250
+ conversation_id: "conv-imp-4",
251
+ turn_id: "turn-imp-4",
252
+ participant_id: "user:admin",
253
+ content_type: "application/json",
254
+ content: JSON.stringify({ schema: "x-dispatch/work", data: { taskId: "t-4" } }),
255
+ importance: "critical", // invalid value
256
+ });
257
+
258
+ expect(inbox.send).toHaveBeenCalledOnce();
259
+ const [, , , opts] = inbox.send.mock.calls[0];
260
+ expect(opts?.importance).toBe("normal");
261
+ });
262
+ });
263
+
176
264
  describe("without dispatcherAgentId (fallback mode)", () => {
177
265
  it("delivers to BRIDGE_RECIPIENT_ID", async () => {
178
266
  await setupMailBridge({
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Structural smoke test for the sidecar's diff-server install hook.
3
+ *
4
+ * `sidecar.ts` wires three things together under the same
5
+ * `if (gitCascadeAdapter)` branch:
6
+ *
7
+ * 1. createCascadeBridge — outbound x-cascade/* events
8
+ * 2. setupCascadeActionHandlers — inbound x-cascade/request.*
9
+ * 3. setupCascadeDiffServer — inbound cascade/diff.request ← (added in S1.11)
10
+ *
11
+ * Plus the capability declaration is conditional on `gitCascadeAdapter`
12
+ * (S1.10) so the hub only gates diff requests on swarms that can serve them.
13
+ *
14
+ * If any of these are dropped or re-conditioned by a refactor, this test
15
+ * fails loudly. A live integration would catch it too, but at much higher
16
+ * setup cost.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { readFileSync } from 'fs';
21
+ import { fileURLToPath } from 'url';
22
+ import { dirname, resolve } from 'path';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const SIDECAR_PATH = resolve(__dirname, '../sidecar.ts');
26
+ const sidecarSource = readFileSync(SIDECAR_PATH, 'utf-8');
27
+
28
+ describe('sidecar.ts cascade-diff install (structural smoke)', () => {
29
+ it('imports setupCascadeDiffServer under the cascade-adapter branch', () => {
30
+ // The import is dynamic (`await import(...)`) inside the
31
+ // `if (gitCascadeAdapter)` branch, like the action-handler import.
32
+ expect(sidecarSource).toMatch(
33
+ /await import\(['"]\.\/cascade-diff-server\.js['"]\)/,
34
+ );
35
+ });
36
+
37
+ it('only installs setupCascadeDiffServer when gitCascadeAdapter is present', () => {
38
+ // Find the cascade-adapter conditional block and make sure the diff
39
+ // server install lives inside it. We assert by checking that
40
+ // `setupCascadeDiffServer(` appears *after* `if (gitCascadeAdapter)`
41
+ // and *before* the matching close — proxied by checking it sits
42
+ // between the action-handler install and the `cascadeBridgeCleanup`
43
+ // assignment.
44
+ const guardIdx = sidecarSource.indexOf('if (gitCascadeAdapter)');
45
+ const actionIdx = sidecarSource.indexOf('setupCascadeActionHandlers(');
46
+ const diffIdx = sidecarSource.indexOf('setupCascadeDiffServer(');
47
+ const cleanupIdx = sidecarSource.indexOf('cascadeBridgeCleanup = () =>');
48
+
49
+ expect(guardIdx).toBeGreaterThan(-1);
50
+ expect(actionIdx).toBeGreaterThan(guardIdx);
51
+ expect(diffIdx).toBeGreaterThan(actionIdx);
52
+ expect(cleanupIdx).toBeGreaterThan(diffIdx);
53
+ });
54
+
55
+ it('adds the diff cleanup to the cascadeBridgeCleanup chain', () => {
56
+ // The cleanup returned by setupCascadeDiffServer must be invoked
57
+ // alongside the bridge.dispose + actionCleanup. Grep for the name
58
+ // used in S1.11: `diffCleanup`.
59
+ expect(sidecarSource).toMatch(/const diffCleanup\s*=\s*setupCascadeDiffServer\(/);
60
+ // And it's called from the composed cleanup function.
61
+ const cleanupBlock = sidecarSource.match(
62
+ /cascadeBridgeCleanup\s*=\s*\(\)\s*=>\s*{[\s\S]*?};/,
63
+ )?.[0];
64
+ expect(cleanupBlock).toBeDefined();
65
+ expect(cleanupBlock).toContain('diffCleanup()');
66
+ });
67
+
68
+ it('only declares cascade.canServeDiff capability when adapter is wired (S1.10)', () => {
69
+ // The capability declaration is a conditional spread keyed on
70
+ // gitCascadeAdapter. Without an adapter, no `cascade:` block is sent.
71
+ expect(sidecarSource).toMatch(
72
+ /\.\.\.\(gitCascadeAdapter\s*\?\s*\{\s*cascade:\s*\{\s*canServeDiff:\s*true\s*\}\s*\}\s*:\s*\{\}\)/,
73
+ );
74
+ });
75
+ });