|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * The agent recognises a "Done:" line written in markdown — run: node test/agentDone.test.js |
| 3 | + * |
| 4 | + * The bug this locks down: a FINISHED run was pushed to keep going. The loop takes a turn with no |
| 5 | + * tool call for the end of the run when a line starts with "Done:", and it read the text literally — |
| 6 | + * so the answer |
| 7 | + * |
| 8 | + * **Done:** created a styled, renderable workflow diagram of how a chat message becomes a reply. |
| 9 | + * |
| 10 | + * did not count: the `**` sits between the line start and the word. The turn then fell through to the |
| 11 | + * stall check, where a "next " anywhere in the answer reads as a promise of more work, and the model |
| 12 | + * was sent "Stop planning. … Make your next edit_file (or write_file) call NOW". It replied with a |
| 13 | + * second "Done:", and the user watched the agent finish twice. |
| 14 | + * |
| 15 | + * Two halves, both asserted. saysDone() is the verdict, and is called directly. That runAgent ASKS it |
| 16 | + * is a separate fact — a verdict the loop does not consult fixes nothing — so the real loop runs here |
| 17 | + * on scripted turns, with `vscode` and the provider replaced as agentRunCommand.test.js replaces them. |
| 18 | + * No scripted turn calls a tool, so no process is started; and runAgent resolves only once the run is |
| 19 | + * over, so there is nothing left to wait for when it returns. |
| 20 | + *--------------------------------------------------------------------------------------------*/ |
| 21 | +// @ts-check |
| 22 | +'use strict'; |
| 23 | + |
| 24 | +const assert = require('assert'); |
| 25 | +const fs = require('fs'); |
| 26 | +const os = require('os'); |
| 27 | +const path = require('path'); |
| 28 | +const Module = require('module'); |
| 29 | + |
| 30 | +// ---- the outside world, replaced -------------------------------------------------------------- |
| 31 | + |
| 32 | +// vscode: one EMPTY workspace folder — no rules file and no .levelcode/mcp.json, so the run has nothing |
| 33 | +// to load and no MCP server to start. |
| 34 | +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-done-')); |
| 35 | +const vscodeMock = { workspace: { workspaceFolders: [{ uri: { fsPath: root }, name: 'app' }] } }; |
| 36 | + |
| 37 | +const origLoad = Module._load; |
| 38 | +// @ts-ignore — test-only loader shim |
| 39 | +Module._load = function (request, parent, isMain) { |
| 40 | + if (request === 'vscode') { return vscodeMock; } |
| 41 | + return origLoad.call(this, request, parent, isMain); |
| 42 | +}; |
| 43 | + |
| 44 | +// The provider: scripted turns instead of a network call. Patched on the module object agent.js shares, |
| 45 | +// and BEFORE agent.js loads, so it holds however agent.js chooses to import it. `asked` counts the |
| 46 | +// turns the loop requested — one more than a finished run needs is the whole bug. |
| 47 | +const providers = require('../providers/index'); |
| 48 | +let script = []; |
| 49 | +let asked = 0; |
| 50 | +providers.streamAgentTurn = async () => { |
| 51 | + asked++; |
| 52 | + const turn = script.shift(); |
| 53 | + if (!turn) { throw new Error('the agent asked for a turn the script does not have'); } |
| 54 | + return turn; |
| 55 | +}; |
| 56 | + |
| 57 | +const { runAgent, saysDone } = require('../agent'); |
| 58 | + |
| 59 | +// ---- harness ---------------------------------------------------------------------------------- |
| 60 | + |
| 61 | +let n = 0; |
| 62 | +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } |
| 63 | +async function testAsync(name, fn) { await fn(); n++; console.log(' ok - ' + name); } |
| 64 | + |
| 65 | +/** |
| 66 | + * Run one goal to the end of the RUN. Every scripted turn is text with no tool call, which is the |
| 67 | + * only kind of turn the Done test and the stall check ever see. |
| 68 | + * @param {string[]} texts what the model says, one entry per turn it is asked for |
| 69 | + */ |
| 70 | +async function run(texts) { |
| 71 | + const posted = [], logged = []; |
| 72 | + script = texts.map((text) => ({ stop_reason: 'end_turn', content: [{ type: 'text', text }] })); |
| 73 | + asked = 0; |
| 74 | + const messages = [{ role: 'user', content: 'draw how a chat message becomes a reply' }]; |
| 75 | + await runAgent({ |
| 76 | + messages, |
| 77 | + maxSteps: 5, |
| 78 | + post: (m) => posted.push(m), |
| 79 | + dbg: (label, data) => logged.push({ label, data }), |
| 80 | + signal: new AbortController().signal |
| 81 | + }); |
| 82 | + return { |
| 83 | + asked, |
| 84 | + roles: messages.map((m) => m.role), |
| 85 | + nudges: logged.filter((l) => l.label === 'nudge').length, |
| 86 | + end: posted.filter((m) => m.type === 'agentError' || m.type === 'agentDone').map((m) => m.reason || m.message) |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +// The answer from the bug report. It is a Done line AND it holds a "next " — the second is what turned |
| 91 | +// an unrecognised ending into a nudge. |
| 92 | +const REPORTED = '**Done:** created a styled, renderable workflow diagram of how a chat message becomes a reply.\n' |
| 93 | + + '\n' |
| 94 | + + 'The last box reads:\n' |
| 95 | + + '“Your next message updates context—not model weights”'; |
| 96 | + |
| 97 | +// A real stall: an edit announced, and no tool call to make it. |
| 98 | +const PROMISE = 'I found the validator. Now I’ll update it to cover subdomains:'; |
| 99 | + |
| 100 | +// Another stall: a command pasted where a run_command call belonged. Its last word is the shell's own |
| 101 | +// "done", behind a closing fence. |
| 102 | +const PASTED_LOOP = 'Let me run this to rename them:\n' |
| 103 | + + '```bash\n' |
| 104 | + + 'for f in *.txt; do\n' |
| 105 | + + ' mv "$f" "${f%.txt}.md"\n' |
| 106 | + + 'done\n' |
| 107 | + + '```'; |
| 108 | + |
| 109 | +// And a file pasted where a write_file call belonged. `_done` is a key: one mark, and nothing closes it. |
| 110 | +const PASTED_FILE = 'Let me write config.yaml:\n' |
| 111 | + + '```yaml\n' |
| 112 | + + '_done: false\n' |
| 113 | + + '```'; |
| 114 | + |
| 115 | +(async () => { |
| 116 | + try { |
| 117 | + // ---- 1. the verdict -------------------------------------------------------------------------- |
| 118 | + |
| 119 | + test('VERDICT: a Done line counts, however markdown dresses it', () => { |
| 120 | + const done = [ |
| 121 | + REPORTED, |
| 122 | + 'Done: created the workflow diagram in docs/flow.html.', |
| 123 | + '## Done: shipped', |
| 124 | + '> Done: quoted', |
| 125 | + '- Done: in a list', |
| 126 | + '1. Done: numbered', |
| 127 | + '__Done:__ underscore', |
| 128 | + '`Done:` code' |
| 129 | + ]; |
| 130 | + for (const text of done) { |
| 131 | + assert.strictEqual(saysDone(text), true, 'not read as finished: ' + JSON.stringify(text)); |
| 132 | + } |
| 133 | + }); |
| 134 | + |
| 135 | + test('VERDICT: the marks may be stacked, and the emphasis may hold the word or the whole sentence', () => { |
| 136 | + const done = [ |
| 137 | + // #102 review: one mark was taken off the line start, where a model may stack several. |
| 138 | + '> ## **Done:** quote, heading and bold', |
| 139 | + '- > **Done:** list, quote and bold', |
| 140 | + '> - Done: a quoted list item', |
| 141 | + // Emphasis is unwrapped as a pair, so every way of closing it has to be found. |
| 142 | + '**Done**: the colon outside the bold', |
| 143 | + '**Done: the whole sentence in bold.**', |
| 144 | + '_Done: the whole sentence in italics._', |
| 145 | + '**_Done:_** two kinds at once', |
| 146 | + // And the line may still sit below blank ones, indented — with marks or without. |
| 147 | + 'The suite passes.\n\n\n\t Done: after blank lines, behind a tab and a space.', |
| 148 | + 'The suite passes.\n\n **Done:** indented, and in no list.' |
| 149 | + ]; |
| 150 | + for (const text of done) { |
| 151 | + assert.strictEqual(saysDone(text), true, 'not read as finished: ' + JSON.stringify(text)); |
| 152 | + } |
| 153 | + }); |
| 154 | + |
| 155 | + test('VERDICT: a lone mark belongs to a name — it is not emphasis', () => { |
| 156 | + // #102 review: every `_` used to be deleted, and that made a Done line of a key in a pasted file. |
| 157 | + // The second text is here for its later `_`: inside a word, it closes nothing. |
| 158 | + const names = [ |
| 159 | + PASTED_FILE, |
| 160 | + '_done: bool = field(default_factory=bool)', |
| 161 | + '*done: x' |
| 162 | + ]; |
| 163 | + for (const text of names) { |
| 164 | + assert.strictEqual(saysDone(text), false, 'read as finished: ' + JSON.stringify(text)); |
| 165 | + } |
| 166 | + }); |
| 167 | + |
| 168 | + test('VERDICT: the word alone, or no word at all, is not a Done line', () => { |
| 169 | + // Stripping marks must not turn "done" somewhere in a sentence into the line the prompt asks for. |
| 170 | + const notDone = [ |
| 171 | + 'Not done: two tests fail', |
| 172 | + 'The validator runs before save. It lives in app/models/link.rb.', |
| 173 | + PROMISE |
| 174 | + ]; |
| 175 | + for (const text of notDone) { |
| 176 | + assert.strictEqual(saysDone(text), false, 'read as finished: ' + JSON.stringify(text)); |
| 177 | + } |
| 178 | + }); |
| 179 | + |
| 180 | + test('VERDICT: an answer that simply ends in "done" still counts, as it did before', () => { |
| 181 | + // The second clause of the test, older than this fix: plain text whose last word is "done". |
| 182 | + assert.strictEqual(saysDone('The diagram is in docs/flow.html and it renders. All done.'), true); |
| 183 | + }); |
| 184 | + |
| 185 | + test('VERDICT: a "done" that is code is not the model saying it', () => { |
| 186 | + // The shell's own keyword, closing a pasted loop — and the same keyword quoted on a turn's last |
| 187 | + // line. The second is why that clause reads the text as written: the marks come off to find a |
| 188 | + // LINE, and taken off here as well they would leave this turn ending in the word. |
| 189 | + assert.strictEqual(saysDone(PASTED_LOOP), false); |
| 190 | + assert.strictEqual(saysDone('Let me check how the loop is closed. Its last line is:\n`done`'), false); |
| 191 | + }); |
| 192 | + |
| 193 | + // ---- 2. the loop asks it --------------------------------------------------------------------- |
| 194 | + |
| 195 | + await testAsync('LOOP: the reported answer ends the run — the model is not asked again', async () => { |
| 196 | + // Scripted the way the bug played out: had the loop asked again, the model's second "Done:" is |
| 197 | + // waiting for it. It must never be requested. |
| 198 | + const r = await run([REPORTED, 'Done: created the workflow diagram.']); |
| 199 | + assert.strictEqual(r.asked, 1, 'a finished run was asked to keep going'); |
| 200 | + assert.strictEqual(r.nudges, 0); |
| 201 | + assert.deepStrictEqual(r.roles, ['user', 'assistant'], 'something was sent to the model after it had finished'); |
| 202 | + assert.deepStrictEqual(r.end, ['done']); |
| 203 | + }); |
| 204 | + |
| 205 | + await testAsync('LOOP: a promise with no tool call is still nudged', async () => { |
| 206 | + // The stall check is deliberately untouched, and this is the case it exists for. If the fix ever |
| 207 | + // reads this text as finished, the edit it announces is never made. |
| 208 | + const r = await run([PROMISE, 'Done: the validator covers subdomains.']); |
| 209 | + assert.strictEqual(r.nudges, 1, 'the stalled turn was not nudged'); |
| 210 | + assert.deepStrictEqual(r.roles, ['user', 'assistant', 'user', 'assistant'], 'the nudge never reached the transcript'); |
| 211 | + assert.strictEqual(r.asked, 2); |
| 212 | + assert.deepStrictEqual(r.end, ['done']); |
| 213 | + }); |
| 214 | + |
| 215 | + await testAsync('LOOP: code pasted in a fence is still nudged, whatever word or key it holds', async () => { |
| 216 | + for (const pasted of [PASTED_LOOP, PASTED_FILE]) { |
| 217 | + const r = await run([pasted, 'Done: it is on disk now.']); |
| 218 | + assert.strictEqual(r.nudges, 1, 'pasted code passed for a finished run: ' + JSON.stringify(pasted)); |
| 219 | + assert.strictEqual(r.asked, 2); |
| 220 | + assert.deepStrictEqual(r.end, ['done']); |
| 221 | + } |
| 222 | + }); |
| 223 | + |
| 224 | + // ---- 3. the cost of asking ------------------------------------------------------------------- |
| 225 | + |
| 226 | + test('SOURCE: the line test does not read on across blank lines', () => { |
| 227 | + // `(^|\n)\s*done` and `(^|\n)[^\S\n]*done` accept exactly the same texts. The first one reads from |
| 228 | + // every line start through all the blank lines below it, so its work grows with the square of |
| 229 | + // their number: a turn that is mostly line breaks — or mostly lines of bare marks, once those are |
| 230 | + // stripped — took seconds at 64,000 lines. No verdict can tell the two apart, and a clock in a |
| 231 | + // test is a flake; so the pattern itself is read out of the source, as agentNoWorkspace.test.js |
| 232 | + // reads NEEDS_ROOT. |
| 233 | + const src = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8'); |
| 234 | + const m = /\/\(\^\|\\n\)(.*?)done\\s\*:\/i\.test\(plain\)/.exec(src); |
| 235 | + assert.ok(m, 'agent.js no longer tests for the Done line where this suite looks'); |
| 236 | + assert.ok(!m[1].includes('\\s'), 'the line test may match whitespace across lines again: ' + m[1]); |
| 237 | + }); |
| 238 | + } finally { |
| 239 | + fs.rmSync(root, { recursive: true, force: true }); |
| 240 | + } |
| 241 | + console.log('\nagentDone: ' + n + ' tests passed.'); |
| 242 | +})().catch((e) => { console.error(e); process.exit(1); }); |
0 commit comments