|
| 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