24c8bb24c6
- Added session-domain modules and exports for session-entries, context, listing, loader, and migrations. - Changed persistence to async append writes plus writeTextAtomic, removing sync line APIs. - Added compaction-aware session context rebuild with dangling tool-call cleanup. - Added resumable session resolution with status inference, id/stem/suffix matching, and backup recovery.
203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
/**
|
|
* 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, 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";
|
|
import type { SessionHeader } from "@oh-my-pi/pi-coding-agent/session/session-entries";
|
|
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing";
|
|
import * as sessionListingModule from "@oh-my-pi/pi-coding-agent/session/session-listing";
|
|
import { loadEntriesFromFile } from "@oh-my-pi/pi-coding-agent/session/session-loader";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
|
|
function buildArgs(resume: string, sessionDir?: string): Args {
|
|
return {
|
|
resume,
|
|
sessionDir,
|
|
messages: [],
|
|
fileArgs: [],
|
|
unknownFlags: new Map(),
|
|
unrecognizedFlags: [],
|
|
};
|
|
}
|
|
|
|
function buildGlobalMatch(cwd: string): { session: SessionInfo; scope: "global" } {
|
|
return {
|
|
scope: "global",
|
|
session: {
|
|
path: `${cwd}/019e84ed-b4cc-7000-9c87-5afe6df992c1.jsonl`,
|
|
id: "019e84ed-b4cc-7000-9c87-5afe6df992c1",
|
|
cwd,
|
|
title: "in-other-project",
|
|
created: new Date(0),
|
|
modified: new Date(0),
|
|
messageCount: 0,
|
|
size: 0,
|
|
firstMessage: "",
|
|
allMessagesText: "",
|
|
},
|
|
};
|
|
}
|
|
|
|
const stubSettings = { get: () => undefined } as unknown as Settings;
|
|
|
|
describe("createSessionManager — cross-project --resume cancellation (#1668)", () => {
|
|
// 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 () => {
|
|
vi.spyOn(sessionListingModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject));
|
|
|
|
const result = await createSessionManager(
|
|
buildArgs("019e84ed"),
|
|
"/current/project",
|
|
stubSettings,
|
|
async () => "declined" as const,
|
|
);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("throws when the cross-project fork prompt is unavailable in non-interactive mode", async () => {
|
|
const originalIsTTY = process.stdin.isTTY;
|
|
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
try {
|
|
vi.spyOn(sessionListingModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject));
|
|
|
|
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.`,
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
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(sessionListingModule, "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 () => {
|
|
const originalIsTTY = process.stdin.isTTY;
|
|
Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
|
|
try {
|
|
vi.spyOn(sessionListingModule, "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.`,
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true });
|
|
}
|
|
});
|
|
|
|
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 = 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(sessionListingModule, "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 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);
|
|
});
|
|
});
|