Skip to content

Commit dd10c19

Browse files
authored
Merge pull request #83 from levelcodeai/fix/close-resets-conversation
fix(chat): closing the tab tears the conversation down, not just seals it
2 parents 1a942f9 + 1ad4f14 commit dd10c19

2 files changed

Lines changed: 172 additions & 13 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ let pendingTranscriptReplay = '';
6363
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
6464
/** @type {{role:string,content:string}[]} */
6565
let conversation = [];
66+
// Bumped by every teardown. A turn captures it when it starts and checks it before writing anything
67+
// back, so work still unwinding after the conversation was torn down cannot repopulate the state the
68+
// teardown just cleared — nor clobber the turn that replaced it.
69+
let conversationEpoch = 0;
6670
/** @type {string | null} */
6771
let pendingContext = null;
6872
/** Files pinned as chat context (whole codebase-wide context). @type {{id:string,uri:vscode.Uri,name:string,rel:string}[]} */
@@ -1106,19 +1110,45 @@ function sealLiveSession(why) {
11061110
}
11071111
}
11081112

1109-
function newChat() {
1110-
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
1111-
// so it lands in History as a finished session and the next turn opens a fresh one.
1112-
sealLiveSession('newChat');
1113+
/**
1114+
* Drop everything the finished conversation was holding, in memory and out of process.
1115+
*
1116+
* Extracted so the CLOSING TAB does the same teardown New Chat does. Sealing alone was not enough and
1117+
* left the two halves disagreeing: `sealLiveSession` ends the session, so `liveId()` goes null and the
1118+
* next chat opens visually empty — while `conversation` and `agentMessages` still hold every previous
1119+
* turn, so the next message silently ships the old history to the model. An empty-looking chat that
1120+
* secretly remembers is worse than either honest option.
1121+
*
1122+
* The out-of-process half matters just as much. Background commands and MCP servers are DETACHED
1123+
* children: without reaping them they outlive the surface that was reporting on them, and an
1124+
* in-flight agent run keeps editing files with nothing left to show for it.
1125+
*
1126+
* Deliberately does NOT post to the webview. New Chat re-renders afterwards because it has a surface
1127+
* to re-render; the close path is tearing one down.
1128+
*/
1129+
function resetConversationState() {
1130+
// FIRST: anything already in flight is now stale, and must not write back. Without this the
1131+
// teardown loses a race it does not know it is in — see handleSend/agentFlow, both of which mutate
1132+
// this state from a catch/finally that runs long after abort() returns.
1133+
conversationEpoch++;
1134+
clearApprovals();
1135+
clearQuestions();
11131136
conversation = [];
11141137
agentMessages = [];
11151138
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
11161139
pendingContext = null;
11171140
contextFiles = [];
1118-
if (abort) { abort.abort(); }
1141+
if (abort) { abort.abort(); } // stop an in-flight run — its surface is going away
11191142
reapCommands(); // kill any background servers/watchers from the old session
11201143
reapMcp(); // …and any MCP servers: they are detached children too
1121-
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
1144+
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
1145+
}
1146+
1147+
function newChat() {
1148+
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
1149+
// so it lands in History as a finished session and the next turn opens a fresh one.
1150+
sealLiveSession('newChat');
1151+
resetConversationState();
11221152
post({ type: 'reset' });
11231153
postContextFiles();
11241154
postMemoryDigest(); // the fresh empty state shows the welcome-back strip
@@ -1574,6 +1604,7 @@ async function agentFlow(text) {
15741604
if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req) }); post({ type: 'agentDone', reason: 'error' }); return; }
15751605

