@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.
- package/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- 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(
|
|
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`
|
|
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
|
|
195
|
-
// (ethical-hacker Finding 2): an open-redirect/on-path
|
|
196
|
-
// on an arbitrary host. A wall-clock timeout is
|
|
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:' ||
|
|
201
|
-
throw new Error(`model download redirected off
|
|
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');
|
package/apps/studio/http.ts
CHANGED
|
@@ -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('
|
|
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
|
|
67
|
-
expect(first).toContain('thinking
|
|
69
|
+
expect(first).toContain('model=<unset>');
|
|
70
|
+
expect(first).toContain('thinking=<unset>');
|
|
68
71
|
|
|
69
|
-
// Changing the config
|
|
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>');
|
|
75
|
-
expect(second).toContain('thinking
|
|
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
|
+
});
|