pi-odd-worktree 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/odd-worktree-tools.ts +129 -5
- package/lib/worktree-warp-sessions.js +28 -0
- package/package.json +1 -1
- package/tools/warp-empty-session.test.js +495 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ alone; Herdr is optional.
|
|
|
15
15
|
| `worktree_create` | tool | Creates a branch and its checkout from the **local** `main`, registers it, runs the creation hooks, and copies the pending changes you name. |
|
|
16
16
|
| `worktree_warp` | tool | Queues the session move to another checkout as a `/warp` handoff; the command performs the move when the turn ends. |
|
|
17
17
|
| `worktree_close` | tool | Removes a registered checkout with `git worktree remove`, keeping the branch, and refuses while a live Pi session is working in it. |
|
|
18
|
-
| `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. |
|
|
18
|
+
| `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. A session with no conversation yet changes checkout **silently** — no fork, no `Warp · moved` card, nothing to carry — and warping back from it is the same empty case. |
|
|
19
19
|
| `/odd-list` | panel | Two-pane reader of this checkout's `odd/tasks/*.md`: list on the left, styled markdown on the right. |
|
|
20
20
|
| `/issues` | panel | Full-screen reader of this checkout's repository issues — My issues / Assigned / Involved / All — with preview, comments, labels, assignees and close, gated on your repository role. |
|
|
21
21
|
| `/codegraph-hook` | command | **Only when the CodeGraph CLI is installed.** Turns the edit/write sync on or off for the session. |
|
|
@@ -4244,6 +4244,16 @@ function warpNoticeText(targetCwd, branch) {
|
|
|
4244
4244
|
return `Worktree warp: now in ${to} (branch '${cleanBranch}').`;
|
|
4245
4245
|
}
|
|
4246
4246
|
|
|
4247
|
+
// S1 (warp-empty-session): what an EMPTY session's warp reports. It names
|
|
4248
|
+
// WHERE the session now is and never a move: nothing was carried, so the
|
|
4249
|
+
// `moved:`/`to:` compact — and the card that accompanies it — would claim a
|
|
4250
|
+
// transition that did not happen.
|
|
4251
|
+
function warpEmptyText(name, targetCwd) {
|
|
4252
|
+
const toName = warpClean(name, 'worktree');
|
|
4253
|
+
const to = warpClean(targetCwd, '(unknown checkout)');
|
|
4254
|
+
return `worktree warp: ${toName} (empty session, nothing to carry; now at ${to})`;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4247
4257
|
// Deepest live checkout containing a cwd, by resolved identity — the one
|
|
4248
4258
|
// rule the listing, the selector title and the already-there check share.
|
|
4249
4259
|
// `extraIds` lets the already-there check keep its target-cwd candidate
|
|
@@ -4813,6 +4823,18 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
4813
4823
|
}
|
|
4814
4824
|
if (handoff) await warpConfirm(r, warpHandoffNotice, true);
|
|
4815
4825
|
};
|
|
4826
|
+
// S1/S3 (warp-empty-session): the switched-to session of an EMPTY warp is
|
|
4827
|
+
// told nothing — no card, and no handoff continuation either, because the
|
|
4828
|
+
// session carries no task yet (the already-there no-op answers a handoff
|
|
4829
|
+
// with text only, for the same reason). The status line stays: it says
|
|
4830
|
+
// WHERE the session is, not what it carried.
|
|
4831
|
+
const warpSilentWithSession = async (r) => {
|
|
4832
|
+
try {
|
|
4833
|
+
r.ui.setStatus("worktree", `${displayName} (${targetCwd})`);
|
|
4834
|
+
} catch {
|
|
4835
|
+
// Status is cosmetic; the silence is the point.
|
|
4836
|
+
}
|
|
4837
|
+
};
|
|
4816
4838
|
// S43 records. The POINT of a session is its ACTIVE branch (the live
|
|
4817
4839
|
// leaf's parentId path, never all append-only entries) filtered to
|
|
4818
4840
|
// entries that are NOT our own bookkeeping — the custom marker, the
|
|
@@ -4855,6 +4877,16 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
4855
4877
|
const currentPoint = warpSessions.sessionPoint(
|
|
4856
4878
|
currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
|
|
4857
4879
|
);
|
|
4880
|
+
// S1/S5 (warp-empty-session): an EMPTY session is one whose active branch
|
|
4881
|
+
// carries no conversation — no `message` entry with role user or assistant
|
|
4882
|
+
// outside the warp bookkeeping, so a rename, the session-worktree
|
|
4883
|
+
// registration or a transition marker never counts as content. It has
|
|
4884
|
+
// nothing to carry, so its warp changes PATH instead of moving work: never
|
|
4885
|
+
// a fork (the host refuses a conversation-less source), never a card, never
|
|
4886
|
+
// a "moved:" claim.
|
|
4887
|
+
const sessionEmpty = !warpSessions.hasConversation(
|
|
4888
|
+
currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
|
|
4889
|
+
);
|
|
4858
4890
|
const warpFileOpen = options && typeof options.warpSessionFileOpen === "function"
|
|
4859
4891
|
? options.warpSessionFileOpen
|
|
4860
4892
|
: undefined;
|
|
@@ -5023,7 +5055,13 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5023
5055
|
// cached fingerprint. An unreadable destination never resumes.
|
|
5024
5056
|
// Never throws: any comparison failure reads as "fork".
|
|
5025
5057
|
const freshDestPoint = warpFreshPoint(recorded.sessionFile);
|
|
5026
|
-
|
|
5058
|
+
// An empty source has no point to compare (its own point is empty
|
|
5059
|
+
// by definition), so it RESUMES the destination session whenever
|
|
5060
|
+
// that file is readable — the conversation it already holds (S4)
|
|
5061
|
+
// or its own emptiness — instead of allocating a copy of nothing. A
|
|
5062
|
+
// session with conversation keeps the same-point gate.
|
|
5063
|
+
if (freshDestPoint !== null
|
|
5064
|
+
&& (sessionEmpty || warpSessions.samePoint(currentPoint, freshDestPoint))) {
|
|
5027
5065
|
recordedSession = recorded;
|
|
5028
5066
|
recordedFreshPoint = freshDestPoint;
|
|
5029
5067
|
}
|
|
@@ -5039,7 +5077,9 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5039
5077
|
if (recordedSessionFile !== null) {
|
|
5040
5078
|
let resumed;
|
|
5041
5079
|
try {
|
|
5042
|
-
resumed = await ctx.switchSession(recordedSessionFile, {
|
|
5080
|
+
resumed = await ctx.switchSession(recordedSessionFile, {
|
|
5081
|
+
withSession: sessionEmpty ? warpSilentWithSession : warpResumeWithSession,
|
|
5082
|
+
});
|
|
5043
5083
|
} catch (err) {
|
|
5044
5084
|
return failAfterCopy(
|
|
5045
5085
|
`worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the recorded warp session was kept`,
|
|
@@ -5066,8 +5106,12 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5066
5106
|
);
|
|
5067
5107
|
emitOsc7(options.oscStream || process.stdout, targetCwd);
|
|
5068
5108
|
return {
|
|
5069
|
-
text: moveText,
|
|
5070
|
-
details: {
|
|
5109
|
+
text: sessionEmpty ? warpEmptyText(name, targetCwd) : moveText,
|
|
5110
|
+
details: {
|
|
5111
|
+
name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
|
|
5112
|
+
alreadyThere: false, cancelled: false,
|
|
5113
|
+
...(sessionEmpty ? { emptySession: true } : {}), ...handoffCopyDetails(),
|
|
5114
|
+
},
|
|
5071
5115
|
};
|
|
5072
5116
|
}
|
|
5073
5117
|
const sm = ctx.sessionManager || {};
|
|
@@ -5082,7 +5126,11 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5082
5126
|
const leaf = typeof sm.getLeafId === "function" ? safe(() => sm.getLeafId()) : null;
|
|
5083
5127
|
const sessionDir = typeof sm.getSessionDir === "function" ? safe(() => sm.getSessionDir()) : undefined;
|
|
5084
5128
|
let carried = [];
|
|
5085
|
-
|
|
5129
|
+
// S1: an empty session is allowed through without a persisted file or
|
|
5130
|
+
// in-memory entries — the refusal below exists because a session with
|
|
5131
|
+
// nothing to carry had nothing to warp FOR, and the empty path now changes
|
|
5132
|
+
// its path instead of carrying anything.
|
|
5133
|
+
if (!sessionEmpty && !warpSourceUsable(file, warpFileOpen)) {
|
|
5086
5134
|
const entries = typeof sm.getEntries === "function" ? safe(() => sm.getEntries()) : undefined;
|
|
5087
5135
|
carried = Array.isArray(entries) ? entries : [];
|
|
5088
5136
|
if (carried.length === 0) {
|
|
@@ -5093,6 +5141,82 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5093
5141
|
}
|
|
5094
5142
|
}
|
|
5095
5143
|
const SM = options.sessionManagerClass || await sessionManagerClassFor(ctx);
|
|
5144
|
+
// S3 (warp-empty-session): an empty session changes PATH. There is nothing
|
|
5145
|
+
// to carry, so nothing is forked (`forkFrom` refuses a conversation-less
|
|
5146
|
+
// source anyway) and the in-place move the host would need does not exist —
|
|
5147
|
+
// `switchSession(file)` is the only mount, and a session's cwd comes from
|
|
5148
|
+
// its own header. So the equivalent is used: a FRESH session allocated at
|
|
5149
|
+
// the target cwd through the same create/switch machinery the non-persisted
|
|
5150
|
+
// path uses, with only its header written (`wx`, never clobbering) so the
|
|
5151
|
+
// mount and the record both have a real file. The switch is silent: no
|
|
5152
|
+
// card, no transition entry, no rename — bookkeeping this flow did not earn.
|
|
5153
|
+
if (sessionEmpty) {
|
|
5154
|
+
if (!SM || typeof SM.create !== "function") {
|
|
5155
|
+
return failAfterCopy(
|
|
5156
|
+
"worktree warp is unavailable here: the host SessionManager has no create",
|
|
5157
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5158
|
+
);
|
|
5159
|
+
}
|
|
5160
|
+
let emptyFile = null;
|
|
5161
|
+
try {
|
|
5162
|
+
const allocated = SM.create(targetCwd, sessionDir);
|
|
5163
|
+
const allocatedFile = allocated && typeof allocated.getSessionFile === "function" ? allocated.getSessionFile() : undefined;
|
|
5164
|
+
if (typeof allocatedFile !== "string" || allocatedFile.length === 0) {
|
|
5165
|
+
throw new Error("the host allocated no session file for the target");
|
|
5166
|
+
}
|
|
5167
|
+
const header = typeof allocated.getHeader === "function" ? allocated.getHeader() : null;
|
|
5168
|
+
if (!header) throw new Error("the allocated session has no header");
|
|
5169
|
+
// The file is the header alone: there is no entry to carry, and a file
|
|
5170
|
+
// naming the target cwd is what makes the mount and the fresh point
|
|
5171
|
+
// real. `wx` never clobbers an existing file.
|
|
5172
|
+
fs.writeFileSync(allocatedFile, `${JSON.stringify(header)}\n`, { flag: "wx" });
|
|
5173
|
+
emptyFile = allocatedFile;
|
|
5174
|
+
} catch (err) {
|
|
5175
|
+
return failAfterCopy(
|
|
5176
|
+
`worktree warp: could not change this session's path to ${targetCwd}: ${(err && err.message) || err}`,
|
|
5177
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5178
|
+
);
|
|
5179
|
+
}
|
|
5180
|
+
let changed;
|
|
5181
|
+
try {
|
|
5182
|
+
changed = await ctx.switchSession(emptyFile, { withSession: warpSilentWithSession });
|
|
5183
|
+
} catch (err) {
|
|
5184
|
+
return failAfterCopy(
|
|
5185
|
+
`worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the new session file was kept at ${emptyFile}`,
|
|
5186
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5187
|
+
);
|
|
5188
|
+
}
|
|
5189
|
+
if (changed && changed.cancelled === true) {
|
|
5190
|
+
try {
|
|
5191
|
+
fs.unlinkSync(emptyFile);
|
|
5192
|
+
} catch {
|
|
5193
|
+
// Best effort: the report below is what matters.
|
|
5194
|
+
}
|
|
5195
|
+
return failAfterCopy(
|
|
5196
|
+
`worktree warp: ${name} cancelled (the session stays in ${fromCwd}; the new session file was removed)`,
|
|
5197
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: true },
|
|
5198
|
+
);
|
|
5199
|
+
}
|
|
5200
|
+
// Recording is unchanged: both sides are recorded at their own points, so
|
|
5201
|
+
// a later warp finds this destination session again (S2: warping back
|
|
5202
|
+
// from an empty session is the same empty case).
|
|
5203
|
+
try {
|
|
5204
|
+
recordBothSides(
|
|
5205
|
+
targetCwd, emptyFile, warpFreshPoint(emptyFile),
|
|
5206
|
+
sourceIdentity, currentFile, currentPoint,
|
|
5207
|
+
);
|
|
5208
|
+
} catch {
|
|
5209
|
+
// Best effort: the warp already switched.
|
|
5210
|
+
}
|
|
5211
|
+
emitOsc7(options.oscStream || process.stdout, targetCwd);
|
|
5212
|
+
return {
|
|
5213
|
+
text: warpEmptyText(name, targetCwd),
|
|
5214
|
+
details: {
|
|
5215
|
+
name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
|
|
5216
|
+
alreadyThere: false, cancelled: false, emptySession: true, ...handoffCopyDetails(),
|
|
5217
|
+
},
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
5096
5220
|
if (!SM || typeof SM.forkFrom !== "function") {
|
|
5097
5221
|
return failAfterCopy(
|
|
5098
5222
|
"worktree warp is unavailable here: the host SessionManager has no forkFrom",
|
|
@@ -158,6 +158,33 @@ function sessionPoint(entries, leafId) {
|
|
|
158
158
|
return { tipId, count };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Does the ACTIVE branch carry a CONVERSATION? The host's own notion of
|
|
162
|
+
// content is a `message` entry whose role is user or assistant
|
|
163
|
+
// (SessionManager._persist only flushes a session file once an assistant
|
|
164
|
+
// message exists), and everything the warp appends — the transition marker,
|
|
165
|
+
// the confirmation notice, the session-worktree registration, a rename — is
|
|
166
|
+
// bookkeeping, so a session that holds only those is still an empty one: it
|
|
167
|
+
// has nothing to carry and therefore changes PATH instead of forking. The
|
|
168
|
+
// branch is the same filtered walk `sessionPoint` uses, so an abandoned leg
|
|
169
|
+
// never counts. The role is read from the persisted `entry.message.role` and
|
|
170
|
+
// from a bare `entry.role` alike, because reading an entry as content must
|
|
171
|
+
// not depend on one spelling. A read that throws answers `true`: an
|
|
172
|
+
// unreadable session keeps the ordinary move (fork) instead of silently
|
|
173
|
+
// dropping context it may hold.
|
|
174
|
+
function hasConversation(entries, leafId) {
|
|
175
|
+
try {
|
|
176
|
+
for (const entry of activeBranch(entries, leafId)) {
|
|
177
|
+
if (!entry || isWarpBookkeepingEntry(entry) || entry.type !== 'message') continue;
|
|
178
|
+
const role = typeof entry.role === 'string' ? entry.role
|
|
179
|
+
: entry.message && typeof entry.message === 'object' ? entry.message.role : null;
|
|
180
|
+
if (role === 'user' || role === 'assistant') return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
} catch {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
// Point evidence for the R3 fork-vs-resume rule: the source session's
|
|
162
189
|
// FILTERED position (S23 sessionPoint) when the record was written. Older
|
|
163
190
|
// records carry {leafId, entryCount} from the raw-leaf comparison; they
|
|
@@ -559,6 +586,7 @@ module.exports = {
|
|
|
559
586
|
recordWarpSession,
|
|
560
587
|
isWarpBookkeepingEntry,
|
|
561
588
|
sessionPoint,
|
|
589
|
+
hasConversation,
|
|
562
590
|
samePoint,
|
|
563
591
|
asPoint,
|
|
564
592
|
pointOfSessionFile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-odd-worktree",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Pi package for odd-worktree: the Agent Worktree tools (create, list, close, warp), the /warp worktree panel, the /odd-list ODD viewer, and the /issues GitHub issue reader. Works in Pi alone; Herdr is optional.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// warp-empty-session T1/T2 (S1-S5): a session with no conversation does not
|
|
3
|
+
// "move" — it changes PATH silently.
|
|
4
|
+
//
|
|
5
|
+
// S1: an empty session warps silently — no `worktree-warp` message, no
|
|
6
|
+
// transition card, no "moved:/to:" wording in the returned text.
|
|
7
|
+
// S2: warping back after writing nothing is the same case, in both directions
|
|
8
|
+
// and any number of times.
|
|
9
|
+
// S3: no in-place path change exists in the host, so the equivalent is a fresh
|
|
10
|
+
// session at the target cwd through the create/switch machinery — never
|
|
11
|
+
// `forkFrom` (the host refuses a conversation-less source).
|
|
12
|
+
// S4: branching semantics stay untouched for a session WITH conversation; an
|
|
13
|
+
// empty source RESUMES a destination that already holds a conversation.
|
|
14
|
+
// S5: empty means "the active branch carries no `message` entry with role user
|
|
15
|
+
// or assistant once the warp bookkeeping is excluded".
|
|
16
|
+
//
|
|
17
|
+
// Everything is injected: the SessionManager statics (`forkFrom`/`create`/
|
|
18
|
+
// `open`), the command context, and the OSC stream. The repositories and the
|
|
19
|
+
// session files live in throwaway directories under the system temp dir; no
|
|
20
|
+
// live host, no network, and no real Pi session is touched.
|
|
21
|
+
|
|
22
|
+
const { test, describe, before, after } = require('node:test');
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { pathToFileURL } = require('node:url');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
29
|
+
const { LIB_DIR, stubbedSource } = require('./fixtures/load-extension.js');
|
|
30
|
+
const warpSessionsLib = require('../lib/worktree-warp-sessions.js');
|
|
31
|
+
|
|
32
|
+
const STAMP = '2026-01-01T00:00:00.000Z';
|
|
33
|
+
const savedEnv = { ...process.env };
|
|
34
|
+
const roots = [];
|
|
35
|
+
|
|
36
|
+
function track(dir) {
|
|
37
|
+
roots.push(dir);
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
after(() => {
|
|
42
|
+
for (const key of Object.keys(process.env)) {
|
|
43
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
44
|
+
}
|
|
45
|
+
for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
|
|
46
|
+
for (const dir of roots) {
|
|
47
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function runGit(cwd, args) {
|
|
52
|
+
const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
53
|
+
if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
|
|
54
|
+
return out.stdout.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let mod;
|
|
58
|
+
|
|
59
|
+
before(async () => {
|
|
60
|
+
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-ext-')));
|
|
61
|
+
const copy = path.join(dir, 'odd-worktree-tools.mjs');
|
|
62
|
+
fs.writeFileSync(copy, stubbedSource());
|
|
63
|
+
process.env.GENTLE_WORKTREE_LIB_DIR = LIB_DIR;
|
|
64
|
+
process.env.HERDR_BIN_PATH = path.join(dir, 'no-such-herdr-binary');
|
|
65
|
+
process.env.GENTLE_WORKTREE_THREAD = 'thread-1';
|
|
66
|
+
mod = await import(pathToFileURL(copy).href);
|
|
67
|
+
mod.default({
|
|
68
|
+
registerCommand() {},
|
|
69
|
+
registerTool() {},
|
|
70
|
+
registerMessageRenderer() {},
|
|
71
|
+
on() { return () => {}; },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Entry fixtures: the shapes the host persists, chained so the active branch
|
|
77
|
+
// is the whole list (each entry's parentId is the previous id).
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const message = (role, text) => (id, parentId) => ({
|
|
81
|
+
type: 'message', id, parentId, timestamp: STAMP, message: { role, content: [{ type: 'text', text }] },
|
|
82
|
+
});
|
|
83
|
+
const userSays = (text) => message('user', text);
|
|
84
|
+
const assistantSays = (text) => message('assistant', text);
|
|
85
|
+
const toolResult = (text) => message('toolResult', text);
|
|
86
|
+
// The four warp bookkeeping shapes (lib/worktree-warp-sessions.js): none of
|
|
87
|
+
// them is conversation.
|
|
88
|
+
const registration = () => (id, parentId) => ({
|
|
89
|
+
type: 'custom', id, parentId, timestamp: STAMP,
|
|
90
|
+
customType: 'gentle-pi.session-worktree/v1', data: { sessionId: 'thread-1', cwd: '/' },
|
|
91
|
+
});
|
|
92
|
+
const rename = (name) => (id, parentId) => ({ type: 'session_info', id, parentId, timestamp: STAMP, name });
|
|
93
|
+
const transition = (from, to) => (id, parentId) => ({
|
|
94
|
+
type: 'custom', id, parentId, timestamp: STAMP, customType: 'worktree-warp', data: { from, to },
|
|
95
|
+
});
|
|
96
|
+
const warpNotice = (text) => (id, parentId) => ({
|
|
97
|
+
type: 'custom_message', id, parentId, timestamp: STAMP, customType: 'worktree-warp', content: text, display: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function chain(builders) {
|
|
101
|
+
const entries = [];
|
|
102
|
+
let parentId = null;
|
|
103
|
+
builders.forEach((build, index) => {
|
|
104
|
+
const entry = build(`e${index + 1}`, parentId);
|
|
105
|
+
entries.push(entry);
|
|
106
|
+
parentId = entry.id;
|
|
107
|
+
});
|
|
108
|
+
return entries;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readJsonl(file) {
|
|
112
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
113
|
+
return raw.split('\n').filter((line) => line.length > 0).map((line) => JSON.parse(line));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeJsonl(file, lines) {
|
|
117
|
+
fs.writeFileSync(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sessionHeader(name, cwd) {
|
|
121
|
+
return { type: 'session', version: 3, id: `id-${name}`, timestamp: STAMP, cwd };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// The injected host: a repository pair, a session directory, the
|
|
126
|
+
// SessionManager double and the command context.
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function makeRepoPair() {
|
|
130
|
+
const parent = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-repo-')));
|
|
131
|
+
const repo = path.join(parent, 'project');
|
|
132
|
+
fs.mkdirSync(repo);
|
|
133
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
134
|
+
runGit(repo, ['config', 'user.email', 'test@example.test']);
|
|
135
|
+
runGit(repo, ['config', 'user.name', 'Test']);
|
|
136
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n');
|
|
137
|
+
runGit(repo, ['add', 'README.md']);
|
|
138
|
+
runGit(repo, ['commit', '-m', 'init']);
|
|
139
|
+
const worktree = path.join(parent, 'project-worktrees', 'feat-login');
|
|
140
|
+
runGit(repo, ['worktree', 'add', worktree, '-b', 'feat/login']);
|
|
141
|
+
return {
|
|
142
|
+
repo: fs.realpathSync(repo),
|
|
143
|
+
target: fs.realpathSync(worktree),
|
|
144
|
+
targetName: 'feat-login',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeWorld() {
|
|
149
|
+
const sessionDir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-sessions-')));
|
|
150
|
+
const calls = {
|
|
151
|
+
forkFrom: [], create: [], open: [], switches: [], messages: [],
|
|
152
|
+
statuses: [], customEntries: [], renames: [],
|
|
153
|
+
};
|
|
154
|
+
const osc = [];
|
|
155
|
+
let created = 0;
|
|
156
|
+
|
|
157
|
+
class FakeSession {
|
|
158
|
+
constructor({ file = null, cwd = null, header = null, entries = [], name = '' }) {
|
|
159
|
+
this.file = file;
|
|
160
|
+
this.cwd = cwd;
|
|
161
|
+
this.header = header;
|
|
162
|
+
this.entries = entries.slice();
|
|
163
|
+
this.name = name;
|
|
164
|
+
const last = this.entries[this.entries.length - 1];
|
|
165
|
+
this.leafId = last ? last.id : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getSessionFile() { return this.file; }
|
|
169
|
+
getSessionDir() { return sessionDir; }
|
|
170
|
+
getSessionId() { return 'thread-1'; }
|
|
171
|
+
getSessionName() { return this.name; }
|
|
172
|
+
getHeader() { return this.header; }
|
|
173
|
+
getEntries() { return this.entries.slice(); }
|
|
174
|
+
getLeafId() { return this.leafId; }
|
|
175
|
+
branch(id) { this.leafId = id; }
|
|
176
|
+
resetLeaf() { this.leafId = null; }
|
|
177
|
+
|
|
178
|
+
appendCustomEntry(customType, data) {
|
|
179
|
+
calls.customEntries.push({ file: this.file, customType, data });
|
|
180
|
+
this.push({ type: 'custom', customType, data });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
appendSessionInfo(name) {
|
|
184
|
+
calls.renames.push({ file: this.file, name });
|
|
185
|
+
this.push({ type: 'session_info', name });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
push(partial) {
|
|
189
|
+
const entry = { ...partial, id: `${this.file || 'memory'}#${this.entries.length + 1}`, parentId: this.leafId, timestamp: STAMP };
|
|
190
|
+
this.entries.push(entry);
|
|
191
|
+
this.leafId = entry.id;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The host allocates a session file for the target cwd and writes nothing to
|
|
196
|
+
// it yet: the file only appears once the manager flushes an assistant turn.
|
|
197
|
+
FakeSession.create = (cwd, dir) => {
|
|
198
|
+
calls.create.push({ cwd, sessionDir: dir === undefined ? null : dir });
|
|
199
|
+
created += 1;
|
|
200
|
+
const header = sessionHeader(`created-${created}`, cwd);
|
|
201
|
+
return new FakeSession({ file: path.join(dir || sessionDir, `created-${created}.jsonl`), cwd, header });
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
FakeSession.open = (file, dir) => {
|
|
205
|
+
calls.open.push({ file, sessionDir: dir === undefined ? null : dir });
|
|
206
|
+
return load(file);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// forkFrom copies the source entries VERBATIM under a new header pointing at
|
|
210
|
+
// the source, exactly like the installed SessionManager.
|
|
211
|
+
FakeSession.forkFrom = (sourcePath, targetCwd, dir) => {
|
|
212
|
+
calls.forkFrom.push({ sourcePath, targetCwd, sessionDir: dir === undefined ? null : dir });
|
|
213
|
+
created += 1;
|
|
214
|
+
const file = path.join(dir || sessionDir, `fork-${created}.jsonl`);
|
|
215
|
+
const lines = readJsonl(sourcePath);
|
|
216
|
+
const header = { ...sessionHeader(`fork-${created}`, targetCwd), parentSession: sourcePath };
|
|
217
|
+
const entries = lines.filter((entry) => entry.type !== 'session');
|
|
218
|
+
writeJsonl(file, [header, ...entries]);
|
|
219
|
+
return new FakeSession({ file, cwd: targetCwd, header, entries });
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function load(file) {
|
|
223
|
+
if (!file || !fs.existsSync(file)) return new FakeSession({ file });
|
|
224
|
+
const lines = readJsonl(file);
|
|
225
|
+
const header = lines.length > 0 && lines[0].type === 'session' ? lines[0] : null;
|
|
226
|
+
return new FakeSession({
|
|
227
|
+
file,
|
|
228
|
+
cwd: header ? header.cwd : null,
|
|
229
|
+
header,
|
|
230
|
+
entries: lines.filter((entry) => entry.type !== 'session'),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function current(file) {
|
|
235
|
+
const manager = load(file);
|
|
236
|
+
manager.name = 'Test session';
|
|
237
|
+
return manager;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A session Pi has not written to disk yet: no file, only memory.
|
|
241
|
+
function bare(entries = []) {
|
|
242
|
+
const manager = new FakeSession({ entries });
|
|
243
|
+
manager.name = 'Test session';
|
|
244
|
+
return manager;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ctx({ cwd, manager }) {
|
|
248
|
+
return {
|
|
249
|
+
cwd,
|
|
250
|
+
hasUI: false,
|
|
251
|
+
mode: 'tui',
|
|
252
|
+
sessionManager: manager,
|
|
253
|
+
ui: { notify: () => {}, setStatus: (key, text) => calls.statuses.push([key, text]) },
|
|
254
|
+
waitForIdle: async () => {},
|
|
255
|
+
switchSession: async (file, options) => {
|
|
256
|
+
calls.switches.push({ file, withSession: !!(options && options.withSession) });
|
|
257
|
+
if (options && typeof options.withSession === 'function') {
|
|
258
|
+
await options.withSession({
|
|
259
|
+
ui: { setStatus: (key, text) => calls.statuses.push([key, text]) },
|
|
260
|
+
sendMessage: (textOrMessage, sendOptions) => {
|
|
261
|
+
calls.messages.push({ message: textOrMessage, options: sendOptions });
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function writeSession(name, cwd, builders) {
|
|
271
|
+
const file = path.join(sessionDir, `${name}.jsonl`);
|
|
272
|
+
writeJsonl(file, [sessionHeader(name, cwd), ...chain(builders)]);
|
|
273
|
+
return file;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function sessionFiles() {
|
|
277
|
+
return fs.readdirSync(sessionDir).filter((entry) => entry.endsWith('.jsonl')).sort();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
sessionDir, calls, osc, SM: FakeSession,
|
|
282
|
+
stream: { isTTY: true, write: (chunk) => osc.push(chunk) },
|
|
283
|
+
ctx, current, bare, load, writeSession, sessionFiles,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function warpOptions(world) {
|
|
288
|
+
return { sessionManagerClass: world.SM, oscStream: world.stream };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function worktreeWarpMessages(world) {
|
|
292
|
+
return world.calls.messages.filter(({ message }) => message && message.customType === 'worktree-warp');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// S5: the predicate itself.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
describe('hasConversation (S5)', () => {
|
|
300
|
+
test('a session that holds only warp bookkeeping carries no conversation', () => {
|
|
301
|
+
const entries = chain([registration(), rename('New session'), transition('/a', '/b'), warpNotice('warped')]);
|
|
302
|
+
assert.equal(warpSessionsLib.hasConversation(entries), false);
|
|
303
|
+
assert.equal(warpSessionsLib.hasConversation([]), false);
|
|
304
|
+
assert.equal(warpSessionsLib.hasConversation(undefined), false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('a user or assistant message is conversation, a tool result alone is not', () => {
|
|
308
|
+
assert.equal(warpSessionsLib.hasConversation(chain([registration(), userSays('hi')])), true);
|
|
309
|
+
assert.equal(warpSessionsLib.hasConversation(chain([assistantSays('hello')])), true);
|
|
310
|
+
assert.equal(warpSessionsLib.hasConversation(chain([registration(), toolResult('ok')])), false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('only the ACTIVE branch decides: an abandoned leg never makes it non-empty', () => {
|
|
314
|
+
const abandoned = { type: 'message', id: 'leg-1', parentId: 'e1', timestamp: STAMP, message: { role: 'user', content: [] } };
|
|
315
|
+
const entries = [{ ...chain([registration()])[0] }, abandoned];
|
|
316
|
+
// The abandoned entry IS the last entry, so it is also the leaf; point the
|
|
317
|
+
// leaf at the bookkeeping entry instead to model a rewind onto the root leg.
|
|
318
|
+
assert.equal(warpSessionsLib.hasConversation(entries, 'e1'), false);
|
|
319
|
+
assert.equal(warpSessionsLib.hasConversation(entries), true);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// S1/S3: an empty session changes path, silently.
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
describe('an empty session warps silently', () => {
|
|
328
|
+
test('no fork, no card, no transition entry, and it continues at the target cwd', async () => {
|
|
329
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
330
|
+
const world = makeWorld();
|
|
331
|
+
const source = world.writeSession('empty-source', repo, [registration(), rename('New session')]);
|
|
332
|
+
|
|
333
|
+
const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
|
|
334
|
+
|
|
335
|
+
assert.notEqual(out.isError, true, out.text);
|
|
336
|
+
assert.deepEqual(world.calls.forkFrom, [], 'an empty session never forks');
|
|
337
|
+
assert.deepEqual(worktreeWarpMessages(world), [], 'no worktree-warp card is sent');
|
|
338
|
+
assert.deepEqual(world.calls.messages, [], 'nothing at all is sent to the empty session');
|
|
339
|
+
assert.deepEqual(world.calls.customEntries, [], 'no transition entry is appended');
|
|
340
|
+
|
|
341
|
+
assert.equal(world.calls.switches.length, 1);
|
|
342
|
+
const switchedFile = world.calls.switches[0].file;
|
|
343
|
+
const header = readJsonl(switchedFile)[0];
|
|
344
|
+
assert.equal(header.cwd, target, 'the session continues in the target checkout');
|
|
345
|
+
assert.equal(world.calls.switches[0].withSession, true);
|
|
346
|
+
|
|
347
|
+
// Where the session IS still travels: the status line and the OSC-7 hint.
|
|
348
|
+
assert.equal(world.calls.statuses.length > 0, true);
|
|
349
|
+
assert.equal(world.calls.statuses[world.calls.statuses.length - 1][0], 'worktree');
|
|
350
|
+
assert.equal(world.calls.statuses[world.calls.statuses.length - 1][1].endsWith(`(${target})`), true);
|
|
351
|
+
assert.deepEqual(world.osc, [`\x1b]7;${pathToFileURL(target).href}\x1b\\`]);
|
|
352
|
+
|
|
353
|
+
// The returned text names the position, never a move.
|
|
354
|
+
assert.doesNotMatch(out.text, /moved/i);
|
|
355
|
+
assert.doesNotMatch(out.text, /(^|\n)to: /);
|
|
356
|
+
assert.doesNotMatch(out.text, /\bfrom: /);
|
|
357
|
+
assert.match(out.text, /empty session/);
|
|
358
|
+
|
|
359
|
+
// Recording is unchanged: both sides are recorded, so a later warp finds
|
|
360
|
+
// the destination session again.
|
|
361
|
+
assert.equal(warpSessionsLib.getWarpSession(repo, target).sessionFile, switchedFile);
|
|
362
|
+
assert.equal(warpSessionsLib.getWarpSession(repo, repo).sessionFile, source);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('a brand-new session with no file and no entries changes path instead of refusing', async () => {
|
|
366
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
367
|
+
const world = makeWorld();
|
|
368
|
+
|
|
369
|
+
const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.bare() }), warpOptions(world));
|
|
370
|
+
|
|
371
|
+
assert.notEqual(out.isError, true, out.text);
|
|
372
|
+
assert.deepEqual(world.calls.forkFrom, []);
|
|
373
|
+
assert.deepEqual(world.calls.messages, []);
|
|
374
|
+
assert.equal(world.calls.switches.length, 1);
|
|
375
|
+
assert.equal(readJsonl(world.calls.switches[0].file)[0].cwd, target);
|
|
376
|
+
assert.equal(warpSessionsLib.getWarpSession(repo, target).sessionFile, world.calls.switches[0].file);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// S2: warping back, and repeating, keeps it empty.
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
describe('warping back from an empty session is the same case', () => {
|
|
385
|
+
test('a round trip warps silently both ways and accumulates nothing', async () => {
|
|
386
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
387
|
+
const world = makeWorld();
|
|
388
|
+
const source = world.writeSession('empty-source', repo, [registration()]);
|
|
389
|
+
|
|
390
|
+
let cwd = repo;
|
|
391
|
+
let file = source;
|
|
392
|
+
const rounds = [
|
|
393
|
+
{ name: targetName, expect: target },
|
|
394
|
+
{ name: 'principal', expect: repo },
|
|
395
|
+
{ name: targetName, expect: target },
|
|
396
|
+
{ name: 'principal', expect: repo },
|
|
397
|
+
];
|
|
398
|
+
for (const round of rounds) {
|
|
399
|
+
const out = await mod.executeWorktreeWarp(round.name, world.ctx({ cwd, manager: world.current(file) }), warpOptions(world));
|
|
400
|
+
assert.notEqual(out.isError, true, out.text);
|
|
401
|
+
assert.match(out.text, /empty session/);
|
|
402
|
+
assert.doesNotMatch(out.text, /moved/i);
|
|
403
|
+
const switched = world.calls.switches[world.calls.switches.length - 1].file;
|
|
404
|
+
assert.equal(readJsonl(switched)[0].cwd, round.expect);
|
|
405
|
+
cwd = round.expect;
|
|
406
|
+
file = switched;
|
|
407
|
+
// Exactly one session file per checkout: the empty warp never allocates a
|
|
408
|
+
// second one for the same destination.
|
|
409
|
+
assert.deepEqual(world.sessionFiles(), ['created-1.jsonl', 'empty-source.jsonl']);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
assert.deepEqual(world.calls.forkFrom, [], 'no round of the round trip forks');
|
|
413
|
+
assert.deepEqual(world.calls.messages, [], 'no round of the round trip sends a card');
|
|
414
|
+
assert.deepEqual(world.calls.customEntries, [], 'no round of the round trip appends a transition');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// S4: an empty source resumes a destination that holds a conversation.
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
describe('an empty source and a destination with conversation', () => {
|
|
423
|
+
test('resumes the recorded destination session, with no fork and no card', async () => {
|
|
424
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
425
|
+
const world = makeWorld();
|
|
426
|
+
const source = world.writeSession('empty-source', repo, [registration()]);
|
|
427
|
+
const destination = world.writeSession('written-earlier', target, [userSays('do the thing'), assistantSays('done')]);
|
|
428
|
+
warpSessionsLib.recordWarpSession(repo, target, {
|
|
429
|
+
sessionFile: destination, tipId: 'e2', count: 2,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
|
|
433
|
+
|
|
434
|
+
assert.notEqual(out.isError, true, out.text);
|
|
435
|
+
assert.deepEqual(world.calls.forkFrom, [], 'a conversation is resumed, never forked over');
|
|
436
|
+
assert.deepEqual(world.calls.messages, [], 'nothing was carried from the source, so nothing is announced');
|
|
437
|
+
assert.deepEqual(world.calls.customEntries, []);
|
|
438
|
+
assert.equal(world.calls.switches.length, 1);
|
|
439
|
+
assert.equal(world.calls.switches[0].file, destination);
|
|
440
|
+
assert.match(out.text, /empty session/);
|
|
441
|
+
assert.doesNotMatch(out.text, /moved/i);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// PRESERVE: a session WITH conversation keeps today's behaviour.
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
describe('a session with conversation keeps today behaviour', () => {
|
|
450
|
+
test('it forks once and sends exactly one Warp card', async () => {
|
|
451
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
452
|
+
const world = makeWorld();
|
|
453
|
+
const source = world.writeSession('with-conversation', repo, [registration(), userSays('hi'), assistantSays('hello')]);
|
|
454
|
+
|
|
455
|
+
const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
|
|
456
|
+
|
|
457
|
+
assert.notEqual(out.isError, true, out.text);
|
|
458
|
+
assert.equal(world.calls.forkFrom.length, 1);
|
|
459
|
+
assert.equal(world.calls.forkFrom[0].sourcePath, source);
|
|
460
|
+
assert.equal(world.calls.forkFrom[0].targetCwd, target);
|
|
461
|
+
assert.equal(world.calls.customEntries.length, 1);
|
|
462
|
+
assert.equal(world.calls.customEntries[0].customType, 'worktree-warp');
|
|
463
|
+
const cards = worktreeWarpMessages(world);
|
|
464
|
+
assert.equal(cards.length, 1, 'exactly one card');
|
|
465
|
+
assert.equal(cards[0].message.details.to, target);
|
|
466
|
+
assert.equal(cards[0].message.details.fromName, 'principal');
|
|
467
|
+
assert.equal(cards[0].options.triggerTurn, false);
|
|
468
|
+
assert.match(cards[0].message.content, /Worktree warp: now in /);
|
|
469
|
+
assert.equal(world.calls.switches.length, 1);
|
|
470
|
+
assert.equal(readJsonl(world.calls.switches[0].file)[0].cwd, target);
|
|
471
|
+
// The returned text is still the two-line compact that names the move.
|
|
472
|
+
assert.match(out.text, /^moved: /);
|
|
473
|
+
assert.match(out.text, /\nto: /);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('a destination at the same point is still resumed silently', async () => {
|
|
477
|
+
const { repo, target, targetName } = makeRepoPair();
|
|
478
|
+
const world = makeWorld();
|
|
479
|
+
const source = world.writeSession('with-conversation', repo, [userSays('hi'), assistantSays('hello')]);
|
|
480
|
+
// Same filtered point: the recorded destination holds the same entries.
|
|
481
|
+
const destination = path.join(world.sessionDir, 'same-point.jsonl');
|
|
482
|
+
writeJsonl(destination, [sessionHeader('same-point', target), ...readJsonl(source).filter((entry) => entry.type !== 'session')]);
|
|
483
|
+
warpSessionsLib.recordWarpSession(repo, target, { sessionFile: destination, tipId: 'e2', count: 2 });
|
|
484
|
+
|
|
485
|
+
const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
|
|
486
|
+
|
|
487
|
+
assert.notEqual(out.isError, true, out.text);
|
|
488
|
+
assert.deepEqual(world.calls.forkFrom, []);
|
|
489
|
+
assert.deepEqual(world.calls.messages, [], 'a resume re-enters silently');
|
|
490
|
+
assert.deepEqual(world.calls.customEntries, []);
|
|
491
|
+
assert.equal(world.calls.switches.length, 1);
|
|
492
|
+
assert.equal(world.calls.switches[0].file, destination);
|
|
493
|
+
assert.match(out.text, /^moved: /);
|
|
494
|
+
});
|
|
495
|
+
});
|