@1agh/maude 0.56.0 → 0.58.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 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The sentence a person reads after pressing Connect.
|
|
2
|
+
//
|
|
3
|
+
// It used to be computed once, from the ATTACH RESPONSE, at the instant the
|
|
4
|
+
// confirm dialog closed — and never again. The attach response reports an
|
|
5
|
+
// intention (`{ syncing: true }` means `runtime.start()` did not throw and one
|
|
6
|
+
// local canvas qualified), so the sentence claimed success before a socket
|
|
7
|
+
// existed and then sat there through every outcome, including total failure.
|
|
8
|
+
//
|
|
9
|
+
// These cases are one per row of the plan's states/copy table, plus the two
|
|
10
|
+
// degradations that must never take the note away entirely: no live payload,
|
|
11
|
+
// and a payload from a server that predates the counts.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import { connectOutcomeNote } from '../client/panels/CloudBar.jsx';
|
|
16
|
+
import type { SyncStatusLike } from '../sync/presentation.ts';
|
|
17
|
+
|
|
18
|
+
const live = (over: SyncStatusLike): SyncStatusLike => ({
|
|
19
|
+
state: 'online',
|
|
20
|
+
queuedOps: 0,
|
|
21
|
+
lastSyncAt: null,
|
|
22
|
+
offlineSince: null,
|
|
23
|
+
flash: null,
|
|
24
|
+
updatedAt: 0,
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const started = { syncing: true, canvases: 75 };
|
|
29
|
+
|
|
30
|
+
describe('with a live payload — the sentence moves', () => {
|
|
31
|
+
test('connecting: nothing has settled yet, and it does not pretend otherwise', () => {
|
|
32
|
+
const n = connectOutcomeNote(
|
|
33
|
+
'alligators',
|
|
34
|
+
started,
|
|
35
|
+
live({ state: 'connecting', docs: { synced: 0, pending: 75, rejected: 0 } })
|
|
36
|
+
);
|
|
37
|
+
expect(n.text).toContain('Connecting to alligators');
|
|
38
|
+
expect(n.text).not.toContain('Synced');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('syncing: real progress, drawn from settled handshakes', () => {
|
|
42
|
+
const n = connectOutcomeNote(
|
|
43
|
+
'alligators',
|
|
44
|
+
started,
|
|
45
|
+
live({ docs: { synced: 40, pending: 35, rejected: 0 } })
|
|
46
|
+
);
|
|
47
|
+
expect(n.text).toContain('40 of 75');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('synced: names what arrived and tells the person to go open it', () => {
|
|
51
|
+
const n = connectOutcomeNote(
|
|
52
|
+
'alligators',
|
|
53
|
+
started,
|
|
54
|
+
live({
|
|
55
|
+
docs: { synced: 75, pending: 0, rejected: 0 },
|
|
56
|
+
pulled: { names: ['ui-welcome'], count: 3 },
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
expect(n.text).toContain('Synced with alligators');
|
|
60
|
+
expect(n.text).toContain('3 came down');
|
|
61
|
+
// Deliberately not an imperative to open it: a pulled canvas is hub-authored
|
|
62
|
+
// TSX, and "open this" inside our own green success sentence is the
|
|
63
|
+
// strongest endorsement we could give content we do not vouch for.
|
|
64
|
+
expect(n.title).toContain('new to this machine');
|
|
65
|
+
expect(n.title).not.toContain('Open one');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('refused: the state that cannot fix itself says so, and says what to do', () => {
|
|
69
|
+
// The reported shape, at its worst: the attach response says syncing, the
|
|
70
|
+
// socket is up, and every single document is going nowhere.
|
|
71
|
+
const n = connectOutcomeNote(
|
|
72
|
+
'alligators',
|
|
73
|
+
started,
|
|
74
|
+
live({ state: 'online', docs: { synced: 0, pending: 0, rejected: 75 } })
|
|
75
|
+
);
|
|
76
|
+
expect(n.text).toContain('refused');
|
|
77
|
+
expect(n.title).toContain('Reconnect');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('unreachable: queued, not lost — and explicitly nothing to do', () => {
|
|
81
|
+
const n = connectOutcomeNote(
|
|
82
|
+
'alligators',
|
|
83
|
+
started,
|
|
84
|
+
live({ state: 'offline', queuedOps: 2, docs: { synced: 75, pending: 0, rejected: 0 } })
|
|
85
|
+
);
|
|
86
|
+
expect(n.text).toContain('Not reachable');
|
|
87
|
+
expect(n.text).toContain('queued');
|
|
88
|
+
expect(n.title).toContain('resumes by itself');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the live payload beats the attach response, not the other way round', () => {
|
|
92
|
+
// `sync.syncing` is true here and the link is in fact dead. Whichever wins
|
|
93
|
+
// decides whether this whole change did anything.
|
|
94
|
+
const n = connectOutcomeNote('alligators', started, live({ state: 'offline' }));
|
|
95
|
+
expect(n.text).toContain('Not reachable');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('without a live payload — degrade, never vanish', () => {
|
|
100
|
+
test('first render falls back to the attach response, phrased as in-flight', () => {
|
|
101
|
+
const n = connectOutcomeNote('alligators', started, undefined);
|
|
102
|
+
expect(n.text).toContain('Connecting to alligators');
|
|
103
|
+
expect(n.text).toContain('75 canvases');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('an unlinked live payload does not swallow the note', () => {
|
|
107
|
+
// `syncPresentation` returns null for `{linked:false}`; the note must fall
|
|
108
|
+
// through to the attach response rather than render nothing at all.
|
|
109
|
+
const n = connectOutcomeNote('alligators', started, { linked: false });
|
|
110
|
+
expect(n.text).toContain('alligators');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('nothing syncable keeps its own copy', () => {
|
|
114
|
+
const n = connectOutcomeNote('alligators', {
|
|
115
|
+
syncing: false,
|
|
116
|
+
reason: 'nothing-syncable',
|
|
117
|
+
detail: 'No canvases in this project are syncable yet.',
|
|
118
|
+
});
|
|
119
|
+
expect(n.text).toContain('nothing to sync yet');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a failed start reports the reason it was given', () => {
|
|
123
|
+
const n = connectOutcomeNote('alligators', {
|
|
124
|
+
syncing: false,
|
|
125
|
+
reason: 'error',
|
|
126
|
+
detail: 'Syncing could not start: hub refused the handshake',
|
|
127
|
+
});
|
|
128
|
+
expect(n.text).toContain('hub refused the handshake');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a missing project name never renders as undefined', () => {
|
|
132
|
+
const n = connectOutcomeNote(null, started, undefined);
|
|
133
|
+
expect(n.text).toContain('the workspace');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -324,12 +324,19 @@ describe('localIdentityHint — only an exact, corroborated agreement is silent'
|
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
describe('connectOutcomeNote — what the rail says after Connect', () => {
|
|
327
|
-
test('
|
|
327
|
+
test('no live payload yet: an in-flight state, not a claimed result', () => {
|
|
328
|
+
// This used to assert "Syncing with alligators — 12 canvases." and a hover
|
|
329
|
+
// pointing at the status bar. Both were wrong: the attach response reports
|
|
330
|
+
// that `runtime.start()` did not throw, which is an INTENTION, and the slot
|
|
331
|
+
// it sent people to was itself keying off `state` alone. See DDR-214 — the
|
|
332
|
+
// sentence now derives from the live payload, and this is only the fallback
|
|
333
|
+
// for the first render (and for a server too old to send one.)
|
|
328
334
|
const n = connectOutcomeNote('alligators', { syncing: true, canvases: 12 });
|
|
329
|
-
expect(n.text).toBe('
|
|
330
|
-
expect(n.title).toContain('
|
|
335
|
+
expect(n.text).toBe('Connecting to alligators — 12 canvases…');
|
|
336
|
+
expect(n.title).toContain('updates as they settle');
|
|
337
|
+
expect(n.text).not.toContain('hub sync');
|
|
331
338
|
expect(connectOutcomeNote('alligators', { syncing: true, canvases: 1 }).text).toContain(
|
|
332
|
-
'1 canvas
|
|
339
|
+
'1 canvas…'
|
|
333
340
|
);
|
|
334
341
|
});
|
|
335
342
|
|
|
@@ -64,8 +64,11 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
64
64
|
// `=== null`, not falsiness. `cloud` is undefined until `/_config` answers,
|
|
65
65
|
// and treating unknown as "not cloud" mounted this bar for one frame in
|
|
66
66
|
// every cloud tab — long enough to fire the one request the cell refuses.
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Matched on the GUARD rather than the whole element, so adding a prop to
|
|
68
|
+
// CloudBar (DDR-214 drilled the live sync payload in) does not read as a
|
|
69
|
+
// regression in a rule that is entirely about `=== null`.
|
|
70
|
+
expect(APP).toMatch(/\{cloud === null \? <CloudBar[^>]*\/> : null\}/);
|
|
71
|
+
expect(APP).not.toMatch(/\{cloud \? null : <CloudBar/);
|
|
69
72
|
});
|
|
70
73
|
|
|
71
74
|
test('the local export queue does not hydrate before the shell is known', () => {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The running version reaches the client — feature-release-reaches-the-fleet.
|
|
2
|
+
//
|
|
3
|
+
// Reading which release a Maude was actually running used to mean probing
|
|
4
|
+
// production by hand, and the first probe of the v0.57.0 incident was MISREAD
|
|
5
|
+
// (a 401 that looked like "route exists" was the blanket answer for any unknown
|
|
6
|
+
// path). The fix has three surfaces reporting one version: the hub's `/health`,
|
|
7
|
+
// this `/_config`, and the status-bar chip the client draws from it.
|
|
8
|
+
//
|
|
9
|
+
// What is asserted here is the middle one, plus the two properties that make it
|
|
10
|
+
// safe to put there: it is resolved through the SINGLE version function, and it
|
|
11
|
+
// carries nothing else.
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
14
|
+
import type { Subprocess } from 'bun';
|
|
15
|
+
|
|
16
|
+
import { resolveMaudeVersion } from '../whats-new.ts';
|
|
17
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
18
|
+
|
|
19
|
+
const procs: Subprocess[] = [];
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
for (const p of procs) await killProc(p);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function boot(env: Record<string, string> = {}) {
|
|
25
|
+
const { root } = makeSandbox();
|
|
26
|
+
const port = nextPort();
|
|
27
|
+
procs.push(await bootServer(root, port, env));
|
|
28
|
+
return `http://localhost:${port}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('/_config names the release it is running', () => {
|
|
32
|
+
test('the version is present, and is a real version', async () => {
|
|
33
|
+
const base = await boot();
|
|
34
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
|
|
35
|
+
|
|
36
|
+
expect(typeof cfg.version).toBe('string');
|
|
37
|
+
// `0.0.0` was the private-workspace placeholder these manifests carried
|
|
38
|
+
// before they joined the release line — reporting it is the bug, not a pass.
|
|
39
|
+
expect(cfg.version).not.toBe('0.0.0');
|
|
40
|
+
expect(cfg.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('it is the SAME answer the What’s New feed uses', async () => {
|
|
44
|
+
// One resolution path (DDR-045). Two would eventually disagree, and a chip
|
|
45
|
+
// that contradicts the feed beside it is worse than no chip.
|
|
46
|
+
const base = await boot();
|
|
47
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
|
|
48
|
+
const feed = (await (await fetch(`${base}/_api/whats-new`)).json()) as { version?: string };
|
|
49
|
+
|
|
50
|
+
expect(cfg.version).toBe(resolveMaudeVersion());
|
|
51
|
+
expect(cfg.version).toBe(feed.version);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('the version rides along, and nothing else does', () => {
|
|
56
|
+
test('a cloud tab gets the version too', async () => {
|
|
57
|
+
// `/_config` reaches a browser on someone else's machine in workspace mode.
|
|
58
|
+
// A git tag is public, so the version is fine there — this pins that it
|
|
59
|
+
// actually arrives, since the chip is the cloud's only version surface.
|
|
60
|
+
const base = await boot({
|
|
61
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
62
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
63
|
+
MAUDE_PROJECT_NAME: 'Alligators',
|
|
64
|
+
});
|
|
65
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as {
|
|
66
|
+
version?: string;
|
|
67
|
+
cloud?: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
expect(typeof cfg.version).toBe('string');
|
|
71
|
+
expect(cfg.cloud).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('no server path or secret came with it', async () => {
|
|
75
|
+
const base = await boot();
|
|
76
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
// The version was the one field this change added. Anything matching these
|
|
79
|
+
// shapes arriving in the browser payload is a leak, and the review that
|
|
80
|
+
// waves it through will be the one that reads "it was already there".
|
|
81
|
+
for (const key of Object.keys(cfg)) {
|
|
82
|
+
expect(key).not.toMatch(/token$/i);
|
|
83
|
+
expect(key).not.toMatch(/secret|password|apiKey/i);
|
|
84
|
+
}
|
|
85
|
+
const serialized = JSON.stringify(cfg);
|
|
86
|
+
expect(serialized).not.toMatch(/BEGIN [A-Z ]*PRIVATE KEY/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// A mock ACP agent that streams SEVERAL updates with a gap between them, so a
|
|
3
|
+
// test can close the socket MID-turn and assert the turn still completes
|
|
4
|
+
// (feature-acp-write-path-scope Addendum, Task 8 — bridges outlive their socket).
|
|
5
|
+
//
|
|
6
|
+
// The pacing is the whole point: `mock-acp-agent-permission.mjs` and friends
|
|
7
|
+
// settle in one tick, which leaves no window in which "mid-turn" is a real
|
|
8
|
+
// state. Six chunks at ~120 ms give a test room to observe the first one, yank
|
|
9
|
+
// the socket, and still have the rest arrive with nobody attached.
|
|
10
|
+
|
|
11
|
+
import { Readable, Writable } from 'node:stream';
|
|
12
|
+
|
|
13
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
14
|
+
|
|
15
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
16
|
+
|
|
17
|
+
const CHUNKS = Number(process.env.MAUDE_TEST_SLOW_CHUNKS || 6);
|
|
18
|
+
const GAP_MS = Number(process.env.MAUDE_TEST_SLOW_GAP_MS || 120);
|
|
19
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
|
|
21
|
+
let n = 0;
|
|
22
|
+
|
|
23
|
+
acp
|
|
24
|
+
.agent({ name: 'mock-acp-agent-slow' })
|
|
25
|
+
.onRequest('initialize', () => ({
|
|
26
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
27
|
+
agentCapabilities: { loadSession: false },
|
|
28
|
+
}))
|
|
29
|
+
.onRequest('session/new', () => ({ sessionId: `mock-slow-session-${++n}` }))
|
|
30
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
31
|
+
for (let i = 0; i < CHUNKS; i++) {
|
|
32
|
+
await ctx.client.notify('session/update', {
|
|
33
|
+
sessionId: ctx.params.sessionId,
|
|
34
|
+
update: {
|
|
35
|
+
sessionUpdate: 'agent_message_chunk',
|
|
36
|
+
// Numbered so a test can tell WHICH chunks it received, not just how
|
|
37
|
+
// many — the difference between "no duplicates" and "no gaps".
|
|
38
|
+
content: { type: 'text', text: `chunk-${i}\n` },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await sleep(GAP_MS);
|
|
42
|
+
}
|
|
43
|
+
return { stopReason: 'end_turn' };
|
|
44
|
+
})
|
|
45
|
+
.connect(stream);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// feature-acp-write-path-scope Task 1 — the RECORDED SHAPE of a write tool's
|
|
3
|
+
// permission request, replayed as a mock ACP agent.
|
|
4
|
+
//
|
|
5
|
+
// This is not an invented shape. It reproduces, field for field, what
|
|
6
|
+
// `@agentclientprotocol/claude-agent-acp@0.57.0` actually puts on the wire for
|
|
7
|
+
// `Write`/`Edit`/`NotebookEdit`, read out of its own `dist/` on 2026-08-07:
|
|
8
|
+
//
|
|
9
|
+
// • `dist/acp-agent.js:2270-2286` builds the permission request's `toolCall`
|
|
10
|
+
// as `{ toolCallId, rawInput, ...toolInfoFromToolUse(name, input, id) }` —
|
|
11
|
+
// note there is NO tool NAME anywhere in that object.
|
|
12
|
+
// • `dist/tools.js` `case "Write"` / `case "Edit"` set
|
|
13
|
+
// `locations: input?.file_path ? [{ path: input.file_path }] : []` — the
|
|
14
|
+
// model's string VERBATIM. No normalization, no absolutization, despite the
|
|
15
|
+
// ACP schema (`types.gen.d.ts:568-572`) describing the field as "The
|
|
16
|
+
// absolute file path being accessed or modified".
|
|
17
|
+
// • `NotebookEdit` has NO case at all — it falls through to `case "Other"`,
|
|
18
|
+
// which emits no `locations` whatsoever. Its target lives only on
|
|
19
|
+
// `rawInput.notebook_path`.
|
|
20
|
+
// • `dist/acp-agent.js:3808-3829` (`toolCallNotification`) is the ONLY carrier
|
|
21
|
+
// of the tool name: `_meta.claudeCode.toolName` on the streamed `tool_call`.
|
|
22
|
+
// • `requestPermissionFromClient` (`:2134-2141`) AWAITS `ensureToolCallEmitted`
|
|
23
|
+
// before issuing the request, so that notification is guaranteed to be on
|
|
24
|
+
// the wire FIRST. The bridge's gate depends on exactly that ordering, so the
|
|
25
|
+
// fixture reproduces it rather than assuming it.
|
|
26
|
+
//
|
|
27
|
+
// Driven by env so one fixture covers every case:
|
|
28
|
+
// MAUDE_TEST_WRITE_PATH — the `file_path` the "model" emitted (required)
|
|
29
|
+
// MAUDE_TEST_WRITE_TOOL — Write (default) | Edit | NotebookEdit | Read
|
|
30
|
+
// MAUDE_TEST_OMIT_TOOL_CALL=1 — skip the `tool_call` notification, i.e.
|
|
31
|
+
// simulate an adapter that reorders or drops it. The gate must fail
|
|
32
|
+
// CLOSED here (prompt), never auto-approve.
|
|
33
|
+
|
|
34
|
+
import { Readable, Writable } from 'node:stream';
|
|
35
|
+
|
|
36
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
37
|
+
|
|
38
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
39
|
+
|
|
40
|
+
const TOOL = process.env.MAUDE_TEST_WRITE_TOOL || 'Write';
|
|
41
|
+
const FILE_PATH = process.env.MAUDE_TEST_WRITE_PATH || '/tmp/unset';
|
|
42
|
+
const OMIT_TOOL_CALL = process.env.MAUDE_TEST_OMIT_TOOL_CALL === '1';
|
|
43
|
+
const TOOL_CALL_ID = 'tc-write-1';
|
|
44
|
+
|
|
45
|
+
// Mirrors `toolInfoFromToolUse`'s per-tool branches. NotebookEdit deliberately
|
|
46
|
+
// yields NO `locations` key — that is the adapter's real behaviour, and the case
|
|
47
|
+
// that makes the rawInput fallback mandatory rather than decorative.
|
|
48
|
+
function toolInfo() {
|
|
49
|
+
if (TOOL === 'NotebookEdit') {
|
|
50
|
+
return { title: 'NotebookEdit', kind: 'other', content: [] };
|
|
51
|
+
}
|
|
52
|
+
if (TOOL === 'Read') {
|
|
53
|
+
return {
|
|
54
|
+
title: `Read ${FILE_PATH}`,
|
|
55
|
+
kind: 'read',
|
|
56
|
+
content: [],
|
|
57
|
+
locations: [{ path: FILE_PATH, line: 1 }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
title: `${TOOL} ${FILE_PATH}`,
|
|
62
|
+
kind: 'edit',
|
|
63
|
+
content: [],
|
|
64
|
+
locations: [{ path: FILE_PATH }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rawInput() {
|
|
69
|
+
if (TOOL === 'NotebookEdit') return { notebook_path: FILE_PATH, new_source: 'x' };
|
|
70
|
+
if (TOOL === 'Write') return { file_path: FILE_PATH, content: 'x' };
|
|
71
|
+
if (TOOL === 'Read') return { file_path: FILE_PATH };
|
|
72
|
+
return { file_path: FILE_PATH, old_string: 'a', new_string: 'b' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
acp
|
|
76
|
+
.agent({ name: 'mock-acp-agent-write' })
|
|
77
|
+
.onRequest('initialize', () => ({
|
|
78
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
79
|
+
agentCapabilities: { loadSession: false },
|
|
80
|
+
}))
|
|
81
|
+
.onRequest('session/new', () => ({ sessionId: 'mock-write-session-1' }))
|
|
82
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
83
|
+
// (1) The `tool_call` notification — the tool NAME's only carrier.
|
|
84
|
+
if (!OMIT_TOOL_CALL) {
|
|
85
|
+
await ctx.client.notify('session/update', {
|
|
86
|
+
sessionId: ctx.params.sessionId,
|
|
87
|
+
update: {
|
|
88
|
+
_meta: { claudeCode: { toolName: TOOL } },
|
|
89
|
+
toolCallId: TOOL_CALL_ID,
|
|
90
|
+
sessionUpdate: 'tool_call',
|
|
91
|
+
rawInput: rawInput(),
|
|
92
|
+
status: 'pending',
|
|
93
|
+
...toolInfo(),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// (2) THEN the permission request, with the adapter's real option set for a
|
|
98
|
+
// routine tool call (`acp-agent.js:2271-2278`) — note `allow_always` is
|
|
99
|
+
// listed FIRST, which is what Decision D's filter has to contend with.
|
|
100
|
+
const outcome = await ctx.client.request(acp.methods.client.session.requestPermission, {
|
|
101
|
+
sessionId: ctx.params.sessionId,
|
|
102
|
+
toolCall: { toolCallId: TOOL_CALL_ID, rawInput: rawInput(), ...toolInfo() },
|
|
103
|
+
options: [
|
|
104
|
+
{ optionId: 'allow_always', name: 'Always Allow', kind: 'allow_always' },
|
|
105
|
+
{ optionId: 'allow', name: 'Allow', kind: 'allow_once' },
|
|
106
|
+
{ optionId: 'reject', name: 'Reject', kind: 'reject_once' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
await ctx.client.notify('session/update', {
|
|
110
|
+
sessionId: ctx.params.sessionId,
|
|
111
|
+
update: {
|
|
112
|
+
sessionUpdate: 'agent_message_chunk',
|
|
113
|
+
content: { type: 'text', text: `outcome=${JSON.stringify(outcome.outcome)}` },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return { stopReason: 'end_turn' };
|
|
117
|
+
})
|
|
118
|
+
.connect(stream);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isArtboardDragChrome,
|
|
11
11
|
isEditableTarget,
|
|
12
12
|
type Tool,
|
|
13
|
+
yieldsClickToArtboardChrome,
|
|
13
14
|
yieldsToArtboardDrag,
|
|
14
15
|
} from '../input-router.tsx';
|
|
15
16
|
|
|
@@ -552,6 +553,61 @@ describe('input-router / yieldsToArtboardDrag', () => {
|
|
|
552
553
|
});
|
|
553
554
|
});
|
|
554
555
|
|
|
556
|
+
// issue-78 — the actual regression: the router's capture-phase `click`
|
|
557
|
+
// listener claimed every bare click while in the Move tool (and every
|
|
558
|
+
// Cmd/Ctrl+click from Browse/annotation tools) and called
|
|
559
|
+
// `stopImmediatePropagation()` unconditionally, unlike its pointerdown/
|
|
560
|
+
// mousedown siblings above. That killed the native click on any artboard-
|
|
561
|
+
// chrome control BEFORE React's delegated onClick ever ran — including the
|
|
562
|
+
// video-timeline badge (`.dc-artboard-video-badge`, DDR-148), which sits
|
|
563
|
+
// outside `.dc-artboard-body` as chrome. Clicking it did nothing.
|
|
564
|
+
// `yieldsClickToArtboardChrome` is the decision the click handler now gates
|
|
565
|
+
// its suppression on, mirroring `yieldsToArtboardDrag` above.
|
|
566
|
+
describe('input-router / yieldsClickToArtboardChrome', () => {
|
|
567
|
+
const chrome = (): EventTarget =>
|
|
568
|
+
({
|
|
569
|
+
closest: (sel: string) => (sel === '[data-dc-screen]' ? {} : null),
|
|
570
|
+
}) as unknown as EventTarget;
|
|
571
|
+
const body = (): EventTarget => ({ closest: () => ({}) }) as unknown as EventTarget; // matches every selector, incl. .dc-artboard-body
|
|
572
|
+
|
|
573
|
+
test('bare click on the video-timeline badge in Move tool (the reported bug) → true', () => {
|
|
574
|
+
expect(
|
|
575
|
+
yieldsClickToArtboardChrome('select', chrome(), { metaKey: false, ctrlKey: false })
|
|
576
|
+
).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('cmd+click on artboard chrome (deep-select, not a chrome control) → false', () => {
|
|
580
|
+
expect(yieldsClickToArtboardChrome('select', chrome(), { metaKey: true, ctrlKey: false })).toBe(
|
|
581
|
+
false
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test('bare click on .dc-artboard-body content (not chrome) → false', () => {
|
|
586
|
+
expect(yieldsClickToArtboardChrome('select', body(), { metaKey: false, ctrlKey: false })).toBe(
|
|
587
|
+
false
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('bare click outside any artboard → false (nothing to yield to)', () => {
|
|
592
|
+
const outside = { closest: () => null } as unknown as EventTarget;
|
|
593
|
+
expect(yieldsClickToArtboardChrome('select', outside, { metaKey: false, ctrlKey: false })).toBe(
|
|
594
|
+
false
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('non-select routed kind (e.g. context-menu) on chrome → false', () => {
|
|
599
|
+
expect(
|
|
600
|
+
yieldsClickToArtboardChrome('context-menu', chrome(), { metaKey: false, ctrlKey: false })
|
|
601
|
+
).toBe(false);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('null routed kind on chrome → false', () => {
|
|
605
|
+
expect(yieldsClickToArtboardChrome(null, chrome(), { metaKey: false, ctrlKey: false })).toBe(
|
|
606
|
+
false
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
555
611
|
// T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
|
|
556
612
|
// (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
|
|
557
613
|
// reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A connect that has not connected must never read as synced.
|
|
2
|
+
//
|
|
3
|
+
// THE FALSIFIER. Written against the pre-fix pipeline and expected to FAIL
|
|
4
|
+
// there — that failure is the evidence that the status signal is optimistic by
|
|
5
|
+
// construction, not merely rendered late. If it ever passes without the
|
|
6
|
+
// monitor changing, the premise of this whole change was wrong and the fix
|
|
7
|
+
// should be dropped rather than the assertion softened.
|
|
8
|
+
//
|
|
9
|
+
// Reported as "vyskočí modal, pak vidím jen syncing canvases, ale reálně se nic
|
|
10
|
+
// nestane": the user was told the truth about an intention and read it as a
|
|
11
|
+
// result.
|
|
12
|
+
//
|
|
13
|
+
// The two monitor-level defects it pins:
|
|
14
|
+
// 1. the monitor is BORN `online` — before a socket exists, before a token is
|
|
15
|
+
// accepted, before a byte moves. Every surface keys off that value.
|
|
16
|
+
// 2. `docs.synced` can only ever rise — a hub that dies leaves the last count
|
|
17
|
+
// frozen on screen forever, indistinguishable from a healthy one.
|
|
18
|
+
//
|
|
19
|
+
// The surface-level defects (one connected provider out of many reading green,
|
|
20
|
+
// and the status bar ignoring `docs` entirely) are pinned in
|
|
21
|
+
// `sync-presentation.test.ts`, because they live in the presentation rule
|
|
22
|
+
// rather than in the state machine.
|
|
23
|
+
|
|
24
|
+
import { describe, expect, test } from 'bun:test';
|
|
25
|
+
|
|
26
|
+
import { createConnectionMonitor, type SyncStatusSnapshot } from '../sync/connection-state.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The rule the shipped status bar applies (`app.jsx` syncSlot, pre-fix):
|
|
30
|
+
* green dot and the word "synced" whenever `state` is `online`.
|
|
31
|
+
*
|
|
32
|
+
* Reproduced verbatim rather than imported, so this test measures what a USER
|
|
33
|
+
* sees and cannot be quietly satisfied by editing the surface.
|
|
34
|
+
*/
|
|
35
|
+
function readsAsSynced(snap: SyncStatusSnapshot): boolean {
|
|
36
|
+
return snap.state === 'online' || snap.flash === 'synced';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeMonitor() {
|
|
40
|
+
const nowMs = 1_000_000;
|
|
41
|
+
const timers = new Map<number, { fireAt: number; cb: () => void }>();
|
|
42
|
+
let nextId = 1;
|
|
43
|
+
const monitor = createConnectionMonitor({
|
|
44
|
+
now: () => nowMs,
|
|
45
|
+
setTimer: (cb, ms) => {
|
|
46
|
+
const id = nextId++;
|
|
47
|
+
timers.set(id, { fireAt: nowMs + ms, cb });
|
|
48
|
+
return id as unknown as ReturnType<typeof setTimeout>;
|
|
49
|
+
},
|
|
50
|
+
clearTimer: (h) => {
|
|
51
|
+
timers.delete(h as unknown as number);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return { monitor };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('a connect that has not connected', () => {
|
|
58
|
+
test('does not read as synced at t=0', () => {
|
|
59
|
+
// The instant after Connect: the runtime started, providers exist on paper,
|
|
60
|
+
// nothing has handshaken. This is precisely the moment the user is staring
|
|
61
|
+
// at the note, and the moment the old pipeline claimed success.
|
|
62
|
+
const { monitor } = makeMonitor();
|
|
63
|
+
expect(readsAsSynced(monitor.snapshot())).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('does not read as synced while every document is still pending', () => {
|
|
67
|
+
const { monitor } = makeMonitor();
|
|
68
|
+
for (const slug of ['ui-home', 'ui-about', 'ui-welcome']) {
|
|
69
|
+
monitor.noteDocState(slug, 'pending');
|
|
70
|
+
}
|
|
71
|
+
const snap = monitor.snapshot();
|
|
72
|
+
expect(snap.docs).toEqual({ synced: 0, pending: 3, rejected: 0 });
|
|
73
|
+
expect(readsAsSynced(snap)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('does not read as synced when the hub refuses every document', () => {
|
|
77
|
+
// A rotated credential: providers reach the hub, the hub says no. Nothing
|
|
78
|
+
// is syncing, and the count that would say so must not claim otherwise.
|
|
79
|
+
const { monitor } = makeMonitor();
|
|
80
|
+
for (const slug of ['ui-home', 'ui-about']) {
|
|
81
|
+
monitor.noteDocState(slug, 'auth-rejected');
|
|
82
|
+
}
|
|
83
|
+
expect(monitor.snapshot().docs).toEqual({ synced: 0, pending: 0, rejected: 2 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('a connect that stops being connected', () => {
|
|
88
|
+
test('docs.synced falls when the providers drop', () => {
|
|
89
|
+
// Today nothing demotes a connected document, so a hub that dies leaves
|
|
90
|
+
// "3 synced" on screen indefinitely — the most convincing possible lie,
|
|
91
|
+
// because it was true a moment ago.
|
|
92
|
+
const { monitor } = makeMonitor();
|
|
93
|
+
for (const slug of ['a', 'b', 'c']) {
|
|
94
|
+
monitor.noteProviderStatus(slug, 'connected');
|
|
95
|
+
monitor.noteDocState(slug, 'connected');
|
|
96
|
+
}
|
|
97
|
+
expect(monitor.snapshot().docs?.synced).toBe(3);
|
|
98
|
+
|
|
99
|
+
for (const slug of ['a', 'b', 'c']) monitor.noteProviderStatus(slug, 'disconnected');
|
|
100
|
+
|
|
101
|
+
const snap = monitor.snapshot();
|
|
102
|
+
expect(snap.docs?.synced).toBe(0);
|
|
103
|
+
expect(snap.docs?.pending).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a refusal is not transient — a dropped provider cannot launder it', () => {
|
|
107
|
+
// `auth-rejected` outranks a disconnect: the hub gave an answer, and losing
|
|
108
|
+
// the socket does not turn that answer back into "still trying".
|
|
109
|
+
const { monitor } = makeMonitor();
|
|
110
|
+
monitor.noteDocState('a', 'auth-rejected');
|
|
111
|
+
monitor.noteProviderStatus('a', 'disconnected');
|
|
112
|
+
expect(monitor.snapshot().docs).toEqual({ synced: 0, pending: 0, rejected: 1 });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -52,12 +52,26 @@ function makeMonitor(overrides = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
describe('connection monitor', () => {
|
|
55
|
-
test('starts
|
|
55
|
+
test('starts connecting, and goes online when the provider connects', () => {
|
|
56
|
+
// The seed used to be `online` — success asserted before a socket existed.
|
|
57
|
+
// See `sync-connect-honesty.test.ts` for why that mattered to a user.
|
|
56
58
|
const { monitor } = makeMonitor();
|
|
59
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
57
60
|
monitor.noteProviderStatus('p1', 'connected');
|
|
58
61
|
expect(monitor.snapshot().state).toBe('online');
|
|
59
62
|
});
|
|
60
63
|
|
|
64
|
+
test('a link nobody answers still escalates to offline', () => {
|
|
65
|
+
// The seed change must not create a state that waits forever: a provider
|
|
66
|
+
// that only ever reports 'connecting' has to reach the offline banner on
|
|
67
|
+
// the same grace clock as a link that dropped after being live.
|
|
68
|
+
const { clock, monitor } = makeMonitor();
|
|
69
|
+
monitor.noteProviderStatus('p1', 'connecting');
|
|
70
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
71
|
+
clock.advance(30_000);
|
|
72
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
73
|
+
});
|
|
74
|
+
|
|
61
75
|
test('disconnect → stays connecting during grace, goes offline after graceMs', () => {
|
|
62
76
|
const { clock, monitor } = makeMonitor();
|
|
63
77
|
monitor.noteProviderStatus('p1', 'connected');
|
|
@@ -212,3 +226,33 @@ describe('connection monitor — per-doc states (DDR-102)', () => {
|
|
|
212
226
|
expect(monitor.snapshot().lastSyncAt).toBe((first as number) + 10_000);
|
|
213
227
|
});
|
|
214
228
|
});
|
|
229
|
+
|
|
230
|
+
describe('what this run pulled down', () => {
|
|
231
|
+
// This field was `remoteGap`, "what the project has that this machine does
|
|
232
|
+
// not" — and it was recorded AFTER the pull, so it named exactly the
|
|
233
|
+
// canvases that had just arrived. `_sync.json` was observed listing two
|
|
234
|
+
// documents that were sitting on disk. The name now matches the fact.
|
|
235
|
+
test('records the canvases that arrived, with a true count', () => {
|
|
236
|
+
const { monitor } = makeMonitor();
|
|
237
|
+
monitor.notePulled(['ui-welcome', 'ui-how-to']);
|
|
238
|
+
expect(monitor.snapshot().pulled).toEqual({
|
|
239
|
+
names: ['ui-welcome', 'ui-how-to'],
|
|
240
|
+
count: 2,
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a run that pulled nothing says nothing', () => {
|
|
245
|
+
const { monitor } = makeMonitor();
|
|
246
|
+
monitor.notePulled([]);
|
|
247
|
+
expect(monitor.snapshot().pulled).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the name list is capped but the count is not — a cap must not falsify a number', () => {
|
|
251
|
+
// Hub-controlled payload size (same reasoning as rejectedSlugs).
|
|
252
|
+
const { monitor } = makeMonitor();
|
|
253
|
+
monitor.notePulled(Array.from({ length: 50 }, (_, i) => `ui-${i}`));
|
|
254
|
+
const pulled = monitor.snapshot().pulled;
|
|
255
|
+
expect(pulled?.names).toHaveLength(20);
|
|
256
|
+
expect(pulled?.count).toBe(50);
|
|
257
|
+
});
|
|
258
|
+
});
|