@1agh/maude 0.45.0 → 0.45.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. package/package.json +11 -9
@@ -125,7 +125,9 @@ describe('downloadWhisperModel', () => {
125
125
  headers: new Headers(),
126
126
  body: new Response(new Uint8Array(1)).body,
127
127
  })) as unknown as typeof fetch;
128
- await expect(downloadWhisperModel('tiny', () => {})).rejects.toThrow(/off huggingface\.co/);
128
+ await expect(downloadWhisperModel('tiny', () => {})).rejects.toThrow(
129
+ /off the allowed HF hosts/
130
+ );
129
131
  expect(existsSync(join(whisperModelsDir(), 'ggml-tiny.bin'))).toBe(false);
130
132
  });
131
133
 
@@ -140,6 +142,31 @@ describe('downloadWhisperModel', () => {
140
142
  const path = await downloadWhisperModel('tiny', () => {});
141
143
  expect(existsSync(path)).toBe(true);
142
144
  });
145
+
146
+ test("accepts a redirect that lands on HF's Xet CDN (xethub.hf.co)", async () => {
147
+ const bytes = new Uint8Array(512);
148
+ globalThis.fetch = (async () => ({
149
+ url: 'https://cas-bridge.xethub.hf.co/repos/x/ggml-tiny.bin',
150
+ ok: true,
151
+ headers: new Headers({ 'content-length': String(bytes.byteLength) }),
152
+ body: new Response(bytes).body,
153
+ })) as unknown as typeof fetch;
154
+ const path = await downloadWhisperModel('tiny', () => {});
155
+ expect(existsSync(path)).toBe(true);
156
+ });
157
+
158
+ test('rejects a lookalike host that merely contains an allowed apex', async () => {
159
+ globalThis.fetch = (async () => ({
160
+ url: 'https://xethub.hf.co.evil.example/ggml-tiny.bin',
161
+ ok: true,
162
+ headers: new Headers(),
163
+ body: new Response(new Uint8Array(1)).body,
164
+ })) as unknown as typeof fetch;
165
+ await expect(downloadWhisperModel('tiny', () => {})).rejects.toThrow(
166
+ /off the allowed HF hosts/
167
+ );
168
+ expect(existsSync(join(whisperModelsDir(), 'ggml-tiny.bin'))).toBe(false);
169
+ });
143
170
  });
144
171
 
