From 3914e96c17741419b1db5ef98525123fedb513f9 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 9 Oct 2026 21:15:22 -0700 Subject: [PATCH] fix(docs): isolate quickstart demo conversations and approved artifacts --- .github/workflows/docs.yml | 4 + docs/assets/experience.js | 26 +++++- tests/javascript/experience.test.cjs | 114 +++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/javascript/experience.test.cjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2f6c001..eba92d1c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,6 +29,10 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: node --test tests/javascript/experience.test.cjs - uses: actions/setup-python@v5 with: python-version: "3.11" diff --git a/docs/assets/experience.js b/docs/assets/experience.js index e213934d..5ff7e3c6 100644 --- a/docs/assets/experience.js +++ b/docs/assets/experience.js @@ -54,6 +54,9 @@ let activeHost = 'claude'; let activeKind = 'skill'; let phase = 0; + // A restart invalidates every delayed continuation from the previous demo. + let conversation = 0; + let plannedKind; const approve = $('#demo-approve'); const start = $('#demo-run'); const status = $('#demo-status'); @@ -81,31 +84,46 @@ })); start.addEventListener('click', async () => { if (start.disabled) return; + const run = ++conversation; start.disabled = true; phase = 0; approve.hidden = true; demoBody.replaceChildren(); const path = $('#demo-path').value.trim() || kinds[activeKind].path; const kind = kinds[activeKind]; + plannedKind = kind; // Preset edits configure the next conversation only. status.textContent = 'PLANNING'; toolLine.textContent = 'Calling doctor and plan…'; addMessage('user', 'YOU', `Improve the ${kind.noun} at ${path}. Show the plan and call count before starting.`); await pause(460); + if (run !== conversation) return; addMessage('tool', 'AGENTDESCENT · DOCTOR', 'Checked the agent, provider configuration and run store.', 'No files changed.'); await pause(580); + if (run !== conversation) return; addMessage('tool', 'AGENTDESCENT · PLAN', 'Sample plan: 12 cases · 4 workers · up to 48 agent calls.', 'Please review the plan and cost before starting.'); status.textContent = 'AWAITING APPROVAL'; toolLine.textContent = 'Plan ready · no run started'; approve.textContent = 'Approve sample run ↗'; approve.hidden = false; phase = 1; start.disabled = false; }); approve.addEventListener('click', async () => { + if (phase !== 1 && phase !== 2) return; + const run = conversation; + const kind = plannedKind; approve.hidden = true; if (phase === 1) { phase = 0; status.textContent = 'RUNNING'; toolLine.textContent = 'start → status → show'; addMessage('user', 'YOU', 'The plan looks good. Start the sample run.'); - await pause(380); addMessage('tool', 'AGENTDESCENT · START', 'Started a background evolution run.'); - await pause(550); addMessage('tool', 'AGENTDESCENT · STATUS', 'Workers proposed edits; the aggregator evaluated the candidate.'); - await pause(550); addMessage('tool', 'AGENTDESCENT · SHOW', `Proposed diff: + ${kinds[activeKind].example}.`, 'The real artifact remains untouched until you approve apply.'); + await pause(380); + if (run !== conversation) return; + addMessage('tool', 'AGENTDESCENT · START', 'Started a background evolution run.'); + await pause(550); + if (run !== conversation) return; + addMessage('tool', 'AGENTDESCENT · STATUS', 'Workers proposed edits; the aggregator evaluated the candidate.'); + await pause(550); + if (run !== conversation) return; + addMessage('tool', 'AGENTDESCENT · SHOW', `Proposed diff: + ${kind.example}.`, 'The real artifact remains untouched until you approve apply.'); status.textContent = 'DIFF READY'; toolLine.textContent = 'Diff ready · waiting for apply approval'; approve.textContent = 'Approve sample apply ↗'; approve.hidden = false; phase = 2; } else if (phase === 2) { phase = 0; addMessage('user', 'YOU', 'Apply the sample result.'); - await pause(350); addMessage('tool', 'AGENTDESCENT · APPLY', 'Sample change applied; the original is backed up.', 'This page is only a simulation and changed no files.'); + await pause(350); + if (run !== conversation) return; + addMessage('tool', 'AGENTDESCENT · APPLY', 'Sample change applied; the original is backed up.', 'This page is only a simulation and changed no files.'); status.textContent = 'COMPLETE'; toolLine.textContent = 'Sample workflow complete · no real files changed'; } }); diff --git a/tests/javascript/experience.test.cjs b/tests/javascript/experience.test.cjs new file mode 100644 index 00000000..bda636ec --- /dev/null +++ b/tests/javascript/experience.test.cjs @@ -0,0 +1,114 @@ +// Execute the shipped script with a minimal DOM and manually advanced timers. +// This is deterministic interaction coverage, not a rendered-browser test. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const {test} = require('node:test'); +const source = fs.readFileSync(process.env.EXPERIENCE_JS || 'docs/assets/experience.js', 'utf8'); + +function demo() { + class Element { + constructor(dataset = {}) { + this.dataset = dataset; this.children = []; this.listeners = {}; + this.textContent = ''; this.value = ''; this.hidden = false; this.disabled = false; + this.classList = {toggle() {}}; + } + addEventListener(name, callback) { this.listeners[name] = callback; } + click() { if (!this.disabled) this.listeners.click?.({currentTarget: this}); } + append(...children) { this.children.push(...children); } + replaceChildren() { this.children = []; } + getAttribute() { return null; } + setAttribute() {} + } + const elements = Object.fromEntries(['demo-chat-body', 'demo-approve', 'demo-run', + 'demo-status', 'demo-tool-line', 'demo-path'].map(id => [id, new Element()])); + const presets = ['skill', 'prompt', 'code'].map(demoKind => new Element({demoKind})); + let now = 0; + const timers = []; + vm.runInNewContext(source, { + document: { + querySelector: selector => elements[selector.slice(1)] || null, + querySelectorAll: selector => selector === '.demo-presets button' ? presets : [], + createElement: () => new Element(), + }, + setTimeout: (callback, delay) => timers.push({callback, at: now + delay}), + }); + async function advance(ms) { + const end = now + ms; + while (true) { + timers.sort((a, b) => a.at - b.at); + if (!timers.length || timers[0].at > end) break; + const timer = timers.shift(); now = timer.at; timer.callback(); + await Promise.resolve(); + } + now = end; + await Promise.resolve(); + } + return { + start: elements['demo-run'], approve: elements['demo-approve'], + status: elements['demo-status'], path: elements['demo-path'], advance, + select: kind => presets.find(button => button.dataset.demoKind === kind).click(), + messages: () => elements['demo-chat-body'].children.map(article => { + const body = article.children[1]; + return {label: body.children[0].textContent, text: body.children[1].textContent}; + }), + }; +} + +test('approval gates and repeated clicks preserve the complete sample workflow', async () => { + const d = demo(); + d.approve.click(); assert.deepEqual(d.messages(), []); + d.start.click(); d.start.click(); d.approve.click(); + await d.advance(1040); + assert.equal(d.messages().length, 3); + assert.equal(d.status.textContent, 'AWAITING APPROVAL'); + await d.advance(5000); assert.equal(d.messages().length, 3); + d.approve.click(); d.approve.click(); + await d.advance(1480); + assert.equal(d.status.textContent, 'DIFF READY'); + assert.equal(d.messages().length, 7); + await d.advance(5000); assert.equal(d.messages().length, 7); + d.approve.click(); d.approve.click(); await d.advance(350); + assert.equal(d.status.textContent, 'COMPLETE'); + assert.equal(d.messages().length, 9); + d.approve.click(); assert.equal(d.messages().length, 9); +}); + +for (const delay of [0, 380, 930, 1480, 1481]) { + test(`restart cancels stale run/apply callbacks after ${delay}ms`, async () => { + const d = demo(); d.start.click(); await d.advance(1040); d.approve.click(); + await d.advance(delay); + if (delay === 1481) d.approve.click(); // Interrupt the apply animation too. + d.select('code'); d.start.click(); await d.advance(5000); + assert.equal(d.status.textContent, 'AWAITING APPROVAL'); + assert.deepEqual(d.messages().map(m => m.label), ['YOU', 'AGENTDESCENT · DOCTOR', 'AGENTDESCENT · PLAN']); + assert.match(d.messages()[0].text, /agent code/); + assert.equal(d.approve.hidden, false); + d.approve.click(); await d.advance(1480); + assert.equal(d.status.textContent, 'DIFF READY'); + assert.match(d.messages().at(-1).text, /Rank retrieved evidence/); + }); +} + +for (const initial of ['skill', 'prompt', 'code']) { + test(`preset edits cannot change the ${initial} plan or approved diff`, async () => { + const examples = {skill: 'missing appendix pages', prompt: 'cite the source', code: 'Rank retrieved evidence'}; + const d = demo(); d.select(initial); d.path.value = './custom-artifact'; d.start.click(); + d.select('prompt'); await d.advance(1040); + d.select('code'); d.approve.click(); await d.advance(380); + d.select('skill'); await d.advance(1100); + assert.match(d.messages()[0].text, /custom-artifact/); + assert.ok(d.messages().at(-1).text.includes(examples[initial])); + d.approve.click(); await d.advance(350); + d.select('prompt'); d.start.click(); await d.advance(1040); d.approve.click(); await d.advance(1480); + assert.ok(d.messages().at(-1).text.includes(examples.prompt)); + }); +} + +test('restart while awaiting approval replaces the old plan', async () => { + const d = demo(); d.start.click(); await d.advance(1040); + d.select('prompt'); d.start.click(); d.approve.click(); await d.advance(1040); + assert.equal(d.messages().length, 3); + d.approve.click(); await d.advance(1480); + assert.match(d.messages().at(-1).text, /cite the source/); +});