15761606
post({ type: 'agentStart' });
1607+
const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW
15771608
abort = new AbortController();
15781609
repairAgentMemory();
15791610
// Open a workspace checkpoint for this turn (before the goal is pushed) so the user can roll back here.
@@ -1669,6 +1700,12 @@ async function agentFlow(text) {
16691700
signal: abort.signal
16701701
});
16711702
} finally {
1703+
// Everything below writes to state a teardown may already have replaced. runAgent holds
1704+
// `agentMessages` BY REFERENCE, so a teardown that rebinds the global leaves this run pushing
1705+
// into an orphaned array — and then `agentMessages.slice(sessTurnStart)` would slice the NEW,
1706+
// empty one with an index into the old, recording an empty turn against the wrong session.
1707+
// `abort` and `currentCheckpoint` are worse: they would clobber whatever turn came next.
1708+
if (epoch !== conversationEpoch) { return; }
16721709
clearApprovals();
16731710
clearQuestions();
16741711
abort = null;
@@ -1716,6 +1753,7 @@ async function handleSend(text) {
17161753
post({ type: 'clearContext' });
17171754
post({ type: 'assistantStart' });
17181755

1756+
const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW
17191757
abort = new AbortController();
17201758
let assistant = '';
17211759
const onDelta = (d) => { assistant += d; post({ type: 'assistantDelta', text: d }); };
@@ -1744,6 +1782,11 @@ async function handleSend(text) {
17441782
conversation.push({ role: 'assistant', content: assistant });
17451783
post({ type: 'assistantDone' });
17461784
} catch (e) {
1785+
// Closing the tab mid-stream aborts the request, which lands HERE — after resetConversationState
1786+
// has already cleared `conversation`. Pushing the partial reply back in would leave a dangling
1787+
// assistant turn with no user turn in front of it, and the next send would ship it to the model:
1788+
// exactly the leak the teardown exists to prevent, reintroduced by the teardown's own abort.
1789+
if (epoch !== conversationEpoch) { return; }
17471790
if (abort && abort.signal.aborted) {
17481791
if (assistant) { conversation.push({ role: 'assistant', content: assistant }); }
17491792
post({ type: 'assistantDone' });
@@ -1752,7 +1795,9 @@ async function handleSend(text) {
17521795
post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code });
17531796
}
17541797
} finally {
1755-
abort = null;
1798+
// Only if this turn still owns it. A new turn may already have installed its own controller, and
1799+
// nulling that one would leave it unstoppable.
1800+
if (epoch === conversationEpoch) { abort = null; }
17561801
}
17571802
}
17581803

@@ -2341,24 +2386,32 @@ async function openChatInEditor(opts) {
23412386
chatEditorPanel = undefined;
23422387
activeWebview = undefined; // nothing may post into a disposed webview
23432388
sealLiveSession('chatClosed');
2389+
resetConversationState(); // …and nothing may survive into the next one
23442390
dbg('chat.closedEditor', {});
23452391
});
23462392
}
23472393

23482394
/**
23492395
* Where the chat opens when the window does.
23502396
*
2351-
* The default is the EDITOR: the chat is the thing most sessions are actually about, and a centred
2352-
* column is where the reference puts it. `secondarySidebar` is the old behaviour, kept because the
2353-
* sidebar is the right answer when you want the chat beside code rather than instead of it, and
2354-
* `none` is the honest opt-out for anyone who would rather open it themselves.
2397+
* Two supported values. `editor` (the default) opens the chat as a centred editor tab — the only
2398+
* surface it has; `none` is the honest opt-out for anyone who would rather open it themselves.
2399+
*
2400+
* `secondarySidebar` is LEGACY. It was valid until the chat became editor-only and is still sitting in
2401+
* real settings.json files, so it is mapped to `editor` rather than left to the unknown-value path —
2402+
* same result, but the debug log then names the surface we actually opened.
23552403
*
23562404
* Unknown values fall back to the default rather than throwing: this is read at startup, and a typo
23572405
* in settings.json should not be able to leave a window with no chat and no explanation.
23582406
*/
23592407
function chatStartLocation() {
23602408
const raw = String(aiConfig().get('chat.startLocation', 'editor') || 'editor');
2361-
return ['editor', 'secondarySidebar', 'none'].includes(raw) ? raw : 'editor';
2409+
// `secondarySidebar` was a valid value until the chat became editor-only, so it is still sitting in
2410+
// real settings.json files. Mapped explicitly rather than left to fall through the unknown-value
2411+
// path: the result is the same, but this way the debug log names the location we actually opened
2412+
// instead of reporting a surface that no longer exists.
2413+
if (raw === 'secondarySidebar') { return 'editor'; }
2414+
return ['editor', 'none'].includes(raw) ? raw : 'editor';
23622415
}
23632416

23642417
/** Open the chat where `chat.startLocation` says, once, as the window finishes starting. */

