Skip to content

Commit 8a758b1

Browse files
authored
Merge branch 'develop' into feat/extension-signature-verification
2 parents 5c9bce0 + edc2752 commit 8a758b1

2 files changed

Lines changed: 273 additions & 2 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,35 @@ async function setupMcp(ctx, wsFolders, dbg) {
715715
}
716716
}
717717

718+
/**
719+
* Did the model say it is finished — a line starting with "Done:" (what SYSTEM_BASE asks for), or
720+
* text that simply ends in "done"?
721+
*
722+
* The LINE is looked for with its markdown marks taken off. A model that writes it as `**Done:** …`,
723+
* `## Done: …` or `- Done: …` has still said it; read literally, the marks sit between the line start
724+
* and the word, so a finished run was taken for an unfinished one and — when the answer also read as
725+
* a promise of more work, for which one "next " is enough — nudged until the model said Done a second
726+
* time.
727+
* @param {string} text the text of a turn that called no tool
728+
* @returns {boolean}
729+
*/
730+
function saysDone(text) {
731+
const plain = text
732+
// Quote, heading and list marks at a line start — as many as are stacked there (`> ## Done:`).
733+
.replace(/^[ \t]*(?:(?:[>#]+|[-*+]|\d+\.)[ \t]+)+/gm, '')
734+
// Then the emphasis that opens the line, and only as a PAIR: `**Done:** …`, `_Done: shipped._`.
735+
// A lone mark is part of a name. Deleting every `_` made a Done line of `_done: false` in a pasted
736+
// file, and the turn that pasted it instead of writing it was no longer nudged.
737+
.replace(/^[ \t]*[*_~`]+(?=\S)(.*?\S)[*_~`]+(?!\w)/gm, '$1');
738+
// `[^\S\n]` is whitespace that stays on its line — the same test as `\s*` there, at a different cost.
739+
// With `\s*` each line start read on through every blank line below it, and a line of nothing but
740+
// marks is blank by now: the work grew with the square of their number, and 64,000 took seconds.
741+
if (/(^|\n)[^\S\n]*done\s*:/i.test(plain)) { return true; }
742+
// The last-word test reads the text AS WRITTEN, as it always has. The marks come off to find a line;
743+
// taken off the last word too, a turn that ends by quoting a loop's `done` would pass for a finish.
744+
return /\bdone\.?\s*$/i.test(text.trim());
745+
}
746+
718747
async function runAgent(ctx) {
719748
// No workspace is no longer a refusal. It used to fail the whole run here, which meant a question
720749
// that never needed a folder — "what does this error mean?", anything through an MCP server, a
@@ -1021,7 +1050,7 @@ async function runAgent(ctx) {
10211050
}
10221051
reason = 'limit'; break;
10231052
}
1024-
if (/(^|\n)\s*done\s*:/i.test(text) || /\bdone\.?\s*$/i.test(text.trim())) {
1053+
if (saysDone(text)) {
10251054
if (await attemptVerify()) { continue; } // verification failed → fix feedback pushed, keep going
10261055
reason = 'done'; break;
10271056
}
@@ -1070,4 +1099,4 @@ async function runAgent(ctx) {
10701099
}
10711100
}
10721101

1073-
module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath };
1102+
module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath, saysDone };
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)