From cfa9a2d53e6bfa9ba05ac95cceaef3b0c4ebd1b5 Mon Sep 17 00:00:00 2001 From: bling <291592093+blingdivinity@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:34:46 -0400 Subject: [PATCH] fix(coding-agent): resume sessions after a worktree move/rename When a session's working directory is moved or renamed (e.g. `git worktree move`), the session file stays under the old cwd-encoded bucket while the new directory is empty. Resuming was lossy: - `--continue` rejected the terminal breadcrumb purely on cwd mismatch and then found nothing in the new bucket, silently starting a fresh empty session. - cross-project `--resume ` only offered to *fork* (duplicate) the session into the new directory, forcing manual id selection and leaving a stale copy. Detect relocation via the strong, low-false-positive signal "recorded cwd no longer exists on disk" and re-root in place with the existing `moveTo()`: - `continueRecent` re-roots the terminal's last session into the current directory when its recorded cwd is gone and the new location has no sessions of its own (otherwise behavior is unchanged). `readTerminalBreadcrumb` is refactored into `readTerminalBreadcrumbEntry` returning the raw cwd + session file so callers can interpret a cwd mismatch. - cross-project `--resume ` offers "Move (re-root)" instead of fork when the source directory is gone; a still-existing different project still forks. Tests: continue-relocation (re-root on move, no-hijack on plain cd, prefer local recent) and cross-project move-vs-fork routing. --- ...ion-operations-export-share-fork-resume.md | 22 +- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/main.ts | 42 +++- .../src/session/session-manager.ts | 100 ++++++-- .../test/main-cross-project-resume.test.ts | 84 +++++-- .../continue-relocation.test.ts | 233 ++++++++++++++++++ 6 files changed, 433 insertions(+), 52 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/continue-relocation.test.ts diff --git a/docs/session-operations-export-share-fork-resume.md b/docs/session-operations-export-share-fork-resume.md index 0d6de4f70..a82ef075d 100644 --- a/docs/session-operations-export-share-fork-resume.md +++ b/docs/session-operations-export-share-fork-resume.md @@ -24,8 +24,8 @@ This document describes operator-visible behavior for session export/share/fork/ | `--fork ` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None | | `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None | | `--resume` | CLI startup picker | Yes after session creation | Opens selected existing session file | None | -| `--resume ` | CLI startup | Yes after session creation | Opens existing session; global cross-project match can fork into current project | None | -| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb or most-recent session; creates new one if none exists | None | +| `--resume ` | CLI startup | Yes after session creation | Opens existing session; global cross-project match re-roots (moved dir) or forks into current project | None | +| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb (re-roots it if its dir was moved) or most-recent session; creates new one if none exists | None | ## Export and dump @@ -215,19 +215,23 @@ Notes: Cross-project id match behavior: -- If matched session cwd differs from current cwd, CLI asks: - - `Session found in different project ... Fork into current directory? [y/N]` -- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file. -- On no/non-TTY default: command errors. +- If matched session cwd differs from current cwd, behavior depends on whether the matched session's recorded directory still exists: + - **Directory gone (moved/renamed, e.g. `git worktree move`)**: CLI asks `Session's directory no longer exists (...). Move (re-root) it into the current directory? [Y/n]`. + - On yes (default): `SessionManager.open(match.path)` then `manager.moveTo(cwd)` re-roots the existing session into the current directory (no duplicate file). + - On no: command cancels (returns no session). On non-TTY: command errors. + - **Directory still exists (genuinely different project)**: CLI asks `Session found in different project ... Fork into current directory? [y/N]`. + - On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file. + - On no: command cancels. On non-TTY: command errors. ## CLI `--continue` `SessionManager.continueRecent(cwd, sessionDir)`: 1. Resolves session dir for current cwd. -2. Reads terminal-scoped breadcrumb first. -3. Falls back to most recently modified session file. -4. Opens found session; if none exists, creates new session. +2. Reads the terminal-scoped breadcrumb. +3. If the breadcrumb points at a session recorded under a different cwd whose directory no longer exists (moved/renamed) **and** the current directory has no sessions of its own, re-roots that session into the current directory via `moveTo` instead of starting fresh. +4. Otherwise, if the breadcrumb's cwd matches the current cwd, uses the breadcrumb session; else falls back to the most recently modified session file. +5. Opens the found session; if none exists, creates a new session. This is startup-only behavior; there is no interactive `/continue` slash command. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8b29b5f31..a56a615d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,10 @@ - Fixed MCP tools hanging in non-yolo modes by declaring `approval = "write"` on `MCPTool` and `DeferredMCPTool`, and propagating the `approval` property through `customToolToDefinition()` in `sdk.ts` +### Fixed + +- Fixed session resumption after a working directory is moved/renamed (e.g. `git worktree move`): `--continue` now re-roots the terminal's last session into the new directory when its original directory no longer exists, instead of silently starting a fresh empty session; cross-project `--resume ` offers to move (re-root) the session rather than only forking a duplicate copy when the source directory is gone + ## [15.10.1] - 2026-06-07 ### Added diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 8d4d30b46..507c59e70 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,6 +5,7 @@ * createAgentSession() options. The SDK does the heavy lifting. */ +import * as fsSync from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -344,11 +345,11 @@ async function runInteractiveMode( } } -type ForkSessionPromptResult = "accepted" | "declined" | "unavailable"; +type SessionPromptResult = "accepted" | "declined" | "unavailable"; -type ForkSessionPrompt = (session: SessionInfo) => Promise; +type SessionPrompt = (session: SessionInfo) => Promise; -async function promptForkSession(session: SessionInfo): Promise { +async function promptForkSession(session: SessionInfo): Promise { if (!process.stdin.isTTY) { return "unavailable"; } @@ -362,6 +363,20 @@ async function promptForkSession(session: SessionInfo): Promise { + if (!process.stdin.isTTY) { + return "unavailable"; + } + const message = `Session's directory no longer exists (${session.cwd}). Move (re-root) it into the current directory? [Y/n] `; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(message)).trim().toLowerCase(); + return answer === "" || answer === "y" || answer === "yes" ? "accepted" : "declined"; + } finally { + rl.close(); + } +} + async function getChangelogForDisplay(parsed: Args): Promise { if (parsed.continue || parsed.resume) { return undefined; @@ -407,7 +422,8 @@ export async function createSessionManager( parsed: Args, cwd: string, activeSettings: Settings = settings, - askToForkSession: ForkSessionPrompt = promptForkSession, + askToForkSession: SessionPrompt = promptForkSession, + askToMoveSession: SessionPrompt = promptMoveSession, ): Promise { if (parsed.fork) { if (parsed.noSession) { @@ -440,6 +456,24 @@ export async function createSessionManager( const normalizedCwd = normalizePathForComparison(cwd); const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd); if (normalizedCwd !== normalizedMatchCwd) { + // If the session's recorded directory no longer exists, it was almost + // certainly moved/renamed (e.g. `git worktree move`). Re-root the existing + // session here instead of forking a duplicate copy. + const sourceCwd = match.session.cwd; + if (sourceCwd && !fsSync.existsSync(sourceCwd)) { + const movePromptResult = await askToMoveSession(match.session); + if (movePromptResult === "unavailable") { + throw new Error( + `Session "${sessionArg}" belongs to a directory that no longer exists (${sourceCwd}); run interactively to move it into the current project.`, + ); + } + if (movePromptResult === "declined") { + return undefined; + } + const manager = await SessionManager.open(match.session.path, parsed.sessionDir); + await manager.moveTo(cwd, parsed.sessionDir); + return manager; + } const forkPromptResult = await askToForkSession(match.session); if (forkPromptResult === "unavailable") { throw new Error( diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 61f78cdcc..58f5a71bb 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -845,11 +845,18 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void { Bun.write(breadcrumbFile, content).catch(() => {}); } +interface TerminalBreadcrumb { + cwd: string; + sessionFile: string; +} + /** - * Read the terminal breadcrumb for the current terminal, scoped to a cwd. - * Returns the session file path if it exists and matches the cwd, null otherwise. + * Read the raw terminal breadcrumb for the current terminal. + * Returns the recorded cwd + session file (verified to exist) regardless of + * whether the recorded cwd still matches the current one. Callers decide how + * to interpret a cwd mismatch (e.g. a moved/renamed worktree). */ -async function readTerminalBreadcrumb(cwd: string): Promise { +async function readTerminalBreadcrumbEntry(): Promise { const terminalId = getTerminalId(); if (!terminalId) return null; @@ -862,12 +869,9 @@ async function readTerminalBreadcrumb(cwd: string): Promise { const breadcrumbCwd = lines[0]; const sessionFile = lines[1]; - // Only return if cwd matches (user might have cd'd) - if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null; - // Verify the session file still exists const stat = fs.statSync(sessionFile, { throwIfNoEntry: false }); - if (stat?.isFile()) return sessionFile; + if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile }; } catch (err) { if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err }); // Breadcrumb doesn't exist or is corrupt — fall through @@ -2163,19 +2167,24 @@ export class SessionManager { /** * Move the session to a new working directory. * Moves session files and artifacts on disk, updates all internal references, - * and rewrites the session header with the new cwd. + * and rewrites the session header with the new cwd. When provided, + * `targetSessionDir` is used instead of deriving the default directory for + * the new cwd (for `--continue --session-dir` / `--resume --session-dir`). */ - async moveTo(newCwd: string): Promise { + async moveTo(newCwd: string, targetSessionDir?: string): Promise { const resolvedCwd = path.resolve(newCwd); - if (resolvedCwd === this.cwd) return; + if (resolvedCwd === this.cwd && (!targetSessionDir || path.resolve(targetSessionDir) === this.sessionDir)) return; const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd); - const newSessionDir = managedSessionsRoot - ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot) - : computeDefaultSessionDir(resolvedCwd, this.storage); + const newSessionDir = targetSessionDir + ? path.resolve(targetSessionDir) + : managedSessionsRoot + ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot) + : computeDefaultSessionDir(resolvedCwd, this.storage); let hadSessionFile = false; if (this.persist && this.#sessionFile) { + this.storage.ensureDirSync(newSessionDir); // Close the persist writer before moving files await this.#closePersistWriter(); this.#persistChain = Promise.resolve(); @@ -2186,25 +2195,29 @@ export class SessionManager { const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile)); const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl const newArtifactDir = newSessionFile.slice(0, -6); + const sameSessionFile = path.resolve(oldSessionFile) === path.resolve(newSessionFile); + const sameArtifactDir = path.resolve(oldArtifactDir) === path.resolve(newArtifactDir); hadSessionFile = this.storage.existsSync(oldSessionFile); let movedSessionFile = false; let movedArtifactDir = false; try { // Guard: session file may not exist yet (no assistant messages persisted) - if (hadSessionFile) { + if (hadSessionFile && !sameSessionFile) { await fs.promises.rename(oldSessionFile, newSessionFile); movedSessionFile = true; } - try { - const stat = await fs.promises.stat(oldArtifactDir); - if (stat.isDirectory()) { - await fs.promises.rename(oldArtifactDir, newArtifactDir); - movedArtifactDir = true; + if (!sameArtifactDir) { + try { + const stat = await fs.promises.stat(oldArtifactDir); + if (stat.isDirectory()) { + await fs.promises.rename(oldArtifactDir, newArtifactDir); + movedArtifactDir = true; + } + } catch (err) { + if (!isEnoent(err)) throw err; } - } catch (err) { - if (!isEnoent(err)) throw err; } } catch (err) { if (movedArtifactDir) { @@ -3491,8 +3504,49 @@ export class SessionManager { ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly) - const terminalSession = await readTerminalBreadcrumb(cwd); - const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage)); + const breadcrumb = await readTerminalBreadcrumbEntry(); + const breadcrumbCwd = breadcrumb ? path.resolve(breadcrumb.cwd) : undefined; + const resolvedCwd = path.resolve(cwd); + let mostRecent: string | null | undefined; + if (breadcrumb && breadcrumbCwd !== resolvedCwd) { + // The terminal's last session was started in a different cwd. If that cwd no + // longer exists (e.g. `git worktree move`/dir rename) and the new location has + // no sessions of its own, re-root the session here instead of silently starting + // fresh — otherwise the relocated session would be unreachable via --continue. + // When an explicit sessionDir is reused across the move, the stale breadcrumb + // file itself may be the most recent entry there; don't count it as a + // current-directory session. If that shared dir also contains an older session + // that already belongs to the current cwd, prefer that local session instead + // of re-rooting the stale breadcrumb over it. + const resolvedBreadcrumbCwd = breadcrumbCwd ?? path.resolve(breadcrumb.cwd); + mostRecent = await findMostRecentSession(dir, storage); + const sourceCwdGone = !fs.existsSync(resolvedBreadcrumbCwd); + const breadcrumbSessionFile = path.resolve(breadcrumb.sessionFile); + const mostRecentIsBreadcrumb = + mostRecent !== null && mostRecent !== undefined && path.resolve(mostRecent) === breadcrumbSessionFile; + let hasCurrentCwdSession = false; + if (sourceCwdGone && mostRecentIsBreadcrumb) { + const currentCwdSession = (await SessionManager.list(cwd, dir, storage)).find( + session => + path.resolve(session.path) !== breadcrumbSessionFile && + session.cwd !== undefined && + path.resolve(session.cwd) === resolvedCwd, + ); + if (currentCwdSession) { + mostRecent = currentCwdSession.path; + hasCurrentCwdSession = true; + } + } + const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession)); + if (relocated) { + process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`); + const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage); + await manager.moveTo(cwd, sessionDir); + return manager; + } + } + const terminalSession = breadcrumb && breadcrumbCwd === resolvedCwd ? breadcrumb.sessionFile : null; + if (mostRecent === undefined) mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage)); const manager = new SessionManager(cwd, dir, true, storage); if (mostRecent) { await manager.#initSessionFile(mostRecent); diff --git a/packages/coding-agent/test/main-cross-project-resume.test.ts b/packages/coding-agent/test/main-cross-project-resume.test.ts index 02348fc4d..6688ca44c 100644 --- a/packages/coding-agent/test/main-cross-project-resume.test.ts +++ b/packages/coding-agent/test/main-cross-project-resume.test.ts @@ -2,8 +2,16 @@ * Regression: declining the cross-project fork prompt during `--resume ` * must exit cleanly, while non-interactive resume still fails instead of * silently succeeding. See #1668. + * + * Also covers the moved/renamed-worktree path: when the matched session's + * recorded directory no longer exists, `--resume ` offers to *move* + * (re-root) the session rather than fork a duplicate. */ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Args } from "@oh-my-pi/pi-coding-agent/cli/args"; import type { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createSessionManager } from "@oh-my-pi/pi-coding-agent/main"; @@ -37,20 +45,27 @@ function buildGlobalMatch(cwd: string): { session: SessionInfo; scope: "global" }; } +const stubSettings = { get: () => undefined } as unknown as Settings; + describe("createSessionManager — cross-project --resume cancellation (#1668)", () => { - afterEach(() => { + // An existing directory so the match is treated as a genuinely different + // project (fork path), not a moved/renamed worktree (move path). + let existingProject: string; + + beforeEach(async () => { + existingProject = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-xproj-")); + }); + + afterEach(async () => { vi.restoreAllMocks(); + await fsp.rm(existingProject, { recursive: true, force: true }); }); it("returns undefined when an interactive user declines the fork prompt instead of throwing", async () => { - const sessionCwd = "/some/other/project"; - vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd)); - - const args = buildArgs("019e84ed"); - const stubSettings = { get: () => undefined } as unknown as Settings; + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject)); const result = await createSessionManager( - args, + buildArgs("019e84ed"), "/current/project", stubSettings, async () => "declined" as const, @@ -61,15 +76,52 @@ describe("createSessionManager — cross-project --resume cancellation (#1668)", it("throws when the cross-project fork prompt is unavailable in non-interactive mode", async () => { expect(process.stdin.isTTY).toBeFalsy(); + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject)); - const sessionCwd = "/some/other/project"; - vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd)); - - const args = buildArgs("019e84ed"); - const stubSettings = { get: () => undefined } as unknown as Settings; - - await expect(createSessionManager(args, "/current/project", stubSettings)).rejects.toThrow( - 'Session "019e84ed" is in another project (/some/other/project); run interactively to fork it into the current project.', + await expect(createSessionManager(buildArgs("019e84ed"), "/current/project", stubSettings)).rejects.toThrow( + `Session "019e84ed" is in another project (${existingProject}); run interactively to fork it into the current project.`, + ); + }); +}); + +describe("createSessionManager — cross-project --resume relocation (moved worktree)", () => { + let missingRoot: string; + let missingProject: string; + + beforeEach(async () => { + missingRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-moved-xproj-")); + missingProject = path.join(missingRoot, "worktree-gone"); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fsp.rm(missingRoot, { recursive: true, force: true }); + }); + + it("offers move (not fork) and returns undefined when the user declines", async () => { + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(missingProject)); + expect(fs.existsSync(missingProject)).toBe(false); + + const forkPrompt = vi.fn(async () => "accepted" as const); + const result = await createSessionManager( + buildArgs("019e84ed"), + "/current/project", + stubSettings, + forkPrompt, + async () => "declined" as const, + ); + + expect(result).toBeUndefined(); + // The fork prompt must NOT be used for a relocated (gone-dir) session. + expect(forkPrompt).not.toHaveBeenCalled(); + }); + + it("throws the move-specific error when unavailable in non-interactive mode", async () => { + expect(process.stdin.isTTY).toBeFalsy(); + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(missingProject)); + + await expect(createSessionManager(buildArgs("019e84ed"), "/current/project", stubSettings)).rejects.toThrow( + `Session "019e84ed" belongs to a directory that no longer exists (${missingProject}); run interactively to move it into the current project.`, ); }); }); diff --git a/packages/coding-agent/test/session-manager/continue-relocation.test.ts b/packages/coding-agent/test/session-manager/continue-relocation.test.ts new file mode 100644 index 000000000..681802f78 --- /dev/null +++ b/packages/coding-agent/test/session-manager/continue-relocation.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + loadEntriesFromFile, + type SessionHeader, + SessionManager, +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { getTerminalId } from "@oh-my-pi/pi-tui"; +import { getConfigRootDir, getTerminalSessionsDir, setAgentDir } from "@oh-my-pi/pi-utils"; + +import { makeAssistantMessage } from "./helpers"; + +function getHeader(entries: unknown[]): SessionHeader | undefined { + return entries.find( + (e): e is SessionHeader => + typeof e === "object" && e !== null && "type" in e && (e as { type: unknown }).type === "session", + ); +} + +function writeBreadcrumb(cwd: string, sessionFile: string): string { + const terminalId = getTerminalId(); + if (!terminalId) throw new Error("Expected a terminal id for breadcrumb test"); + const dir = getTerminalSessionsDir(); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, terminalId); + fs.writeFileSync(file, `${cwd}\n${sessionFile}\n`); + return file; +} + +describe("SessionManager.continueRecent relocation", () => { + let testAgentDir: string; + let cwdA: string; + let cwdB: string; + const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + const originalTmuxPane = process.env.TMUX_PANE; + const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); + + beforeEach(async () => { + // Force a deterministic, non-TTY terminal id so breadcrumb read/write is stable. + process.env.TMUX_PANE = "%relocation-test"; + testAgentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-reloc-test-")); + setAgentDir(testAgentDir); + cwdA = path.join(testAgentDir, "worktree-old"); + cwdB = path.join(testAgentDir, "worktree-new"); + fs.mkdirSync(cwdA, { recursive: true }); + fs.mkdirSync(cwdB, { recursive: true }); + }); + + afterEach(async () => { + if (originalTmuxPane === undefined) delete process.env.TMUX_PANE; + else process.env.TMUX_PANE = originalTmuxPane; + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + await fsp.rm(testAgentDir, { recursive: true, force: true }); + }); + + it("re-roots the terminal's session when its directory was moved/renamed", async () => { + const session = SessionManager.create(cwdA); + session.appendMessage({ role: "user", content: "before move", timestamp: 1 }); + session.appendMessage(makeAssistantMessage()); + await session.flush(); + const oldFile = session.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted session file"); + await session.close(); + + // Breadcrumb points at the old session, recorded under the old cwd. + writeBreadcrumb(cwdA, oldFile); + // Simulate `git worktree move`: the old directory no longer exists. + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(cwdB); + try { + // The relocated session is adopted, not discarded for a fresh one. + expect(resumed.getCwd()).toBe(path.resolve(cwdB)); + const newFile = resumed.getSessionFile(); + if (!newFile) throw new Error("Expected re-rooted session file"); + expect(newFile).not.toBe(oldFile); + expect(fs.existsSync(oldFile)).toBe(false); + + const entries = await loadEntriesFromFile(newFile); + expect(getHeader(entries)?.cwd).toBe(path.resolve(cwdB)); + const userMessages = entries.filter(e => e.type === "message" && e.message.role === "user"); + expect(userMessages).toHaveLength(1); + } finally { + await resumed.close(); + } + }); + + it("does not hijack the session when the recorded directory still exists (plain cd)", async () => { + const session = SessionManager.create(cwdA); + session.appendMessage({ role: "user", content: "other project", timestamp: 1 }); + session.appendMessage(makeAssistantMessage()); + await session.flush(); + const oldFile = session.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted session file"); + await session.close(); + + // Breadcrumb from a still-existing different project; user just cd'd elsewhere. + writeBreadcrumb(cwdA, oldFile); + + const resumed = await SessionManager.continueRecent(cwdB); + try { + // Old project's session is left untouched; a fresh session starts in cwdB. + expect(fs.existsSync(oldFile)).toBe(true); + expect(resumed.getSessionFile()).not.toBe(oldFile); + expect(resumed.getEntries()).toHaveLength(0); + } finally { + await resumed.close(); + } + }); + + it("does not re-root when the new directory already has its own sessions", async () => { + const moved = SessionManager.create(cwdA); + moved.appendMessage({ role: "user", content: "moved", timestamp: 1 }); + moved.appendMessage(makeAssistantMessage()); + await moved.flush(); + const movedFile = moved.getSessionFile(); + if (!movedFile) throw new Error("Expected persisted session file"); + await moved.close(); + + // cwdB already owns a local session. + const local = SessionManager.create(cwdB); + local.appendMessage({ role: "user", content: "local", timestamp: 2 }); + local.appendMessage(makeAssistantMessage()); + await local.flush(); + const localFile = local.getSessionFile(); + if (!localFile) throw new Error("Expected persisted local session file"); + await local.close(); + + writeBreadcrumb(cwdA, movedFile); + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(cwdB); + try { + // Prefer cwdB's own recent session over re-rooting the moved one. + expect(resumed.getSessionFile()).toBe(localFile); + expect(fs.existsSync(movedFile)).toBe(true); + } finally { + await resumed.close(); + } + }); + + it("moves a relocated breadcrumb session into an explicit sessionDir", async () => { + const session = SessionManager.create(cwdA); + session.appendMessage({ role: "user", content: "explicit dir", timestamp: 1 }); + session.appendMessage(makeAssistantMessage()); + await session.flush(); + const oldFile = session.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted session file"); + await session.close(); + + const explicitSessionDir = path.join(testAgentDir, "custom-sessions"); + writeBreadcrumb(cwdA, oldFile); + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir); + try { + const newFile = resumed.getSessionFile(); + if (!newFile) throw new Error("Expected re-rooted session file"); + expect(path.dirname(newFile)).toBe(path.resolve(explicitSessionDir)); + expect(fs.existsSync(oldFile)).toBe(false); + expect(getHeader(await loadEntriesFromFile(newFile))?.cwd).toBe(path.resolve(cwdB)); + } finally { + await resumed.close(); + } + }); + + it("re-roots when the stale breadcrumb file is already in the explicit sessionDir", async () => { + const explicitSessionDir = path.join(testAgentDir, "shared-custom-sessions"); + const session = SessionManager.create(cwdA, explicitSessionDir); + session.appendMessage({ role: "user", content: "same explicit dir", timestamp: 1 }); + session.appendMessage(makeAssistantMessage()); + await session.flush(); + const oldFile = session.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted session file"); + expect(path.dirname(oldFile)).toBe(path.resolve(explicitSessionDir)); + await session.close(); + + writeBreadcrumb(cwdA, oldFile); + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir); + try { + const newFile = resumed.getSessionFile(); + if (!newFile) throw new Error("Expected re-rooted session file"); + expect(newFile).toBe(oldFile); + expect(resumed.getCwd()).toBe(path.resolve(cwdB)); + expect(getHeader(await loadEntriesFromFile(newFile))?.cwd).toBe(path.resolve(cwdB)); + } finally { + await resumed.close(); + } + }); + + it("prefers an existing current-cwd session in a shared explicit sessionDir", async () => { + const explicitSessionDir = path.join(testAgentDir, "shared-current-sessions"); + const local = SessionManager.create(cwdB, explicitSessionDir); + local.appendMessage({ role: "user", content: "local current cwd", timestamp: 1 }); + local.appendMessage(makeAssistantMessage()); + await local.flush(); + const localFile = local.getSessionFile(); + if (!localFile) throw new Error("Expected persisted local session file"); + await local.close(); + + // Ensure the stale moved session is newer than the local current-cwd session. + await new Promise(resolve => setTimeout(resolve, 20)); + const moved = SessionManager.create(cwdA, explicitSessionDir); + moved.appendMessage({ role: "user", content: "newer stale moved cwd", timestamp: 2 }); + moved.appendMessage(makeAssistantMessage()); + await moved.flush(); + const movedFile = moved.getSessionFile(); + if (!movedFile) throw new Error("Expected persisted moved session file"); + await moved.close(); + + writeBreadcrumb(cwdA, movedFile); + await fsp.rm(cwdA, { recursive: true, force: true }); + + const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir); + try { + expect(resumed.getSessionFile()).toBe(localFile); + expect(resumed.getCwd()).toBe(path.resolve(cwdB)); + expect(fs.existsSync(movedFile)).toBe(true); + } finally { + await resumed.close(); + } + }); +});