@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/transcript-view.js (Task C4).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TRANSCRIPT_VIEW,
|
|
7
|
+
filterTranscriptParts,
|
|
8
|
+
TRANSCRIPT_VIEWS,
|
|
9
|
+
transcriptForcesExpand,
|
|
10
|
+
} from '../client/panels/transcript-view.js';
|
|
11
|
+
|
|
12
|
+
const PARTS = [
|
|
13
|
+
{ type: 'reasoning', text: 'let me think' },
|
|
14
|
+
{ type: 'tool-call', toolCallId: '1', toolName: 'Read file' },
|
|
15
|
+
{ type: 'text', text: 'first answer' },
|
|
16
|
+
{ type: 'reasoning', text: 'more thinking' },
|
|
17
|
+
{ type: 'text', text: 'final answer' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('filterTranscriptParts', () => {
|
|
21
|
+
test('normal hides reasoning, keeps text + tool-calls in order', () => {
|
|
22
|
+
const result = filterTranscriptParts(PARTS, 'normal');
|
|
23
|
+
expect(result.map((p) => p.type)).toEqual(['tool-call', 'text', 'text']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('thinking keeps everything, including reasoning', () => {
|
|
27
|
+
expect(filterTranscriptParts(PARTS, 'thinking')).toEqual(PARTS);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('verbose also keeps everything (the extra verbosity is a render-detail toggle)', () => {
|
|
31
|
+
expect(filterTranscriptParts(PARTS, 'verbose')).toEqual(PARTS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('summary keeps ONLY the last text part', () => {
|
|
35
|
+
const result = filterTranscriptParts(PARTS, 'summary');
|
|
36
|
+
expect(result).toEqual([{ type: 'text', text: 'final answer' }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('summary with no text parts at all returns empty, not a crash', () => {
|
|
40
|
+
expect(filterTranscriptParts([{ type: 'tool-call', toolCallId: '1' }], 'summary')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an unrecognized mode fails toward normal (less noise), not everything', () => {
|
|
44
|
+
expect(filterTranscriptParts(PARTS, 'nonsense')).toEqual(
|
|
45
|
+
filterTranscriptParts(PARTS, 'normal')
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('tolerates missing/empty input', () => {
|
|
50
|
+
expect(filterTranscriptParts(undefined, 'normal')).toEqual([]);
|
|
51
|
+
expect(filterTranscriptParts(null, 'thinking')).toEqual([]);
|
|
52
|
+
expect(filterTranscriptParts([], 'summary')).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('transcriptForcesExpand', () => {
|
|
57
|
+
test('only verbose forces expansion', () => {
|
|
58
|
+
expect(transcriptForcesExpand('verbose')).toBe(true);
|
|
59
|
+
expect(transcriptForcesExpand('normal')).toBe(false);
|
|
60
|
+
expect(transcriptForcesExpand('thinking')).toBe(false);
|
|
61
|
+
expect(transcriptForcesExpand('summary')).toBe(false);
|
|
62
|
+
expect(transcriptForcesExpand(undefined)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('constants', () => {
|
|
67
|
+
test('the view list + default are stable and consistent', () => {
|
|
68
|
+
expect(TRANSCRIPT_VIEWS).toEqual(['normal', 'thinking', 'verbose', 'summary']);
|
|
69
|
+
expect(TRANSCRIPT_VIEWS).toContain(DEFAULT_TRANSCRIPT_VIEW);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// acp/transcript — repo-level chat list + raw-transcript → clean-messages.
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
4
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
deleteChat,
|
|
10
|
+
listChats,
|
|
11
|
+
readChatMessages,
|
|
12
|
+
readChatMeta,
|
|
13
|
+
writeChatMeta,
|
|
14
|
+
} from '../acp/transcript.ts';
|
|
9
15
|
|
|
10
16
|
let root: string;
|
|
11
17
|
afterEach(() => {
|
|
@@ -109,6 +115,73 @@ describe('deleteChat', () => {
|
|
|
109
115
|
expect(listChats(designRoot).length).toBe(0);
|
|
110
116
|
expect(deleteChat(designRoot, 'gone')).toBe(false);
|
|
111
117
|
});
|
|
118
|
+
|
|
119
|
+
test('also removes the .meta.json and .session.json sidecars (Task C5)', () => {
|
|
120
|
+
const designRoot = seed('withmeta', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
121
|
+
writeChatMeta(designRoot, 'withmeta', { title: 'Renamed' });
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(designRoot, '_chat', 'withmeta.session.json'),
|
|
124
|
+
JSON.stringify({ sessionId: 'x' })
|
|
125
|
+
);
|
|
126
|
+
expect(deleteChat(designRoot, 'withmeta')).toBe(true);
|
|
127
|
+
expect(readChatMeta(designRoot, 'withmeta')).toEqual({});
|
|
128
|
+
expect(existsSync(join(designRoot, '_chat', 'withmeta.session.json'))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('readChatMeta / writeChatMeta (Task C5 — rename + archive)', () => {
|
|
133
|
+
test('missing sidecar → {} (no override), never throws', () => {
|
|
134
|
+
const designRoot = seed('nometa', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
135
|
+
expect(readChatMeta(designRoot, 'nometa')).toEqual({});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a corrupt sidecar degrades to {} rather than throwing', () => {
|
|
139
|
+
const designRoot = seed('badmeta', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
140
|
+
writeFileSync(join(designRoot, '_chat', 'badmeta.meta.json'), '{not valid json');
|
|
141
|
+
expect(readChatMeta(designRoot, 'badmeta')).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('writeChatMeta sets a title and it wins over the auto-derived one in listChats', () => {
|
|
145
|
+
const designRoot = seed('rn1', [
|
|
146
|
+
{ ts: 1, role: 'user', text: 'the auto-derived first-line title' },
|
|
147
|
+
]);
|
|
148
|
+
expect(listChats(designRoot)[0]?.title).toBe('the auto-derived first-line title');
|
|
149
|
+
expect(listChats(designRoot)[0]?.renamed).toBeFalsy();
|
|
150
|
+
writeChatMeta(designRoot, 'rn1', { title: 'My Renamed Chat' });
|
|
151
|
+
expect(listChats(designRoot)[0]?.title).toBe('My Renamed Chat');
|
|
152
|
+
expect(listChats(designRoot)[0]?.renamed).toBe(true);
|
|
153
|
+
expect(readChatMeta(designRoot, 'rn1')).toEqual({ title: 'My Renamed Chat' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('writeChatMeta merges — setting archived does not clobber an existing title', () => {
|
|
157
|
+
const designRoot = seed('rn2', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
158
|
+
writeChatMeta(designRoot, 'rn2', { title: 'Kept Title' });
|
|
159
|
+
writeChatMeta(designRoot, 'rn2', { archived: true });
|
|
160
|
+
expect(readChatMeta(designRoot, 'rn2')).toEqual({ title: 'Kept Title', archived: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('archived chats are excluded from listChats but the transcript survives', () => {
|
|
164
|
+
const designRoot = seed('arch1', [{ ts: 1, role: 'user', text: 'archive me' }]);
|
|
165
|
+
writeChatMeta(designRoot, 'arch1', { archived: true });
|
|
166
|
+
expect(listChats(designRoot)).toEqual([]);
|
|
167
|
+
expect(readChatMessages(designRoot, 'arch1').length).toBe(1); // still readable directly
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('unarchiving (archived: false) brings the chat back into listChats', () => {
|
|
171
|
+
const designRoot = seed('arch2', [{ ts: 1, role: 'user', text: 'x' }]);
|
|
172
|
+
writeChatMeta(designRoot, 'arch2', { archived: true });
|
|
173
|
+
expect(listChats(designRoot)).toEqual([]);
|
|
174
|
+
writeChatMeta(designRoot, 'arch2', { archived: false });
|
|
175
|
+
expect(listChats(designRoot).length).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('clearing a title (title: null) falls back to the auto-derived one again', () => {
|
|
179
|
+
const designRoot = seed('rn3', [{ ts: 1, role: 'user', text: 'original first line' }]);
|
|
180
|
+
writeChatMeta(designRoot, 'rn3', { title: 'Overridden' });
|
|
181
|
+
expect(listChats(designRoot)[0]?.title).toBe('Overridden');
|
|
182
|
+
writeChatMeta(designRoot, 'rn3', { title: null });
|
|
183
|
+
expect(listChats(designRoot)[0]?.title).toBe('original first line');
|
|
184
|
+
});
|
|
112
185
|
});
|
|
113
186
|
|
|
114
187
|
describe('context-hardening projection (feature-acp-context-hardening)', () => {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Proves the usage-channel harvest (Milestone D, Task D1) end-to-end against
|
|
2
|
+
// a mock ACP agent (fixtures/mock-acp-agent-usage.mjs) — no real `claude`
|
|
3
|
+
// needed. Covers: onUsage fires with {used,size,cost}, the rate-limit _meta
|
|
4
|
+
// payload is carried through opaque, usage_update never leaks into onUpdate
|
|
5
|
+
// (chrome, not turn content), and the Acp manager caches + replays it to a
|
|
6
|
+
// freshly-opened socket.
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import type { ServerWebSocket } from 'bun';
|
|
12
|
+
|
|
13
|
+
import { AcpBridge, type BridgeUsage } from '../acp/bridge.ts';
|
|
14
|
+
import { createAcp } from '../acp/index.ts';
|
|
15
|
+
import type { Context } from '../context.ts';
|
|
16
|
+
import type { WsData } from '../ws.ts';
|
|
17
|
+
|
|
18
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-usage.mjs');
|
|
19
|
+
const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
|
|
20
|
+
|
|
21
|
+
function useMockAgent() {
|
|
22
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
23
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
24
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const key of TEST_ENV_KEYS) delete process.env[key];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
|
|
32
|
+
const start = performance.now();
|
|
33
|
+
for (;;) {
|
|
34
|
+
const v = fn();
|
|
35
|
+
if (v !== undefined) return v;
|
|
36
|
+
if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
|
|
37
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fakeWs(id: string) {
|
|
42
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
43
|
+
const ws = {
|
|
44
|
+
data: { id } as WsData,
|
|
45
|
+
send: (raw: string) => {
|
|
46
|
+
frames.push(JSON.parse(raw));
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ServerWebSocket<WsData>;
|
|
49
|
+
return { ws, frames };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('AcpBridge — usage harvest (Task D1)', () => {
|
|
53
|
+
test('onUsage fires with used/size/cost; usage_update never reaches onUpdate', async () => {
|
|
54
|
+
useMockAgent();
|
|
55
|
+
const usages: BridgeUsage[] = [];
|
|
56
|
+
const updates: unknown[] = [];
|
|
57
|
+
const bridge = new AcpBridge({
|
|
58
|
+
repoRoot: process.cwd(),
|
|
59
|
+
onUpdate: (u) => updates.push(u),
|
|
60
|
+
onUsage: (u) => usages.push(u),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await bridge.prompt('hi', 'c1');
|
|
64
|
+
const usage = await until(() => (usages.length ? usages[usages.length - 1] : undefined));
|
|
65
|
+
expect(usage.used).toBe(4200);
|
|
66
|
+
expect(usage.size).toBe(200000);
|
|
67
|
+
expect(usage.cost).toEqual({ amount: 0.0123, currency: 'USD' });
|
|
68
|
+
expect(bridge.usage).toEqual(usage);
|
|
69
|
+
// chrome, not turn content — no usage_update leaked into onUpdate
|
|
70
|
+
expect(
|
|
71
|
+
updates.some((u) => (u as { sessionUpdate?: string }).sessionUpdate === 'usage_update')
|
|
72
|
+
).toBe(false);
|
|
73
|
+
} finally {
|
|
74
|
+
await bridge.stop();
|
|
75
|
+
}
|
|
76
|
+
}, 15000);
|
|
77
|
+
|
|
78
|
+
test('the rate-limit _meta payload is carried through opaque', async () => {
|
|
79
|
+
useMockAgent();
|
|
80
|
+
const usages: BridgeUsage[] = [];
|
|
81
|
+
const bridge = new AcpBridge({
|
|
82
|
+
repoRoot: process.cwd(),
|
|
83
|
+
onUpdate: () => {},
|
|
84
|
+
onUsage: (u) => usages.push(u),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await bridge.prompt('trigger-rate-limit', 'c1');
|
|
88
|
+
const withRateLimit = await until(() => usages.find((u) => u.rateLimit != null));
|
|
89
|
+
expect(withRateLimit.rateLimit).toMatchObject({
|
|
90
|
+
status: 'allowed_warning',
|
|
91
|
+
rateLimitType: 'five_hour',
|
|
92
|
+
utilization: 82,
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
await bridge.stop();
|
|
96
|
+
}
|
|
97
|
+
}, 15000);
|
|
98
|
+
|
|
99
|
+
test('no usage yet → bridge.usage is null', async () => {
|
|
100
|
+
useMockAgent();
|
|
101
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
102
|
+
try {
|
|
103
|
+
expect(bridge.usage).toBeNull();
|
|
104
|
+
} finally {
|
|
105
|
+
await bridge.stop();
|
|
106
|
+
}
|
|
107
|
+
}, 15000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('Acp manager — usage frame is cached + replayed on a fresh socket (Task D1)', () => {
|
|
111
|
+
test('a usage frame is broadcast during a turn, then replayed to a newly-opened socket', async () => {
|
|
112
|
+
useMockAgent();
|
|
113
|
+
const ctx = {
|
|
114
|
+
paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-usage' },
|
|
115
|
+
} as unknown as Context;
|
|
116
|
+
const acp = createAcp(ctx);
|
|
117
|
+
|
|
118
|
+
const a = fakeWs('usage-ws-a');
|
|
119
|
+
try {
|
|
120
|
+
acp.onOpen(a.ws);
|
|
121
|
+
acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'c1' }));
|
|
122
|
+
const usageFrame = await until(() => a.frames.find((f) => f.t === 'usage'));
|
|
123
|
+
expect((usageFrame.usage as BridgeUsage).used).toBe(4200);
|
|
124
|
+
|
|
125
|
+
const b = fakeWs('usage-ws-b');
|
|
126
|
+
acp.onOpen(b.ws);
|
|
127
|
+
const replay = b.frames.find((f) => f.t === 'usage');
|
|
128
|
+
if (!replay) throw new Error('expected a replayed usage frame');
|
|
129
|
+
expect((replay.usage as BridgeUsage).used).toBe(4200);
|
|
130
|
+
acp.onClose(b.ws);
|
|
131
|
+
} finally {
|
|
132
|
+
acp.onClose(a.ws);
|
|
133
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
|
+
}
|
|
135
|
+
}, 20000);
|
|
136
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Pure unit tests for client/panels/acp-usage.js — parseUsage (Tasks D2/D4).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { parseUsage } from '../client/panels/acp-usage.js';
|
|
6
|
+
|
|
7
|
+
const NOW = 1_700_000_000_000;
|
|
8
|
+
|
|
9
|
+
describe('parseUsage — context + cost', () => {
|
|
10
|
+
test('computes a rounded percentage from used/size', () => {
|
|
11
|
+
const result = parseUsage({ t: 'usage', usage: { used: 4200, size: 200000 } }, NOW);
|
|
12
|
+
expect(result.context).toEqual({ used: 4200, size: 200000, pct: 2 });
|
|
13
|
+
expect(result.asOf).toBe(NOW);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('clamps the percentage to [0,100] against a bogus used > size', () => {
|
|
17
|
+
const result = parseUsage({ usage: { used: 999999, size: 1000 } }, NOW);
|
|
18
|
+
expect(result.context?.pct).toBe(100);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('size:0 (or missing) yields no context gauge rather than dividing by zero', () => {
|
|
22
|
+
expect(parseUsage({ usage: { used: 10, size: 0 } }, NOW).context).toBeNull();
|
|
23
|
+
expect(parseUsage({ usage: { used: 10 } }, NOW).context).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('carries cost through, defaulting currency to USD when absent', () => {
|
|
27
|
+
const result = parseUsage(
|
|
28
|
+
{ usage: { used: 1, size: 2, cost: { amount: 0.5, currency: 'EUR' } } },
|
|
29
|
+
NOW
|
|
30
|
+
);
|
|
31
|
+
expect(result.cost).toEqual({ amount: 0.5, currency: 'EUR' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('no cost on the frame → null, not undefined/0', () => {
|
|
35
|
+
expect(parseUsage({ usage: { used: 1, size: 2 } }, NOW).cost).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('parseUsage — rate limit (SDKRateLimitInfo fixtures)', () => {
|
|
40
|
+
const TYPES = [
|
|
41
|
+
['five_hour', '5-hour limit'],
|
|
42
|
+
['seven_day', 'Weekly limit'],
|
|
43
|
+
['seven_day_opus', 'Weekly · Opus'],
|
|
44
|
+
['seven_day_sonnet', 'Weekly · Sonnet'],
|
|
45
|
+
['seven_day_overage_included', 'Weekly (overage included)'],
|
|
46
|
+
['overage', 'Overage'],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const [type, label] of TYPES) {
|
|
50
|
+
test(`maps rateLimitType "${type}" → "${label}"`, () => {
|
|
51
|
+
const result = parseUsage(
|
|
52
|
+
{
|
|
53
|
+
usage: {
|
|
54
|
+
used: 1,
|
|
55
|
+
size: 2,
|
|
56
|
+
rateLimit: {
|
|
57
|
+
status: 'allowed_warning',
|
|
58
|
+
rateLimitType: type,
|
|
59
|
+
utilization: 61,
|
|
60
|
+
resetsAt: 123,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
NOW
|
|
65
|
+
);
|
|
66
|
+
expect(result.rateLimit).toEqual({
|
|
67
|
+
type,
|
|
68
|
+
label,
|
|
69
|
+
pct: 61,
|
|
70
|
+
resetsAt: 123,
|
|
71
|
+
status: 'allowed_warning',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test('an unrecognized rateLimitType falls back to a generic label, not undefined', () => {
|
|
77
|
+
const result = parseUsage(
|
|
78
|
+
{
|
|
79
|
+
usage: {
|
|
80
|
+
used: 1,
|
|
81
|
+
size: 2,
|
|
82
|
+
rateLimit: { status: 'rejected', rateLimitType: 'something_new' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
NOW
|
|
86
|
+
);
|
|
87
|
+
expect(result.rateLimit?.label).toBe('Usage limit');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('null/missing rate-limit on the frame → gauge-only (rateLimit: null), no crash', () => {
|
|
91
|
+
expect(parseUsage({ usage: { used: 1, size: 2, rateLimit: null } }, NOW).rateLimit).toBeNull();
|
|
92
|
+
expect(parseUsage({ usage: { used: 1, size: 2 } }, NOW).rateLimit).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('utilization/resetsAt clamp + tolerate missing fields', () => {
|
|
96
|
+
const result = parseUsage(
|
|
97
|
+
{ usage: { used: 1, size: 2, rateLimit: { status: 'allowed', utilization: 250 } } },
|
|
98
|
+
NOW
|
|
99
|
+
);
|
|
100
|
+
expect(result.rateLimit).toEqual({
|
|
101
|
+
type: null,
|
|
102
|
+
label: 'Usage limit',
|
|
103
|
+
pct: 100,
|
|
104
|
+
resetsAt: null,
|
|
105
|
+
status: 'allowed',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('parseUsage — malformed input tolerance', () => {
|
|
111
|
+
test('missing usage entirely → all-null shape, never throws', () => {
|
|
112
|
+
expect(parseUsage({}, NOW)).toEqual({ context: null, cost: null, rateLimit: null, asOf: NOW });
|
|
113
|
+
expect(parseUsage(undefined, NOW)).toEqual({
|
|
114
|
+
context: null,
|
|
115
|
+
cost: null,
|
|
116
|
+
rateLimit: null,
|
|
117
|
+
asOf: NOW,
|
|
118
|
+
});
|
|
119
|
+
expect(parseUsage(null, NOW)).toEqual({
|
|
120
|
+
context: null,
|
|
121
|
+
cost: null,
|
|
122
|
+
rateLimit: null,
|
|
123
|
+
asOf: NOW,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a malformed _meta-derived rateLimit (not an object) is tolerated', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
parseUsage({ usage: { used: 1, size: 2, rateLimit: 'not-an-object' } }, NOW)
|
|
130
|
+
).not.toThrow();
|
|
131
|
+
expect(
|
|
132
|
+
parseUsage({ usage: { used: 1, size: 2, rateLimit: 'not-an-object' } }, NOW).rateLimit
|
|
133
|
+
).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('defaults `now` to Date.now() when not injected', () => {
|
|
137
|
+
const before = Date.now();
|
|
138
|
+
const result = parseUsage({ usage: { used: 1, size: 2 } });
|
|
139
|
+
const after = Date.now();
|
|
140
|
+
expect(result.asOf).toBeGreaterThanOrEqual(before);
|
|
141
|
+
expect(result.asOf).toBeLessThanOrEqual(after);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent exercising the session-capabilities channel (feature-acp-
|
|
3
|
+
// panel-dynamic-claude-code-capabilities) — modes + configOptions returned
|
|
4
|
+
// from session/new, live session/set_mode + session/set_config_option, and
|
|
5
|
+
// the config_option_update / session_info_update notifications a real
|
|
6
|
+
// claude-agent-acp session emits. No real `claude` needed.
|
|
7
|
+
//
|
|
8
|
+
// Mirrors the REAL adapter's observed behavior (read from the installed
|
|
9
|
+
// @agentclientprotocol/claude-agent-acp source, not guessed):
|
|
10
|
+
// - session/set_mode updates the mode AND mirrors it into configOptions'
|
|
11
|
+
// "mode" entry, emitting ONLY a config_option_update notification (no
|
|
12
|
+
// current_mode_update) — bridge.ts cross-derives lastModes from that.
|
|
13
|
+
// - session/set_config_option('model', …) can change which OTHER options
|
|
14
|
+
// are offered (here: picking "opus" adds a "fast" option) and returns
|
|
15
|
+
// the FULL refreshed configOptions in its RPC response (no notification).
|
|
16
|
+
|
|
17
|
+
import { Readable, Writable } from 'node:stream';
|
|
18
|
+
|
|
19
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
20
|
+
|
|
21
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
22
|
+
|
|
23
|
+
const MODEL_OPTIONS = [
|
|
24
|
+
{ value: 'sonnet', name: 'Sonnet' },
|
|
25
|
+
{ value: 'opus', name: 'Opus' },
|
|
26
|
+
];
|
|
27
|
+
const EFFORT_OPTIONS = [
|
|
28
|
+
{ value: 'default', name: 'Default' },
|
|
29
|
+
{ value: 'high', name: 'High' },
|
|
30
|
+
];
|
|
31
|
+
const MODE_OPTIONS = [
|
|
32
|
+
{ value: 'default', name: 'Manual' },
|
|
33
|
+
{ value: 'plan', name: 'Plan Mode' },
|
|
34
|
+
];
|
|
35
|
+
const AVAILABLE_MODES = [
|
|
36
|
+
{
|
|
37
|
+
id: 'default',
|
|
38
|
+
name: 'Manual',
|
|
39
|
+
description: 'Standard behavior, prompts for dangerous operations',
|
|
40
|
+
},
|
|
41
|
+
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// `session` carries every tracked field (model/effort/mode/fast) so a call
|
|
45
|
+
// that rebuilds the FULL option set (e.g. session/set_mode, which is scoped
|
|
46
|
+
// to just the mode) never regresses a field a PRIOR call already changed —
|
|
47
|
+
// mirrors the real adapter's `session.configOptions` being one persistent
|
|
48
|
+
// object, not independently recomputed per field.
|
|
49
|
+
function baseConfigOptions(session) {
|
|
50
|
+
const opts = [
|
|
51
|
+
{
|
|
52
|
+
id: 'model',
|
|
53
|
+
name: 'Model',
|
|
54
|
+
category: 'model',
|
|
55
|
+
type: 'select',
|
|
56
|
+
currentValue: session.model,
|
|
57
|
+
options: MODEL_OPTIONS,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'effort',
|
|
61
|
+
name: 'Effort',
|
|
62
|
+
category: 'thought_level',
|
|
63
|
+
type: 'select',
|
|
64
|
+
currentValue: session.effort,
|
|
65
|
+
options: EFFORT_OPTIONS,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'mode',
|
|
69
|
+
name: 'Mode',
|
|
70
|
+
category: 'mode',
|
|
71
|
+
type: 'select',
|
|
72
|
+
currentValue: session.mode,
|
|
73
|
+
options: MODE_OPTIONS,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
if (session.model === 'opus') {
|
|
77
|
+
opts.push({
|
|
78
|
+
id: 'fast',
|
|
79
|
+
name: 'Fast mode',
|
|
80
|
+
category: 'model_config',
|
|
81
|
+
type: 'select',
|
|
82
|
+
currentValue: session.fast,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 'on', name: 'On' },
|
|
85
|
+
{ value: 'off', name: 'Off' },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let n = 0;
|
|
93
|
+
const sessions = new Map(); // sessionId -> { model, effort, mode, fast }
|
|
94
|
+
|
|
95
|
+
acp
|
|
96
|
+
.agent({ name: 'mock-acp-agent-caps' })
|
|
97
|
+
.onRequest('initialize', () => ({
|
|
98
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
99
|
+
agentCapabilities: { loadSession: false },
|
|
100
|
+
}))
|
|
101
|
+
.onRequest('session/new', async (ctx) => {
|
|
102
|
+
const sessionId = `mock-caps-session-${++n}`;
|
|
103
|
+
const session = { model: 'sonnet', effort: 'default', mode: 'default', fast: 'off' };
|
|
104
|
+
sessions.set(sessionId, session);
|
|
105
|
+
await ctx.client.notify('session/update', {
|
|
106
|
+
sessionId,
|
|
107
|
+
update: { sessionUpdate: 'session_info_update', title: 'New chat' },
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
sessionId,
|
|
111
|
+
modes: { currentModeId: 'default', availableModes: AVAILABLE_MODES },
|
|
112
|
+
configOptions: baseConfigOptions(session),
|
|
113
|
+
};
|
|
114
|
+
})
|
|
115
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
116
|
+
await ctx.client.notify('session/update', {
|
|
117
|
+
sessionId: ctx.params.sessionId,
|
|
118
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
|
|
119
|
+
});
|
|
120
|
+
return { stopReason: 'end_turn' };
|
|
121
|
+
})
|
|
122
|
+
.onRequest('session/set_mode', async (ctx) => {
|
|
123
|
+
const session = sessions.get(ctx.params.sessionId);
|
|
124
|
+
if (!session) throw new Error('session not found');
|
|
125
|
+
if (!AVAILABLE_MODES.some((m) => m.id === ctx.params.modeId)) {
|
|
126
|
+
throw new Error(`unknown mode: ${ctx.params.modeId}`);
|
|
127
|
+
}
|
|
128
|
+
session.mode = ctx.params.modeId;
|
|
129
|
+
// Real adapter: setSessionMode emits ONLY config_option_update, never
|
|
130
|
+
// current_mode_update — bridge.ts must cross-derive lastModes from it.
|
|
131
|
+
await ctx.client.notify('session/update', {
|
|
132
|
+
sessionId: ctx.params.sessionId,
|
|
133
|
+
update: { sessionUpdate: 'config_option_update', configOptions: baseConfigOptions(session) },
|
|
134
|
+
});
|
|
135
|
+
return {};
|
|
136
|
+
})
|
|
137
|
+
.onRequest('session/set_config_option', async (ctx) => {
|
|
138
|
+
const session = sessions.get(ctx.params.sessionId);
|
|
139
|
+
if (!session) throw new Error('session not found');
|
|
140
|
+
const { configId, value } = ctx.params;
|
|
141
|
+
if (configId === 'model') {
|
|
142
|
+
if (!MODEL_OPTIONS.some((o) => o.value === value)) throw new Error(`unknown model: ${value}`);
|
|
143
|
+
session.model = value;
|
|
144
|
+
} else if (configId === 'effort') {
|
|
145
|
+
if (!EFFORT_OPTIONS.some((o) => o.value === value))
|
|
146
|
+
throw new Error(`unknown effort: ${value}`);
|
|
147
|
+
session.effort = value;
|
|
148
|
+
} else if (configId === 'mode') {
|
|
149
|
+
if (!AVAILABLE_MODES.some((m) => m.id === value)) throw new Error(`unknown mode: ${value}`);
|
|
150
|
+
session.mode = value;
|
|
151
|
+
} else if (configId === 'fast') {
|
|
152
|
+
session.fast = value;
|
|
153
|
+
} else {
|
|
154
|
+
throw new Error(`unknown config option: ${configId}`);
|
|
155
|
+
}
|
|
156
|
+
return { configOptions: baseConfigOptions(session) };
|
|
157
|
+
})
|
|
158
|
+
.connect(stream);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent proving the MAX_PENDING_ELICITATIONS cap (feature-acp-ask-
|
|
3
|
+
// user-question, SECURITY / ethical-hacker finding — an unbounded queue lets
|
|
4
|
+
// any connected MCP server flood the client with elicitation requests). Fires
|
|
5
|
+
// MORE than the cap concurrently, without waiting for each one — mirroring a
|
|
6
|
+
// hostile/compromised MCP server that never waits for an answer before
|
|
7
|
+
// issuing the next request — and reports which ones actually round-tripped to
|
|
8
|
+
// the client vs. were declined immediately by the bridge's own cap.
|
|
9
|
+
|
|
10
|
+
import { Readable, Writable } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
13
|
+
|
|
14
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
15
|
+
|
|
16
|
+
const FLOOD_COUNT = 8; // > MAX_PENDING_ELICITATIONS (5) in acp/bridge.ts
|
|
17
|
+
|
|
18
|
+
let n = 0;
|
|
19
|
+
|
|
20
|
+
acp
|
|
21
|
+
.agent({ name: 'mock-acp-agent-elicit-flood' })
|
|
22
|
+
.onRequest('initialize', () => ({
|
|
23
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
24
|
+
agentCapabilities: { loadSession: false },
|
|
25
|
+
}))
|
|
26
|
+
.onRequest('session/new', () => ({ sessionId: `mock-elicit-flood-session-${++n}` }))
|
|
27
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
28
|
+
const requests = Array.from({ length: FLOOD_COUNT }, (_, i) =>
|
|
29
|
+
ctx.client.request(acp.methods.client.elicitation.create, {
|
|
30
|
+
mode: 'form',
|
|
31
|
+
sessionId: ctx.params.sessionId,
|
|
32
|
+
message: `Flood question ${i}`,
|
|
33
|
+
requestedSchema: { type: 'object', properties: { [`q${i}`]: { type: 'string' } } },
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
const responses = await Promise.all(requests);
|
|
37
|
+
await ctx.client.notify('session/update', {
|
|
38
|
+
sessionId: ctx.params.sessionId,
|
|
39
|
+
update: {
|
|
40
|
+
sessionUpdate: 'agent_message_chunk',
|
|
41
|
+
content: { type: 'text', text: `responses=${JSON.stringify(responses)}` },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return { stopReason: 'end_turn' };
|
|
45
|
+
})
|
|
46
|
+
.connect(stream);
|