Skip to content

Commit 82e76fc

Browse files
committed
feat(sessions): fork a session — the what-if branch
The largest designed-but-unbuilt piece of the triad. §313: "resume a copy from any point, leaving the original intact — the what-if-I'd-told-it-to-do-X-instead branch." Fork-from-END, as §411 settles the scoping: per-turn fork needs a transcript picker, which is separate work. WHICH EVENTS TRAVEL is the entire design, and the answer is not "all of them": user / agent / assistant YES — this is the conversation being forked title YES, then overridden, so the fork is not a second row with an identical name end NO — that is the ORIGINAL's terminal state. Copying it renders a live fork as `done` while you type label NO — lifecycle and pinning are the original's. A fork of an archived session must arrive visible, and must not silently take a second pin The copy records `forkedFrom` in its meta — §4 provenance, and what a later branch-graph draws from. The key is written ONLY on a fork, so every existing session file stays byte-identical and an older build reading a forked session simply does not know it was forked rather than failing to read it. A fork IS a resume, into a copy, so the handler calls resumeSession() on the new id rather than duplicating the replay, the budget planning, or the honest "resumed from a summary" note. The sixth card button: labels now collapse below 420px (chat) / 400px (panel), up from 360/340 — six labelled buttons need ~370px against five at ~300px, and the row is nowrap so overshoot overflows rather than wraps. The guard added with export already covered this; only the threshold moved. Tests: 8 in sessions.test.js against a fixture that is sealed AND archived AND pinned — every state a fork must not inherit. Verified non-vacuous: copy the end event 20/25 copy label events 20/25 drop the (fork) suffix 21/25 drop provenance 22/25 do not go live 23/25 All 32 suites green. One test bug worth recording: `m.list().find(e => e.id === m.fork(id))` calls fork() once per row against a list fetched before any fork existed. Hoisted.
1 parent a86120a commit 82e76fc

9 files changed

Lines changed: 173 additions & 14 deletions

File tree

docs/levelcode-sessions-experience.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ Global rule: **nothing animates on scroll at all** — the sparkline lives in th
310310

311311
Beyond the card and the search, the "wow" list — each grounded in the JSONL substrate so it's cheap to build:
312312

313-
- **Fork a session** (`⇧⏎`) — resume a *copy* from any point, leaving the original intact. The "what if I'd told it to do X instead" branch. Cursor's checkpoints rewind *files*; forking rewinds the *conversation*. Both, together, is new.
313+
- **Fork a session** *(fork-from-end shipped)* — resume a *copy*, leaving the original intact. `sessions.fork()` seeds a new session with the original's conversation and **drops its `end` and `label` events**: a copied `end` would render a live fork as `done` while you typed into it, and a copied `label` would have a fork of an archived session born invisible in the default Active scope, or silently taking a second pin. The copy is titled `… (fork)`, records `forkedFrom` in its meta (§4 provenance, and what a later branch-graph would draw from), and becomes live — a fork *is* a resume, into a copy, so it reuses the resume path wholesale. Per-turn fork still awaits the transcript picker (§411). The "what if I'd told it to do X instead" branch. Cursor's checkpoints rewind *files*; forking rewinds the *conversation*. Both, together, is new.
314314
- **Files-touched search & chips** (§4.2/4.4) — find work by what it changed. The structural lead over Cursor.
315315
- **Activity sparkline** (§4.2) — session *shape* at a glance.
316316
- **The honesty layer as UI** — interrupted / reloaded / summarized states are pills and strips, not silence (§4.5, §4.7).

