Files
ECC/tests/scripts/plan-canvas.test.js
T
ae303fb6c1 fix(plan-canvas): deliver browser chat to the agent every time (#2739)
Feedback sent from the canvas only reached an agent through a live
/api/await long poll. When a turn ended with no await parked,
queueFeedback wrote the message to sessions.json and nothing ever
consumed it, so sending appeared to do nothing at all. The presence pill
made it worse: workingKeys had no expiry and the feedback handler never
broadcast presence, so it froze on "agent working" while nobody was
listening.

Delivery:
- Add the stop:plan-canvas-pending hook. It drains undelivered feedback
  and blocks the Stop, handing the messages to the agent, so a canvas
  message lands even when no await is running. Scoped to sessions under
  cwd so parallel agents cannot swallow each other's feedback; set
  ECC_PLAN_CANVAS_STOP_SCOPE=all to widen. Honors stop_hook_active and
  fails open on every error path.
- run-with-flags.js did not await a hook's run(), so any async hook
  silently degraded to pass-through. Fixed; plan-canvas-pending is the
  only async hook today.

Presence and indicators:
- Presence is now ended/typing/thinking/listening/queued/waiting.
  thinking and typing self-expire (90s/30s) and a 5s sweep pushes the
  decay to an idle browser, so the pill can no longer stick.
- Broadcast presence when feedback is queued, and clear the activity
  state when an agent reply lands.
- Add POST /api/session/:key/typing so agents can drive the indicator.
- Chat shows an animated dots bubble for thinking and typing, plus an
  explicit note when a message is queued with nobody listening.
  Respects prefers-reduced-motion.
- Send status reports what actually happened instead of always claiming
  the agent will pick it up.

CLI and skill:
- Add `ecc-plan-canvas pending` and `typing <file> --state ...`.
- SKILL.md documents background await as the primary pattern and makes
  replying in the canvas mandatory.

Tests: 6 new server cases covering queued presence, the typing endpoint,
state expiry and the sweep, plus a new hook suite covering delivery,
drain-once, stop_hook_active, cwd scoping and fail-open.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:15:25 -04:00

488 lines
21 KiB
JavaScript

/**
* 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.
*
* Run with: node tests/scripts/plan-canvas.test.js
*/
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions');
const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server');
async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
return true;
} catch (err) {
console.log(` ✗ ${name}`);
console.log(` Error: ${err.stack || err.message}`);
return false;
}
}
function request(port, method, requestPath, { body = null, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const req = http.request(
{
host: '127.0.0.1',
port,
method,
path: requestPath,
agent: false,
headers: payload
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), ...headers }
: headers
},
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: data }));
}
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
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() };
}
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (predicate()) {
clearInterval(timer);
resolve();
} else if (Date.now() - startedAt > timeoutMs) {
clearInterval(timer);
reject(new Error('waitFor timed out'));
}
}, intervalMs);
});
}
async function main() {
console.log('\n=== Testing plan-canvas server ===\n');
let passed = 0;
let failed = 0;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-server-'));
const artifact = path.join(tmp, 'demo.plan.md');
fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n');
const htmlArtifact = path.join(tmp, 'report.html');
fs.writeFileSync(htmlArtifact, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-outside-'));
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
let idleFired = false;
const canvas = createPlanCanvasServer({
store,
version: '9.9.9-test',
heartbeatMs: 25,
idleTimeoutMs: 0,
onIdleShutdown: () => {
idleFired = true;
}
});
const { port } = await canvas.listen(0);
let key = null;
let htmlKey = null;
if (await test('GET /health identifies the app and version', async () => {
const res = await request(port, 'GET', '/health');
assert.deepStrictEqual(jsonBody(res), { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test' });
})) passed++; else failed++;
if (await test('requests with a non-loopback Host header are rejected', async () => {
const res = await request(port, 'GET', '/health', { headers: { host: 'evil.example.com' } });
assert.strictEqual(res.statusCode, 403);
})) passed++; else failed++;
if (await test('requests with a cross-site Origin are rejected', async () => {
const res = await request(port, 'POST', '/shutdown', { headers: { origin: 'https://evil.example.com' } });
assert.strictEqual(res.statusCode, 403);
})) passed++; else failed++;
if (await test('POST /api/sessions opens a session for an existing artifact', async () => {
const res = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(res.statusCode, 200);
const body = jsonBody(res);
assert.strictEqual(body.status, 'open');
assert.match(body.key, /^[a-f0-9]{12}$/);
key = body.key;
})) passed++; else failed++;
if (await test('POST /api/sessions 404s for a missing artifact', async () => {
const res = await request(port, 'POST', '/api/sessions', { body: { file: path.join(tmp, 'nope.md') } });
assert.strictEqual(res.statusCode, 404);
})) passed++; else failed++;
if (await test('GET /canvas/:key serves the ECC chrome with CSP', async () => {
const res = await request(port, 'GET', `/canvas/${key}`);
assert.strictEqual(res.statusCode, 200);
assert.ok(res.headers['content-security-policy'].includes("default-src 'self'"));
assert.ok(res.body.includes('Plan Canvas'));
assert.ok(res.body.includes('pc-session'));
assert.ok(res.body.includes('Approve plan'));
assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"'));
})) passed++; else failed++;
if (await test('markdown artifacts render in the ECC plan template with the SDK', async () => {
const res = await request(port, 'GET', `/artifact/${key}/`);
assert.strictEqual(res.statusCode, 200);
assert.ok(res.body.includes('<h1 id="plan-demo">'));
assert.ok(res.body.includes('<table>'));
assert.ok(res.body.includes('<script src="/sdk.js">'));
assert.strictEqual(res.headers['content-security-policy'], undefined);
// No diagram in this plan → no Mermaid loader shipped.
assert.ok(!res.body.includes('mermaid.run'));
})) passed++; else failed++;
if (await test('a plan containing ```mermaid serves the themed Mermaid loader', async () => {
const diagram = path.join(tmp, 'flow.plan.md');
fs.writeFileSync(diagram, '# Flow\n\n```mermaid\nflowchart LR\n A --> B\n```\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: diagram } }));
const res = await request(port, 'GET', `/artifact/${opened.key}/`);
assert.ok(res.body.includes('<pre class="mermaid">'), 'diagram container present');
assert.ok(res.body.includes('mermaid.run'), 'loader injected');
assert.ok(res.body.includes("securityLevel: 'strict'"), 'sanitizing config present');
await request(port, 'POST', '/api/end', { body: { file: diagram } });
})) passed++; else failed++;
if (await test('HTML artifacts pass through with the SDK injected before </body>', async () => {
const open = await request(port, 'POST', '/api/sessions', { body: { file: htmlArtifact } });
htmlKey = jsonBody(open).key;
const res = await request(port, 'GET', `/artifact/${htmlKey}/`);
assert.ok(res.body.includes('<h1>Report</h1>'));
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
})) passed++; else failed++;
if (await test('sibling assets are served, traversal is blocked', async () => {
const ok = await request(port, 'GET', `/artifact/${key}/style.css`);
assert.strictEqual(ok.statusCode, 200);
assert.ok(ok.body.includes('color: red'));
const escape = await request(port, 'GET', `/artifact/${key}/..%2F${path.basename(outsideDir)}%2Fsecret.txt`);
assert.strictEqual(escape.statusCode, 403);
})) passed++; else failed++;
if (await test('static chrome assets are served', async () => {
for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) {
const res = await request(port, 'GET', asset);
assert.strictEqual(res.statusCode, 200, `${asset} should be 200`);
}
})) 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');
})) passed++; else failed++;
if (await test('await returns missing for files without a session', async () => {
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(path.join(tmp, 'other.md'))}`);
assert.strictEqual(jsonBody(res).status, 'missing');
})) 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'));
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
body: {
items: [
{ kind: 'annotation', text: 'tighten this', anchor: { selector: 'h2:nth-of-type(1)', tag: 'h2', snippet: 'Files to Change' } },
{ kind: 'verdict', verdict: 'request-changes' }
]
}
});
assert.strictEqual(jsonBody(post).accepted, 2);
const result = jsonBody(await awaitPromise);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.items.length, 2);
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();
})) passed++; else failed++;
// Regression: feedback sent with nobody parked on `await` used to leave the
// pill claiming "agent working" while the message sat undelivered forever.
if (await test('feedback with no listener reports queued, not working', async () => {
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'));
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'));
// 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();
})) 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'));
const thinking = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
assert.strictEqual(jsonBody(thinking).presence, 'thinking');
const bad = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'dancing' } });
assert.strictEqual(bad.statusCode, 400);
// 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();
})) passed++; else failed++;
if (await test('thinking and typing states expire instead of sticking', async () => {
const staleArtifact = path.join(tmp, 'stale.plan.md');
fs.writeFileSync(staleArtifact, '# Plan: Stale\n');
const staleStore = createSessionStore({ stateDir: path.join(tmp, 'stale-state') });
const staleCanvas = createPlanCanvasServer({
store: staleStore,
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 40,
typingExpiryMs: 20,
presenceSweepMs: 0
});
const bound = await staleCanvas.listen(0);
const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } }));
await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } });
assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing');
await new Promise(resolve => setTimeout(resolve, 60));
assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting');
// An abandoned agent decays to queued so the human is never told a
// stalled session is still being worked on.
await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } });
await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, {
body: { items: [{ kind: 'chat', text: 'still there?' }] }
});
assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking');
await new Promise(resolve => setTimeout(resolve, 60));
assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued');
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 () => {
const sweepArtifact = path.join(tmp, 'sweep.plan.md');
fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n');
const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') });
const sweepCanvas = createPlanCanvasServer({
store: sweepStore,
version: '9.9.9-test',
idleTimeoutMs: 0,
thinkingStaleMs: 50,
presenceSweepMs: 20
});
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();
await sweepCanvas.close();
})) passed++; else failed++;
if (await test('long-poll heartbeat whitespace arrives before the payload', async () => {
const chunks = [];
const done = new Promise((resolve, reject) => {
const req = http.get(
{ host: '127.0.0.1', port, path: `/api/await?file=${encodeURIComponent(artifact)}`, agent: false },
res => {
res.on('data', chunk => chunks.push(chunk.toString()));
res.on('end', resolve);
}
);
req.on('error', reject);
});
// Heartbeats tick every 25ms in this test server; wait for a few first.
await waitFor(() => chunks.join('').length >= 3);
assert.ok(/^\s+$/.test(chunks.join('')), 'expected only whitespace before payload');
await request(port, 'POST', `/api/session/${key}/feedback`, { body: { items: [{ kind: 'chat', text: 'wake up' }] } });
await done;
const full = chunks.join('');
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;
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();
})) 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;
fs.appendFileSync(artifact, '\n## Addendum\n');
await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 });
sse.close();
})) passed++; else failed++;
if (await test('send-and-end delivers the final batch and ends the session', async () => {
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => canvas.presenceFor(key) === 'listening');
await request(port, 'POST', `/api/session/${key}/feedback`, {
body: { items: [{ kind: 'chat', text: 'looks good, wrapping up' }], endSession: true }
});
const result = jsonBody(await awaitPromise);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.sessionEnded, true);
assert.strictEqual(result.endedBy, 'user');
const after = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=0`);
assert.strictEqual(jsonBody(after).status, 'ended');
})) passed++; else failed++;
if (await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => {
const refused = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(refused.statusCode, 409);
assert.strictEqual(jsonBody(refused).status, 'user-ended');
const forced = await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
assert.strictEqual(forced.statusCode, 200);
})) passed++; else failed++;
if (await test('agent end via POST /api/end allows plain reopen', async () => {
const res = await request(port, 'POST', '/api/end', { body: { file: artifact } });
assert.strictEqual(jsonBody(res).endedBy, 'agent');
const reopened = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(reopened.statusCode, 200);
})) passed++; else failed++;
if (await test('feedback on an ended session is refused with 409', async () => {
await request(port, 'POST', `/api/end`, { body: { file: htmlArtifact } });
const res = await request(port, 'POST', `/api/session/${htmlKey}/feedback`, {
body: { items: [{ kind: 'chat', text: 'too late' }] }
});
assert.strictEqual(res.statusCode, 409);
})) passed++; else failed++;
if (await test('GET / lists sessions in the ECC shell', async () => {
const res = await request(port, 'GET', '/');
assert.ok(res.body.includes('Plan Canvas sessions'));
assert.ok(res.body.includes('demo.plan.md'));
})) passed++; else failed++;
if (await test('POST /shutdown triggers the shutdown callback', async () => {
const res = await request(port, 'POST', '/shutdown');
assert.strictEqual(jsonBody(res).status, 'stopping');
await waitFor(() => idleFired);
})) passed++; else failed++;
if (await test('close() settles a held long-poll instead of hanging', async () => {
await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
const held = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => canvas.presenceFor(store.findByFile(artifact).key) === 'listening');
await canvas.close();
const result = jsonBody(await held);
assert.strictEqual(result.status, 'waiting');
assert.ok(result.note.includes('shutting down'));
})) passed++; else failed++;
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error(err);
console.log('Passed: 0');
console.log('Failed: 1');
process.exit(1);
});