Skip to content

Commit 2685c38

Browse files
ndemiancclaude
andcommitted
feat(levelcode): persist chat sessions — the write-side adapter
Wire the tested session engine (sessionStore + sessionEvents) into the live chat loop so real chats are saved to History, and the /sessions modal + the Sessions sidebar list actual sessions instead of mock data. - sessions.js: a vscode-free, per-workspace lifecycle manager (ensure/recordTurn/seal/list/liveId) over the pure store. recordTurn is handed only THIS turn's new messages, so the persisted session stays the full append-only history even though agentMessages is a trimmed live window. - extension.js: a lazy sessionsManager() (off when sessions.enabled=false or there is no workspace folder); record each turn in agentFlow's finally (so an interrupted/errored turn is saved too); seal on New Chat before the transcript is cleared; both webview providers answer listSessions with the real index. Every hook is best-effort — a persistence failure never surfaces into a chat turn, and the index is a rebuildable cache. - package.json: levelcode.ai.sessions.enabled (default true) + sessions.dir (default ~/.levelcode/sessions, machine-scoped). - test/sessions.test.js: 5 tests — a turn persists + derives the card, the transcript rebuilds verbatim, seal writes the terminal state + a fresh turn opens a new session, and the caller contract (slice-from-goal captures exactly this turn as the live window trims). Read side (resume + actions) is the next slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 087220d commit 2685c38

4 files changed

Lines changed: 279 additions & 3 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
const vscode = require('vscode');
1212
const path = require('path');
1313
const fs = require('fs');
14+
const os = require('os');
1415
const cp = require('child_process');
1516
const crypto = require('crypto');
1617
const providers = require('./providers/index');
@@ -21,6 +22,8 @@ const { registerLmProvider } = require('./lmProvider');
2122
const { registerInlineComplete } = require('./inlineComplete');
2223
const { runAgent } = require('./agent');
2324
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
25+
const sessionStore = require('./sessionStore');
26+
const { createSessions } = require('./sessions');
2427
const { registerReview } = require('./reviewSession');
2528
const { formatDiagnosticLines, diagKey, createPreviewGate } = require('./verify');
2629
const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
@@ -636,7 +639,43 @@ function workspaceMapBlock(allFiles) {
636639
return 'Project files (paths only, for orientation):\n```\n' + list + '\n```';
637640
}
638641

