diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md index e6a56db6..3ab1faf0 100644 --- a/packages/agent/docs/agent-harness.md +++ b/packages/agent/docs/agent-harness.md @@ -387,10 +387,11 @@ Implemented so far: - Added generic `Result` plus helpers. - Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. +- Split filesystem/shell capabilities and moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. - Added `ExecutionEnv.appendFile()` for streaming append use cases. - Updated skill and prompt-template loaders to consume `ExecutionEnv` results. - Updated shell output capture to return a result and use `ExecutionEnv` instead of Node APIs directly, including full-output spill via `appendFile()`. -- Removed `NodeExecutionEnv` from the browser-safe `execution-env.ts` re-export; Node-specific callers import from `harness/env/nodejs.js`. +- Removed `NodeExecutionEnv` from browser-safe root exports; Node-specific callers use the `node` entry point or `harness/env/nodejs.js`. - Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. - Converted compaction summary helpers to typed result returns and added error-path coverage. - Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output full-output spill. @@ -402,7 +403,7 @@ Still needed: - Convert structural `AgentHarness` operations to typed result returns for busy, missing-resource, auth, compaction, and branch-summary failures. - Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts` and Node-backed storage/session implementations, or move those implementations behind explicit Node-only entry points. - Audit remaining generic harness utilities for Node globals as new APIs are added. -- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv` or JSONL storage. +- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv`. - Keep expanding `ExecutionEnv` and shell-output contract tests as the API evolves, especially for non-Node implementations. - Add tests proving harness APIs return `ok: false` instead of throwing for expected failure paths. diff --git a/packages/agent/package.json b/packages/agent/package.json index c2d2695b..4fe6d2fc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -5,6 +5,17 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "README.md" diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index e44bf458..38d3a18e 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -5,7 +5,7 @@ * a summary of the branch being left so context isn't lost. */ -import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; +import type { Model } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.js"; import { @@ -148,16 +148,10 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined case "message": // Skip tool results - context is in assistant's tool call if (entry.message.role === "toolResult") return undefined; - return entry.message as AgentMessage; + return entry.message; case "custom_message": - return createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index 6e79cf2c..0b23a2d1 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -218,6 +218,20 @@ export class NodeExecutionEnv implements ExecutionEnv { this.shellEnv = options.shellEnv; } + async absolutePath(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + return ok(resolved); + } + + async joinPath(parts: string[], abortSignal?: AbortSignal): Promise> { + const joined = join(...parts); + const aborted = abortResult(abortSignal, joined); + if (aborted) return aborted; + return ok(joined); + } + async exec( command: string, options?: { @@ -438,7 +452,7 @@ export class NodeExecutionEnv implements ExecutionEnv { } } - async realPath(path: string, abortSignal?: AbortSignal): Promise> { + async canonicalPath(path: string, abortSignal?: AbortSignal): Promise> { const resolved = resolvePath(this.cwd, path); const aborted = abortResult(abortSignal, resolved); if (aborted) return aborted; diff --git a/packages/agent/src/harness/execution-env.ts b/packages/agent/src/harness/execution-env.ts deleted file mode 100644 index 915fa4da..00000000 --- a/packages/agent/src/harness/execution-env.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { - ExecutionEnv, - ExecutionEnvExecOptions, - ExecutionErrorCode, - FileErrorCode, - FileInfo, - FileKind, - Result, -} from "./types.js"; -export { ExecutionError, err, FileError, getOrThrow, getOrUndefined, ok } from "./types.js"; diff --git a/packages/agent/src/harness/prompt-templates.ts b/packages/agent/src/harness/prompt-templates.ts index e627b66c..abcaad5a 100644 --- a/packages/agent/src/harness/prompt-templates.ts +++ b/packages/agent/src/harness/prompt-templates.ts @@ -148,9 +148,9 @@ async function loadTemplateFromFile( async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> { if (info.kind === "file" || info.kind === "directory") return info.kind; - const realPath = await env.realPath(info.path); - if (!realPath.ok) return undefined; - const target = getOrUndefined(await env.fileInfo(realPath.value)); + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) return undefined; + const target = getOrUndefined(await env.fileInfo(canonicalPath.value)); if (!target) return undefined; return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; } diff --git a/packages/agent/src/harness/session/jsonl-repo.ts b/packages/agent/src/harness/session/jsonl-repo.ts new file mode 100644 index 00000000..e41f0fb3 --- /dev/null +++ b/packages/agent/src/harness/session/jsonl-repo.ts @@ -0,0 +1,138 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types.js"; +import { getOrThrow } from "../types.js"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise { + if (!this.sessionsRoot) { + this.sessionsRoot = getOrThrow(await this.fs.absolutePath(this.sessionsRootInput)); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise { + return getOrThrow(await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)])); + } + + private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise { + return getOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + ); + } + + async create(options: JsonlSessionCreateOptions): Promise> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getOrThrow(await this.fs.createDir(sessionDir, { recursive: true })); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise> { + if (!getOrThrow(await this.fs.exists(metadata.path))) { + throw new Error(`Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise { + const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if (!getOrThrow(await this.fs.exists(dir))) continue; + const files = getOrThrow(await this.fs.listDir(dir)).filter( + (file) => file.kind !== "directory" && file.name.endsWith(".jsonl"), + ); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch { + // Ignore invalid session files when listing a directory. + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise { + getOrThrow(await this.fs.remove(metadata.path, { force: true })); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getOrThrow(await this.fs.createDir(sessionDir, { recursive: true })); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise { + const sessionsRoot = await this.getSessionsRoot(); + if (!getOrThrow(await this.fs.exists(sessionsRoot))) return []; + const entries = getOrThrow(await this.fs.listDir(sessionsRoot)); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/packages/agent/src/harness/session/storage/jsonl.ts b/packages/agent/src/harness/session/jsonl-storage.ts similarity index 69% rename from packages/agent/src/harness/session/storage/jsonl.ts rename to packages/agent/src/harness/session/jsonl-storage.ts index 72ce6ce3..a6f370f7 100644 --- a/packages/agent/src/harness/session/storage/jsonl.ts +++ b/packages/agent/src/harness/session/jsonl-storage.ts @@ -1,9 +1,8 @@ -import { randomUUID } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { createInterface } from "node:readline"; -import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; +import type { FileSystem, JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js"; +import { getOrThrow } from "../types.js"; +import { uuidv7 } from "./uuid.js"; + +type JsonlSessionStorageFileSystem = Pick; interface SessionHeader { type: "session"; @@ -34,10 +33,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map { function generateEntryId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { - const id = randomUUID().slice(0, 8); + const id = uuidv7().slice(0, 8); if (!byId.has(id)) return id; } - return randomUUID(); + return uuidv7(); } function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { @@ -50,32 +49,32 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess }; } -export async function loadJsonlSessionMetadata(filePath: string): Promise { - const stream = createReadStream(filePath, { encoding: "utf8" }); - const lines = createInterface({ input: stream, crlfDelay: Infinity }); - try { - for await (const line of lines) { - if (!line.trim()) break; - try { - const header = JSON.parse(line) as SessionHeader; - return headerToSessionMetadata(header, resolve(filePath)); - } catch { - throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`); - } +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise { + const content = getOrThrow(await fs.readTextFile(filePath)); + for (const line of content.split("\n")) { + if (!line.trim()) break; + try { + const header = JSON.parse(line) as SessionHeader; + return headerToSessionMetadata(header, filePath); + } catch { + throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`); } - throw new Error(`Invalid JSONL session file ${filePath}: missing session header`); - } finally { - lines.close(); - stream.destroy(); } + throw new Error(`Invalid JSONL session file ${filePath}: missing session header`); } -async function loadJsonlStorage(filePath: string): Promise<{ +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ header: SessionHeader; entries: SessionTreeEntry[]; leafId: string | null; }> { - const content = await readFile(filePath, "utf8"); + const content = getOrThrow(await fs.readTextFile(filePath)); const lines = content.split("\n").filter((line) => line.trim()); if (lines.length === 0) { throw new Error(`Invalid JSONL session file ${filePath}: missing session header`); @@ -103,6 +102,7 @@ async function loadJsonlStorage(filePath: string): Promise<{ } export class JsonlSessionStorage implements SessionStorage { + private readonly fs: JsonlSessionStorageFileSystem; private readonly filePath: string; private readonly metadata: JsonlSessionMetadata; private entries: SessionTreeEntry[]; @@ -110,8 +110,15 @@ export class JsonlSessionStorage implements SessionStorage private labelsById: Map; private currentLeafId: string | null; - private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) { - this.filePath = resolve(filePath); + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; this.metadata = headerToSessionMetadata(header, this.filePath); this.entries = entries; this.byId = new Map(entries.map((entry) => [entry.id, entry])); @@ -119,13 +126,13 @@ export class JsonlSessionStorage implements SessionStorage this.currentLeafId = leafId; } - static async open(filePath: string): Promise { - const resolvedPath = resolve(filePath); - const loaded = await loadJsonlStorage(resolvedPath); - return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId); + static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); } static async create( + fs: JsonlSessionStorageFileSystem, filePath: string, options: { cwd: string; @@ -133,7 +140,6 @@ export class JsonlSessionStorage implements SessionStorage parentSessionPath?: string; }, ): Promise { - const resolvedPath = resolve(filePath); const header: SessionHeader = { type: "session", version: 3, @@ -142,9 +148,8 @@ export class JsonlSessionStorage implements SessionStorage cwd: options.cwd, parentSession: options.parentSessionPath, }; - await mkdir(dirname(resolvedPath), { recursive: true }); - await writeFile(resolvedPath, `${JSON.stringify(header)}\n`); - return new JsonlSessionStorage(resolvedPath, header, [], null); + getOrThrow(await fs.writeFile(filePath, `${JSON.stringify(header)}\n`)); + return new JsonlSessionStorage(fs, filePath, header, [], null); } async getMetadata(): Promise { @@ -167,7 +172,7 @@ export class JsonlSessionStorage implements SessionStorage } async appendEntry(entry: SessionTreeEntry): Promise { - await appendFile(this.filePath, `${JSON.stringify(entry)}\n`); + getOrThrow(await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`)); this.entries.push(entry); this.byId.set(entry.id, entry); updateLabelCache(this.labelsById, entry); diff --git a/packages/agent/src/harness/session/repo/memory.ts b/packages/agent/src/harness/session/memory-repo.ts similarity index 91% rename from packages/agent/src/harness/session/repo/memory.ts rename to packages/agent/src/harness/session/memory-repo.ts index 3846ae2a..85401801 100644 --- a/packages/agent/src/harness/session/repo/memory.ts +++ b/packages/agent/src/harness/session/memory-repo.ts @@ -1,6 +1,6 @@ -import type { Session, SessionMetadata, SessionRepo } from "../../types.js"; -import { InMemorySessionStorage } from "../storage/memory.js"; -import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js"; +import type { Session, SessionMetadata, SessionRepo } from "../types.js"; +import { InMemorySessionStorage } from "./memory-storage.js"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js"; export class InMemorySessionRepo implements SessionRepo { private sessions = new Map>(); diff --git a/packages/agent/src/harness/session/storage/memory.ts b/packages/agent/src/harness/session/memory-storage.ts similarity index 94% rename from packages/agent/src/harness/session/storage/memory.ts rename to packages/agent/src/harness/session/memory-storage.ts index 652f633f..bf519b38 100644 --- a/packages/agent/src/harness/session/storage/memory.ts +++ b/packages/agent/src/harness/session/memory-storage.ts @@ -1,6 +1,5 @@ -import { randomUUID } from "node:crypto"; -import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; -import { uuidv7 } from "../uuid.js"; +import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js"; +import { uuidv7 } from "./uuid.js"; function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { if (entry.type !== "label") return; @@ -22,10 +21,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map { function generateEntryId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { - const id = randomUUID().slice(0, 8); + const id = uuidv7().slice(0, 8); if (!byId.has(id)) return id; } - return randomUUID(); + return uuidv7(); } export class InMemorySessionStorage implements SessionStorage { diff --git a/packages/agent/src/harness/session/repo/shared.ts b/packages/agent/src/harness/session/repo-utils.ts similarity index 91% rename from packages/agent/src/harness/session/repo/shared.ts rename to packages/agent/src/harness/session/repo-utils.ts index 2c628ca9..e842a718 100644 --- a/packages/agent/src/harness/session/repo/shared.ts +++ b/packages/agent/src/harness/session/repo-utils.ts @@ -1,6 +1,6 @@ -import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js"; -import { Session } from "../session.js"; -import { uuidv7 } from "../uuid.js"; +import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js"; +import { Session } from "./session.js"; +import { uuidv7 } from "./uuid.js"; export function createSessionId(): string { return uuidv7(); diff --git a/packages/agent/src/harness/session/repo/jsonl.ts b/packages/agent/src/harness/session/repo/jsonl.ts deleted file mode 100644 index a4c5ec5b..00000000 --- a/packages/agent/src/harness/session/repo/jsonl.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { constants } from "node:fs"; -import { access, mkdir, readdir, rm } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import type { - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - JsonlSessionRepoApi, - Session, -} from "../../types.js"; -import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js"; -import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js"; - -async function exists(path: string): Promise { - try { - await access(path, constants.F_OK); - return true; - } catch { - return false; - } -} - -function encodeCwd(cwd: string): string { - return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; -} - -export class JsonlSessionRepo implements JsonlSessionRepoApi { - private sessionsRoot: string; - - constructor(options: { sessionsRoot: string }) { - this.sessionsRoot = resolve(options.sessionsRoot); - } - - private getSessionDir(cwd: string): string { - return join(this.sessionsRoot, encodeCwd(cwd)); - } - - private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string { - return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`); - } - - async create(options: JsonlSessionCreateOptions): Promise> { - await mkdir(this.sessionsRoot, { recursive: true }); - const id = options.id ?? createSessionId(); - const createdAt = createTimestamp(); - const filePath = this.createSessionFilePath(options.cwd, id, createdAt); - const storage = await JsonlSessionStorage.create(filePath, { - cwd: options.cwd, - sessionId: id, - parentSessionPath: options.parentSessionPath, - }); - return toSession(storage); - } - - async open(metadata: JsonlSessionMetadata): Promise> { - if (!(await exists(metadata.path))) { - throw new Error(`Session not found: ${metadata.path}`); - } - const storage = await JsonlSessionStorage.open(metadata.path); - return toSession(storage); - } - - async list(options: JsonlSessionListOptions = {}): Promise { - const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs(); - const sessions: JsonlSessionMetadata[] = []; - for (const dir of dirs) { - if (!(await exists(dir))) continue; - const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file)); - for (const filePath of files) { - try { - sessions.push(await loadJsonlSessionMetadata(filePath)); - } catch { - // Ignore invalid session files when listing a directory. - } - } - } - sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - return sessions; - } - - async delete(metadata: JsonlSessionMetadata): Promise { - await rm(metadata.path, { force: true }); - } - - async fork( - sourceMetadata: JsonlSessionMetadata, - options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, - ): Promise> { - const source = await this.open(sourceMetadata); - const forkedEntries = await getEntriesToFork(source.getStorage(), options); - const id = options.id ?? createSessionId(); - const createdAt = createTimestamp(); - const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), { - cwd: options.cwd, - sessionId: id, - parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, - }); - for (const entry of forkedEntries) { - await storage.appendEntry(entry); - } - return toSession(storage); - } - - private async listSessionDirs(): Promise { - if (!(await exists(this.sessionsRoot))) return []; - const entries = await readdir(this.sessionsRoot, { withFileTypes: true }); - return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name)); - } -} diff --git a/packages/agent/src/harness/session/uuid.ts b/packages/agent/src/harness/session/uuid.ts index c7e2896e..0c4cef8e 100644 --- a/packages/agent/src/harness/session/uuid.ts +++ b/packages/agent/src/harness/session/uuid.ts @@ -1,10 +1,20 @@ -import { randomBytes } from "node:crypto"; - let lastTimestamp = -Infinity; let sequence = 0; +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + export function uuidv7(): string { - const random = randomBytes(16); + const random = new Uint8Array(16); + fillRandomBytes(random); const timestamp = Date.now(); if (timestamp > lastTimestamp) { diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index bb8931f6..d26c8627 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -261,9 +261,9 @@ function parseFrontmatter>( async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> { if (info.kind === "file" || info.kind === "directory") return info.kind; - const realPath = await env.realPath(info.path); - if (!realPath.ok) return undefined; - const target = getOrUndefined(await env.fileInfo(realPath.value)); + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) return undefined; + const target = getOrUndefined(await env.fileInfo(canonicalPath.value)); if (!target) return undefined; return target.kind === "file" || target.kind === "directory" ? target.kind : undefined; } diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 91e2d87a..d25f181c 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -93,10 +93,10 @@ export interface AgentHarnessStreamOptionsPatch metadata?: Record; } -/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */ +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ export type FileKind = "file" | "directory" | "symlink"; -/** Stable, backend-independent file error codes returned by {@link ExecutionEnv} file operations. */ +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ export type FileErrorCode = | "aborted" | "not_found" @@ -107,7 +107,7 @@ export type FileErrorCode = | "not_supported" | "unknown"; -/** Error returned by {@link ExecutionEnv} file operations. */ +/** Error returned by {@link FileSystem} file operations. */ export class FileError extends Error { constructor( /** Backend-independent error code. */ @@ -154,13 +154,13 @@ export class CompactionError extends Error { } } -/** Metadata for one filesystem object in an {@link ExecutionEnv}. */ +/** Metadata for one filesystem object in a {@link FileSystem}. */ export interface FileInfo { /** Basename of {@link path}. */ name: string; /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ path: string; - /** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */ + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ kind: FileKind; /** Size in bytes for the addressed filesystem object. */ size: number; @@ -168,7 +168,7 @@ export interface FileInfo { mtimeMs: number; } -/** Options for {@link ExecutionEnv.exec}. */ +/** Options for {@link Shell.exec}. */ export interface ExecutionEnvExecOptions { /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ cwd?: string; @@ -185,24 +185,22 @@ export interface ExecutionEnvExecOptions { } /** - * Filesystem and process execution environment used by the harness. + * Filesystem capability used by the harness. * - * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute - * addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}. + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. * - * Operation methods must never throw or reject. All filesystem/process failures, including unexpected backend failures, - * must be encoded in the returned {@link Result}. Implementations must preserve this invariant. + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. */ -export interface ExecutionEnv { - /** Current working directory for relative paths and command execution. */ +export interface FileSystem { + /** Current working directory for relative paths. */ cwd: string; - /** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */ - exec( - command: string, - options?: ExecutionEnvExecOptions, - ): Promise>; - + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; /** Read a UTF-8 text file. */ readTextFile(path: string, abortSignal?: AbortSignal): Promise>; /** Read a binary file. */ @@ -215,8 +213,8 @@ export interface ExecutionEnv { fileInfo(path: string, abortSignal?: AbortSignal): Promise>; /** List direct children of a directory without following symlinks. */ listDir(path: string, abortSignal?: AbortSignal): Promise>; - /** Return the canonical path for a path, following symlinks. */ - realPath(path: string, abortSignal?: AbortSignal): Promise>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ exists(path: string, abortSignal?: AbortSignal): Promise>; /** Create a directory. Defaults: `recursive: true`, no abort signal. */ @@ -238,10 +236,24 @@ export interface ExecutionEnv { abortSignal?: AbortSignal; }): Promise>; - /** Release resources owned by the environment. Must be best-effort and must not throw or reject. */ + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ cleanup(): Promise; } +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ + exec( + command: string, + options?: ExecutionEnvExecOptions, + ): Promise>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + export interface SessionTreeEntryBase { type: string; id: string; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 293ce196..345def15 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -22,12 +22,11 @@ export { serializeConversation, shouldCompact, } from "./harness/compaction/compaction.js"; -export * from "./harness/execution-env.js"; export * from "./harness/messages.js"; export * from "./harness/prompt-templates.js"; -export * from "./harness/session/repo/jsonl.js"; -export * from "./harness/session/repo/memory.js"; -export * from "./harness/session/repo/shared.js"; +export * from "./harness/session/jsonl-repo.js"; +export * from "./harness/session/memory-repo.js"; +export * from "./harness/session/repo-utils.js"; export * from "./harness/session/session.js"; export { uuidv7 } from "./harness/session/uuid.js"; export * from "./harness/skills.js"; diff --git a/packages/agent/src/node.ts b/packages/agent/src/node.ts new file mode 100644 index 00000000..23ccae43 --- /dev/null +++ b/packages/agent/src/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs.js"; +export * from "./index.js"; diff --git a/packages/agent/test/harness/agent-harness-stream.test.ts b/packages/agent/test/harness/agent-harness-stream.test.ts index e5ca193f..dba98f91 100644 --- a/packages/agent/test/harness/agent-harness-stream.test.ts +++ b/packages/agent/test/harness/agent-harness-stream.test.ts @@ -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 }> = []; diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index e0b4497e..6ded341c 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -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"; diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 97c5da23..fa3d6aa9 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -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 }), diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts index 310d8898..fda51b03 100644 --- a/packages/agent/test/harness/repo.test.ts +++ b/packages/agent/test/harness/repo.test.ts @@ -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")); diff --git a/packages/agent/test/harness/session-uuid.test.ts b/packages/agent/test/harness/session-uuid.test.ts index 0c7f42e2..247f3177 100644 --- a/packages/agent/test/harness/session-uuid.test.ts +++ b/packages/agent/test/harness/session-uuid.test.ts @@ -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(); } diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts index d6b7f365..bfb0a6b3 100644 --- a/packages/agent/test/harness/session.test.ts +++ b/packages/agent/test/harness/session.test.ts @@ -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(); diff --git a/packages/agent/test/harness/storage.test.ts b/packages/agent/test/harness/storage.test.ts index 8c05702b..20f7ac8d 100644 --- a/packages/agent/test/harness/storage.test.ts +++ b/packages/agent/test/harness/storage.test.ts @@ -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, diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts index 296b2160..2b8fed11 100644 --- a/packages/agent/test/scratch/simple.ts +++ b/packages/agent/test/scratch/simple.ts @@ -2,7 +2,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js"; -import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js"; +import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js"; import { AgentHarness, formatSkillsForSystemPrompt,