@1agh/maude 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// End-to-end bridge test against a mock ACP agent (test/fixtures/mock-acp-agent.mjs)
|
|
2
|
+
// so no real `claude` install is needed. Proves: spawn + handshake + streamed
|
|
3
|
+
// update + turn completion, AND that ANTHROPIC_API_KEY never reaches the child
|
|
4
|
+
// (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { AcpBridge } from '../acp/bridge.ts';
|
|
10
|
+
import { probeAcpAvailability } from '../acp/probe.ts';
|
|
11
|
+
|
|
12
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
|
|
13
|
+
const TEST_ENV_KEYS = [
|
|
14
|
+
'ANTHROPIC_API_KEY',
|
|
15
|
+
'MAUDE_ACP_ADAPTER_ENTRY',
|
|
16
|
+
'MAUDE_ACP_RUNTIME',
|
|
17
|
+
'MAUDE_CLAUDE_BIN',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const key of TEST_ENV_KEYS) delete process.env[key];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('AcpBridge — round-trip + subscription guardrail', () => {
|
|
25
|
+
test('connects, streams an update, completes a turn, and scrubs ANTHROPIC_API_KEY', async () => {
|
|
26
|
+
process.env.ANTHROPIC_API_KEY = 'sk-must-be-scrubbed';
|
|
27
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
28
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath; // run the mock under this bun
|
|
29
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath; // satisfy the claude-present check
|
|
30
|
+
|
|
31
|
+
const updates: unknown[] = [];
|
|
32
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
33
|
+
try {
|
|
34
|
+
const result = await bridge.prompt('hello', 'c1');
|
|
35
|
+
expect(bridge.connected).toBe(true);
|
|
36
|
+
expect(bridge.sessionId).toBe('mock-session-1');
|
|
37
|
+
expect(result.stopReason).toBe('end_turn');
|
|
38
|
+
|
|
39
|
+
// The mock streamed its own ANTHROPIC_API_KEY — it must read `<unset>`,
|
|
40
|
+
// proving scrubAgentEnv stripped it from the child env before spawn.
|
|
41
|
+
const streamed = JSON.stringify(updates);
|
|
42
|
+
expect(streamed).toContain('apiKey=<unset>');
|
|
43
|
+
expect(streamed).not.toContain('sk-must-be-scrubbed');
|
|
44
|
+
} finally {
|
|
45
|
+
await bridge.stop();
|
|
46
|
+
}
|
|
47
|
+
}, 15000);
|
|
48
|
+
|
|
49
|
+
test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
|
|
50
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
51
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
52
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
53
|
+
|
|
54
|
+
const updates: unknown[] = [];
|
|
55
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
|
|
56
|
+
try {
|
|
57
|
+
bridge.setConfig('opus', 'thorough');
|
|
58
|
+
await bridge.prompt('hi', 'c1');
|
|
59
|
+
const first = JSON.stringify(updates);
|
|
60
|
+
expect(first).toContain('model=opus');
|
|
61
|
+
expect(first).toContain('thinking=31999');
|
|
62
|
+
|
|
63
|
+
// Changing the config re-spawns with the new env on the next prompt.
|
|
64
|
+
updates.length = 0;
|
|
65
|
+
bridge.setConfig(null, 'fast');
|
|
66
|
+
await bridge.prompt('again', 'c1');
|
|
67
|
+
const second = JSON.stringify(updates);
|
|
68
|
+
expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
|
|
69
|
+
expect(second).toContain('thinking=0'); // fast → thinking disabled
|
|
70
|
+
} finally {
|
|
71
|
+
await bridge.stop();
|
|
72
|
+
}
|
|
73
|
+
}, 15000);
|
|
74
|
+
|
|
75
|
+
test('a second prompt reuses the same live session', async () => {
|
|
76
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
77
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
78
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
79
|
+
|
|
80
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
81
|
+
try {
|
|
82
|
+
await bridge.prompt('first', 'c1');
|
|
83
|
+
const sid = bridge.sessionId;
|
|
84
|
+
await bridge.prompt('second', 'c1');
|
|
85
|
+
expect(bridge.sessionId).toBe(sid);
|
|
86
|
+
} finally {
|
|
87
|
+
await bridge.stop();
|
|
88
|
+
}
|
|
89
|
+
}, 15000);
|
|
90
|
+
|
|
91
|
+
test('different chat ids get separate claude sessions; same id reuses its own', async () => {
|
|
92
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
93
|
+
process.env.MAUDE_ACP_RUNTIME = process.execPath;
|
|
94
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
95
|
+
|
|
96
|
+
const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
|
|
97
|
+
try {
|
|
98
|
+
await bridge.prompt('a', 'chatA');
|
|
99
|
+
const sa = bridge.sessionId;
|
|
100
|
+
await bridge.prompt('b', 'chatB');
|
|
101
|
+
const sb = bridge.sessionId;
|
|
102
|
+
expect(sa).not.toBe(sb); // separate per-chat contexts
|
|
103
|
+
await bridge.prompt('a again', 'chatA');
|
|
104
|
+
expect(bridge.sessionId).toBe(sa); // chatA reuses its own session
|
|
105
|
+
} finally {
|
|
106
|
+
await bridge.stop();
|
|
107
|
+
}
|
|
108
|
+
}, 15000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('probeAcpAvailability — not-connected detection', () => {
|
|
112
|
+
test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
|
|
113
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
114
|
+
process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
|
|
115
|
+
const probe = probeAcpAvailability();
|
|
116
|
+
expect(probe.available).toBe(false);
|
|
117
|
+
expect(probe.reason ?? '').toMatch(/Claude Code/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('reports available when both the adapter and a claude binary resolve', () => {
|
|
121
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
122
|
+
process.env.MAUDE_CLAUDE_BIN = process.execPath;
|
|
123
|
+
const probe = probeAcpAvailability();
|
|
124
|
+
expect(probe.available).toBe(true);
|
|
125
|
+
expect(probe.adapterEntry).toBe(FIXTURE);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// DDR-123 guardrail #1 — the single load-bearing detail that keeps the chat
|
|
2
|
+
// panel on the user's Pro/Max subscription instead of metered API billing.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { SUBSCRIPTION_SCRUBBED_ENV_KEYS, scrubAgentEnv } from '../acp/env.ts';
|
|
7
|
+
|
|
8
|
+
describe('scrubAgentEnv — subscription guardrail', () => {
|
|
9
|
+
test('strips the billing-switching keys, keeps everything else', () => {
|
|
10
|
+
const out = scrubAgentEnv({
|
|
11
|
+
PATH: '/usr/bin',
|
|
12
|
+
HOME: '/Users/x',
|
|
13
|
+
ANTHROPIC_API_KEY: 'sk-live-xxx',
|
|
14
|
+
ANTHROPIC_AUTH_TOKEN: 'tok-xxx',
|
|
15
|
+
UNDEFINED_VAR: undefined,
|
|
16
|
+
});
|
|
17
|
+
expect(out.PATH).toBe('/usr/bin');
|
|
18
|
+
expect(out.HOME).toBe('/Users/x');
|
|
19
|
+
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
20
|
+
expect('ANTHROPIC_AUTH_TOKEN' in out).toBe(false);
|
|
21
|
+
// undefined values are dropped (Bun.spawn env wants Record<string,string>)
|
|
22
|
+
expect('UNDEFINED_VAR' in out).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('pins exactly the two precedence-relevant keys', () => {
|
|
26
|
+
expect([...SUBSCRIPTION_SCRUBBED_ENV_KEYS].sort()).toEqual([
|
|
27
|
+
'ANTHROPIC_API_KEY',
|
|
28
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('scrubs the whole provider/billing namespace, not just the two keys (F1)', () => {
|
|
33
|
+
const out = scrubAgentEnv({
|
|
34
|
+
PATH: '/usr/bin',
|
|
35
|
+
ANTHROPIC_BASE_URL: 'https://evil.example/v1',
|
|
36
|
+
ANTHROPIC_MODEL: 'attacker-pinned',
|
|
37
|
+
ANTHROPIC_BEDROCK_BASE_URL: 'https://evil',
|
|
38
|
+
CLAUDE_CODE_USE_BEDROCK: '1',
|
|
39
|
+
CLAUDE_CODE_USE_VERTEX: '1',
|
|
40
|
+
AWS_BEARER_TOKEN_BEDROCK: 'tok',
|
|
41
|
+
claude_session: 'keep-me', // not a provider redirect → kept
|
|
42
|
+
});
|
|
43
|
+
expect(out.PATH).toBe('/usr/bin');
|
|
44
|
+
expect('ANTHROPIC_BASE_URL' in out).toBe(false);
|
|
45
|
+
expect('ANTHROPIC_MODEL' in out).toBe(false); // re-added by the bridge from a validated value
|
|
46
|
+
expect('ANTHROPIC_BEDROCK_BASE_URL' in out).toBe(false);
|
|
47
|
+
expect('CLAUDE_CODE_USE_BEDROCK' in out).toBe(false);
|
|
48
|
+
expect('CLAUDE_CODE_USE_VERTEX' in out).toBe(false);
|
|
49
|
+
expect('AWS_BEARER_TOKEN_BEDROCK' in out).toBe(false);
|
|
50
|
+
expect(out.claude_session).toBe('keep-me');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('never mutates the source (process.env stays intact for the parent)', () => {
|
|
54
|
+
const src: Record<string, string | undefined> = { ANTHROPIC_API_KEY: 'sk', PATH: '/bin' };
|
|
55
|
+
const out = scrubAgentEnv(src);
|
|
56
|
+
expect(src.ANTHROPIC_API_KEY).toBe('sk');
|
|
57
|
+
expect(out).not.toBe(src);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('defaults to process.env when called with no argument', () => {
|
|
61
|
+
// Smoke: doesn't throw, returns a string map without the scrubbed keys.
|
|
62
|
+
const out = scrubAgentEnv();
|
|
63
|
+
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ACP bridge origin gate (DDR-123). The agent bridge spawns the user's `claude`
|
|
2
|
+
// and can drive file edits, so it lives ONLY on the main (privileged) origin and
|
|
3
|
+
// only over loopback. This proves:
|
|
4
|
+
// (1) GET /_api/acp/status serves on the main origin;
|
|
5
|
+
// (2) it is 403'd on the untrusted canvas origin (off CANVAS_SAFE_API +
|
|
6
|
+
// startCanvasServer routes — the dual-allowlist rule);
|
|
7
|
+
// (3) /_ws/acp is wired on the main origin (upgrade-without-headers → 400);
|
|
8
|
+
// (4) the loopback guard that fronts /_ws/acp rejects non-loopback hosts.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { isLoopbackHost } from '../ws.ts';
|
|
15
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
16
|
+
|
|
17
|
+
async function readCanvasOrigin(designRoot: string): Promise<string> {
|
|
18
|
+
for (let i = 0; i < 40; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
|
|
21
|
+
if (info.canvasOrigin) return info.canvasOrigin as string;
|
|
22
|
+
} catch {
|
|
23
|
+
/* not written yet */
|
|
24
|
+
}
|
|
25
|
+
await Bun.sleep(50);
|
|
26
|
+
}
|
|
27
|
+
throw new Error('canvasOrigin never appeared in _server.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('ACP bridge origin gate', () => {
|
|
31
|
+
test('status is main-origin only; /_ws/acp wired on main', async () => {
|
|
32
|
+
const { root, designRoot } = makeSandbox();
|
|
33
|
+
const port = nextPort();
|
|
34
|
+
const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
|
|
35
|
+
try {
|
|
36
|
+
const main = `http://localhost:${port}`;
|
|
37
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
38
|
+
const status = async (base: string, path: string) =>
|
|
39
|
+
(await fetch(base + path, { signal: AbortSignal.timeout(2000) })).status;
|
|
40
|
+
|
|
41
|
+
// (1) main origin exposes the readiness probe
|
|
42
|
+
const probe = await fetch(`${main}/_api/acp/status`, { signal: AbortSignal.timeout(2000) });
|
|
43
|
+
expect(probe.status).toBe(200);
|
|
44
|
+
expect(typeof (await probe.json()).available).toBe('boolean');
|
|
45
|
+
|
|
46
|
+
// (2) the untrusted canvas origin must NOT reach the probe
|
|
47
|
+
expect(await status(canvas, '/_api/acp/status')).toBe(403);
|
|
48
|
+
|
|
49
|
+
// (3) /_ws/acp is wired on the main origin — a plain GET (no Upgrade
|
|
50
|
+
// headers) passes the loopback guard then fails the WS upgrade → 400,
|
|
51
|
+
// which proves the route branch exists (a 404 would mean it's unwired).
|
|
52
|
+
expect(await status(main, '/_ws/acp')).toBe(400);
|
|
53
|
+
|
|
54
|
+
// (4) /_api/acp/focus (the /design:chat hook) — POST-only on the main
|
|
55
|
+
// origin, 403 on the canvas origin.
|
|
56
|
+
const focusPost = await fetch(`${main}/_api/acp/focus`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
signal: AbortSignal.timeout(2000),
|
|
59
|
+
});
|
|
60
|
+
expect(focusPost.status).toBe(200);
|
|
61
|
+
expect((await focusPost.json()).ok).toBe(true);
|
|
62
|
+
expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
|
|
63
|
+
expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
|
|
64
|
+
} finally {
|
|
65
|
+
await killProc(proc);
|
|
66
|
+
}
|
|
67
|
+
}, 15000);
|
|
68
|
+
|
|
69
|
+
test('the loopback guard fronting /_ws/acp rejects non-loopback hosts', () => {
|
|
70
|
+
// Same guard server.ts applies before upgrading the acp socket.
|
|
71
|
+
expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
|
|
72
|
+
expect(isLoopbackHost('localhost:4399')).toBe(true);
|
|
73
|
+
expect(isLoopbackHost('[::1]:4399')).toBe(true);
|
|
74
|
+
expect(isLoopbackHost('evil.example.com')).toBe(false);
|
|
75
|
+
expect(isLoopbackHost('10.0.0.5:4399')).toBe(false);
|
|
76
|
+
expect(isLoopbackHost(null)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// acp/transcript — repo-level chat list + raw-transcript → clean-messages.
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { deleteChat, listChats, readChatMessages } from '../acp/transcript.ts';
|
|
9
|
+
|
|
10
|
+
let root: string;
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function seed(chatId: string, lines: object[]): string {
|
|
16
|
+
root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
|
|
17
|
+
const dir = join(root, '_chat');
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
writeFileSync(join(dir, `${chatId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('readChatMessages — raw → clean turns', () => {
|
|
24
|
+
test('aggregates agent updates into one assistant message; drops noise', () => {
|
|
25
|
+
const designRoot = seed('c1', [
|
|
26
|
+
{ ts: 1, role: 'user', text: 'make the button red' },
|
|
27
|
+
{
|
|
28
|
+
ts: 2,
|
|
29
|
+
role: 'agent',
|
|
30
|
+
update: { sessionUpdate: 'available_commands_update', availableCommands: [] },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
ts: 3,
|
|
34
|
+
role: 'agent',
|
|
35
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Sure, ' } },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
ts: 4,
|
|
39
|
+
role: 'agent',
|
|
40
|
+
update: {
|
|
41
|
+
sessionUpdate: 'tool_call',
|
|
42
|
+
toolCallId: 't1',
|
|
43
|
+
title: 'Edit Button.tsx',
|
|
44
|
+
kind: 'edit',
|
|
45
|
+
status: 'pending',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
ts: 5,
|
|
50
|
+
role: 'agent',
|
|
51
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done.' } },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
ts: 6,
|
|
55
|
+
role: 'agent',
|
|
56
|
+
update: { sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' },
|
|
57
|
+
},
|
|
58
|
+
{ ts: 7, role: 'agent', update: { sessionUpdate: 'usage_update', used: 10 } },
|
|
59
|
+
{ ts: 8, role: 'stop', stopReason: 'end_turn' },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const msgs = readChatMessages(designRoot, 'c1');
|
|
63
|
+
expect(msgs.length).toBe(2);
|
|
64
|
+
expect(msgs[0]).toEqual({
|
|
65
|
+
role: 'user',
|
|
66
|
+
parts: [{ type: 'text', text: 'make the button red' }],
|
|
67
|
+
});
|
|
68
|
+
expect(msgs[1].role).toBe('assistant');
|
|
69
|
+
// text parts merged across the tool call boundary order-preserving
|
|
70
|
+
const texts = msgs[1].parts.filter((p) => p.type === 'text').map((p) => p.text);
|
|
71
|
+
expect(texts.join('')).toBe('Sure, done.');
|
|
72
|
+
const tool = msgs[1].parts.find((p) => p.type === 'tool');
|
|
73
|
+
expect(tool?.toolName).toBe('Edit Button.tsx');
|
|
74
|
+
expect(tool?.done).toBe(true);
|
|
75
|
+
// noise (available_commands_update / usage_update) is not rendered
|
|
76
|
+
expect(JSON.stringify(msgs)).not.toContain('availableCommands');
|
|
77
|
+
expect(JSON.stringify(msgs)).not.toContain('usage');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('missing chat → empty', () => {
|
|
81
|
+
const designRoot = seed('c1', [{ ts: 1, role: 'user', text: 'hi' }]);
|
|
82
|
+
expect(readChatMessages(designRoot, 'nope')).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('listChats', () => {
|
|
87
|
+
test('lists chats with a title derived from the first user line', () => {
|
|
88
|
+
const designRoot = seed('chat-7', [
|
|
89
|
+
{ ts: 1, role: 'user', text: 'redesign the pricing page please' },
|
|
90
|
+
{ ts: 2, role: 'stop', stopReason: 'end_turn' },
|
|
91
|
+
]);
|
|
92
|
+
const chats = listChats(designRoot);
|
|
93
|
+
expect(chats.length).toBe(1);
|
|
94
|
+
expect(chats[0].id).toBe('chat-7');
|
|
95
|
+
expect(chats[0].title).toBe('redesign the pricing page please');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('no _chat dir → empty', () => {
|
|
99
|
+
root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
|
|
100
|
+
expect(listChats(root)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('deleteChat', () => {
|
|
105
|
+
test('removes the transcript; missing → false', () => {
|
|
106
|
+
const designRoot = seed('gone', [{ ts: 1, role: 'user', text: 'bye' }]);
|
|
107
|
+
expect(listChats(designRoot).length).toBe(1);
|
|
108
|
+
expect(deleteChat(designRoot, 'gone')).toBe(true);
|
|
109
|
+
expect(listChats(designRoot).length).toBe(0);
|
|
110
|
+
expect(deleteChat(designRoot, 'gone')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -122,6 +122,71 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
|
|
126
|
+
const { root } = makeSandbox();
|
|
127
|
+
const port = nextPort();
|
|
128
|
+
const proc = await bootServer(root, port);
|
|
129
|
+
let socket: WebSocket | null = null;
|
|
130
|
+
try {
|
|
131
|
+
const messages: Array<{ type: string; payload?: { action?: string; slug?: string } }> = [];
|
|
132
|
+
const sock = new WebSocket(`ws://localhost:${port}/_ws`);
|
|
133
|
+
socket = sock;
|
|
134
|
+
await new Promise<void>((res, rej) => {
|
|
135
|
+
sock.addEventListener('open', () => res());
|
|
136
|
+
sock.addEventListener('error', () => rej(new Error('ws error')));
|
|
137
|
+
setTimeout(() => rej(new Error('ws open timeout')), 2000);
|
|
138
|
+
});
|
|
139
|
+
sock.addEventListener('message', (ev) => {
|
|
140
|
+
try {
|
|
141
|
+
messages.push(JSON.parse(String(ev.data)));
|
|
142
|
+
} catch {
|
|
143
|
+
/* ignore non-JSON */
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const waitFor = async (pred: (m: (typeof messages)[number]) => boolean) => {
|
|
148
|
+
const deadline = Date.now() + 2000;
|
|
149
|
+
while (Date.now() < deadline) {
|
|
150
|
+
const hit = messages.find(pred);
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
await Bun.sleep(25);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const created = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ name: 'Live Add', kind: 'brief-board' }),
|
|
161
|
+
});
|
|
162
|
+
expect(created.status).toBe(201);
|
|
163
|
+
const cj = (await created.json()) as { rel: string };
|
|
164
|
+
|
|
165
|
+
const added = await waitFor(
|
|
166
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'added'
|
|
167
|
+
);
|
|
168
|
+
expect(added?.payload?.slug).toBe('ui-live_add');
|
|
169
|
+
|
|
170
|
+
const del = await fetch(
|
|
171
|
+
`http://localhost:${port}/_api/canvas?file=${encodeURIComponent(cj.rel)}`,
|
|
172
|
+
{ method: 'DELETE' }
|
|
173
|
+
);
|
|
174
|
+
expect(del.status).toBe(200);
|
|
175
|
+
|
|
176
|
+
const removed = await waitFor(
|
|
177
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'removed'
|
|
178
|
+
);
|
|
179
|
+
expect(removed?.payload?.slug).toBe('ui-live_add');
|
|
180
|
+
} finally {
|
|
181
|
+
try {
|
|
182
|
+
socket?.close();
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
await killProc(proc);
|
|
187
|
+
}
|
|
188
|
+
}, 10_000);
|
|
189
|
+
|
|
125
190
|
test('defaults kind to brief-board when omitted', async () => {
|
|
126
191
|
const { root } = makeSandbox();
|
|
127
192
|
const port = nextPort();
|
|
@@ -284,6 +349,10 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
284
349
|
join(designRoot, `${created.slug}.annotations.svg`),
|
|
285
350
|
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>'
|
|
286
351
|
);
|
|
352
|
+
// Seed the per-machine camera view file (DDR-115) to prove it's swept too.
|
|
353
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
354
|
+
const viewAbs = join(designRoot, '_canvas-state', `${created.slug}.view.json`);
|
|
355
|
+
writeFileSync(viewAbs, JSON.stringify({ viewport: { x: 1, y: 2, zoom: 1 } }));
|
|
287
356
|
const tsx = join(designRoot, 'ui', 'Trash Me.tsx');
|
|
288
357
|
expect(existsSync(tsx)).toBe(true);
|
|
289
358
|
|
|
@@ -293,14 +362,17 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
293
362
|
expect(j.ok).toBe(true);
|
|
294
363
|
expect(j.trashed.some((t) => t.endsWith('Trash Me.tsx'))).toBe(true);
|
|
295
364
|
expect(j.trashed.some((t) => t.endsWith('.annotations.svg'))).toBe(true);
|
|
365
|
+
expect(j.trashed.some((t) => t.endsWith('.view.json'))).toBe(true);
|
|
296
366
|
|
|
297
367
|
// Gone from the group; present (recoverable) under _trash/ with a manifest.
|
|
298
368
|
expect(existsSync(tsx)).toBe(false);
|
|
299
369
|
expect(existsSync(join(designRoot, `${created.slug}.annotations.svg`))).toBe(false);
|
|
370
|
+
expect(existsSync(viewAbs)).toBe(false);
|
|
300
371
|
const trashDirs = readdirSync(join(designRoot, '_trash'));
|
|
301
372
|
expect(trashDirs.length).toBe(1);
|
|
302
373
|
const td = join(designRoot, '_trash', trashDirs[0] as string);
|
|
303
374
|
expect(existsSync(join(td, 'Trash Me.tsx'))).toBe(true);
|
|
375
|
+
expect(existsSync(join(td, `_canvas-state__${created.slug}.view.json`))).toBe(true);
|
|
304
376
|
expect(existsSync(join(td, '_trash-manifest.json'))).toBe(true);
|
|
305
377
|
} finally {
|
|
306
378
|
await killProc(proc);
|