chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
import { constants as bufferConstants } from "buffer";
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs";
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts";
|
||||
import { assistantMsg, readSessionFileRoles, userMsg } from "../utilities.ts";
|
||||
|
||||
const HEADER_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
@@ -325,6 +337,22 @@ describe("SessionManager custom flat session directory", () => {
|
||||
const continuedA = SessionManager.continueRecent(projectA, tempDir);
|
||||
expect(continuedA.getSessionFile()).toBe(sessionA);
|
||||
});
|
||||
|
||||
it("rejects a cancelled session listing", async () => {
|
||||
createPersistedSession(projectA, "from A");
|
||||
createPersistedSession(projectB, "from B");
|
||||
const controller = new AbortController();
|
||||
const listing = SessionManager.listAll(
|
||||
tempDir,
|
||||
(_loaded, _total, partialSessions) => {
|
||||
if (partialSessions) controller.abort();
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
await expect(listing).rejects.toMatchObject({ name: "AbortError" });
|
||||
await expect(SessionManager.listAll(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager.setSessionFile with corrupted files", () => {
|
||||
@@ -404,3 +432,43 @@ describe("SessionManager.setSessionFile with corrupted files", () => {
|
||||
expect(sm2.getHeader()?.type).toBe("session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager session file creation", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-session-persist-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not create a file for a session with only setup entries", () => {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||
session.appendThinkingLevelChange("off");
|
||||
|
||||
expect(existsSync(session.getSessionFile()!)).toBe(false);
|
||||
});
|
||||
|
||||
// #10000: the first prompt must survive a first turn that never produces an assistant message
|
||||
it("creates the file when the first user message is appended", () => {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||
session.appendMessage(userMsg("first question"));
|
||||
|
||||
const file = session.getSessionFile()!;
|
||||
expect(readSessionFileRoles(file)).toEqual(["session", "model_change", "user"]);
|
||||
expect(SessionManager.open(file, tempDir).buildSessionContext().messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appends later entries to the file without rewriting earlier ones", () => {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendMessage(userMsg("first question"));
|
||||
session.appendCustomEntry("preset-state", { name: "plan" });
|
||||
session.appendMessage(assistantMsg("first answer"));
|
||||
|
||||
expect(readSessionFileRoles(session.getSessionFile()!)).toEqual(["session", "user", "custom", "assistant"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { existsSync, mkdirSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CustomEntry, SessionManager } from "../../src/core/session-manager.ts";
|
||||
import { assistantMsg, userMsg } from "../utilities.ts";
|
||||
import { assistantMsg, readSessionFileRoles, userMsg } from "../utilities.ts";
|
||||
|
||||
describe("SessionManager append and tree traversal", () => {
|
||||
describe("append operations", () => {
|
||||
@@ -480,45 +480,33 @@ describe("createBranchedSession", () => {
|
||||
expect(entries.map((e) => e.id)).toEqual([id1, id2, id4, id5]);
|
||||
});
|
||||
|
||||
it("does not duplicate entries when forking from first user message", () => {
|
||||
it("does not duplicate entries when forking from before the first user message", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-dedup-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a persisted session with a couple of turns
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
const id1 = session.appendMessage(userMsg("first question"));
|
||||
const modelChangeId = session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||
session.appendMessage(userMsg("first question"));
|
||||
session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
|
||||
// Fork from the very first user message (no assistant in the branched path)
|
||||
const newFile = session.createBranchedSession(id1);
|
||||
// Fork from a setup entry (no user or assistant message in the branched path)
|
||||
const newFile = session.createBranchedSession(modelChangeId);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// The branched path has no assistant, so the file should not exist yet
|
||||
// (deferred to _persist on first assistant, matching newSession() contract)
|
||||
// Nothing to save yet, so the file is created later by the first user message
|
||||
expect(existsSync(newFile!)).toBe(false);
|
||||
|
||||
session.appendMessage(userMsg("new question"));
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
|
||||
// Simulate extension adding entry before assistant (like preset on turn_start)
|
||||
session.appendCustomEntry("preset-state", { name: "plan" });
|
||||
|
||||
// Now the assistant responds
|
||||
session.appendMessage(assistantMsg("new answer"));
|
||||
|
||||
// File should now exist with exactly one header and no duplicate IDs
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
|
||||
const entryIds = records
|
||||
.filter((r) => r.type !== "session")
|
||||
.map((r) => r.id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
expect(new Set(entryIds).size).toBe(entryIds.length);
|
||||
// Exactly one header and each entry written once
|
||||
expect(readSessionFileRoles(newFile!)).toEqual(["session", "model_change", "user", "custom", "assistant"]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -570,27 +558,20 @@ describe("createBranchedSession", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("writes file immediately when forking from a point with assistant messages", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-with-assistant-${Date.now()}`);
|
||||
it("writes file immediately when forking at a user message", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-with-user-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendMessage(userMsg("first question"));
|
||||
const id2 = session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
const id1 = session.appendMessage(userMsg("first question"));
|
||||
session.appendMessage(assistantMsg("first answer"));
|
||||
|
||||
// Fork including the assistant message
|
||||
const newFile = session.createBranchedSession(id2);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// Path includes an assistant, so file should be written immediately
|
||||
const newFile = session.createBranchedSession(id1);
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
|
||||
session.appendMessage(assistantMsg("new answer"));
|
||||
expect(readSessionFileRoles(newFile!)).toEqual(["session", "user", "assistant"]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user