642+
// ── Sessions (History) persistence adapter ───────────────────────────────────────────────────────
643+
// A lazy, per-workspace lifecycle manager (sessions.js) wrapping the pure engine (sessionStore +
644+
// sessionEvents). It persists every turn to ~/.levelcode/sessions/<project>/ and lists the index for the
645+
// /sessions modal + the sidebar view. Everything here is best-effort: a persistence failure must never
646+
// disturb a chat turn — callers guard, and the manager swallows index errors (the index is a rebuildable
647+
// cache). Off entirely when levelcode.ai.sessions.enabled is false.
648+
let _sessionsMgr = null, _sessionsSlug = null;
649+
function sessionsRoot() {
650+
const dir = String(aiConfig().get('sessions.dir', '') || '').trim();
651+
return dir ? dir : path.join(os.homedir(), '.levelcode', 'sessions');
652+
}
653+
/** The session manager for the current workspace, or null (feature off / no folder / init failed). */
654+
function sessionsManager() {
655+
if (!aiConfig().get('sessions.enabled', true)) { return null; }
656+
const folder = (vscode.workspace.workspaceFolders || [])[0];
657+
if (!folder) { return null; } // no workspace → nowhere to scope a project's sessions
658+
const projectPath = folder.uri.fsPath;
659+
const slug = sessionStore.projectSlug(projectPath);
660+
if (_sessionsMgr && _sessionsSlug === slug) { return _sessionsMgr; }
661+
try {
662+
_sessionsMgr = createSessions({
663+
root: sessionsRoot(), slug, projectPath,
664+
// A tiny workspaceState-backed pointer to the live session id (a reload can tell what was live;
665+
// used later by resume). Namespaced + guarded — a state failure must not break persistence.
666+
state: ctx ? { get: (k) => ctx.workspaceState.get('levelcode.ai.session.' + k), set: (k, v) => ctx.workspaceState.update('levelcode.ai.session.' + k, v) } : null
667+
});
668+
_sessionsSlug = slug;
669+
} catch (e) { dbg('sessions.init.error', { msg: String((e && e.message) || e) }); return null; }
670+
return _sessionsMgr;
671+
}
672+
/** The session index for the current workspace (what the panel/view list). Empty, never throws. */
673+
function sessionList() { const m = sessionsManager(); return m ? m.list() : []; }
674+
639675
function newChat() {
676+
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
677+
// so it lands in History as a finished session and the next turn opens a fresh one.
678+
try { const m = sessionsManager(); if (m) { m.seal('done'); } } catch (e) { dbg('sessions.seal.error', { msg: String((e && e.message) || e) }); }
640679
conversation = [];
641680
agentMessages = [];
642681
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
@@ -1109,6 +1148,10 @@ async function agentFlow(text) {
11091148
post({ type: 'checkpointOpened', turnId: currentCheckpoint.turnId });
11101149
agentMessages.push(goalMsg);
11111150
trimAgentMemory();
1151+
// Sessions: mark where THIS turn's messages begin (the goal, post-trim). agentMessages is a trimmed
1152+
// live window, but a persisted session is the FULL append-only history — so after the run we persist
1153+
// exactly agentMessages.slice(from here). See sessions.js / recordTurn.
1154+
const sessTurnStart = agentMessages.indexOf(goalMsg);
11121155
dbg('agent.start', { provider: req.providerId, model: req.model, maxSteps: cfg.get('agent.maxSteps', 25), transcriptMsgs: agentMessages.length, goalChars: text.length });
11131156
// Auto-verify: capture a pre-run diagnostics baseline (so we only flag NEW problems) and a fresh
11141157
// per-run set of edited files to verify afterward.
@@ -1191,6 +1234,13 @@ async function agentFlow(text) {
11911234
clearQuestions();
11921235
abort = null;
11931236
if (currentCheckpoint) { post({ type: 'checkpointClosed', turnId: currentCheckpoint.turnId, fileCount: currentCheckpoint.files.size }); currentCheckpoint = null; }
1237+
// Sessions: persist this turn's slice (goal + the agent's messages) verbatim. In the finally so an
1238+
// interrupted or errored turn is recorded too. Best-effort — a persistence failure never surfaces
1239+
// into the chat, and never re-throws past this turn's own error.
1240+
try {
1241+
const m = sessionsManager();
1242+
if (m && sessTurnStart >= 0) { m.recordTurn(agentMessages.slice(sessTurnStart), req.model); }
1243+
} catch (e) { dbg('sessions.record.error', { msg: String((e && e.message) || e) }); }
11941244
}
11951245
}
11961246

@@ -1725,6 +1775,8 @@ class ChatViewProvider {
17251775
case 'restoreCheckpoint': dbg('restoreCheckpoint', { turnId: msg.turnId, running: !!abort }); await restoreCheckpoint(msg.turnId); break;
17261776
case 'listSkills': { const en = aiConfig().get('skills.enabled', true); const idx = en ? loadSkills(ctx.extensionPath, dbg) : new Map(); dbg('listSkills', { enabled: en, count: idx.size }); post({ type: 'skillsList', enabled: en, skills: skillsMenu(idx) }); break; }
17271777
case 'listMcp': post({ type: 'mcpList', mcp: mcpOverview() }); break;
1778+
case 'listSessions': post({ type: 'sessions', entries: sessionList(), open: false }); break; // reply into the already-open modal
1779+
case 'sessionAction': dbg('sessions.action', { action: msg.action, id: msg.id }); break; // TODO(next): resume/done/rename/delete
17281780
case 'feedback': dbg('feedback', { value: msg.value, model: msg.model }); await recordFeedback(msg.value, msg.model); break;
17291781
case 'openFile': await openWorkspaceFile(msg.path); break;
17301782
case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break;
@@ -1774,10 +1826,11 @@ class SessionsViewProvider {
17741826
view.webview.html = getSessionsHtml();
17751827
view.webview.onDidReceiveMessage((msg) => {
17761828
switch (msg.type) {
1777-
// TODO(adapter): post the real session index here; until then the view shows its own mock.
1778-
case 'listSessions': break;
1829+
// The real session index for this workspace (empty on a fresh install — the view shows its
1830+
// own empty state). Posted to THIS view's webview, not the chat's.
1831+
case 'listSessions': view.webview.postMessage({ type: 'sessions', entries: sessionList() }); break;
17791832
case 'newSession': newChat(); vscode.commands.executeCommand('levelcodeAi.chat.focus'); break;
1780-
// TODO(adapter): resume/done/rename/delete once sessions are persisted.
1833+
// TODO(next): resume/done/rename/delete once the read side (planResume + replay) lands.
17811834
case 'sessionAction': dbg('sessions.action', { action: msg.action, id: msg.id }); break;
17821835
}
17831836
});

‎extensions/levelcode-ai/package.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,17 @@
430430
"default": true,
431431
"description": "Let the agent self-select bundled skills (expert playbooks) for matching tasks like code review, bug fixes, and test writing. Each skill is a SKILL.md the agent loads on demand."
432432
},
433+
"levelcode.ai.sessions.enabled": {
434+
"type": "boolean",
435+
"default": true,
436+
"description": "Save each chat/agent session to History so you can browse and reopen past work. Sessions are stored as plain-text JSONL files under levelcode.ai.sessions.dir (default ~/.levelcode/sessions), one folder per project, and listed with /sessions or the Sessions panel. Turn off to stop recording new sessions (existing files are left untouched)."
437+
},
438+
"levelcode.ai.sessions.dir": {
439+
"type": "string",
440+
"default": "",
441+
"description": "Folder to store session History in. Empty = ~/.levelcode/sessions. Each project gets its own subfolder; the files are append-only JSONL you can back up, diff, or delete by hand.",
442+
"scope": "machine"
443+
},
433444
"levelcode.ai.verify.enabled": {
434445
"type": "boolean",
435446
"default": true,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @ts-check
2+
'use strict';
3+
4+
/*
5+
* sessions.js — the live-session lifecycle manager: turns the chat/agent loop's turns into a persisted,
6+
* append-only session on disk, and back. It is the thin seam between extension.js and the pure engine
7+
* (sessionStore + sessionEvents), and it is deliberately vscode-FREE: its deps (root/slug/projectPath, an
8+
* optional workspaceState-like pointer, and the clock) are injected, so the lifecycle unit-tests against a
9+
* temp directory like sessionStore does. extension.js just calls ensure/recordTurn/seal/list at four points.
10+
*
11+
* The one subtlety it handles: `agentMessages` in the loop is a TRIMMED live window, but a session must be
12+
* the FULL append-only history. So recordTurn is handed only THIS turn's new messages (the caller snapshots
13+
* the array length before the turn and slices after) and appends them — every turn is persisted verbatim
14+
* before the next turn can trim it away. Nothing here ever throws into a chat: index writes are best-effort
15+
* (the index is a rebuildable cache) and seal/pointer updates are guarded.
16+
*/
17+
18+
const store = require('./sessionStore');
19+
const events = require('./sessionEvents');
20+
21+
/**
22+
* @param {{ root: string, slug: string, projectPath: string,
23+
* state?: { get: (k: string) => any, set: (k: string, v: any) => any } | null,
24+
* now?: () => Date }} opts
25+
*/
26+
function createSessions(opts) {
27+
const root = opts.root, slug = opts.slug, projectPath = opts.projectPath;
28+
const state = opts.state || null;
29+
const clock = opts.now || (() => new Date());
30+
/** @type {{ id: string, file: string } | null} */
31+
let live = null;
32+
33+
const iso = () => clock().toISOString();
34+
35+
// Refresh this session's index row from its file. Best-effort: the index is a cache (scan rebuilds it),
36+
// so a failure here must never surface into a chat turn.
37+
function reindex() {
38+
if (!live) { return; }
39+
try {
40+
const s = store.readSession(live.file);
41+
const idx = store.loadIndex(root, slug);
42+
store.writeIndex(root, slug, store.upsertEntry(idx.entries, store.deriveEntry(s.meta, s.events)));
43+
} catch (e) { /* index is a rebuildable cache */ }
44+
}
45+
46+
/** Open a fresh session (its dir + meta birth line) if none is live. Idempotent while one is live. */
47+
function ensure() {
48+
if (live) { return live; }
49+
const id = store.newSessionId(clock());
50+
store.createSession(root, slug, id, projectPath, iso(), null);
51+
live = { id, file: store.sessionFile(root, slug, id) };
52+
if (state) { try { state.set('liveSessionId', id); } catch (e) { /* pointer is a convenience */ } }
53+
reindex(); // a meta-only row, so even a one-message crash lists
54+
return live;
55+
}
56+
57+
/**
58+
* Persist ONE turn — the new messages the loop produced this turn (goal + the agent's messages). The
59+
* leading user message becomes a `user` event (turn count / title / preview); the rest a verbatim
60+
* `agent` event (sparkline + files-edited derive from it). Creates the session on the first turn.
61+
*/
62+
function recordTurn(turnMessages, model) {
63+
ensure();
64+
const msgs = Array.isArray(turnMessages) ? turnMessages : [];
65+
if (!msgs.length) { return; }
66+
if (msgs[0] && msgs[0].role === 'user') {
67+
store.appendEvent(live.file, events.userTurnEvent(msgs[0], iso()));
68+
const rest = msgs.slice(1);
69+
if (rest.length) { store.appendEvent(live.file, events.agentTurnEvent(rest, model, iso())); }
70+
} else {
71+
store.appendEvent(live.file, events.agentTurnEvent(msgs, model, iso()));
72+
}
73+
reindex();
74+
}
75+
76+
/** Seal the live session (append its terminal state, finalize the row) and clear the live pointer. */
77+
function seal(endState) {
78+
if (!live) { return; }
79+
try { store.appendEvent(live.file, events.endEvent(endState || 'done', iso())); reindex(); } catch (e) { /* best-effort */ }
80+
live = null;
81+
if (state) { try { state.set('liveSessionId', null); } catch (e) { /* convenience */ } }
82+
}
83+
84+
/** The session index for this project (what the panel/view list). Empty (never throws) if unreadable. */
85+
function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } }
86+
87+
function liveId() { return live ? live.id : null; }
88+
89+
return { ensure, recordTurn, seal, list, liveId };
90+
}
91+
92+
module.exports = { createSessions };
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* sessions — the live-session lifecycle manager — run: node test/sessions.test.js
3+
*
4+
* Drives the manager the way extension.js will: recordTurn(this turn's messages), seal on New Chat, list
5+
* for the panel. Asserts a turn is persisted + derives the right card, the FULL transcript rebuilds
6+
* verbatim (not the trimmed live window), seal writes the terminal state and clears the pointer, and a
7+
* turn after seal opens a fresh session (old one stays in History). Real store, temp dir, fake pointer.
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
const assert = require('assert');
13+
const fs = require('fs');
14+
const os = require('os');
15+
const path = require('path');
16+
const store = require('../sessionStore');
17+
const events = require('../sessionEvents');
18+
const { createSessions } = require('../sessions');
19+
20+
let n = 0;
21+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
22+
23+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-sessmgr-'));
24+
process.on('exit', () => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } });
25+
let seq = 0;
26+
const freshRoot = () => path.join(tmpRoot, 'r' + (seq++));
27+
const fakeState = () => { const s = {}; return { store: s, get: (k) => s[k], set: (k, v) => { s[k] = v; } }; };
28+
29+
const turn = (prompt, editPath) => [
30+
{ role: 'user', content: prompt },
31+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'edit_file', input: { path: editPath } }] },
32+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a' }] },
33+
{ role: 'assistant', content: 'Done.' }
34+
];
35+
36+
test('MANAGER: the first turn creates + persists a session and lists it with the derived row', () => {
37+
const root = freshRoot(), slug = store.projectSlug('/proj'), st = fakeState();
38+
const m = createSessions({ root, slug, projectPath: '/proj', state: st });
39+
assert.strictEqual(m.liveId(), null, 'no session before the first turn');
40+
41+
m.recordTurn(turn('Add idempotency to refunds', 'refund.rb'), 'anthropic/claude-opus-5');
42+
assert.ok(m.liveId(), 'a session is live after the first turn');
43+
assert.strictEqual(st.store.liveSessionId, m.liveId(), 'the workspaceState pointer tracks it');
44+
45+
const entries = m.list();
46+
assert.strictEqual(entries.length, 1);
47+
const e = entries[0];
48+
assert.match(e.title, /Add idempotency/, 'title falls back to the first user message');
49+
assert.strictEqual(e.turns, 1);
50+
assert.strictEqual(e.model, 'anthropic/claude-opus-5');
51+
assert.deepStrictEqual(e.filesEdited, ['refund.rb'], 'files-edited derives from the agent event');
52+
assert.deepStrictEqual(e.spark, [1]);
53+
assert.strictEqual(e.state, 'active', 'a live session is active until sealed');
54+
});
55+
56+
test('MANAGER: the persisted transcript rebuilds VERBATIM (full history, not the trimmed window)', () => {
57+
const root = freshRoot(), slug = store.projectSlug('/p2');
58+
const m = createSessions({ root, slug, projectPath: '/p2' });
59+
const t1 = [{ role: 'user', content: 'one' }, { role: 'assistant', content: 'a1' }];
60+
const t2 = [{ role: 'user', content: 'two' }, { role: 'assistant', content: 'a2' }];
61+
m.recordTurn(t1, 'm');
62+
m.recordTurn(t2, 'm');
63+
const rebuilt = events.eventsToMessages(store.readSession(store.sessionFile(root, slug, m.liveId())).events);
64+
assert.deepStrictEqual(rebuilt, t1.concat(t2), 'both turns, in order, byte-identical — the append-only history');
65+
assert.strictEqual(m.list()[0].turns, 2, 'two user turns counted');
66+
});
67+
68+
test('MANAGER: seal writes the terminal state, clears the pointer, and a new turn opens a fresh session', () => {
69+
const root = freshRoot(), slug = store.projectSlug('/p3'), st = fakeState();
70+
const m = createSessions({ root, slug, projectPath: '/p3', state: st });
71+
m.recordTurn([{ role: 'user', content: 'hi' }], 'm');
72+
const first = m.liveId();
73+
74+
m.seal('done');
75+
assert.strictEqual(m.liveId(), null, 'no live session after seal');
76+
assert.strictEqual(st.store.liveSessionId, null, 'pointer cleared on seal');
77+
assert.strictEqual(m.list().find((x) => x.id === first).state, 'done', 'the sealed session records its terminal state');
78+
79+
m.recordTurn([{ role: 'user', content: 'a new chat' }], 'm');
80+
assert.notStrictEqual(m.liveId(), first, 'New Chat starts a fresh session; the old one stays in History');
81+
assert.strictEqual(m.list().length, 2);
82+
});
83+
84+
test('CALLER CONTRACT: recordTurn(slice-from-goal) captures exactly this turn as the live window grows + trims', () => {
85+
// Mirrors extension.js: agentMessages persists across turns and is trimmed to a tail each turn; the caller
86+
// snapshots indexOf(goal) post-trim and records agentMessages.slice(from). This proves the persisted
87+
// session stays the FULL history even after the live window drops older turns.
88+
const root = freshRoot(), slug = store.projectSlug('/p5');
89+
const m = createSessions({ root, slug, projectPath: '/p5' });
90+
let win = [];
91+
const turnFlow = (goalText, reply) => {
92+
const goal = { role: 'user', content: goalText };
93+
win.push(goal);
94+
if (win.length > 3) { win = win.slice(win.length - 3); } // a real trim keeps the tail incl. the just-pushed goal
95+
const from = win.indexOf(goal);
96+
win.push({ role: 'assistant', content: reply }); // runAgent appends the agent's messages
97+
m.recordTurn(win.slice(from), 'm');
98+
};
99+
turnFlow('first goal', 'a1');
100+
turnFlow('second goal', 'a2'); // by now the window has trimmed 'first goal' away
101+
turnFlow('third goal', 'a3');
102+
const rebuilt = events.eventsToMessages(store.readSession(store.sessionFile(root, slug, m.liveId())).events);
103+
assert.deepStrictEqual(rebuilt, [
104+
{ role: 'user', content: 'first goal' }, { role: 'assistant', content: 'a1' },
105+
{ role: 'user', content: 'second goal' }, { role: 'assistant', content: 'a2' },
106+
{ role: 'user', content: 'third goal' }, { role: 'assistant', content: 'a3' }
107+
], 'the session is the full append-only history even though the live window only ever held 3 messages');
108+
assert.strictEqual(m.list()[0].turns, 3);
109+
});
110+
111+
test('MANAGER: an interrupted run seals as interrupted (honest history)', () => {
112+
const root = freshRoot(), slug = store.projectSlug('/p4');
113+
const m = createSessions({ root, slug, projectPath: '/p4' });
114+
m.recordTurn([{ role: 'user', content: 'go' }], 'm');
115+
const id = m.liveId();
116+
m.seal('interrupted');
117+
assert.strictEqual(m.list().find((x) => x.id === id).state, 'interrupted');
118+
});
119+
120+
console.log('sessions: ' + n + ' tests passed');

0 commit comments

Comments
 (0)