@1agh/maude 0.37.0 → 0.38.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 (61) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +683 -33
  17. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  18. package/apps/studio/client/panels/acp-runtime.js +227 -70
  19. package/apps/studio/client/panels/chat-context.js +124 -0
  20. package/apps/studio/client/panels/slash-commands.js +147 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  22. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  23. package/apps/studio/commands/reorder-command.ts +77 -0
  24. package/apps/studio/config.schema.json +6 -0
  25. package/apps/studio/dist/client.bundle.js +25 -53617
  26. package/apps/studio/dist/styles.css +1 -1
  27. package/apps/studio/hmr-broadcast.ts +27 -19
  28. package/apps/studio/http.ts +168 -26
  29. package/apps/studio/inspect.ts +108 -1
  30. package/apps/studio/paths.ts +32 -0
  31. package/apps/studio/readiness.ts +79 -30
  32. package/apps/studio/server.ts +11 -2
  33. package/apps/studio/test/acp-activity.test.ts +154 -0
  34. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  35. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  36. package/apps/studio/test/acp-commands.test.ts +108 -0
  37. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  39. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  40. package/apps/studio/test/acp-transcript.test.ts +53 -0
  41. package/apps/studio/test/active-state.test.ts +41 -0
  42. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  45. package/apps/studio/test/chat-context.test.ts +129 -0
  46. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  47. package/apps/studio/test/edit-suppress.test.ts +170 -0
  48. package/apps/studio/test/ensure-browser.test.ts +92 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  50. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  51. package/apps/studio/test/inspect-selections.test.ts +219 -0
  52. package/apps/studio/test/paths.test.ts +57 -0
  53. package/apps/studio/test/readiness.test.ts +83 -13
  54. package/apps/studio/test/reorder-api.test.ts +210 -0
  55. package/apps/studio/test/slash-commands.test.ts +117 -0
  56. package/apps/studio/undo-stack.ts +6 -0
  57. package/apps/studio/whats-new.json +45 -0
  58. package/apps/studio/ws.ts +37 -0
  59. package/cli/commands/design.mjs +1 -0
  60. package/package.json +8 -8
  61. package/plugins/design/dependencies.json +3 -3
@@ -14,8 +14,10 @@
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import { homedir } from 'node:os';
16
16
  import { join } from 'node:path';
17
-
17
+ import { isNativePluginContext } from './acp/plugin-bootstrap.ts';
18
18
  import { resolveAdapterEntry, resolveClaudePath } from './acp/probe.ts';
19
+ import { resolveBrowser } from './bin/_ensure-browser.mjs';
20
+ import { DESIGN_PLUGIN_DIR } from './paths.ts';
19
21
 
20
22
  export type ReadinessStatus = 'present' | 'missing' | 'unknown';
21
23
 
@@ -101,7 +103,7 @@ function readJson<T = unknown>(path: string): T | null {
101
103
  }
102
104
  }
103
105
 