145
172
  describe('registry integrity', () => {
@@ -81,6 +81,18 @@ export const WHISPER_MODELS: readonly WhisperModelDescriptor[] = [
81
81
  const MODEL_HOST = 'https://huggingface.co';
82
82
  const MODEL_REPO_PATH = '/ggerganov/whisper.cpp/resolve/main';
83
83
 
84
+ // Apex domains a model-download redirect is allowed to land on. `huggingface.co`
85
+ // is the classic Git-LFS CDN; `xethub.hf.co` is HF's newer Xet storage backend,
86
+ // which several repos (incl. ggerganov/whisper.cpp) have been migrated to — its
87
+ // edge nodes (e.g. `cas-bridge.xethub.hf.co`) are a different apex entirely, not
88
+ // a subdomain of huggingface.co. Anchored subdomain match only (never
89
+ // `includes()`), so `xethub.hf.co.evil.com` / `evilxethub.hf.co` still reject.
90
+ const ALLOWED_REDIRECT_APEXES = ['huggingface.co', 'xethub.hf.co'] as const;
91
+
92
+ function isAllowedRedirectHost(hostname: string): boolean {
93
+ return ALLOWED_REDIRECT_APEXES.some((apex) => hostname === apex || hostname.endsWith(`.${apex}`));
94
+ }
95
+
84
96
  /** The fixed, non-interpolated download URL for a model (SSRF-safe — the file
85
97
  * comes from the frozen registry above, never from a request). */
86
98
  export function whisperModelUrl(m: WhisperModelDescriptor): string {
@@ -163,7 +175,9 @@ export function resolveWhisperModel(preferId?: string): string | null {
163
175
  * (Task 2.7). Egress discipline: the URL is derived from the FROZEN registry
164
176
  * (initial host is always huggingface.co, asserted https + fixed host); the
165
177
  * redirect the HF blob store issues is followed but the FINAL hop is re-asserted
166
- * https + a `*.huggingface.co` host (ethical-hacker Finding 2); the body is
178
+ * https + a host on `ALLOWED_REDIRECT_APEXES` (`*.huggingface.co` for the
179
+ * classic LFS CDN, `*.xethub.hf.co` for HF's newer Xet storage backend —
180
+ * ethical-hacker Finding 2 plus the Xet-migration follow-up); the body is
167
181
  * size-capped per-chunk to the model's expected size (+20% margin) before it can
168
182
  * fill the disk; written to a `.part` temp and atomically renamed so a
169
183
  * partial/aborted download never looks complete. Pass an AbortSignal (a timeout)
@@ -190,15 +204,16 @@ export async function downloadWhisperModel(
190
204
  const tmpPath = `${finalPath}.part`;
191
205
 
192
206
  const cap = Math.ceil(m.sizeMB * 1.2) * 1024 * 1024; // expected size + 20% margin
193
- // huggingface.co 302-redirects model blobs to its own LFS CDN, so we must
194
- // follow — but re-assert the FINAL hop is still https + a huggingface.co host
195
- // (ethical-hacker Finding 2): an open-redirect/on-path must not land the fetch
196
- // on an arbitrary host. A wall-clock timeout is layered by the caller's signal.
207
+ // huggingface.co 302-redirects model blobs to its own LFS/Xet CDN, so we must
208
+ // follow — but re-assert the FINAL hop is still https + a host on
209
+ // ALLOWED_REDIRECT_APEXES (ethical-hacker Finding 2): an open-redirect/on-path
210
+ // must not land the fetch on an arbitrary host. A wall-clock timeout is
211
+ // layered by the caller's signal.
197
212
  const res = await fetch(url, { signal, redirect: 'follow' });
198
213
  try {
199
214
  const finalUrl = new URL(res.url || url);
200
- if (finalUrl.protocol !== 'https:' || !/(^|\.)huggingface\.co$/.test(finalUrl.hostname))
201
- throw new Error(`model download redirected off huggingface.co (${finalUrl.hostname})`);
215
+ if (finalUrl.protocol !== 'https:' || !isAllowedRedirectHost(finalUrl.hostname))
216
+ throw new Error(`model download redirected off the allowed HF hosts (${finalUrl.hostname})`);
202
217
  } catch (err) {
203
218
  if (err instanceof Error && err.message.startsWith('model download redirected')) throw err;
204
219
  throw new Error('model download resolved to an invalid URL');
@@ -17,7 +17,7 @@ import {
17
17
  startSignin,
18
18
  } from './acp/login-state.ts';
19
19
  import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
20
- import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
20
+ import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
21
21
  import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
22
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
23
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
@@ -1017,7 +1017,7 @@ export function createHttp(
1017
1017
  Response.json(listChats(ctx.paths.designRoot), {
1018
1018
  headers: { 'Cache-Control': 'no-store' },
1019
1019
  }),
1020
- '/_api/acp/chat': (req: Request) => {
1020
+ '/_api/acp/chat': async (req: Request) => {
1021
1021
  const id = (new URL(req.url).searchParams.get('id') ?? '')
1022
1022
  .replace(/[^a-z0-9_-]/gi, '')
1023
1023
  .slice(0, 64);
@@ -1025,6 +1025,37 @@ export function createHttp(
1025
1025
  const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
1026
1026
  return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
1027
1027
  }
1028
+ // Task C5 — Rename / Archive from the chat switcher's overflow menu.
1029
+ // Same CSRF + loopback gate as every other privileged write route
1030
+ // (DDR-088); id is already sanitized/contained above.
1031
+ if (req.method === 'PATCH') {
1032
+ if (!sameOriginWrite(req))
1033
+ return new Response('cross-origin write rejected', { status: 403 });
1034
+ if (!isLoopbackHost(req.headers.get('host')))
1035
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1036
+ if (!id) return new Response('missing id', { status: 400 });
1037
+ let body: unknown;
1038
+ try {
1039
+ body = await req.json();
1040
+ } catch {
1041
+ return new Response('invalid JSON body', { status: 400 });
1042
+ }
1043
+ if (!body || typeof body !== 'object') return new Response('invalid body', { status: 400 });
1044
+ const b = body as { title?: unknown; archived?: unknown };
1045
+ const patch: { title?: string | null; archived?: boolean } = {};
1046
+ if ('title' in b) {
1047
+ if (b.title === null) patch.title = null;
1048
+ else if (typeof b.title === 'string') patch.title = b.title;
1049
+ else return new Response('title must be a string or null', { status: 400 });
1050
+ }
1051
+ if ('archived' in b) {
1052
+ if (typeof b.archived !== 'boolean')
1053
+ return new Response('archived must be a boolean', { status: 400 });
1054
+ patch.archived = b.archived;
1055
+ }
1056
+ const meta = writeChatMeta(ctx.paths.designRoot, id, patch);
1057
+ return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
1058
+ }
1028
1059
  if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
1029
1060
  return Response.json(readChatMessages(ctx.paths.designRoot, id), {
1030
1061
  headers: { 'Cache-Control': 'no-store' },
@@ -52,7 +52,7 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
52
52
  }
53
53
  }, 15000);
54
54
 
55
- test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
55
+ test('model/effort are no longer env-at-spawn a config change never respawns the running adapter', async () => {
56
56
  process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
57
57
  process.env.MAUDE_ACP_RUNTIME = process.execPath;
58
58
  process.env.MAUDE_CLAUDE_BIN = process.execPath;
@@ -60,19 +60,24 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
60
60
  const updates: unknown[] = [];
61
61
  const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
62
62
  try {
63
+ // A persisted pick this mock doesn't advertise (it has no configOptions at
64
+ // all) is skipped best-effort, never forwarded to ANTHROPIC_MODEL/env.
63
65
  bridge.setConfig('opus', 'thorough');
64
66
  await bridge.prompt('hi', 'c1');
67
+ const sid = bridge.sessionId;
65
68
  const first = JSON.stringify(updates);
66
- expect(first).toContain('model=opus');
67
- expect(first).toContain('thinking=31999');
69
+ expect(first).toContain('model=<unset>');
70
+ expect(first).toContain('thinking=<unset>');
68
71
 
69
- // Changing the config re-spawns with the new env on the next prompt.
72
+ // Changing the config again must NOT tear down / respawn the live process
73
+ // (Task A3 — the old configChanged()-triggered stop() is gone).
70
74
  updates.length = 0;
71
75
  bridge.setConfig(null, 'fast');
72
76
  await bridge.prompt('again', 'c1');
77
+ expect(bridge.sessionId).toBe(sid); // same session — no respawn happened
73
78
  const second = JSON.stringify(updates);
74
- expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
75
- expect(second).toContain('thinking=0'); // fast → thinking disabled
79
+ expect(second).toContain('model=<unset>');
80
+ expect(second).toContain('thinking=<unset>');
76
81
  } finally {
77
82
  await bridge.stop();
78
83
  }
@@ -0,0 +1,123 @@
1
+ // Pure unit tests for client/panels/acp-capabilities.js — the parsing +
2
+ // persisted-pick-resolution helpers the dynamic CapabilityBar renders from.
3
+ // No hardcoded model/effort/mode list is under test here BY DESIGN: these
4
+ // functions must work for whatever the session actually advertises.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ flattenSelectOptions,
10
+ parseConfigOptions,
11
+ parseModes,
12
+ resolvePersistedPick,
13
+ } from '../client/panels/acp-capabilities.js';
14
+
15
+ describe('parseConfigOptions', () => {
16
+ test('identifies model/effort/fast by id, excludes the mode-mirror entry, and buckets everything else into others', () => {
17
+ const configOptions = [
18
+ { id: 'model', category: 'model', currentValue: 'sonnet', options: [] },
19
+ { id: 'effort', category: 'thought_level', currentValue: 'default', options: [] },
20
+ { id: 'mode', category: 'mode', currentValue: 'default', options: [] },
21
+ { id: 'fast', category: 'model_config', currentValue: 'off', options: [] },
22
+ { id: 'agent', currentValue: 'default', options: [] }, // no category — the documented gap
23
+ { id: 'some-future-option', category: '_custom', currentValue: 'x', options: [] },
24
+ ];
25
+ const { model, effort, fast, others } = parseConfigOptions(configOptions);
26
+ expect(model?.id).toBe('model');
27
+ expect(effort?.id).toBe('effort');
28
+ expect(fast?.id).toBe('fast');
29
+ expect(others.map((o) => o.id)).toEqual(['agent', 'some-future-option']);
30
+ });
31
+
32
+ test('tolerates missing/empty/malformed input', () => {
33
+ expect(parseConfigOptions(undefined)).toEqual({
34
+ model: null,
35
+ effort: null,
36
+ fast: null,
37
+ others: [],
38
+ });
39
+ expect(parseConfigOptions(null)).toEqual({ model: null, effort: null, fast: null, others: [] });
40
+ expect(parseConfigOptions([])).toEqual({ model: null, effort: null, fast: null, others: [] });
41
+ // A non-object entry must not throw.
42
+ expect(parseConfigOptions([null, 'x', 42]).others).toEqual([]);
43
+ });
44
+
45
+ test('a session with only a subset of options omits the rest as null, not a stale default', () => {
46
+ const { model, effort, fast } = parseConfigOptions([{ id: 'model', options: [] }]);
47
+ expect(model).not.toBeNull();
48
+ expect(effort).toBeNull();
49
+ expect(fast).toBeNull();
50
+ });
51
+ });
52
+
53
+ describe('parseModes', () => {
54
+ test('returns current + available from a real SessionModeState', () => {
55
+ const modes = {
56
+ currentModeId: 'plan',
57
+ availableModes: [
58
+ { id: 'default', name: 'Manual' },
59
+ { id: 'plan', name: 'Plan Mode' },
60
+ ],
61
+ };
62
+ expect(parseModes(modes)).toEqual({
63
+ current: 'plan',
64
+ available: modes.availableModes,
65
+ });
66
+ });
67
+
68
+ test('an agent that never advertises modes yields an empty, non-throwing shape', () => {
69
+ expect(parseModes(null)).toEqual({ current: null, available: [] });
70
+ expect(parseModes(undefined)).toEqual({ current: null, available: [] });
71
+ expect(parseModes({})).toEqual({ current: null, available: [] });
72
+ });
73
+ });
74
+
75
+ describe('flattenSelectOptions', () => {
76
+ test('passes through a flat option list unchanged', () => {
77
+ const options = [
78
+ { value: 'a', name: 'A' },
79
+ { value: 'b', name: 'B' },
80
+ ];
81
+ expect(flattenSelectOptions(options)).toEqual(options);
82
+ });
83
+
84
+ test('flattens grouped SessionConfigSelectGroup[] into one leaf list', () => {
85
+ const options = [
86
+ { group: 'g1', name: 'Group 1', options: [{ value: 'a', name: 'A' }] },
87
+ {
88
+ group: 'g2',
89
+ name: 'Group 2',
90
+ options: [
91
+ { value: 'b', name: 'B' },
92
+ { value: 'c', name: 'C' },
93
+ ],
94
+ },
95
+ ];
96
+ expect(flattenSelectOptions(options).map((o) => o.value)).toEqual(['a', 'b', 'c']);
97
+ });
98
+
99
+ test('tolerates missing input', () => {
100
+ expect(flattenSelectOptions(undefined)).toEqual([]);
101
+ expect(flattenSelectOptions(null)).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe('resolvePersistedPick', () => {
106
+ test('prefers the saved pick when the session still offers it', () => {
107
+ expect(resolvePersistedPick(['opus', 'sonnet'], 'opus', 'sonnet')).toBe('opus');
108
+ });
109
+
110
+ test('falls back to the session default when the saved pick is no longer offered', () => {
111
+ expect(resolvePersistedPick(['sonnet', 'haiku'], 'opus', 'sonnet')).toBe('sonnet');
112
+ });
113
+
114
+ test('falls back to null when there is neither a valid saved pick nor a default', () => {
115
+ expect(resolvePersistedPick([], 'opus', undefined)).toBeNull();
116
+ expect(resolvePersistedPick([], null, null)).toBeNull();
117
+ });
118
+
119
+ test('a null/undefined saved value always defers to the default', () => {
120
+ expect(resolvePersistedPick(['a', 'b'], null, 'b')).toBe('b');
121
+ expect(resolvePersistedPick(['a', 'b'], undefined, 'a')).toBe('a');
122
+ });
123
+ });
@@ -0,0 +1,274 @@
1
+ // Proves the session-capabilities channel end-to-end against a mock ACP agent
2
+ // (fixtures/mock-acp-agent-caps.mjs) — no real `claude` needed. Covers Tasks
3
+ // A1/A2/A3/A4 of feature-acp-panel-dynamic-claude-code-capabilities:
4
+ // • AcpBridge captures modes/configOptions from session/new and fires onCaps
5
+ // once the resume-replay window is closed.
6
+ // • AcpBridge.setMode/setConfigOption drive LIVE changes on an established
7
+ // session (no respawn) and the full refreshed configOptions echoes back.
8
+ // • setConfig's persisted model/effort/mode picks are applied ONCE, best-
9
+ // effort, right after a session establishes (never onto an already-live one).
10
+ // • The Acp manager (index.ts) validates `set-mode`/`set-config` frames
11
+ // against the bridge's last-advertised caps before ever calling the bridge
12
+ // (DDR-125 F1 — the dynamic replacement for VALID_MODELS/VALID_EFFORT).
13
+
14
+ import { afterEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtemp, rm } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ import type { ServerWebSocket } from 'bun';
20
+
21
+ import { AcpBridge } from '../acp/bridge.ts';
22
+ import { createAcp } from '../acp/index.ts';
23
+ import type { Context } from '../context.ts';
24
+ import type { WsData } from '../ws.ts';
25
+
26
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-caps.mjs');
27
+ const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
28
+
29
+ function useMockAgent() {
30
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
31
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
32
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
33
+ }
34
+
35
+ afterEach(() => {
36
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
37
+ });
38
+
39
+ async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
40
+ const start = performance.now();
41
+ for (;;) {
42
+ const v = fn();
43
+ if (v !== undefined) return v;
44
+ if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
45
+ await new Promise((r) => setTimeout(r, 25));
46
+ }
47
+ }
48
+
49
+ function fakeWs(id: string) {
50
+ const frames: Array<Record<string, unknown>> = [];
51
+ const ws = {
52
+ data: { id } as WsData,
53
+ send: (raw: string) => {
54
+ frames.push(JSON.parse(raw));
55
+ },
56
+ } as unknown as ServerWebSocket<WsData>;
57
+ return { ws, frames };
58
+ }
59
+
60
+ describe('AcpBridge — capability channel (Tasks A1/A2/A3)', () => {
61
+ test('onCaps fires with the initial modes + configOptions once a session establishes', async () => {
62
+ useMockAgent();
63
+ const caps: Array<[unknown, unknown]> = [];
64
+ const bridge = new AcpBridge({
65
+ repoRoot: process.cwd(),
66
+ onUpdate: () => {},
67
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
68
+ });
69
+ try {
70
+ await bridge.warmUp('c1');
71
+ const [modes, configOptions] = await until(() =>
72
+ caps.length ? caps[caps.length - 1] : undefined
73
+ );
74
+ expect((modes as { currentModeId: string }).currentModeId).toBe('default');
75
+ expect(
76
+ (modes as { availableModes: Array<{ id: string }> }).availableModes.map((m) => m.id)
77
+ ).toEqual(['default', 'plan']);
78
+ const ids = (configOptions as Array<{ id: string }>).map((o) => o.id);
79
+ expect(ids).toEqual(['model', 'effort', 'mode']);
80
+ expect(bridge.modes?.currentModeId).toBe('default');
81
+ expect(bridge.configOptions.length).toBe(3);
82
+ } finally {
83
+ await bridge.stop();
84
+ }
85
+ }, 15000);
86
+
87
+ test('onSessionInfo fires with the agent-generated title', async () => {
88
+ useMockAgent();
89
+ const infos: Array<{ title?: string | null }> = [];
90
+ const bridge = new AcpBridge({
91
+ repoRoot: process.cwd(),
92
+ onUpdate: () => {},
93
+ onSessionInfo: (info) => infos.push(info),
94
+ });
95
+ try {
96
+ await bridge.warmUp('c1');
97
+ const info = await until(() => (infos.length ? infos[infos.length - 1] : undefined));
98
+ expect(info.title).toBe('New chat');
99
+ } finally {
100
+ await bridge.stop();
101
+ }
102
+ }, 15000);
103
+
104
+ test('setMode live-changes the mode on an established session (no respawn) and lastModes cross-derives from config_option_update', async () => {
105
+ useMockAgent();
106
+ const caps: Array<[unknown, unknown]> = [];
107
+ const bridge = new AcpBridge({
108
+ repoRoot: process.cwd(),
109
+ onUpdate: () => {},
110
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
111
+ });
112
+ try {
113
+ await bridge.warmUp('c1');
114
+ await until(() => (caps.length ? true : undefined));
115
+ const procBefore = bridge.connected;
116
+
117
+ await bridge.setMode('c1', 'plan');
118
+ await until(() => {
119
+ const last = caps[caps.length - 1];
120
+ const modes = last?.[0] as { currentModeId: string } | null;
121
+ return modes?.currentModeId === 'plan' ? true : undefined;
122
+ });
123
+
124
+ expect(bridge.connected).toBe(procBefore); // never tore down / respawned
125
+ expect(bridge.modes?.currentModeId).toBe('plan');
126
+ const modeOpt = bridge.configOptions.find((o) => o.id === 'mode') as
127
+ | { currentValue?: unknown }
128
+ | undefined;
129
+ expect(modeOpt?.currentValue).toBe('plan');
130
+ } finally {
131
+ await bridge.stop();
132
+ }
133
+ }, 15000);
134
+
135
+ test('setConfigOption live-changes the model and the FULL refreshed option set echoes back through onCaps', async () => {
136
+ useMockAgent();
137
+ const caps: Array<[unknown, unknown]> = [];
138
+ const bridge = new AcpBridge({
139
+ repoRoot: process.cwd(),
140
+ onUpdate: () => {},
141
+ onCaps: (modes, configOptions) => caps.push([modes, configOptions]),
142
+ });
143
+ try {
144
+ await bridge.warmUp('c1');
145
+ await until(() => (caps.length ? true : undefined));
146
+ expect(bridge.configOptions.some((o) => o.id === 'fast')).toBe(false);
147
+
148
+ await bridge.setConfigOption('c1', 'model', 'opus');
149
+ await until(() => (bridge.configOptions.some((o) => o.id === 'fast') ? true : undefined));
150
+
151
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
152
+ | { currentValue?: unknown }
153
+ | undefined;
154
+ expect(modelOpt?.currentValue).toBe('opus');
155
+ // Switching to opus offers a NEW "fast" option in this fixture — proves
156
+ // the bridge feeds back the full refreshed set, not just the changed field.
157
+ expect(bridge.configOptions.some((o) => o.id === 'fast')).toBe(true);
158
+ } finally {
159
+ await bridge.stop();
160
+ }
161
+ }, 15000);
162
+
163
+ test('setConfig persists a pick applied ONCE on a fresh session — never forced onto an already-live one', async () => {
164
+ useMockAgent();
165
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
166
+ try {
167
+ bridge.setConfig('opus', 'high', 'plan');
168
+ await bridge.warmUp('c1');
169
+ await until(() => (bridge.configOptions.length ? true : undefined));
170
+
171
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
172
+ | { currentValue?: unknown }
173
+ | undefined;
174
+ const effortOpt = bridge.configOptions.find((o) => o.id === 'effort') as
175
+ | { currentValue?: unknown }
176
+ | undefined;
177
+ expect(modelOpt?.currentValue).toBe('opus');
178
+ expect(effortOpt?.currentValue).toBe('high');
179
+ expect(bridge.modes?.currentModeId).toBe('plan');
180
+
181
+ // A DIFFERENT persisted pick set later must NOT retroactively change this
182
+ // already-established chat — it only affects the NEXT fresh session.
183
+ bridge.setConfig('sonnet', 'default', 'default');
184
+ await bridge.prompt('hi', 'c1');
185
+ const modelAfter = bridge.configOptions.find((o) => o.id === 'model') as
186
+ | { currentValue?: unknown }
187
+ | undefined;
188
+ expect(modelAfter?.currentValue).toBe('opus'); // unchanged — no forced respawn
189
+ } finally {
190
+ await bridge.stop();
191
+ }
192
+ }, 15000);
193
+
194
+ test('an unsupported persisted pick is skipped, not thrown — the session keeps its own default', async () => {
195
+ useMockAgent();
196
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
197
+ try {
198
+ bridge.setConfig('does-not-exist-model', null, 'does-not-exist-mode');
199
+ await bridge.warmUp('c1'); // must not throw
200
+ await until(() => (bridge.configOptions.length ? true : undefined));
201
+ const modelOpt = bridge.configOptions.find((o) => o.id === 'model') as
202
+ | { currentValue?: unknown }
203
+ | undefined;
204
+ expect(modelOpt?.currentValue).toBe('sonnet'); // session's own default, untouched
205
+ expect(bridge.modes?.currentModeId).toBe('default');
206
+ } finally {
207
+ await bridge.stop();
208
+ }
209
+ }, 15000);
210
+ });
211
+
212
+ describe('Acp manager — set-mode/set-config frames validate against last-advertised caps (DDR-125 F1)', () => {
213
+ test('a caps frame is broadcast on establish; set-mode/set-config apply live; unadvertised values are rejected silently', async () => {
214
+ useMockAgent();
215
+ const designRoot = await mkdtemp(join(tmpdir(), 'acp-caps-test-'));
216
+ const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
217
+ const acp = createAcp(ctx);
218
+
219
+ const a = fakeWs('caps-ws-a');
220
+ try {
221
+ acp.onOpen(a.ws);
222
+ acp.onMessage(a.ws, JSON.stringify({ t: 'warm', chat: 'c1' }));
223
+ const capsFrame = await until(() => a.frames.find((f) => f.t === 'caps'));
224
+ expect((capsFrame.modes as { currentModeId: string }).currentModeId).toBe('default');
225
+
226
+ // Valid live mode change — advertised by the caps frame above.
227
+ acp.onMessage(a.ws, JSON.stringify({ t: 'set-mode', chat: 'c1', modeId: 'plan' }));
228
+ await until(() =>
229
+ a.frames.find(
230
+ (f) =>
231
+ f.t === 'caps' &&
232
+ (f.modes as { currentModeId?: string } | null)?.currentModeId === 'plan'
233
+ )
234
+ );
235
+
236
+ // An unadvertised modeId must be rejected BEFORE ever reaching the bridge
237
+ // — no new caps frame, no error frame, no crash.
238
+ const framesBefore = a.frames.length;
239
+ acp.onMessage(
240
+ a.ws,
241
+ JSON.stringify({ t: 'set-mode', chat: 'c1', modeId: 'bypassPermissions' })
242
+ );
243
+ await new Promise((r) => setTimeout(r, 150));
244
+ expect(a.frames.length).toBe(framesBefore); // nothing new sent
245
+
246
+ // An unadvertised config value is likewise rejected pre-call.
247
+ acp.onMessage(
248
+ a.ws,
249
+ JSON.stringify({ t: 'set-config', chat: 'c1', configId: 'model', value: 'gpt-5' })
250
+ );
251
+ await new Promise((r) => setTimeout(r, 150));
252
+ expect(a.frames.length).toBe(framesBefore);
253
+
254
+ // A valid config change still works after the rejected attempts.
255
+ acp.onMessage(
256
+ a.ws,
257
+ JSON.stringify({ t: 'set-config', chat: 'c1', configId: 'model', value: 'opus' })
258
+ );
259
+ await until(() =>
260
+ a.frames.find(
261
+ (f) =>
262
+ f.t === 'caps' &&
263
+ (f.configOptions as Array<{ id: string; currentValue?: unknown }>).find(
264
+ (o) => o.id === 'model'
265
+ )?.currentValue === 'opus'
266
+ )
267
+ );
268
+ } finally {
269
+ acp.onClose(a.ws);
270
+ await new Promise((r) => setTimeout(r, 50));
271
+ await rm(designRoot, { recursive: true, force: true });
272
+ }
273
+ }, 20000);
274
+ });