fix(coding-agent): handle missing session cwd
This commit is contained in:
@@ -5,6 +5,7 @@ import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agen
|
||||
import type { SessionStartEvent } from "./extensions/index.js";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.js";
|
||||
import type { CreateAgentSessionResult } from "./sdk.js";
|
||||
import { assertSessionCwdExists } from "./session-cwd.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
|
||||
/**
|
||||
@@ -124,14 +125,15 @@ export class AgentSessionRuntime {
|
||||
this._modelFallbackMessage = result.modelFallbackMessage;
|
||||
}
|
||||
|
||||
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||
async switchSession(sessionPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
|
||||
if (beforeResult.cancelled) {
|
||||
return beforeResult;
|
||||
}
|
||||
|
||||
const previousSessionFile = this.session.sessionFile;
|
||||
const sessionManager = SessionManager.open(sessionPath);
|
||||
const sessionManager = SessionManager.open(sessionPath, undefined, cwdOverride);
|
||||
assertSessionCwdExists(sessionManager, this.cwd);
|
||||
await this.teardownCurrent();
|
||||
this.apply(
|
||||
await this.createRuntime({
|
||||
@@ -246,7 +248,7 @@ export class AgentSessionRuntime {
|
||||
return { cancelled: false, selectedText };
|
||||
}
|
||||
|
||||
async importFromJsonl(inputPath: string): Promise<{ cancelled: boolean }> {
|
||||
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
|
||||
const resolvedPath = resolve(inputPath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`File not found: ${resolvedPath}`);
|
||||
@@ -268,7 +270,8 @@ export class AgentSessionRuntime {
|
||||
copyFileSync(resolvedPath, destinationPath);
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.open(destinationPath, sessionDir);
|
||||
const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride);
|
||||
assertSessionCwdExists(sessionManager, this.cwd);
|
||||
await this.teardownCurrent();
|
||||
this.apply(
|
||||
await this.createRuntime({
|
||||
@@ -302,6 +305,7 @@ export async function createAgentSessionRuntime(
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
},
|
||||
): Promise<AgentSessionRuntime> {
|
||||
assertSessionCwdExists(options.sessionManager, options.cwd);
|
||||
const result = await createRuntime(options);
|
||||
if (process.cwd() !== result.services.cwd) {
|
||||
process.chdir(result.services.cwd);
|
||||
|
||||
59
packages/coding-agent/src/core/session-cwd.ts
Normal file
59
packages/coding-agent/src/core/session-cwd.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export interface SessionCwdIssue {
|
||||
sessionFile?: string;
|
||||
sessionCwd: string;
|
||||
fallbackCwd: string;
|
||||
}
|
||||
|
||||
interface SessionCwdSource {
|
||||
getCwd(): string;
|
||||
getSessionFile(): string | undefined;
|
||||
}
|
||||
|
||||
export function getMissingSessionCwdIssue(
|
||||
sessionManager: SessionCwdSource,
|
||||
fallbackCwd: string,
|
||||
): SessionCwdIssue | undefined {
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
if (!sessionFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionCwd = sessionManager.getCwd();
|
||||
if (!sessionCwd || existsSync(sessionCwd)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionFile,
|
||||
sessionCwd,
|
||||
fallbackCwd,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMissingSessionCwdError(issue: SessionCwdIssue): string {
|
||||
const sessionFile = issue.sessionFile ? `\nSession file: ${issue.sessionFile}` : "";
|
||||
return `Stored session working directory does not exist: ${issue.sessionCwd}${sessionFile}\nCurrent working directory: ${issue.fallbackCwd}`;
|
||||
}
|
||||
|
||||
export function formatMissingSessionCwdPrompt(issue: SessionCwdIssue): string {
|
||||
return `cwd from session file does not exist\n${issue.sessionCwd}\n\ncontinue in current cwd\n${issue.fallbackCwd}`;
|
||||
}
|
||||
|
||||
export class MissingSessionCwdError extends Error {
|
||||
readonly issue: SessionCwdIssue;
|
||||
|
||||
constructor(issue: SessionCwdIssue) {
|
||||
super(formatMissingSessionCwdError(issue));
|
||||
this.name = "MissingSessionCwdError";
|
||||
this.issue = issue;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSessionCwdExists(sessionManager: SessionCwdSource, fallbackCwd: string): void {
|
||||
const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd);
|
||||
if (issue) {
|
||||
throw new MissingSessionCwdError(issue);
|
||||
}
|
||||
}
|
||||
@@ -1270,12 +1270,13 @@ export class SessionManager {
|
||||
* Open a specific session file.
|
||||
* @param path Path to session file
|
||||
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
||||
* @param cwdOverride Optional cwd override instead of the session header cwd.
|
||||
*/
|
||||
static open(path: string, sessionDir?: string): SessionManager {
|
||||
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
|
||||
// Extract cwd from session header if possible, otherwise use process.cwd()
|
||||
const entries = loadEntriesFromFile(path);
|
||||
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||
const cwd = header?.cwd ?? process.cwd();
|
||||
const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
|
||||
// If no sessionDir provided, derive from file's parent directory
|
||||
const dir = sessionDir ?? resolve(path, "..");
|
||||
return new SessionManager(cwd, dir, path, true);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import { ProcessTerminal, setKeybindings, TUI } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { createInterface } from "readline";
|
||||
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.js";
|
||||
@@ -23,16 +24,24 @@ import {
|
||||
} from "./core/agent-session-services.js";
|
||||
import { AuthStorage } from "./core/auth-storage.js";
|
||||
import { exportFromFile } from "./core/export-html/index.js";
|
||||
import { KeybindingsManager } from "./core/keybindings.js";
|
||||
import type { ModelRegistry } from "./core/model-registry.js";
|
||||
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||
import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
|
||||
import type { CreateAgentSessionOptions } from "./core/sdk.js";
|
||||
import {
|
||||
formatMissingSessionCwdPrompt,
|
||||
getMissingSessionCwdIssue,
|
||||
MissingSessionCwdError,
|
||||
type SessionCwdIssue,
|
||||
} from "./core/session-cwd.js";
|
||||
import { SessionManager } from "./core/session-manager.js";
|
||||
import { SettingsManager } from "./core/settings-manager.js";
|
||||
import { printTimings, resetTimings, time } from "./core/timings.js";
|
||||
import { allTools } from "./core/tools/index.js";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.js";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js";
|
||||
import { isLocalPath } from "./utils/paths.js";
|
||||
@@ -375,6 +384,40 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
|
||||
return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value));
|
||||
}
|
||||
|
||||
async function promptForMissingSessionCwd(
|
||||
issue: SessionCwdIssue,
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
|
||||
let settled = false;
|
||||
const finish = (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
formatMissingSessionCwdPrompt(issue),
|
||||
["Continue", "Cancel"],
|
||||
(option) => finish(option === "Continue" ? issue.fallbackCwd : undefined),
|
||||
() => finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
ui.setFocus(selector);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(args: string[]) {
|
||||
resetTimings();
|
||||
const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
|
||||
@@ -448,12 +491,21 @@ export async function main(args: string[]) {
|
||||
// settings, resources, provider registrations, and models must be resolved only after
|
||||
// the target session cwd is known. The startup-cwd settings manager is used only for
|
||||
// sessionDir lookup during session selection.
|
||||
const sessionManager = await createSessionManager(
|
||||
parsed,
|
||||
cwd,
|
||||
parsed.sessionDir ?? startupSettingsManager.getSessionDir(),
|
||||
startupSettingsManager,
|
||||
);
|
||||
const sessionDir = parsed.sessionDir ?? startupSettingsManager.getSessionDir();
|
||||
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
|
||||
const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);
|
||||
if (missingSessionCwdIssue) {
|
||||
if (appMode === "interactive") {
|
||||
const selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);
|
||||
if (!selectedCwd) {
|
||||
process.exit(0);
|
||||
}
|
||||
sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);
|
||||
} else {
|
||||
console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
time("createSessionManager");
|
||||
|
||||
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
|
||||
|
||||
@@ -61,6 +61,7 @@ import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||
import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
|
||||
import { DefaultPackageManager } from "../../core/package-manager.js";
|
||||
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
|
||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
|
||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
|
||||
import type { SourceInfo } from "../../core/source-info.js";
|
||||
@@ -1719,6 +1720,14 @@ export class InteractiveMode {
|
||||
return result === "Yes";
|
||||
}
|
||||
|
||||
private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> {
|
||||
const confirmed = await this.showExtensionConfirm(
|
||||
"Session cwd not found",
|
||||
formatMissingSessionCwdPrompt(error.issue),
|
||||
);
|
||||
return confirmed ? error.issue.fallbackCwd : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a text input for extensions.
|
||||
*/
|
||||
@@ -3843,6 +3852,21 @@ export class InteractiveMode {
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus("Resumed session");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MissingSessionCwdError) {
|
||||
const selectedCwd = await this.promptForMissingSessionCwd(error);
|
||||
if (!selectedCwd) {
|
||||
this.showStatus("Resume cancelled");
|
||||
return;
|
||||
}
|
||||
const result = await this.runtimeHost.switchSession(sessionPath, selectedCwd);
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus("Resumed session in current cwd");
|
||||
return;
|
||||
}
|
||||
await this.handleFatalRuntimeError("Failed to resume session", error);
|
||||
}
|
||||
}
|
||||
@@ -4109,6 +4133,22 @@ export class InteractiveMode {
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MissingSessionCwdError) {
|
||||
const selectedCwd = await this.promptForMissingSessionCwd(error);
|
||||
if (!selectedCwd) {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd);
|
||||
if (result.cancelled) {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
return;
|
||||
}
|
||||
await this.handleFatalRuntimeError("Failed to import session", error);
|
||||
}
|
||||
}
|
||||
|
||||
91
packages/coding-agent/test/session-cwd.test.ts
Normal file
91
packages/coding-agent/test/session-cwd.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.js";
|
||||
import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.js";
|
||||
import { SessionManager } from "../src/core/session-manager.js";
|
||||
|
||||
function createTempDir(name: string): string {
|
||||
const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeSessionFile(path: string, cwd: string): void {
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("session cwd handling", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects missing session cwd from persisted sessions", () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-fallback");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd);
|
||||
expect(issue).toEqual({
|
||||
sessionFile: sessionManager.getSessionFile(),
|
||||
sessionCwd: missingCwd,
|
||||
fallbackCwd,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports overriding the effective cwd when opening a session", () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-override");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-override-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, undefined, fallbackCwd);
|
||||
expect(sessionManager.getCwd()).toBe(fallbackCwd);
|
||||
expect(getMissingSessionCwdIssue(sessionManager, fallbackCwd)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws a controlled error before runtime creation when the stored cwd is missing", async () => {
|
||||
const fallbackCwd = createTempDir("pi-session-cwd-runtime");
|
||||
const missingCwd = join(fallbackCwd, "does-not-exist");
|
||||
const sessionDir = createTempDir("pi-session-cwd-runtime-session-dir");
|
||||
const sessionFile = join(sessionDir, "session.jsonl");
|
||||
cleanupPaths.push(fallbackCwd, sessionDir);
|
||||
writeSessionFile(sessionFile, missingCwd);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
let createRuntimeCalled = false;
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async () => {
|
||||
createRuntimeCalled = true;
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
await expect(
|
||||
createAgentSessionRuntime(createRuntime, {
|
||||
cwd: fallbackCwd,
|
||||
agentDir: fallbackCwd,
|
||||
sessionManager,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MissingSessionCwdError);
|
||||
expect(createRuntimeCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user