diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 1d2966a85..ae36a43ce 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -375,6 +375,38 @@ async function promptMoveSession(session: SessionInfo): Promise { + const sourceCwd = session.cwd; + if (!sourceCwd || fsSync.existsSync(sourceCwd)) { + return { status: "not-needed" }; + } + + const movePromptResult = await askToMoveSession(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 { status: "declined" }; + } + + const manager = await SessionManager.open(session.path, sessionDir); + await manager.moveTo(cwd, sessionDir); + return { status: "moved", manager }; +} + async function getChangelogForDisplay(parsed: Args): Promise { if (parsed.continue || parsed.resume) { return undefined; @@ -450,27 +482,37 @@ export async function createSessionManager( if (!match) { throw new Error(`Session "${sessionArg}" not found.`); } + if (match.scope === "local") { + const moveResult = await moveMissingCwdSessionIfNeeded( + sessionArg, + match.session, + cwd, + parsed.sessionDir, + askToMoveSession, + ); + if (moveResult.status === "moved") { + return moveResult.manager; + } + if (moveResult.status === "declined") { + return undefined; + } + } if (match.scope === "global") { 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 moveResult = await moveMissingCwdSessionIfNeeded( + sessionArg, + match.session, + cwd, + parsed.sessionDir, + askToMoveSession, + ); + if (moveResult.status === "moved") { + return moveResult.manager; + } + if (moveResult.status === "declined") { + return undefined; } const forkPromptResult = await askToForkSession(match.session); if (forkPromptResult === "unavailable") { 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 6688ca44c..459a88d78 100644 --- a/packages/coding-agent/test/main-cross-project-resume.test.ts +++ b/packages/coding-agent/test/main-cross-project-resume.test.ts @@ -15,12 +15,13 @@ 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"; -import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import type { SessionHeader, SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as sessionManagerModule from "@oh-my-pi/pi-coding-agent/session/session-manager"; -function buildArgs(resume: string): Args { +function buildArgs(resume: string, sessionDir?: string): Args { return { resume, + sessionDir, messages: [], fileArgs: [], unknownFlags: new Map(), @@ -124,4 +125,64 @@ describe("createSessionManager — cross-project --resume relocation (moved work `Session "019e84ed" belongs to a directory that no longer exists (${missingProject}); run interactively to move it into the current project.`, ); }); + + it("moves a local explicit-session-dir match whose recorded cwd is gone", async () => { + const currentProject = path.join(missingRoot, "current-project"); + const explicitSessionDir = path.join(missingRoot, "sessions"); + await fsp.mkdir(currentProject, { recursive: true }); + + const moved = sessionManagerModule.SessionManager.create(missingProject, explicitSessionDir); + moved.appendMessage({ role: "user", content: "before local move", timestamp: 1 }); + await moved.flush(); + const oldFile = moved.getSessionFile(); + if (!oldFile) throw new Error("Expected persisted session file"); + const resumePrefix = moved.getSessionId().slice(0, 8); + const sessionInfo: SessionInfo = { + path: oldFile, + id: moved.getSessionId(), + cwd: missingProject, + title: "moved-local", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + size: 0, + firstMessage: "before local move", + allMessagesText: "before local move", + }; + await moved.close(); + expect(fs.existsSync(missingProject)).toBe(false); + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue({ + scope: "local", + session: sessionInfo, + }); + + const forkPrompt = vi.fn(async () => "accepted" as const); + const movePrompt = vi.fn(async () => "accepted" as const); + const result = await createSessionManager( + buildArgs(resumePrefix, explicitSessionDir), + currentProject, + stubSettings, + forkPrompt, + movePrompt, + ); + + if (!result) throw new Error("Expected moved session manager"); + try { + expect(result.getSessionFile()).toBe(oldFile); + expect(result.getCwd()).toBe(path.resolve(currentProject)); + const entries = await sessionManagerModule.loadEntriesFromFile(oldFile); + const header = entries.find( + (entry): entry is SessionHeader => + typeof entry === "object" && + entry !== null && + "type" in entry && + (entry as { type: unknown }).type === "session", + ); + expect(header?.cwd).toBe(path.resolve(currentProject)); + } finally { + await result.close(); + } + expect(forkPrompt).not.toHaveBeenCalled(); + expect(movePrompt).toHaveBeenCalledTimes(1); + }); });