refactor(agent): isolate node filesystem session dependencies
This commit is contained in:
@@ -2,8 +2,8 @@ import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOp
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import { calculateTool } from "../utils/calculate.js";
|
||||
|
||||
const registrations: Array<{ unregister(): void }> = [];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } fr
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
|
||||
import type { AgentMessage, AgentTool } from "../../src/types.js";
|
||||
import { calculateTool } from "../utils/calculate.js";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { access, chmod, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { FileError, getOrThrow } from "../../src/harness/execution-env.js";
|
||||
import { FileError, getOrThrow } from "../../src/harness/types.js";
|
||||
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
|
||||
@@ -21,6 +21,8 @@ describe("NodeExecutionEnv", () => {
|
||||
it("reads, writes, lists, and removes files and directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
|
||||
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
|
||||
getOrThrow(await env.createDir("nested/child"));
|
||||
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
|
||||
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
|
||||
@@ -71,7 +73,7 @@ describe("NodeExecutionEnv", () => {
|
||||
path: join(root, "dir-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(getOrThrow(await env.realPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
});
|
||||
|
||||
it("lists symlinks as symlinks", async () => {
|
||||
@@ -168,7 +170,7 @@ describe("NodeExecutionEnv", () => {
|
||||
env.appendFile("other.txt", "hello", signal),
|
||||
env.fileInfo("file.txt", signal),
|
||||
env.listDir(".", signal),
|
||||
env.realPath("file.txt", signal),
|
||||
env.canonicalPath("file.txt", signal),
|
||||
env.exists("file.txt", signal),
|
||||
env.createDir("dir", { abortSignal: signal }),
|
||||
env.remove("file.txt", { abortSignal: signal }),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js";
|
||||
import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.js";
|
||||
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
|
||||
|
||||
describe("InMemorySessionRepo", () => {
|
||||
@@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => {
|
||||
describe("JsonlSessionRepo", () => {
|
||||
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const cwd = "/tmp/my-project";
|
||||
const otherCwd = "/tmp/other-project";
|
||||
const repo = new JsonlSessionRepo({ sessionsRoot: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
|
||||
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
|
||||
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
|
||||
const metadata = await session.getMetadata();
|
||||
@@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => {
|
||||
|
||||
it("opens, deletes, and forks by metadata", async () => {
|
||||
const root = createTempDir();
|
||||
const repo = new JsonlSessionRepo({ sessionsRoot: root });
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
|
||||
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
|
||||
const sourceMetadata = await source.getMetadata();
|
||||
const user1 = await source.appendMessage(createUserMessage("one"));
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const randomBytesMock = vi.hoisted(() => vi.fn<(size: number) => Buffer>());
|
||||
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomBytes: randomBytesMock,
|
||||
}));
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { uuidv7 } from "../../src/harness/session/uuid.js";
|
||||
|
||||
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
@@ -15,14 +8,22 @@ function parseTimestamp(uuid: string): number {
|
||||
return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("uuidv7", () => {
|
||||
it("uses the RFC 9562 layout and preserves monotonic order", () => {
|
||||
randomBytesMock
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
|
||||
)
|
||||
.mockReturnValueOnce(Buffer.alloc(16))
|
||||
.mockReturnValueOnce(Buffer.alloc(16));
|
||||
const randomValues = [
|
||||
new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
|
||||
new Uint8Array(16),
|
||||
new Uint8Array(16),
|
||||
];
|
||||
const getRandomValues = vi.fn((bytes: Uint8Array) => {
|
||||
bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length));
|
||||
return bytes;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP);
|
||||
|
||||
try {
|
||||
@@ -41,8 +42,7 @@ describe("uuidv7", () => {
|
||||
expect(parseTimestamp(third)).toBe(TIMESTAMP + 1);
|
||||
expect(first < second).toBe(true);
|
||||
expect(second < third).toBe(true);
|
||||
expect(randomBytesMock).toHaveBeenCalledTimes(3);
|
||||
expect(randomBytesMock).toHaveBeenCalledWith(16);
|
||||
expect(getRandomValues).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { SessionStorage } from "../../src/harness/types.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js";
|
||||
|
||||
@@ -128,7 +129,8 @@ runSessionSuite(
|
||||
"Session with JSONL storage",
|
||||
async () => {
|
||||
const dir = createTempDir();
|
||||
return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
|
||||
},
|
||||
() => {
|
||||
const dir = getLatestTempDir();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
|
||||
import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
|
||||
|
||||
@@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => {
|
||||
describe("JsonlSessionStorage", () => {
|
||||
it("throws for missing files when opening", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
it("writes the header on create", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(await storage.getLeafId()).toBeNull();
|
||||
@@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => {
|
||||
|
||||
it("throws for malformed session headers", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
writeFileSync(filePath, "not json\n");
|
||||
await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header");
|
||||
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
|
||||
});
|
||||
|
||||
it("ignores malformed entry lines", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
@@ -152,15 +157,16 @@ describe("JsonlSessionStorage", () => {
|
||||
message: createUserMessage("one"),
|
||||
};
|
||||
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
|
||||
const storage = await JsonlSessionStorage.open(filePath);
|
||||
const storage = await JsonlSessionStorage.open(env, filePath);
|
||||
expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]);
|
||||
expect(await storage.getLeafId()).toBe("entry-1");
|
||||
});
|
||||
|
||||
it("creates and reads session metadata from the header", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, {
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, {
|
||||
cwd: dir,
|
||||
sessionId: "session-1",
|
||||
parentSessionPath: "/tmp/parent.jsonl",
|
||||
@@ -179,13 +185,14 @@ describe("JsonlSessionStorage", () => {
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: createUserMessage("one"),
|
||||
});
|
||||
expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata);
|
||||
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("loads existing entries and reconstructs leaf", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const root: MessageEntry = {
|
||||
type: "message",
|
||||
id: "root",
|
||||
@@ -201,7 +208,7 @@ describe("JsonlSessionStorage", () => {
|
||||
};
|
||||
await storage.appendEntry(root);
|
||||
await storage.appendEntry(child);
|
||||
const loaded = await JsonlSessionStorage.open(filePath);
|
||||
const loaded = await JsonlSessionStorage.open(env, filePath);
|
||||
expect(await loaded.getLeafId()).toBe("child");
|
||||
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||
@@ -209,8 +216,9 @@ describe("JsonlSessionStorage", () => {
|
||||
|
||||
it("finds entries by type", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
await storage.appendEntry({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
@@ -224,8 +232,9 @@ describe("JsonlSessionStorage", () => {
|
||||
|
||||
it("maintains label lookup", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
await storage.appendEntry({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
@@ -252,12 +261,13 @@ describe("JsonlSessionStorage", () => {
|
||||
label: undefined,
|
||||
});
|
||||
expect(await storage.getLabel("entry-1")).toBeUndefined();
|
||||
const loaded = await JsonlSessionStorage.open(filePath);
|
||||
const loaded = await JsonlSessionStorage.open(env, filePath);
|
||||
expect(await loaded.getLabel("entry-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads session metadata from only the first JSONL line", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
@@ -268,7 +278,7 @@ describe("JsonlSessionStorage", () => {
|
||||
};
|
||||
const malformedSecondLine = "{".repeat(10000);
|
||||
writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`);
|
||||
expect(await loadJsonlSessionMetadata(filePath)).toEqual({
|
||||
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual({
|
||||
id: "session-1",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
|
||||
Reference in New Issue
Block a user