104
- interface PluginScan {
106
+ export interface PluginScan {
105
107
  /** 'unknown' when the registry couldn't be read (Claude Code's internal contract). */
106
108
  status: 'present' | 'unknown';
107
109
  marketplace: boolean;
@@ -114,8 +116,17 @@ interface PluginScan {
114
116
  * (`repo: 1aGh/maude`) and the `design@maude` / `flow@maude` plugins. `readFileSync`
115
117
  * follows symlinks (a dev's `~/.claude` is symlinked into Dotfiles). Never writes,
116
118
  * never throws — an unrecognized layout yields `status: 'unknown'`.
119
+ *
120
+ * A plugin counts as present if EITHER the marketplace registry
121
+ * (`installed_plugins.json`) lists it OR the user enabled it in `settings.json`
122
+ * `enabledPlugins` (`"<plugin>@<marketplace>": true`). The adapter spawns the
123
+ * user's `claude` with `settingSources: ["user","project","local"]`, so a
124
+ * settings-enabled plugin loads NATIVELY in the session — the ACP plugin
125
+ * auto-bootstrap (DDR-143) must treat it as already-present and skip injection,
126
+ * or the command set double-registers. Exported so `acp/plugin-bootstrap.ts`
127
+ * reuses this single registry-scan instead of duplicating it.
117
128
  */
118
- function scanPlugins(): PluginScan {
129
+ export function scanPlugins(): PluginScan {
119
130
  const dir = claudeConfigDir();
120
131
  const markets = readJson<Record<string, { source?: { repo?: string } }>>(
121
132
  join(dir, 'plugins', 'known_marketplaces.json')
@@ -123,7 +134,12 @@ function scanPlugins(): PluginScan {
123
134
  const installed = readJson<{ plugins?: Record<string, unknown> }>(
124
135
  join(dir, 'plugins', 'installed_plugins.json')
125
136
  );
126
- if (!markets && !installed) {
137
+ // `settingSources:user` a plugin the user enabled in ~/.claude/settings.json
138
+ // loads natively even if it's not in installed_plugins.json (DDR-143 no-op gate).
139
+ const settings = readJson<{ enabledPlugins?: Record<string, unknown> }>(
140
+ join(dir, 'settings.json')
141
+ );
142
+ if (!markets && !installed && !settings) {
127
143
  return { status: 'unknown', marketplace: false, design: false, flow: false };
128
144
  }
129
145
  const marketplace =
@@ -132,9 +148,11 @@ function scanPlugins(): PluginScan {
132
148
  (m) => String(m?.source?.repo ?? '').toLowerCase() === '1agh/maude'
133
149
  );
134
150
  const plugins = installed?.plugins ?? {};
151
+ const enabled = settings?.enabledPlugins ?? {};
135
152
  const has = (key: string): boolean => {
136
153
  const v = plugins[key];
137
- return Array.isArray(v) ? v.length > 0 : !!v;
154
+ const installedHit = Array.isArray(v) ? v.length > 0 : !!v;
155
+ return installedHit || enabled[key] === true;
138
156
  };
139
157
  return { status: 'present', marketplace, design: has('design@maude'), flow: has('flow@maude') };
140
158
  }
@@ -179,40 +197,71 @@ export async function probeReadiness(): Promise<ReadinessReport> {
179
197
  });
180
198
 
181
199
  const scan = scanPlugins();
182
- const pluginsPresent = scan.design && scan.flow;
183
- const pluginStatus: ReadinessStatus =
184
- scan.status === 'unknown' ? 'unknown' : pluginsPresent ? 'present' : 'missing';
185
- const missing = [!scan.design && 'design@maude', !scan.flow && 'flow@maude']
186
- .filter(Boolean)
187
- .join(' + ');
200
+ // DDR-143 on the native/desktop path the ACP chat session AUTO-LOADS the
201
+ // bundled `design` plugin (acp/plugin-bootstrap.ts → session-scoped
202
+ // `_meta.claudeCode.options.plugins`), so a design plugin that isn't
203
+ // marketplace-installed is still available in the chat. Count that as
204
+ // satisfied: the check collapses from a red install-wall to green. `/flow` is
205
+ // intentionally NOT part of the chat for now (2026-07-03), so the gate tracks
206
+ // `design` alone — demanding a plugin the chat doesn't use would be a false red.
207
+ // The web `maude design serve` path ships no bundle (native=false, dir null) →
208
+ // the manual marketplace remediation still shows there.
209
+ const native = isNativePluginContext();
210
+ const designAutoloaded = native && DESIGN_PLUGIN_DIR !== null && !scan.design;
211
+ const designReady = scan.design || designAutoloaded;
212
+ const pluginStatus: ReadinessStatus = designReady
213
+ ? 'present'
214
+ : scan.status === 'unknown'
215
+ ? 'unknown'
216
+ : 'missing';
188
217
  items.push({
189
218
  id: 'plugins',
190
- label: 'Maude plugins in Claude Code',
219
+ label: 'Maude design plugin in Claude Code',
191
220
  required: true,
192
221
  status: pluginStatus,
193
- detail:
194
- scan.status === 'unknown'
222
+ detail: designReady
223
+ ? designAutoloaded
224
+ ? 'Auto-loaded in the Maude chat session — nothing to install.'
225
+ : 'design@maude is installed.'
226
+ : scan.status === 'unknown'
195
227
  ? "Couldn't read Claude Code's plugin registry — check it manually."
196
- : pluginsPresent
197
- ? 'design@maude + flow@maude are installed.'
198
- : `Missing: ${missing}${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
199
- remediation:
200
- pluginStatus === 'present'
201
- ? undefined
202
- : 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude` and `/plugin install flow@maude`.',
228
+ : `Missing: design@maude${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
229
+ remediation: designReady
230
+ ? undefined
231
+ : 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude`.',
203
232
  });
204
233
 
234
+ // agent-browser + a headless Chromium capture artboards so `/design:*` critics
235
+ // can SEE the work — effectively required for the full design workflow. On the
236
+ // native/desktop path agent-browser is BUNDLED (externalBin, DDR — bundled
237
+ // screenshots) and the browser engine is resolved (or provisioned as
238
+ // chrome-headless-shell on first use) by ensure-browser, so it's a first-class,
239
+ // satisfied capability — not a red "optional install" wall. On web/CLI it stays
240
+ // optional (the user brings agent-browser or playwright).
241
+ let browserReady = false;
242
+ if (native) {
243
+ try {
244
+ browserReady = !!(await resolveBrowser({ download: false })).path;
245
+ } catch {
246
+ /* best-effort — the browser provisions at screenshot time regardless */
247
+ }
248
+ }
205
249
  items.push({
206
250
  id: 'agent-browser',
207
- label: 'agent-browser (optional)',
208
- required: false,
209
- status: agentBrowser ? 'present' : 'missing',
210
- detail: agentBrowser
211
- ? 'Installed — screenshot evidence during edits.'
212
- : 'Optionalricher screenshot evidence during edits.',
213
- remediation: agentBrowser
214
- ? undefined
215
- : 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
251
+ label: native ? 'Screenshot engine (design critics)' : 'agent-browser (optional)',
252
+ required: native,
253
+ status: native || agentBrowser ? 'present' : 'missing',
254
+ detail: native
255
+ ? browserReady
256
+ ? 'Bundledartboard screenshots for `/design:*` critics are ready.'
257
+ : 'Bundled — the browser engine downloads on your first screenshot (~94 MB, one-time).'
258
+ : agentBrowser
259
+ ? 'Installed screenshot evidence during edits.'
260
+ : 'Optional — richer screenshot evidence during edits.',
261
+ remediation:
262
+ native || agentBrowser
263
+ ? undefined
264
+ : 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
216
265
  });
217
266
 
218
267
  // The chat bridge itself: the adapter must be resolvable on disk. This is what
@@ -31,7 +31,7 @@ import { createHttp } from './http.ts';
31
31
  import { createInspect } from './inspect.ts';
32
32
  import { startHeapWatch } from './mem.ts';
33
33
  import { createSyncRuntime } from './sync/index.ts';
34
- import { createWs, isLoopbackHost, parseCollabSlug, type WsData } from './ws.ts';
34
+ import { createWs, isLoopbackHost, isSameOriginWs, parseCollabSlug, type WsData } from './ws.ts';
35
35
 
36
36
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
37
37
  // node_modules/ ships empty (git clone honors .gitignore). Auto-installs +
@@ -100,7 +100,10 @@ const gitLifecycle = createGitLifecycle(ctx, collab.registry);
100
100
  const activity = createActivity(ctx);
101
101
  // Phase 31 (DDR-123) — ACP chat bridge manager. Owns one claude-agent-acp
102
102
  // subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
103
- const acp = createAcp(ctx);
103
+ // Gets the ai-activity registry so agent edits raise the same "Claude is
104
+ // editing" banner + presence as /design:edit (RC5,
105
+ // rca/issue-canvas-hmr-optimistic-update-consistency).
106
+ const acp = createAcp(ctx, aiActivity);
104
107
  const ws = createWs(ctx, api, inspect, collab, activity, acp);
105
108
  const http = createHttp(ctx, api, inspect, aiActivity);
106
109
  const fsWatch = createFsWatch(ctx);
@@ -170,6 +173,12 @@ function startServer(port: number): BunServer {
170
173
  if (!isLoopbackHost(req.headers.get('host'))) {
171
174
  return new Response('ACP chat is loopback-only', { status: 403 });
172
175
  }
176
+ // CSWSH defense: a WS handshake bypasses SOP, so loopback-Host alone would
177
+ // let a cross-origin drive-by open this privileged socket (spawns `claude`,
178
+ // drives edits). Reject any Origin that isn't this same loopback server.
179
+ if (!isSameOriginWs(req)) {
180
+ return new Response('ACP chat is same-origin only', { status: 403 });
181
+ }
173
182
  const ok = srv.upgrade(req, {
174
183
  data: {
175
184
  id: crypto.randomUUID(),
@@ -0,0 +1,154 @@
1
+ // RCA issue-acp-subagent-activity-invisible — the ACP chat "still working"
2
+ // indicator must survive the premature turn-end that claude-agent-acp emits
3
+ // while background subagents are still running (adapter settles the prompt at
4
+ // the main agent's `result`, #773). These lock the pure activity reducer +
5
+ // label so a background subagent stays visible instead of the panel looking idle.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import {
10
+ activityLabel,
11
+ applyUpdate,
12
+ isSubagentTool,
13
+ reduceActivity,
14
+ } from '../client/panels/acp-runtime.js';
15
+
16
+ const call = (id: string, toolName?: string, over: Record<string, unknown> = {}) => ({
17
+ t: 'update' as const,
18
+ update: {
19
+ sessionUpdate: 'tool_call',
20
+ toolCallId: id,
21
+ title: over.title,
22
+ kind: over.kind,
23
+ ...(toolName ? { _meta: { claudeCode: { toolName } } } : {}),
24
+ },
25
+ });
26
+ const done = (id: string, status = 'completed') => ({
27
+ t: 'update' as const,
28
+ update: { sessionUpdate: 'tool_call_update', toolCallId: id, status },
29
+ });
30
+
31
+ describe('reduceActivity', () => {
32
+ test('a Task tool_call survives turn-end — the background subagent stays tracked', () => {
33
+ const m = new Map();
34
+ expect(
35
+ reduceActivity(m, call('t1', 'Task', { title: 'map the acp module', kind: 'think' }))
36
+ ).toBe(true);
37
+ expect(m.size).toBe(1);
38
+
39
+ // The regression this fix exists for: turn-end must NOT wipe the map.
40
+ expect(reduceActivity(m, { t: 'turn-end', stopReason: 'end_turn' })).toBe(false);
41
+ expect(m.size).toBe(1);
42
+
43
+ // …it drains on the subagent's OWN completed update, not on turn-end.
44
+ expect(reduceActivity(m, done('t1'))).toBe(true);
45
+ expect(m.size).toBe(0);
46
+ });
47
+
48
+ test('a hard error still wipes the map (teardown)', () => {
49
+ const m = new Map();
50
+ reduceActivity(m, call('t1', 'Read', { kind: 'read' }));
51
+ expect(reduceActivity(m, { t: 'error', message: 'boom' })).toBe(true);
52
+ expect(m.size).toBe(0);
53
+ });
54
+
55
+ test('captures the concrete tool name for subagent classification', () => {
56
+ const m = new Map();
57
+ reduceActivity(m, call('t1', 'Task', { title: 'x', kind: 'think' }));
58
+ reduceActivity(m, call('t2', 'Read', { title: 'foo.ts', kind: 'read' }));
59
+ const tools = [...m.values()];
60
+ expect(isSubagentTool(tools[0])).toBe(true);
61
+ expect(isSubagentTool(tools[1])).toBe(false);
62
+ });
63
+
64
+ test('failed updates remove the tool; unknown ids are a no-op', () => {
65
+ const m = new Map();
66
+ reduceActivity(m, call('t1', 'Bash', { kind: 'execute' }));
67
+ expect(reduceActivity(m, done('nope'))).toBe(false);
68
+ expect(reduceActivity(m, done('t1', 'failed'))).toBe(true);
69
+ expect(m.size).toBe(0);
70
+ });
71
+ });
72
+
73
+ describe('activityLabel', () => {
74
+ test('subagents get explicit, counted wording', () => {
75
+ expect(activityLabel([{ toolName: 'Task', title: 'a' }])).toBe('1 subagent running');
76
+ expect(activityLabel([{ toolName: 'Task' }, { toolName: 'Agent' }])).toBe(
77
+ '2 subagents running'
78
+ );
79
+ });
80
+
81
+ test('non-subagent tools fall back to title / count / Working…', () => {
82
+ expect(activityLabel([{ title: 'Edit foo.ts' }])).toBe('Edit foo.ts');
83
+ expect(activityLabel([{ title: 'a' }, { title: 'b' }])).toBe('2 tasks running');
84
+ expect(activityLabel([])).toBe('Working…');
85
+ });
86
+ });
87
+
88
+ // The F2 fix: the SAME reducer folds live-turn frames AND the post-turn-end
89
+ // "background continuation" frames the client used to drop. If this drifts, the
90
+ // dropped subagent results / consolidation render wrong (or not at all).
91
+ describe('applyUpdate (shared part reducer)', () => {
92
+ const chunk = (text: string) => ({
93
+ sessionUpdate: 'agent_message_chunk',
94
+ content: { type: 'text', text },
95
+ });
96
+ const call = (id: string, title: string, kind: string, rawInput: unknown = {}) => ({
97
+ sessionUpdate: 'tool_call',
98
+ toolCallId: id,
99
+ title,
100
+ kind,
101
+ rawInput,
102
+ });
103
+ const upd = (id: string, status: string, rawOutput: unknown) => ({
104
+ sessionUpdate: 'tool_call_update',
105
+ toolCallId: id,
106
+ status,
107
+ rawOutput,
108
+ });
109
+
110
+ test('coalesces consecutive text chunks into one part', () => {
111
+ const parts: unknown[] = [];
112
+ const ti = new Map();
113
+ applyUpdate(parts, ti, chunk('Panel: '));
114
+ applyUpdate(parts, ti, chunk('9 blockers'));
115
+ expect(parts).toEqual([{ type: 'text', text: 'Panel: 9 blockers' }]);
116
+ });
117
+
118
+ test('tool_call adds a pending part; tool_call_update fills the result', () => {
119
+ const parts: Array<Record<string, unknown>> = [];
120
+ const ti = new Map();
121
+ applyUpdate(parts, ti, call('t1', 'Write critique/001.md', 'edit', { path: 'x' }));
122
+ expect(parts[0]).toMatchObject({ type: 'tool-call', toolName: 'Write critique/001.md' });
123
+ expect(parts[0].result).toBeUndefined();
124
+ applyUpdate(parts, ti, upd('t1', 'completed', { ok: true }));
125
+ expect(parts[0]).toMatchObject({ result: { ok: true }, isError: false });
126
+ });
127
+
128
+ test('failed update marks isError', () => {
129
+ const parts: Array<Record<string, unknown>> = [];
130
+ const ti = new Map();
131
+ applyUpdate(parts, ti, call('t1', 'Bash', 'execute'));
132
+ applyUpdate(parts, ti, upd('t1', 'failed', null));
133
+ expect(parts[0].isError).toBe(true);
134
+ });
135
+
136
+ test('thought → reasoning part; usage/plan ignored', () => {
137
+ const parts: unknown[] = [];
138
+ const ti = new Map();
139
+ applyUpdate(parts, ti, {
140
+ sessionUpdate: 'agent_thought_chunk',
141
+ content: { type: 'text', text: 'hmm' },
142
+ });
143
+ applyUpdate(parts, ti, { sessionUpdate: 'usage_update' });
144
+ applyUpdate(parts, ti, { sessionUpdate: 'plan' });
145
+ expect(parts).toEqual([{ type: 'reasoning', text: 'hmm' }]);
146
+ });
147
+
148
+ test('update for an unknown tool id is a no-op', () => {
149
+ const parts: unknown[] = [];
150
+ const ti = new Map();
151
+ applyUpdate(parts, ti, upd('nope', 'completed', {}));
152
+ expect(parts).toEqual([]);
153
+ });
154
+ });
@@ -0,0 +1,182 @@
1
+ // RC5 (rca/issue-canvas-hmr-optimistic-update-consistency) — the ACP chat agent
2
+ // must raise the same "Claude is editing" ai-activity banner as /design:edit.
3
+ // The tracker watches streamed tool_call / tool_call_update notifications for
4
+ // edit-kind tools touching canvas files and drives start/heartbeat/end.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { join } from 'node:path';
8
+
9
+ import type { SessionUpdate } from '@agentclientprotocol/sdk';
10
+
11
+ import { createAgentActivityTracker } from '../acp/index.ts';
12
+ import type { AiActivity, AiActivityEntry } from '../collab/ai-activity.ts';
13
+ import { type Context, createBus } from '../context.ts';
14
+
15
+ const ROOT = join('/tmp', 'acp-ai-root');
16
+ const DESIGN = join(ROOT, '.design');
17
+
18
+ function mkCtx(): Context {
19
+ return {
20
+ cfg: {} as Context['cfg'],
21
+ projectLabel: 'test',
22
+ bus: createBus(),
23
+ paths: {
24
+ repoRoot: ROOT,
25
+ designRel: '.design',
26
+ designRoot: DESIGN,
27
+ serverInfoFile: join(DESIGN, '_server.json'),
28
+ activeFile: join(DESIGN, '_active.json'),
29
+ commentsDir: join(DESIGN, '_comments'),
30
+ canvasStateDir: join(DESIGN, '_canvas-state'),
31
+ historyDir: join(DESIGN, '_history'),
32
+ tokensUrlRel: '',
33
+ systemDirRel: 'system',
34
+ },
35
+ };
36
+ }
37
+
38
+ function mkAi(): { ai: AiActivity; calls: string[] } {
39
+ const calls: string[] = [];
40
+ const entry = (file: string): AiActivityEntry => ({
41
+ file,
42
+ author: 'x',
43
+ startedAt: 0,
44
+ lastHeartbeat: 0,
45
+ });
46
+ const ai: AiActivity = {
47
+ start(file, author) {
48
+ calls.push(`start:${file}:${author}`);
49
+ return entry(file);
50
+ },
51
+ heartbeat(file) {
52
+ calls.push(`beat:${file}`);
53
+ return entry(file);
54
+ },
55
+ end(file) {
56
+ calls.push(`end:${file}`);
57
+ return true;
58
+ },
59
+ list: () => [],
60
+ get: () => null,
61
+ stop: () => {},
62
+ };
63
+ return { ai, calls };
64
+ }
65
+
66
+ const upd = (u: Record<string, unknown>): SessionUpdate => u as unknown as SessionUpdate;
67
+
68
+ describe('acp / agent activity tracker', () => {
69
+ test('edit tool_call on a canvas starts ai-activity with the designRel key', () => {
70
+ const { ai, calls } = mkAi();
71
+ const t = createAgentActivityTracker(mkCtx(), ai);
72
+ t.onUpdate(
73
+ upd({
74
+ sessionUpdate: 'tool_call',
75
+ toolCallId: 't1',
76
+ kind: 'edit',
77
+ locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
78
+ })
79
+ );
80
+ expect(calls).toHaveLength(1);
81
+ expect(calls[0]).toStartWith('start:.design/ui/Foo.tsx:');
82
+ });
83
+
84
+ test('follow-up update for the same file inside the beat throttle adds nothing', () => {
85
+ const { ai, calls } = mkAi();
86
+ const t = createAgentActivityTracker(mkCtx(), ai);
87
+ const loc = { locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }] };
88
+ t.onUpdate(upd({ sessionUpdate: 'tool_call', toolCallId: 't1', kind: 'edit', ...loc }));
89
+ // kind omitted on the update — must be recalled from the tool_call by id.
90
+ t.onUpdate(upd({ sessionUpdate: 'tool_call_update', toolCallId: 't1', ...loc }));
91
+ expect(calls).toHaveLength(1);
92
+ });
93
+
94
+ test('read/search tools and non-canvas paths never banner', () => {
95
+ const { ai, calls } = mkAi();
96
+ const t = createAgentActivityTracker(mkCtx(), ai);
97
+ t.onUpdate(
98
+ upd({
99
+ sessionUpdate: 'tool_call',
100
+ toolCallId: 'r1',
101
+ kind: 'read',
102
+ locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
103
+ })
104
+ );
105
+ t.onUpdate(
106
+ upd({
107
+ sessionUpdate: 'tool_call',
108
+ toolCallId: 'e2',
109
+ kind: 'edit',
110
+ locations: [{ path: join(ROOT, 'src', 'App.tsx') }], // outside designRoot
111
+ })
112
+ );
113
+ t.onUpdate(
114
+ upd({
115
+ sessionUpdate: 'tool_call',
116
+ toolCallId: 'e3',
117
+ kind: 'edit',
118
+ locations: [{ path: join(DESIGN, '_history', 'x', 'Foo.tsx') }], // runtime state
119
+ })
120
+ );
121
+ t.onUpdate(
122
+ upd({
123
+ sessionUpdate: 'tool_call',
124
+ toolCallId: 'e4',
125
+ kind: 'edit',
126
+ locations: [{ path: join(DESIGN, 'ui', 'Foo.meta.json') }], // not a canvas
127
+ })
128
+ );
129
+ expect(calls).toEqual([]);
130
+ });
131
+
132
+ test('rawInput.file_path works when locations are absent', () => {
133
+ const { ai, calls } = mkAi();
134
+ const t = createAgentActivityTracker(mkCtx(), ai);
135
+ t.onUpdate(
136
+ upd({
137
+ sessionUpdate: 'tool_call',
138
+ toolCallId: 'w1',
139
+ kind: 'edit',
140
+ rawInput: { file_path: join(DESIGN, 'ui', 'Bar.tsx') },
141
+ })
142
+ );
143
+ expect(calls).toHaveLength(1);
144
+ expect(calls[0]).toStartWith('start:.design/ui/Bar.tsx:');
145
+ });
146
+
147
+ test('endTurn ends every banner this turn raised', () => {
148
+ const { ai, calls } = mkAi();
149
+ const t = createAgentActivityTracker(mkCtx(), ai);
150
+ t.onUpdate(
151
+ upd({
152
+ sessionUpdate: 'tool_call',
153
+ toolCallId: 't1',
154
+ kind: 'edit',
155
+ locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
156
+ })
157
+ );
158
+ t.onUpdate(
159
+ upd({
160
+ sessionUpdate: 'tool_call',
161
+ toolCallId: 't2',
162
+ kind: 'edit',
163
+ locations: [{ path: join(DESIGN, 'ui', 'Bar.tsx') }],
164
+ })
165
+ );
166
+ t.endTurn();
167
+ expect(calls.filter((c) => c.startsWith('end:'))).toEqual([
168
+ 'end:.design/ui/Foo.tsx',
169
+ 'end:.design/ui/Bar.tsx',
170
+ ]);
171
+ // A later turn on the same file must start (not silently beat) again.
172
+ t.onUpdate(
173
+ upd({
174
+ sessionUpdate: 'tool_call',
175
+ toolCallId: 't3',
176
+ kind: 'edit',
177
+ locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
178
+ })
179
+ );
180
+ expect(calls.filter((c) => c.startsWith('start:'))).toHaveLength(3);
181
+ });
182
+ });