extensions/levelcode-ai/extension.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,16 @@ async function handleSessionAction(action, id) {
936936
return;
937937
}
938938
if (action === 'export') { await exportSession(id); return; }
939+
if (action === 'fork') {
940+
// A fork IS a resume, into a copy — so it reuses resumeSession wholesale rather than
941+
// duplicating the transcript replay, the budget planning, or the "resumed from a summary"
942+
// note. m.fork() has already made the copy live.
943+
const forkId = m.fork(id);
944+
if (!forkId) { vscode.window.showErrorMessage('Could not fork that session — it may have been deleted.'); return; }
945+
dbg('sessions.fork', { from: id, to: forkId });
946+
await resumeSession(forkId);
947+
return;
948+
}
939949
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
940950
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
941951
if (action === 'rename') {

extensions/levelcode-ai/media/chat.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,7 +1112,7 @@
11121112
`title` + `aria-label` are on every button already, so nothing is lost to a pointer or a screen
11131113
reader — only to the eye, and only when there is no room for it. */
11141114
.sesscard { container-type: inline-size; }
1115-
@container (max-width: 360px) { .sesscard .sesslbl { display: none; } }
1115+
@container (max-width: 420px) { .sesscard .sesslbl { display: none; } }
11161116
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
11171117
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
11181118
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -3351,7 +3351,8 @@
33513351
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
33523352
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
33533353
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
3354-
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
3354+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>',
3355+
fork: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.5 2a2 2 0 0 0-.5 3.93v1.32c0 1.1.9 2 2 2h1.5V11a2 2 0 1 0 1 0V9.25H10c1.1 0 2-.9 2-2V5.93A2 2 0 1 0 11 5.93v1.32a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5.93A2 2 0 0 0 4.5 2zm0 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM8 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>'
33553356
};
33563357
function sessActBtn(act, label, svg, cls){
33573358
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -3377,6 +3378,7 @@
33773378
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
33783379
const acts = '<span class="sessacts">'
33793380
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
3381+
+ sessActBtn('fork', 'Fork', SESS_IC.fork)
33803382
+ sessActBtn('export', 'Copy', SESS_IC.copy)
33813383
+ sessActBtn('done', 'Done', SESS_IC.check)
33823384
+ sessActBtn('delete', 'Delete', SESS_IC.trash)

extensions/levelcode-ai/media/sessionsView.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
pane. Below the width where five labelled buttons fit, drop to icons — title + aria-label are
9292
already on every button, so only the eye loses anything, and only when there is no room. */
9393
.sesscard { container-type: inline-size; }
94-
@container (max-width: 340px) { .sesscard .sesslbl { display: none; } }
94+
@container (max-width: 400px) { .sesscard .sesslbl { display: none; } }
9595
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
9696
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
9797
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -151,7 +151,8 @@
151151
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
152152
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
153153
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
154-
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
154+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>',
155+
fork: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.5 2a2 2 0 0 0-.5 3.93v1.32c0 1.1.9 2 2 2h1.5V11a2 2 0 1 0 1 0V9.25H10c1.1 0 2-.9 2-2V5.93A2 2 0 1 0 11 5.93v1.32a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5.93A2 2 0 0 0 4.5 2zm0 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM8 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>'
155156
};
156157
function sessActBtn(act, label, svg, cls){
157158
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -177,6 +178,7 @@
177178
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
178179
const acts = '<span class="sessacts">'
179180
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
181+
+ sessActBtn('fork', 'Fork', SESS_IC.fork)
180182
+ sessActBtn('export', 'Copy', SESS_IC.copy)
181183
+ sessActBtn('done', 'Done', SESS_IC.check)
182184
+ sessActBtn('delete', 'Delete', SESS_IC.trash)

extensions/levelcode-ai/sessionStore.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,15 @@ function indexFile(root, slug) { return path.join(root, slug, INDEX_NAME); }
7474
// ── event encoding & parsing (pure) ──────────────────────────────────────────────────────────────
7575

7676
/** The birth line — written once, so even a one-message crash leaves a listable session (design §4). */
77-
function metaLine(id, projectPath, createdAtIso, title) {
78-
return { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
77+
function metaLine(id, projectPath, createdAtIso, title, forkedFrom) {
78+
const m = { kind: 'meta', v: SCHEMA_V, id: String(id), project: String(projectPath == null ? '' : projectPath),
7979
createdAt: String(createdAtIso), title: title == null ? null : String(title) };
80+
// Provenance for a forked session, and ONLY for one — the key is absent on an ordinary session so
81+
// every existing file and expectation stays byte-identical. Unknown meta keys are ignored by
82+
// parseSession and deriveEntry, so this is additive in both directions: an older build reading a
83+
// forked session simply does not know it was forked, rather than failing to read it.
84+
if (forkedFrom != null && String(forkedFrom)) { m.forkedFrom = String(forkedFrom); }
85+
return m;
8086
}
8187

8288
function encodeEvent(event) { return JSON.stringify(event) + '\n'; }
@@ -151,9 +157,9 @@ function deriveEntry(meta, events) {
151157
// ── writing (append-only) ────────────────────────────────────────────────────────────────────────
152158

153159
/** Create a session: its directory + the meta birth line. Idempotent-ish (a re-create just rewrites meta). */
154-
function createSession(root, slug, id, projectPath, createdAtIso, title) {
160+
function createSession(root, slug, id, projectPath, createdAtIso, title, forkedFrom) {
155161
fs.mkdirSync(projectDir(root, slug), { recursive: true });
156-
fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title)));
162+
fs.writeFileSync(sessionFile(root, slug, id), encodeEvent(metaLine(id, projectPath, createdAtIso, title, forkedFrom)));
157163
return sessionFile(root, slug, id);
158164
}
159165

extensions/levelcode-ai/sessions.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,56 @@ function createSessions(opts) {
120120
note: planner.describeResume(plan, turnsSummarized) };
121121
}
122122

123+
/**
124+
* FORK a session (experience doc §6): a NEW session seeded with a copy of this one's conversation,
125+
* leaving the original completely untouched — "the what-if-I'd-told-it-to-do-X-instead branch".
126+
*
127+
* Fork-from-END, deliberately. §411 settles the scoping question: "fork-from-end first; per-turn
128+
* fork rides the transcript picker later." Forking from an arbitrary turn needs a UI for choosing
129+
* the turn, and that is a different piece of work from the copy itself.
130+
*
131+
* WHICH EVENTS TRAVEL is the whole design here, and the answer is not "all of them":
132+
* • `user` / `agent` / `assistant` — YES. This is the conversation; it is the thing being forked.
133+
* • `title` — YES, then overridden below, so the fork is recognisable in a
134+
* list where it would otherwise be a second row with the
135+
* identical name.
136+
* • `end` — NO. That is the original's terminal state. Copying it would
137+
* make a live fork claim it had already finished, and
138+
* deriveEntry would render it `done` while you typed into it.
139+
* • `label` — NO. Lifecycle and pinning belong to the ORIGINAL. A fork of
140+
* an archived session must arrive active, or it is born
141+
* invisible in the default Active scope; a fork of a pinned
142+
* one must not silently take up a second pin.
143+
*
144+
* Returns the new id, or null if the source is gone/unreadable. The caller resumes it — a fork IS a
145+
* resume, just into a copy — so nothing here duplicates the replay logic.
146+
*/
147+
function fork(id) {
148+
let s;
149+
try { s = store.readSession(store.sessionFile(root, slug, id)); }
150+
catch (e) { return null; }
151+
152+
const src = store.deriveEntry(s.meta, s.events);
153+
const newId = store.newSessionId(clock());
154+
try {
155+
// The meta records what it came from — provenance is the honesty guarantee everywhere else
156+
// in this system (§4), and it is also what a later branch-graph would draw from.
157+
store.createSession(root, slug, newId, projectPath, iso(), null, id);
158+
const file = store.sessionFile(root, slug, newId);
159+
for (const e of (Array.isArray(s.events) ? s.events : [])) {
160+
if (!e || e.kind === 'end' || e.kind === 'label') { continue; }
161+
store.appendEvent(file, e);
162+
}
163+
// Last, so it wins: deriveEntry takes the LATEST title event.
164+
const base = src.title || 'Untitled';
165+
store.appendEvent(file, events.titleEvent(/\(fork\)\s*$/.test(base) ? base : base + ' (fork)', iso()));
166+
live = { id: newId, file };
167+
if (state) { try { state.set('liveSessionId', newId); } catch (e2) { /* convenience */ } }
168+
reindexId(newId);
169+
return newId;
170+
} catch (e) { return null; }
171+
}
172+
123173
// Append-only lifecycle edits (§4.9): each writes one event, then refreshes that id's index row. All
124174
// best-effort (return false rather than throw) — a History edit must never disrupt anything.
125175
function appendTo(id, event) {
@@ -282,7 +332,7 @@ function createSessions(opts) {
282332

283333
function liveId() { return live ? live.id : null; }
284334

285-
return { ensure, recordTurn, seal, resume, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, list, liveId };
335+
return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, list, liveId };
286336
}
287337

288338
module.exports = { createSessions };

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,93 @@ test('MEMORY: supersedeFact drops a stale fact from the digest but keeps it (res
337337
assert.strictEqual(m.digest().facts.length, 1, 'Keep/Confirm restores it to the digest');
338338
});
339339

