Skip to content

Commit 6fd14c2

Browse files
committed
fix(chat): closing the tab tears the conversation down, not just seals it
Both review points on #82. They landed on that PR's stale diff — I branched the wordmark off feat/chat-editor-only instead of develop, so #82 carried #81's commits until #81 merged. Both are real, and both are about code that is now on develop, so they are fixed here rather than in the wordmark PR (whose diff is now the two files it should always have been). 1. SEALING WAS ONLY HALF THE TEARDOWN sealLiveSession ends the SESSION — liveId() goes null, so the next chat opens visually empty — while `conversation` and `agentMessages` still held every previous turn. The next message therefore shipped the old history to the model. An empty-looking chat that secretly remembers is worse than either honest option. The out-of-process half was worse: background commands and MCP servers are DETACHED children, so they outlived the surface that was reporting on them, and an in-flight agent run kept editing files with nothing left to show for it. newChat's teardown moves into resetConversationState() and the close path calls it. One implementation, or the close path drifts — and it is the path nobody watches. It deliberately does NOT post: New Chat re-renders afterwards because it has a surface to re-render; the close path is tearing one down. 2. A LEGACY `secondarySidebar` SETTING PRODUCED A LYING LOG The value was valid until the chat became editor-only, so it is still sitting in real settings.json files. chatStartLocation still accepted it, so revealChatAtStartup logged `where: secondarySidebar` and then opened the editor tab. Right behaviour, wrong story — and the accepted set no longer matched the enum the package ships. Now mapped explicitly. Guards, each bypass-verified by reverting the fix: - the close sealing but leaving history loaded (the reported bug) - conversation, checkpoints, abort, reapCommands and reapMcp each removed individually - newChat growing its own copy of the teardown - the shared teardown starting to post, which is wrong on the close path - the legacy value no longer mapped; the removed surface accepted again Two bypasses initially looked like misses: commenting out `reapMcp()` and neutering the abort with `if (false)` both left the strings present, and these are presence checks. Redone as deletions — which is how they would actually regress — they fail correctly. Worth stating plainly: these guards catch removal, not disabling. 21 tests in chatSurface, 34 suites green.
1 parent df1046d commit 6fd14c2

2 files changed

Lines changed: 87 additions & 8 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,19 +1106,39 @@ function sealLiveSession(why) {
11061106
}
11071107
}
11081108

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');
1109+
/**
1110+
* Drop everything the finished conversation was holding, in memory and out of process.
1111+
*
1112+
* Extracted so the CLOSING TAB does the same teardown New Chat does. Sealing alone was not enough and
1113+
* left the two halves disagreeing: `sealLiveSession` ends the session, so `liveId()` goes null and the
1114+
* next chat opens visually empty — while `conversation` and `agentMessages` still hold every previous
1115+
* turn, so the next message silently ships the old history to the model. An empty-looking chat that
1116+
* secretly remembers is worse than either honest option.
1117+
*
1118+
* The out-of-process half matters just as much. Background commands and MCP servers are DETACHED
1119+
* children: without reaping them they outlive the surface that was reporting on them, and an
1120+
* in-flight agent run keeps editing files with nothing left to show for it.
1121+
*
1122+
* Deliberately does NOT post to the webview. New Chat re-renders afterwards because it has a surface
1123+
* to re-render; the close path is tearing one down.
1124+
*/
1125+
function resetConversationState() {
11131126
conversation = [];
11141127
agentMessages = [];
11151128
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
11161129
pendingContext = null;
11171130
contextFiles = [];
1118-
if (abort) { abort.abort(); }
1131+
if (abort) { abort.abort(); } // stop an in-flight run — its surface is going away
11191132
reapCommands(); // kill any background servers/watchers from the old session
11201133
reapMcp(); // …and any MCP servers: they are detached children too
1121-
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
1134+
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
1135+
}
1136+
1137+
function newChat() {
1138+
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
1139+
// so it lands in History as a finished session and the next turn opens a fresh one.
1140+
sealLiveSession('newChat');
1141+
resetConversationState();
11221142
post({ type: 'reset' });
11231143
postContextFiles();
11241144
postMemoryDigest(); // the fresh empty state shows the welcome-back strip
@@ -2341,6 +2361,7 @@ async function openChatInEditor(opts) {
23412361
chatEditorPanel = undefined;
23422362
activeWebview = undefined; // nothing may post into a disposed webview
23432363
sealLiveSession('chatClosed');
2364+
resetConversationState(); // …and nothing may survive into the next one
23442365
dbg('chat.closedEditor', {});
23452366
});
23462367
}
@@ -2358,7 +2379,12 @@ async function openChatInEditor(opts) {
23582379
*/
23592380
function chatStartLocation() {
23602381
const raw = String(aiConfig().get('chat.startLocation', 'editor') || 'editor');
2361-
return ['editor', 'secondarySidebar', 'none'].includes(raw) ? raw : 'editor';
2382+
// `secondarySidebar` was a valid value until the chat became editor-only, so it is still sitting in
2383+
// real settings.json files. Mapped explicitly rather than left to fall through the unknown-value
2384+
// path: the result is the same, but this way the debug log names the location we actually opened
2385+
// instead of reporting a surface that no longer exists.
2386+
if (raw === 'secondarySidebar') { return 'editor'; }
2387+
return ['editor', 'none'].includes(raw) ? raw : 'editor';
23622388
}
23632389

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

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

Lines changed: 54 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,57 @@ 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+
351404
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)