extensions/levelcode-ai/test/chatSurface.test.js

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ test('START: the chat opens centred by default, and the setting is the only plac
218218

219219
// One reader, so a second caller cannot quietly disagree about what an unknown value means.
220220
const body = fnBody(ext, 'chatStartLocation');
221-
assert.match(body, /'editor', 'secondarySidebar', 'none'/, 'the reader no longer validates against the enum');
221+
assert.match(body, /'editor', 'none'/, 'the reader must validate against exactly the enum the package ships');
222222
assert.match(body, /: 'editor'/, 'an unknown value must fall back to the default, not leave the window with no chat');
223223
});
224224

@@ -348,4 +348,110 @@ test('CLOSE: nothing resurrects the chat on the right', () => {
348348
'Sessions must KEEP its view — it is the right-hand container\'s reason to exist');
349349
});
350350

351+
test('CLOSE: the conversation is torn down, not just sealed', () => {
352+
// Review caught the two halves disagreeing. sealLiveSession ends the SESSION — liveId() goes null,
353+
// so the next chat opens visually empty — while `conversation` and `agentMessages` still held every
354+
// previous turn, so the next message shipped the old history to the model anyway. An empty-looking
355+
// chat that secretly remembers is worse than either honest option.
356+
const dispose = ext.slice(ext.indexOf('panel.onDidDispose'), ext.indexOf('panel.onDidDispose') + 1400);
357+
assert.match(dispose, /sealLiveSession\('chatClosed'\)/, 'closing no longer seals the session');
358+
assert.match(dispose, /resetConversationState\(\)/,
359+
'closing seals but leaves conversation/agentMessages loaded — the next send replays the old history');
360+
361+
// ONE teardown, shared with New Chat, or the close path drifts — and it is the path nobody watches.
362+
assert.match(fnBody(ext, 'newChat'), /resetConversationState\(\)/,
363+
'New Chat has its own copy of the teardown again');
364+
const reset = fnBody(ext, 'resetConversationState');
365+
for (const [frag, why] of [
366+
['conversation = []', 'the model history survives the close'],
367+
['agentMessages = []', 'the agent history survives the close'],
368+
['checkpoints.length = 0', 'the restore stack still points at a finished turn'],
369+
['contextFiles = []', 'stale attachments carry into the next chat'],
370+
['abort.abort()', 'an in-flight run keeps going with no surface to report to'],
371+
['reapCommands()', 'background commands outlive the chat — they are detached children'],
372+
['reapMcp()', 'MCP servers outlive the chat — they are detached children too']
373+
]) {
374+
assert.ok(reset.includes(frag), why + ' (missing: ' + frag + ')');
375+
}
376+
377+
// It must NOT post: the close path is tearing the surface down, and New Chat re-renders itself.
378+
assert.ok(!/\bpost\(/.test(reset),
379+
'resetConversationState posts to the webview — on the close path that webview is being disposed');
380+
});
381+
382+
test('START: a legacy secondarySidebar setting maps to the editor, and says so', () => {
383+
// The value was valid until the chat became editor-only, so it is still sitting in real
384+
// settings.json files. Left to fall through the unknown-value path it produced the right BEHAVIOUR
385+
// with a lying debug log — `where: secondarySidebar` while opening the editor tab.
386+
const body = fnBody(ext, 'chatStartLocation');
387+
assert.match(body, /raw === 'secondarySidebar'/, 'the legacy value is not mapped explicitly');
388+
assert.ok(!/\['editor', 'secondarySidebar', 'none'\]/.test(body),
389+
'secondarySidebar is still an accepted value — it names a surface that no longer exists');
390+
assert.match(body, /\['editor', 'none'\]/, 'the accepted set should be exactly what the enum ships');
391+
392+
// Behaviour, evaluated from the SHIPPED source rather than a copy of it: fnBody hands back the
393+
// braces, so wrapping it in a declaration gives the real function with aiConfig injected.
394+
// aiConfig is CALLED and returns the config object, so the stub has to be a function that returns
395+
// one — passing the object itself is the obvious thing and it is wrong.
396+
const run = (v) => new Function('aiConfig',
397+
'function chatStartLocation() ' + body + '\nreturn chatStartLocation();')(() => ({ get: () => v }));
398+
assert.strictEqual(run('secondarySidebar'), 'editor', 'a legacy setting must resolve to the editor');
399+
assert.strictEqual(run('none'), 'none', 'the opt-out must survive');
400+
assert.strictEqual(run('editor'), 'editor');
401+
assert.strictEqual(run('nonsense'), 'editor', 'an unknown value must fall back to the default');
402+
});
403+
404+
test('CLOSE: work still unwinding cannot repopulate the state the teardown just cleared', () => {
405+
// Review found the teardown losing a race it did not know it was in. Closing mid-stream aborts the
406+
// request, and the abort lands in handleSend's catch AFTER resetConversationState has cleared
407+
// `conversation` — where it pushed the partial reply straight back in. The result was a dangling
408+
// assistant turn with no user turn in front of it, shipped to the model on the next send: the exact
409+
// leak the teardown exists to prevent, reintroduced by the teardown's own abort().
410+
//
411+
// agentFlow's finally was worse. runAgent holds `agentMessages` BY REFERENCE, so a teardown that
412+
// rebinds the global leaves the run pushing into an orphaned array — and then
413+
// `agentMessages.slice(sessTurnStart)` slices the NEW empty one with an index into the old.
414+
// `abort = null` and `currentCheckpoint = null` would clobber whatever turn came next.
415+
const reset = fnBody(ext, 'resetConversationState');
416+
assert.match(reset, /conversationEpoch\+\+/, 'the teardown does not invalidate in-flight work');
417+
418+
// FIRST, before anything is cleared: an abort landing mid-teardown must already read as stale.
419+
assert.ok(reset.indexOf('conversationEpoch++') < reset.indexOf('conversation = []'),
420+
'the epoch must be bumped before the state is cleared, or the race window survives the fix');
421+
422+
for (const fn of ['handleSend', 'agentFlow']) {
423+
const body = fnBody(ext, fn);
424+
const captured = body.indexOf('const epoch = conversationEpoch');
425+
assert.ok(captured >= 0, fn + ' never captures the epoch — it cannot tell if its turn is still current');
426+
assert.ok(captured < body.indexOf('abort = new AbortController()'),
427+
fn + ' captures the epoch after installing its controller; capture it before the turn can be torn down');
428+
assert.match(body, /epoch !== conversationEpoch/, fn + ' writes back without checking it is still current');
429+
}
430+
431+
// The guard has to come before the first write in the block it protects, or it guards nothing.
432+
const send = fnBody(ext, 'handleSend');
433+
const tail = send.slice(send.lastIndexOf('} catch (e) {'));
434+
assert.ok(tail.indexOf('epoch !== conversationEpoch') < tail.indexOf("conversation.push"),
435+
'handleSend pushes the partial reply before checking the turn is still current');
436+
assert.match(tail, /if \(epoch === conversationEpoch\) \{ abort = null; \}/,
437+
'the finally nulls `abort` unconditionally — that clobbers the controller of the turn that replaced this one');
438+
439+
const agent = fnBody(ext, 'agentFlow');
440+
const afin = agent.slice(agent.lastIndexOf('} finally {'));
441+
assert.ok(afin.indexOf('epoch !== conversationEpoch') < afin.indexOf('abort = null'),
442+
'agentFlow clobbers abort/currentCheckpoint/recordTurn before checking the turn is still current');
443+
});
444+
445+
test('START: the docstring describes the values that actually exist', () => {
446+
// It still called secondarySidebar a supported surface ("kept because the sidebar is the right
447+
// answer when…") while the code below mapped it away as legacy. In-code documentation sitting
448+
// directly on top of the change is the worst place to leave a contradiction.
449+
const at = ext.indexOf('Where the chat opens when the window does');
450+
assert.ok(at > 0, 'the chatStartLocation docstring is gone');
451+
const doc = ext.slice(at, ext.indexOf('function chatStartLocation', at));
452+
assert.match(doc, /LEGACY/, 'the docstring does not mark secondarySidebar as legacy');
453+
assert.ok(!/kept because/.test(doc), 'the docstring still describes secondarySidebar as a supported surface');
454+
assert.match(doc, /`editor`[\s\S]*`none`/, 'the docstring should name the two values that are actually supported');
455+
});
456+
351457
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)