340+
// ---- Fork (experience doc §6, "the what-if-I'd-told-it-to-do-X-instead branch") -----------------
341+
342+
/** A sealed, archived, pinned session with two turns — every state a fork must NOT inherit. */
343+
function forkFixture() {
344+
const root = freshRoot(), slug = 'proj';
345+
const m = createSessions({ root, slug, projectPath: '/proj', memory: false });
346+
m.ensure();
347+
m.recordTurn(turn('add idempotency', 'refund.rb'), 'opus');
348+
m.recordTurn(turn('now add retries', 'retry.rb'), 'opus');
349+
const id = m.liveId();
350+
m.rename(id, 'Idempotent refunds');
351+
m.archive(id);
352+
m.setPinned(id, true);
353+
m.seal('done');
354+
return { root, slug, m, id };
355+
}
356+
357+
test('FORK: the copy carries the conversation and the original is left completely alone', () => {
358+
const { m, id } = forkFixture();
359+
const before = JSON.stringify(m.list().find((e) => e.id === id));
360+
361+
const forkId = m.fork(id);
362+
assert.ok(forkId && forkId !== id, 'fork must produce a NEW session, not reuse the id');
363+
364+
const fork = m.list().find((e) => e.id === forkId);
365+
assert.strictEqual(fork.turns, 2, 'the conversation did not come across');
366+
assert.deepStrictEqual(fork.filesEdited, ['refund.rb', 'retry.rb'], 'the derived work came across too');
367+
368+
assert.strictEqual(JSON.stringify(m.list().find((e) => e.id === id)), before,
369+
'forking mutated the original — the whole promise is that it does not');
370+
});
371+
372+
test('FORK: state, lifecycle and pinning belong to the ORIGINAL and do not travel', () => {
373+
// The event-selection rules, which are the entire design of fork(). A copied `end` would make a
374+
// live fork render as `done` while you typed into it; a copied `label` would have a fork of an
375+
// archived session born invisible in the default Active scope, or silently taking a second pin.
376+
const { m, id } = forkFixture();
377+
const forkId = m.fork(id); // hoisted: inside find() this runs once per row
378+
const fork = m.list().find((e) => e.id === forkId);
379+
assert.strictEqual(fork.state, 'active', "the original's terminal state was copied");
380+
assert.strictEqual(fork.lifecycle, 'active', 'a fork of an archived session must arrive visible');
381+
assert.strictEqual(fork.pinned, false, 'pinning is the original\'s, not the copy\'s');
382+
});
383+
384+
test('FORK: the copy is recognisable in a list, and marked only once', () => {
385+
const { m, id } = forkFixture();
386+
const firstId = m.fork(id);
387+
const first = m.list().find((e) => e.id === firstId);
388+
assert.strictEqual(first.title, 'Idempotent refunds (fork)', 'two identical rows would be unreadable');
389+
390+
// Forking a fork must not stutter into "x (fork) (fork)".
391+
const secondId = m.fork(first.id);
392+
const second = m.list().find((e) => e.id === secondId);
393+
assert.strictEqual(second.title, 'Idempotent refunds (fork)');
394+
});
395+
396+
test('FORK: provenance is recorded, and only on a fork', () => {
397+
const { root, slug, m, id } = forkFixture();
398+
const forkId = m.fork(id);
399+
const meta = (f) => store.readSession(store.sessionFile(root, slug, f)).meta;
400+
assert.strictEqual(meta(forkId).forkedFrom, id, 'a fork must know where it came from (§4 provenance)');
401+
assert.ok(!('forkedFrom' in meta(id)),
402+
'an ordinary session gained the key — every existing file must stay byte-identical');
403+
});
404+
405+
test('FORK: the copy becomes live, so the next turn appends to it and not the original', () => {
406+
const { m, id } = forkFixture();
407+
const forkId = m.fork(id);
408+
assert.strictEqual(m.liveId(), forkId, 'a fork IS a resume, into a copy');
409+
410+
m.recordTurn(turn('try it differently', 'other.rb'), 'opus');
411+
assert.strictEqual(m.list().find((e) => e.id === forkId).turns, 3, 'the new turn landed on the fork');
412+
assert.strictEqual(m.list().find((e) => e.id === id).turns, 2, 'and NOT on the original');
413+
});
414+
415+
test('FORK: a missing source fails soft, and an empty session forks without inventing turns', () => {
416+
const { m } = forkFixture();
417+
assert.strictEqual(m.fork('does-not-exist'), null, 'a gone session must not throw into the UI');
418+
assert.strictEqual(m.fork(''), null);
419+
420+
const root2 = freshRoot();
421+
const m2 = createSessions({ root: root2, slug: 'p', projectPath: '/p', memory: false });
422+
m2.ensure();
423+
const emptyId = m2.liveId();
424+
const forkId = m2.fork(emptyId);
425+
assert.ok(forkId, 'a session with no turns is still forkable');
426+
assert.strictEqual(m2.list().find((e) => e.id === forkId).turns, 0);
427+
});
428+
340429
console.log('sessions: ' + n + ' tests passed');

0 commit comments

Comments
 (0)