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 <id>` 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 <id>` 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.
This commit is contained in:
@@ -24,8 +24,8 @@ This document describes operator-visible behavior for session export/share/fork/
|
||||
| `--fork <id\|path>` | 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 <id\|path>` | 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 <id\|path>` | 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.
|
||||
|
||||
|
||||
@@ -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 <id>` 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
|
||||
|
||||
@@ -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<ForkSessionPromptResult>;
|
||||
type SessionPrompt = (session: SessionInfo) => Promise<SessionPromptResult>;
|
||||
|
||||
async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromptResult> {
|
||||
async function promptForkSession(session: SessionInfo): Promise<SessionPromptResult> {
|
||||
if (!process.stdin.isTTY) {
|
||||
return "unavailable";
|
||||
}
|
||||
@@ -362,6 +363,20 @@ async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromp
|
||||
}
|
||||
}
|
||||
|
||||
async function promptMoveSession(session: SessionInfo): Promise<SessionPromptResult> {
|
||||
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<string | undefined> {
|
||||
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<SessionManager | undefined> {
|
||||
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(
|
||||
|
||||
@@ -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<string | null> {
|
||||
async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
|
||||
const terminalId = getTerminalId();
|
||||
if (!terminalId) return null;
|
||||
|
||||
@@ -862,12 +869,9 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
||||
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<void> {
|
||||
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
||||
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<SessionManager> {
|
||||
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);
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
* Regression: declining the cross-project fork prompt during `--resume <id>`
|
||||
* 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 <id>` 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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user