- Implemented mutable session titles with audit tracking, including storage persistence for SQL and Redis backends. - Added comprehensive session management features such as idle recap triggers, incremental subagent yield submissions, and automated title refreshing. - Enhanced task tracking in the TodoTool with progress prioritization and improved session cleanup logic. - Introduced citation tag handling for OpenAI-compatible source markers and improved edit parsing.
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
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 { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
|
|
import { serializeTitleSlot } from "@oh-my-pi/pi-coding-agent/session/session-title-slot";
|
|
|
|
describe("FileSessionStorage.deleteSessionWithArtifacts", () => {
|
|
let tempDir: string;
|
|
let storage: FileSessionStorage;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-session-storage-"));
|
|
storage = new FileSessionStorage();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await fsp.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function createSessionFile(name: string): Promise<string> {
|
|
const sessionPath = path.join(tempDir, `${name}.jsonl`);
|
|
await Bun.write(
|
|
sessionPath,
|
|
`${JSON.stringify({ type: "session", id: "session-id", timestamp: "2025-01-01T00:00:00Z", cwd: tempDir })}\n`,
|
|
);
|
|
return sessionPath;
|
|
}
|
|
|
|
it("succeeds when the artifact directory is already absent", async () => {
|
|
const sessionPath = await createSessionFile("missing-artifacts");
|
|
const artifactsDir = sessionPath.slice(0, -6);
|
|
|
|
expect(fs.existsSync(sessionPath)).toBe(true);
|
|
expect(fs.existsSync(artifactsDir)).toBe(false);
|
|
|
|
await expect(storage.deleteSessionWithArtifacts(sessionPath)).resolves.toBeUndefined();
|
|
expect(fs.existsSync(sessionPath)).toBe(false);
|
|
expect(fs.existsSync(artifactsDir)).toBe(false);
|
|
});
|
|
|
|
it("throws when artifact cleanup fails after the session file is deleted", async () => {
|
|
const sessionPath = await createSessionFile("cleanup-failure");
|
|
const artifactsDir = sessionPath.slice(0, -6);
|
|
await fsp.mkdir(artifactsDir, { recursive: true });
|
|
await Bun.write(path.join(artifactsDir, "artifact.txt"), "artifact payload");
|
|
|
|
const rmError = new Error("permission denied");
|
|
const rmSpy = vi.spyOn(fsp, "rm").mockRejectedValueOnce(rmError);
|
|
|
|
await expect(storage.deleteSessionWithArtifacts(sessionPath)).rejects.toThrow(
|
|
`Session file deleted but failed to remove artifacts directory ${artifactsDir}: permission denied`,
|
|
);
|
|
expect(rmSpy).toHaveBeenCalledWith(artifactsDir, { recursive: true, force: true });
|
|
expect(fs.existsSync(sessionPath)).toBe(false);
|
|
expect(fs.existsSync(artifactsDir)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("FileSessionStorage.writeTextSync", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-session-storage-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("replaces the file identity so transcript tailers detect rewrites", async () => {
|
|
const storage = new FileSessionStorage();
|
|
const sessionPath = path.join(tempDir, "session.jsonl");
|
|
|
|
storage.writeTextSync(sessionPath, "first\n");
|
|
const first = fs.statSync(sessionPath);
|
|
storage.writeTextSync(sessionPath, "second\n");
|
|
const second = fs.statSync(sessionPath);
|
|
|
|
expect(second.ino).not.toBe(first.ino);
|
|
expect(await Bun.file(sessionPath).text()).toBe("second\n");
|
|
});
|
|
});
|
|
|
|
describe("FileSessionStorage.updateSessionTitle", () => {
|
|
let tempDir: string;
|
|
let storage: FileSessionStorage;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-session-storage-"));
|
|
storage = new FileSessionStorage();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("updates the fixed title slot without truncating the tail", async () => {
|
|
const sessionPath = path.join(tempDir, "session.jsonl");
|
|
const tail = `${JSON.stringify({ type: "session", id: "s", timestamp: "t", cwd: tempDir })}\n`;
|
|
storage.writeTextSync(
|
|
sessionPath,
|
|
`${serializeTitleSlot({ title: "Old", source: "auto", updatedAt: "t1" })}${tail}`,
|
|
);
|
|
|
|
await storage.updateSessionTitle(sessionPath, { title: "New", source: "user", updatedAt: "t2" });
|
|
|
|
const content = await Bun.file(sessionPath).text();
|
|
const [slotLine, ...rest] = content.split("\n");
|
|
expect(JSON.parse(slotLine)).toMatchObject({ type: "title", title: "New", source: "user", updatedAt: "t2" });
|
|
expect(`${rest.join("\n")}`).toBe(tail);
|
|
expect(fs.statSync(sessionPath).size).toBe(
|
|
Buffer.byteLength(`${serializeTitleSlot({ title: "Old", source: "auto", updatedAt: "t1" })}${tail}`, "utf-8"),
|
|
);
|
|
});
|
|
|
|
it("uses the existing file-open error for missing paths", async () => {
|
|
const sessionPath = path.join(tempDir, "missing.jsonl");
|
|
|
|
await expect(
|
|
storage.updateSessionTitle(sessionPath, { title: "New", source: "user", updatedAt: "t2" }),
|
|
).rejects.toThrow(/ENOENT|no such file/i);
|
|
});
|
|
});
|