@1agh/maude 0.56.0 → 0.57.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/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/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- 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/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- 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/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -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/whats-new.json +18 -0
- 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,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);
|
|
@@ -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
|
+
});
|