fix: prevent plan canvas connection starvation

This commit is contained in:
haelyra
2026-08-27 21:24:03 -04:00
parent bb4adc6462
commit 61652b74ff
6 changed files with 197 additions and 222 deletions
+60 -13
View File
@@ -13,6 +13,9 @@ opening a second Plan Canvas in one agent chat.
disable or stall the page.
2. As a reviewer opening a plan with Mermaid diagrams, I want the Canvas page to
finish loading even when the Mermaid CDN is slow or unavailable.
3. As a reviewer with several Canvas tabs still open, I want the next Canvas to
receive and render its document instead of waiting forever for a browser
connection slot.
## Task report
@@ -43,6 +46,36 @@ opening a second Plan Canvas in one agent chat.
failures.
- GREEN checkpoint: `7fc31254 fix: keep mermaid from blocking canvas load`.
### Stop open canvases from exhausting the browser connection pool
- RED: the normal Chrome profile showed an `Untitled` blank tab with its load
indicator running indefinitely. `lsof -nP -iTCP:4517` showed exactly six
established connections from Chrome's network service to Plan Canvas. Each
older Canvas tab owned one permanent `EventSource`, exhausting Chromium's
six-connection HTTP/1 pool before the next document request could receive a
byte.
- RED: the focused integration test failed because `/client.js` still created
`EventSource`, `/api/session/:key/state` did not exist, `/events/:key` held
its response open, and health still advertised protocol 2.
- GREEN: protocol 3 replaces per-tab EventSource streams with one-second,
finite state requests carrying chat, presence, session status, and artifact
revision. Polls are sequential and abort after five seconds, so they cannot
pile up. Legacy `/events/:key` now returns HTTP 204, the status that tells an
existing EventSource client not to reconnect after the server upgrade.
- GREEN: `node tests/scripts/plan-canvas.test.js` produced 31 passes and 0
failures, including preservation of the active-browser idle lifecycle.
Focused ESLint and `git diff --check` passed.
- BROWSER: nine Canvas tabs were opened in one Chrome automation profile on an
isolated protocol-3 server. Every tab reached `document.readyState =
complete`, exposed its expected plan heading through the iframe accessibility
tree, and reported zero EventSource resources. Two shared keep-alive sockets
served the nine tabs at the observation point.
- DESKTOP: the normal Chrome profile kept the original blank protocol-2 tab
visible while three protocol-3 canvases on the isolated port rendered the
Sandbox Execution Fabric, Feature Fleet, and ECC 2 to ECC 3 plans. A captured
desktop-window image showed all three rendered tabs and live `agent listening`
presence.
## Test specification
| # | What is guaranteed | Test target | Type | Result |
@@ -52,30 +85,44 @@ opening a second Plan Canvas in one agent chat.
| 3 | Mermaid remote enhancement starts only after document load | `a plan containing mermaid serves the themed Mermaid loader` | Integration | PASS |
| 4 | Open, browser load, await, feedback, reply, approval, reopen, end, and stop still work together | `tests/integration/plan-canvas-e2e.test.js` | End to end | PASS |
| 5 | The large ECC 2 to ECC 3 master plan bootstraps under blocked local storage | Headless Chrome DOM and screenshot check against `/canvas/24af75d4c4fe` | Browser | PASS |
| 6 | The browser client never reserves one permanent HTTP connection per open Canvas | `browser client uses finite polling instead of one permanent connection per canvas` | Integration | PASS |
| 7 | Old EventSource clients stop reconnecting after upgrading the server | `legacy EventSource endpoint retires without reconnecting` | Integration | PASS |
| 8 | Browser polling carries chat, presence, end state, and artifact revision | Browser-state integration cases in `tests/scripts/plan-canvas.test.js` | Integration | PASS |
| 9 | More than Chromium's six HTTP/1 connection slots can coexist without blocking a new Canvas | Nine-tab Chrome DOM, accessibility-tree, resource-timing, and screenshot run | Browser | PASS |
| 10 | An actively viewed Canvas keeps the shared server alive through finite polls | `finite browser polls keep an actively viewed canvas server alive` | Integration | PASS |
## Coverage and full-suite evidence
`npm run coverage` passed all 3,993 discovered tests with these project totals:
`npm run coverage` passed all 3,996 discovered tests with these project totals:
- Statements: 88.98%
- Branches: 80.66%
- Functions: 94.32%
- Branches: 80.65%
- Functions: 94.34%
- Lines: 88.98%
The `scripts/lib/plan-canvas` group reached 98.38% statements, 88.2% branches,
98.64% functions, and 98.38% lines. Focused ESLint checks passed for every
The `scripts/lib/plan-canvas` group reached 98.53% statements, 87.82% branches,
100% functions, and 98.53% lines. Focused ESLint checks passed for every
modified JavaScript test and production file.
## Browser evidence and known gaps
The live shared server was initially process `15351`, started from the older
`ecc-tiered-sandbox` worktree, and served an unguarded `localStorage` client even
when invoked from current main. The patched CLI replaced it with the isolated
worktree server and restored persisted sessions. Two real plan sessions then
opened successfully. Headless Chrome with local storage disabled completed the
large master-plan DOM load in about two seconds and produced a rendered Canvas
screenshot.
when invoked from current main. The first patch replaced it with the isolated
worktree server and restored persisted sessions. That did not establish the
reported bug was fixed: HTTP 200 responses and a fresh-profile screenshot did
not exercise the saturated normal browser profile.
Chrome was exercised directly on macOS. Safari and Firefox were not run. The
fix relies only on standard health JSON, dynamic `import()`, and the standard
window `load` event.
The corrected investigation captured the real blank tab, its six live browser
connections, and the exact release-preview session behind it. The final browser
run used an isolated protocol-3 server on port 4518 so other agents could not
replace the executable under test. It rendered the actual in-progress Sandbox
Execution Fabric from the tiered-sandbox worktree, plus Feature Fleet and the
ECC 2 to ECC 3 master plan, in the normal desktop Chrome profile. Active agent
listeners remained attached to all three.
Chrome was exercised directly on macOS through both the user's normal profile
and a browser-automation profile. Safari and Firefox were not run. The
connection-pool fix relies on ordinary finite `fetch` requests, `AbortController`,
HTTP 204 EventSource retirement behavior, and file metadata for live-reload
revision checks.
+34 -113
View File
@@ -4,7 +4,7 @@
* Plan Canvas loopback server.
*
* One detached process serves every open review session: the browser chrome,
* the rendered artifact, an SSE stream for live updates, and the long-poll
* the rendered artifact, finite browser state polling, and the long-poll
* endpoint agents block on. Sessions are keyed by canonical artifact path
* (see sessions.js).
*/
@@ -35,9 +35,7 @@ const MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
// An explicit typing signal expires faster: it means "a reply is seconds away".
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
// Presence is push-based, so expiring states need a tick to re-broadcast on.
const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
const PLAN_CANVAS_PROTOCOL_VERSION = 2;
const PLAN_CANVAS_PROTOCOL_VERSION = 3;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
// Package versions do not distinguish two worktrees on the same release.
@@ -145,7 +143,6 @@ function createPlanCanvasServer({
heartbeatMs = 15000,
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS,
onIdleShutdown = null,
log = () => {}
} = {}) {
@@ -154,17 +151,13 @@ function createPlanCanvasServer({
const allowedHostnames = buildAllowedHostnames(host);
const wake = new EventEmitter();
wake.setMaxListeners(0);
const sseClients = new Map(); // key -> Set<res>
const awaitCounts = new Map(); // key -> active long-poll count
const workingKeys = new Map(); // key -> ms timestamp the agent took feedback
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
const watchers = new Map(); // key -> fs.FSWatcher
const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing
let idleTimer = null;
let presenceSweep = null;
let closed = false;
// --- presence + SSE ---------------------------------------------------
// --- presence ---------------------------------------------------------
/**
* Presence never claims more than the server actually knows:
@@ -192,34 +185,6 @@ function createPlanCanvasServer({
return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
}
function broadcast(key, event, payload) {
const clients = sseClients.get(key);
if (!clients) return;
const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of clients) client.write(frameText);
}
function broadcastPresence(key) {
const state = presenceFor(key);
lastPresence.set(key, state);
broadcast(key, 'presence', { state });
}
// Re-broadcast only where an expiry actually changed the answer, so an
// untouched canvas sees the thinking bubble clear itself.
function sweepPresence() {
for (const key of sseClients.keys()) {
const state = presenceFor(key);
if (lastPresence.get(key) !== state) broadcastPresence(key);
}
}
function startPresenceSweep() {
if (presenceSweep || !presenceSweepMs) return;
presenceSweep = setInterval(sweepPresence, presenceSweepMs);
if (presenceSweep.unref) presenceSweep.unref();
}
// The agent is off working on this feedback batch; start the thinking clock.
function markThinking(key) {
workingKeys.set(key, Date.now());
@@ -234,7 +199,6 @@ function createPlanCanvasServer({
function connectionCount() {
let total = 0;
for (const clients of sseClients.values()) total += clients.size;
for (const count of awaitCounts.values()) total += count;
return total;
}
@@ -260,31 +224,12 @@ function createPlanCanvasServer({
armIdleTimer();
}
// --- artifact watching --------------------------------------------------
function watchSession(session) {
if (watchers.has(session.key)) return;
const dir = path.dirname(session.file);
const base = path.basename(session.file);
let debounce = null;
function artifactVersionFor(session) {
try {
const watcher = fs.watch(dir, (eventType, filename) => {
if (filename && filename !== base) return;
clearTimeout(debounce);
debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
});
watcher.on('error', () => watchers.delete(session.key));
watchers.set(session.key, watcher);
const stat = fs.statSync(session.file, { bigint: true });
return `${stat.mtimeNs}:${stat.size}`;
} catch {
// Watching is best-effort; manual reload still works.
}
}
function unwatchSession(key) {
const watcher = watchers.get(key);
if (watcher) {
watcher.close();
watchers.delete(key);
return null;
}
}
@@ -295,9 +240,6 @@ function createPlanCanvasServer({
if (!session) return null;
clearAgentActivity(key);
wake.emit(`wake:${key}`);
broadcast(key, 'ended', { endedBy: session.endedBy });
broadcastPresence(key);
unwatchSession(key);
return session;
}
@@ -322,8 +264,6 @@ function createPlanCanvasServer({
next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
});
}
watchSession(session);
broadcastPresence(session.key);
return sendJson(res, 200, {
status: 'open',
key: session.key,
@@ -350,7 +290,6 @@ function createPlanCanvasServer({
const first = store.takeFeedback(key);
if (first.status !== 'waiting') {
if (first.status === 'feedback') markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, first);
}
@@ -358,7 +297,6 @@ function createPlanCanvasServer({
noteConnectionOpened();
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
clearAgentActivity(key);
broadcastPresence(key);
let settled = false;
let heartbeat = null;
@@ -371,7 +309,6 @@ function createPlanCanvasServer({
if (payload.status === 'feedback') markThinking(key);
res.end(JSON.stringify(payload));
}
broadcastPresence(key);
noteConnectionClosed();
};
const onWake = () => {
@@ -417,6 +354,23 @@ function createPlanCanvasServer({
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
}
const stateMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/state$/);
if (stateMatch && req.method === 'GET') {
const session = store.get(stateMatch[1]);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
// A visible Canvas used to keep the shared server alive through its SSE
// connection. Preserve that lifecycle with finite polling by restarting
// the idle clock whenever an active browser reports in.
armIdleTimer();
return sendJson(res, 200, {
status: session.status,
endedBy: session.endedBy || null,
chat: session.chat,
presence: presenceFor(session.key),
artifactVersion: artifactVersionFor(session)
});
}
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
if (sessionMatch && req.method === 'POST') {
const [, key, action] = sessionMatch;
@@ -428,13 +382,10 @@ function createPlanCanvasServer({
const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
if (!result) return sendJson(res, 409, { error: 'session already ended' });
wake.emit(`wake:${key}`);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
// A parked `await` takes the batch synchronously on the wake above, so
// presence is already `thinking` by now; with nobody listening it
// reports `queued`. Either way the browser must be told, which the
// original handler never did, leaving a stale pill on screen.
broadcastPresence(key);
// reports `queued`. The browser sees the current answer on its next
// finite state poll.
return sendJson(res, 200, {
status: 'queued',
accepted: result.accepted.length,
@@ -455,8 +406,6 @@ function createPlanCanvasServer({
}
const entry = store.addAgentReply(key, body.text);
clearAgentActivity(key);
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
broadcastPresence(key);
return sendJson(res, 200, { status: 'sent', at: entry.at });
}
@@ -471,7 +420,6 @@ function createPlanCanvasServer({
if (state === 'idle') clearAgentActivity(key);
else if (state === 'typing') typingKeys.set(key, Date.now());
else markThinking(key);
broadcastPresence(key);
return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) });
}
}
@@ -482,32 +430,13 @@ function createPlanCanvasServer({
function handleEvents(req, res, key) {
const session = store.get(key);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
noteConnectionOpened();
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive'
});
res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
if (!sseClients.has(key)) sseClients.set(key, new Set());
sseClients.get(key).add(res);
lastPresence.set(key, presenceFor(key));
startPresenceSweep();
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
if (ping.unref) ping.unref();
req.on('close', () => {
clearInterval(ping);
const clients = sseClients.get(key);
if (clients) {
clients.delete(res);
if (clients.size === 0) {
sseClients.delete(key);
lastPresence.delete(key);
}
}
noteConnectionClosed();
});
// Older Canvas clients opened one permanent EventSource per tab. Six open
// tabs exhausted Chromium's HTTP/1 connection pool for this origin, so
// the next top-level navigation waited forever without receiving a byte.
// HTTP 204 tells EventSource not to reconnect, releasing legacy tabs after
// a server upgrade. Current clients use finite state polling below.
res.writeHead(204, { 'cache-control': 'no-store', connection: 'close' });
res.end();
}
function serveArtifact(res, key, assetPath) {
@@ -626,14 +555,6 @@ function createPlanCanvasServer({
function close() {
closed = true;
clearTimeout(idleTimer);
clearInterval(presenceSweep);
presenceSweep = null;
lastPresence.clear();
for (const key of watchers.keys()) unwatchSession(key);
for (const clients of sseClients.values()) {
for (const client of clients) client.end();
}
sseClients.clear();
wake.emit('server-close');
return new Promise((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()));
@@ -652,7 +573,7 @@ function createPlanCanvasServer({
});
}
return { server, listen, close, presenceFor, sweepPresence, watchSession };
return { server, listen, close, presenceFor };
}
module.exports = {
+40 -14
View File
@@ -209,6 +209,8 @@ function canvasClientJs() {
let lastScroll = { x: 0, y: 0 };
let ended = boot.status === 'ended';
let sending = false;
let chatFingerprint = JSON.stringify(boot.chat || []);
let artifactVersion = null;
try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
@@ -436,7 +438,7 @@ function canvasClientJs() {
}
if (ended) markEnded(boot.endedBy);
// --- server events ----------------------------------------------------
// --- server state -----------------------------------------------------
const PRESENCE_LABELS = {
waiting: 'agent not connected',
listening: 'agent listening',
@@ -450,20 +452,44 @@ function canvasClientJs() {
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
renderActivity(state);
}
function connectEvents() {
const es = new EventSource('/events/' + key);
es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
es.addEventListener('presence', e => applyPresence(JSON.parse(e.data).state));
es.addEventListener('reload', reloadArtifact);
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
es.onerror = () => {
if (ended) return;
renderActivity('offline');
presence.setAttribute('data-state', 'waiting');
presence.querySelector('.label').textContent = 'canvas server offline';
};
async function pollState() {
if (ended) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/session/' + key + '/state', {
cache: 'no-store',
signal: controller.signal
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const state = await res.json();
const nextChatFingerprint = JSON.stringify(state.chat || []);
if (nextChatFingerprint !== chatFingerprint) {
chatFingerprint = nextChatFingerprint;
renderChat(state.chat || []);
}
if (state.status === 'ended') {
markEnded(state.endedBy);
return;
}
applyPresence(state.presence);
if (artifactVersion === null) artifactVersion = state.artifactVersion;
else if (state.artifactVersion !== artifactVersion) {
artifactVersion = state.artifactVersion;
reloadArtifact();
}
} catch {
if (!ended) {
renderActivity('offline');
presence.setAttribute('data-state', 'waiting');
presence.querySelector('.label').textContent = 'canvas server offline';
}
} finally {
clearTimeout(timeout);
if (!ended) setTimeout(pollState, 1000);
}
}
connectEvents();
pollState();
})();`;
}
-4
View File
@@ -390,10 +390,6 @@ async function cmdServer(args, { stateDir, port }) {
startedAt: new Date().toISOString()
}, null, 2)
);
// Sessions restored from disk resume their file watchers.
for (const session of store.list()) {
if (session.status !== 'ended') canvas.watchSession(store.get(session.key));
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
process.stderr.write(`[plan-canvas] serving on http://${bound.host}:${bound.port}\n`);
+1 -1
View File
@@ -176,7 +176,7 @@ async function main() {
assert.strictEqual(result.parsed.status, 'open');
assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server');
const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body);
assert.strictEqual(health.protocolVersion, 2);
assert.strictEqual(health.protocolVersion, 3);
} finally {
if (legacyServer.listening) {
await new Promise(resolve => legacyServer.close(resolve));
+62 -77
View File
@@ -2,7 +2,7 @@
* Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/).
*
* Spins up the real HTTP server in-process and drives it exactly like the
* browser chrome (fetch + SSE) and the agent CLI (long-poll) do.
* browser chrome (finite fetch polling) and the agent CLI (long-poll) do.
*
* Run with: node tests/scripts/plan-canvas.test.js
*/
@@ -63,35 +63,8 @@ function jsonBody(res) {
return JSON.parse(res.body.trim());
}
// Open an SSE stream and collect parsed events into `received`.
function openSse(port, key) {
const received = [];
let close = () => {};
const ready = new Promise((resolve, reject) => {
const req = http.get(
{ host: '127.0.0.1', port, path: `/events/${key}`, agent: false },
res => {
let buffer = '';
res.on('data', chunk => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const eventMatch = frame.match(/^event: (.+)$/m);
const dataMatch = frame.match(/^data: (.+)$/m);
if (eventMatch && dataMatch) {
received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) });
}
}
});
resolve();
}
);
req.on('error', reject);
close = () => req.destroy();
});
return { received, ready, close: () => close() };
async function browserState(port, key) {
return jsonBody(await request(port, 'GET', `/api/session/${key}/state`));
}
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) {
@@ -146,7 +119,7 @@ async function main() {
ok: true,
app: 'ecc-plan-canvas',
version: '9.9.9-test',
protocolVersion: 2,
protocolVersion: 3,
runtimeId: PLAN_CANVAS_RUNTIME_ID
});
})) passed++; else failed++;
@@ -236,6 +209,41 @@ async function main() {
}
})) passed++; else failed++;
if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => {
const res = await request(port, 'GET', '/client.js');
assert.ok(res.body.includes("'/api/session/' + key + '/state'"));
assert.ok(!res.body.includes('new EventSource('));
})) passed++; else failed++;
if (await test('legacy EventSource endpoint retires without reconnecting', async () => {
const res = await request(port, 'GET', `/events/${key}`);
assert.strictEqual(res.statusCode, 204);
assert.strictEqual(res.body, '');
})) passed++; else failed++;
if (await test('finite browser polls keep an actively viewed canvas server alive', async () => {
const idleArtifact = path.join(tmp, 'browser-active.plan.md');
fs.writeFileSync(idleArtifact, '# Plan: Browser Active\n');
const idleStore = createSessionStore({ stateDir: path.join(tmp, 'browser-active-state') });
let shutdowns = 0;
const idleCanvas = createPlanCanvasServer({
store: idleStore,
version: '9.9.9-test',
idleTimeoutMs: 200,
onIdleShutdown: () => { shutdowns += 1; }
});
const bound = await idleCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: idleArtifact } }));
await new Promise(resolve => setTimeout(resolve, 100));
await browserState(bound.port, opened.key);
await new Promise(resolve => setTimeout(resolve, 100));
assert.strictEqual(shutdowns, 0);
await new Promise(resolve => setTimeout(resolve, 120));
assert.strictEqual(shutdowns, 1);
await idleCanvas.close();
})) passed++; else failed++;
if (await test('await with timeoutMs returns waiting when idle', async () => {
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`);
assert.strictEqual(jsonBody(res).status, 'waiting');
@@ -247,10 +255,9 @@ async function main() {
})) passed++; else failed++;
if (await test('browser feedback wakes a blocking await; presence transitions', async () => {
const sse = openSse(port, key);
await sse.ready;
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening'));
await waitFor(() => canvas.presenceFor(key) === 'listening');
assert.strictEqual((await browserState(port, key)).presence, 'listening');
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
body: {
@@ -268,9 +275,9 @@ async function main() {
assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)');
assert.strictEqual(result.items[1].verdict, 'request-changes');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking'));
await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2));
sse.close();
const state = await browserState(port, key);
assert.strictEqual(state.presence, 'thinking');
assert.strictEqual(state.chat.length, 2);
})) passed++; else failed++;
// Regression: feedback sent with nobody parked on `await` used to leave the
@@ -279,34 +286,30 @@ async function main() {
const queuedArtifact = path.join(tmp, 'queued.plan.md');
fs.writeFileSync(queuedArtifact, '# Plan: Queued\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: queuedArtifact } }));
const sse = openSse(port, opened.key);
await sse.ready;
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting');
const post = await request(port, 'POST', `/api/session/${opened.key}/feedback`, {
body: { items: [{ kind: 'chat', text: 'anyone there?' }] }
});
assert.strictEqual(jsonBody(post).presence, 'queued');
assert.strictEqual(canvas.presenceFor(opened.key), 'queued');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'queued'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'queued');
// Draining it hands the batch over and flips the indicator to thinking.
const drained = jsonBody(await request(port, 'GET', `/api/await?key=${opened.key}&timeoutMs=0`));
assert.strictEqual(drained.status, 'feedback');
assert.strictEqual(canvas.presenceFor(opened.key), 'thinking');
sse.close();
assert.strictEqual((await browserState(port, opened.key)).presence, 'thinking');
})) passed++; else failed++;
if (await test('typing endpoint drives the indicator and reply clears it', async () => {
const typingArtifact = path.join(tmp, 'typing.plan.md');
fs.writeFileSync(typingArtifact, '# Plan: Typing\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: typingArtifact } }));
const sse = openSse(port, opened.key);
await sse.ready;
const typing = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } });
assert.strictEqual(jsonBody(typing).presence, 'typing');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'typing'));
assert.strictEqual((await browserState(port, opened.key)).presence, 'typing');
const thinking = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
assert.strictEqual(jsonBody(thinking).presence, 'thinking');
@@ -317,8 +320,7 @@ async function main() {
// A landed reply must take the bubble down, not leave it spinning.
await request(port, 'POST', `/api/session/${opened.key}/reply`, { body: { text: 'done' } });
assert.strictEqual(canvas.presenceFor(opened.key), 'waiting');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting'));
sse.close();
assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting');
})) passed++; else failed++;
if (await test('thinking and typing states expire instead of sticking', async () => {
@@ -330,8 +332,7 @@ async function main() {
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 40,
typingExpiryMs: 20,
presenceSweepMs: 0
typingExpiryMs: 20
});
const bound = await staleCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } }));
@@ -353,9 +354,7 @@ async function main() {
await staleCanvas.close();
})) passed++; else failed++;
// The stuck pill only self-heals if the decay is pushed to an idle browser
// that is not making any requests of its own.
if (await test('presence sweep pushes the decayed state to an idle browser', async () => {
if (await test('finite browser polling observes a decayed presence state', async () => {
const sweepArtifact = path.join(tmp, 'sweep.plan.md');
fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n');
const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') });
@@ -363,22 +362,15 @@ async function main() {
store: sweepStore,
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 50,
presenceSweepMs: 20
thinkingStaleMs: 50
});
const bound = await sweepCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: sweepArtifact } }));
const sse = openSse(bound.port, opened.key);
await sse.ready;
await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking'));
const before = sse.received.length;
await waitFor(() =>
sse.received.slice(before).some(e => e.event === 'presence' && e.data.state === 'waiting')
);
sse.close();
assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'thinking');
await new Promise(resolve => setTimeout(resolve, 80));
assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'waiting');
await sweepCanvas.close();
})) passed++; else failed++;
@@ -403,25 +395,18 @@ async function main() {
assert.strictEqual(JSON.parse(full.trim()).status, 'feedback');
})) passed++; else failed++;
if (await test('agent reply lands in the chat via SSE chat-sync', async () => {
const sse = openSse(port, key);
await sse.ready;
if (await test('agent reply lands in the finite browser state response', async () => {
const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } });
assert.strictEqual(jsonBody(res).status, 'sent');
await waitFor(() =>
sse.received.some(
e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked'))
)
);
sse.close();
const state = await browserState(port, key);
assert.ok(state.chat.some(m => m.role === 'agent' && m.text.includes('reworked')));
})) passed++; else failed++;
if (await test('live reload: editing the artifact emits an SSE reload event', async () => {
const sse = openSse(port, key);
await sse.ready;
if (await test('live reload: editing the artifact changes the finite state revision', async () => {
const before = (await browserState(port, key)).artifactVersion;
fs.appendFileSync(artifact, '\n## Addendum\n');
await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 });
sse.close();
const after = (await browserState(port, key)).artifactVersion;
assert.notStrictEqual(after, before);
})) passed++; else failed++;
if (await test('send-and-end delivers the final batch and ends the session', async () => {