fix: harden agent profile boundaries

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 23:39:49 +02:00
parent 97e0afc6fa
commit adc2e297a4
25 changed files with 419 additions and 150 deletions
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { agentSessionDirEnvKeys } from "../../config.js";
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { PiSessionManager } from "./piSessionService.js";
@@ -24,7 +25,7 @@ afterEach(async () => {
describe("SessionDirResolver", () => {
it("uses Pi default session storage when no Pi override is configured", () => {
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
@@ -34,7 +35,7 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
});
@@ -45,7 +46,7 @@ describe("SessionDirResolver", () => {
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
});
@@ -55,7 +56,7 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } });
const resolver = new SessionDirResolver(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
});
@@ -65,10 +66,22 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: { PI_WEB_AGENT_SESSION_DIR: envDir } });
const resolver = new SessionDirResolver(piProfileOptions({ PI_WEB_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
});
it("snapshots the daemon epoch's injected session-directory environment", () => {
const firstDir = join(tempDir, "first-env-sessions");
const env = { PI_WEB_AGENT_SESSION_DIR: firstDir };
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
const resolver = new SessionDirResolver({ agentDir, env, sessionDirEnvKeys });
env.PI_WEB_AGENT_SESSION_DIR = join(tempDir, "mutated-env-sessions");
sessionDirEnvKeys[0] = "OTHER_SESSION_DIR";
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: firstDir, usesConfiguredSessionDir: true });
});
});
describe("Pi session manager gateway", () => {
@@ -76,7 +89,7 @@ describe("Pi session manager gateway", () => {
const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
@@ -86,7 +99,7 @@ describe("Pi session manager gateway", () => {
const envSessionDir = join(tempDir, "env-sessions");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
await writeSessionFile(envSessionDir, "env-session", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envSessionDir }));
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
@@ -99,6 +112,7 @@ describe("Pi session manager gateway", () => {
const gateway = createPiSessionManagerGateway({
agentDir,
env: { [envKey]: envSessionDir },
sessionDirEnvKeys: [envKey],
});
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
@@ -111,7 +125,7 @@ describe("Pi session manager gateway", () => {
const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(sharedSessionDir, "session-a", cwd);
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } });
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: sharedSessionDir }));
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
const created = gateway.create(cwd);
@@ -125,7 +139,7 @@ describe("Pi session manager gateway", () => {
// hiding every session outside the daemon's own launch directory.
expect(cwd).not.toBe(process.cwd());
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
});
@@ -153,12 +167,16 @@ describe("session listing canonicalization", () => {
// Headers are written by the Pi CLI / SDK consumers and may contain
// unnormalized paths (trailing separators, redundant segments).
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
});
});
function piProfileOptions(env: NodeJS.ProcessEnv = {}) {
return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() };
}
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
}
+26 -30
View File
@@ -3,7 +3,6 @@ import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { agentSessionDirEnvKeys, effectiveAgentConfig } from "../../config.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
@@ -16,20 +15,23 @@ export interface SessionDirResolution {
}
export interface SessionDirResolverOptions {
agentDir?: string;
env?: NodeJS.ProcessEnv;
sessionDirEnvKeys?: readonly string[];
agentDir: string;
env: Readonly<NodeJS.ProcessEnv>;
sessionDirEnvKeys: readonly string[];
}
export class SessionDirResolver {
private readonly agentDir: string;
private readonly env: NodeJS.ProcessEnv;
private readonly sessionDirEnvKeys: readonly string[];
private readonly envSessionDir: string | undefined;
private readonly homeDir: string;
constructor(options: SessionDirResolverOptions = {}) {
this.agentDir = options.agentDir ?? effectiveAgentConfig().dir;
this.env = options.env ?? process.env;
this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? agentSessionDirEnvKeys();
constructor(options: SessionDirResolverOptions) {
this.agentDir = options.agentDir;
this.envSessionDir = options.sessionDirEnvKeys
.map((key) => options.env[key])
.find((value) => value !== undefined && value !== "");
const configuredHome = options.env["HOME"];
this.homeDir = configuredHome !== undefined && configuredHome !== "" && isAbsolute(configuredHome) ? configuredHome : homedir();
}
defaultSessionsRoot(): string {
@@ -37,34 +39,28 @@ export class SessionDirResolver {
}
globalEnvSessionDir(): string | undefined {
const envSessionDir = this.envSessionDir();
if (envSessionDir === undefined) return undefined;
const expanded = expandTildePath(envSessionDir);
if (this.envSessionDir === undefined) return undefined;
const expanded = expandTildePath(this.envSessionDir, this.homeDir);
return isAbsolute(expanded) ? expanded : undefined;
}
resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.envSessionDir();
if (envSessionDir !== undefined) {
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
if (this.envSessionDir !== undefined) {
return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
}
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true };
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
}
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
}
private envSessionDir(): string | undefined {
return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== "");
}
}
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway {
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway {
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
}
@@ -105,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise<PiSessionLi
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
}
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> {
export async function listSessionsInDefaultPiStore(storeRoot: string): Promise<PiSessionListEntry[]> {
let entries: Dirent[];
try {
entries = await readdir(storeRoot, { withFileTypes: true });
@@ -130,11 +126,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
}
export function defaultPiSessionsRoot(agentDir = effectiveAgentConfig().dir): string {
export function defaultPiSessionsRoot(agentDir: string): string {
return join(agentDir, "sessions");
}
export function defaultPiSessionDir(cwd: string, agentDir = effectiveAgentConfig().dir): string {
export function defaultPiSessionDir(cwd: string, agentDir: string): string {
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
}
@@ -143,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri
return join(storeRoot, safePath);
}
export function resolveConfiguredPath(path: string, cwd: string): string {
const expanded = expandTildePath(path);
export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string {
const expanded = expandTildePath(path, homeDir);
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
}
function expandTildePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
function expandTildePath(path: string, homeDir: string): string {
if (path === "~") return homeDir;
if (path.startsWith("~/")) return join(homeDir, path.slice(2));
return path;
}
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService archive and cleanup", () => {
it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = [];
@@ -12,6 +14,7 @@ describe("PiSessionService archive and cleanup", () => {
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => {
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -78,6 +82,7 @@ describe("PiSessionService archive and cleanup", () => {
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
@@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => {
let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
@@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => {
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
@@ -188,6 +195,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
@@ -230,6 +238,7 @@ describe("PiSessionService archive and cleanup", () => {
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
@@ -282,6 +291,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
@@ -323,6 +333,7 @@ describe("PiSessionService archive and cleanup", () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
@@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
let runtimeAgentDir: string | undefined;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
createCalls += 1;
runtimeAgentDir = options.agentDir;
await Promise.resolve();
return fake.runtime;
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const session = await service.start("/workspace");
expect(createCalls).toBe(1);
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
@@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
@@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const gateway = sessionGateway([sessionRecord(sessionId)]);
const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: gateway,
@@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
: Promise.resolve(runtime);
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]),
@@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const runtimeResult = deferred<PiSessionRuntime>();
const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => {
createStarted.resolve();
@@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
@@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
@@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
@@ -394,6 +410,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -479,6 +498,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
@@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -514,6 +535,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
@@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
@@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["then do this"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
@@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["already queued"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
@@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
@@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return Promise.resolve();
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
@@ -215,6 +225,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
@@ -231,6 +242,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears prompts queued during compaction when aborting active work", async () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
@@ -255,6 +267,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("auth-session", { model, modelRegistry });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRegistry,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
@@ -285,6 +298,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
@@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
@@ -41,6 +44,7 @@ describe("PiSessionService", () => {
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
@@ -75,6 +79,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
@@ -32,6 +34,7 @@ describe("PiSessionService", () => {
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
@@ -72,6 +75,7 @@ describe("PiSessionService", () => {
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
@@ -111,6 +115,7 @@ describe("PiSessionService", () => {
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -162,6 +167,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -203,6 +209,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -227,6 +234,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -248,6 +256,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -268,6 +277,7 @@ describe("PiSessionService", () => {
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -288,6 +298,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -326,6 +337,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: (_createRuntime, options) => {
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? parent.runtime;
@@ -388,6 +400,7 @@ describe("PiSessionService", () => {
return childManager;
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -445,6 +458,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -509,6 +523,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: {
create: () => parentManager,
@@ -584,6 +599,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
@@ -641,6 +657,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -693,6 +710,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -733,6 +751,7 @@ describe("PiSessionService", () => {
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
@@ -842,6 +861,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
+5 -7
View File
@@ -25,8 +25,6 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { effectiveAgentConfig } from "../../config.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
@@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) {
}
export interface PiSessionServiceDependencies {
agentDir: string;
sessionManager: PiSessionManagerGateway;
archiveStore?: SessionArchiveRepository;
agentDir?: string;
sessionManager?: PiSessionManagerGateway;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance;
@@ -441,10 +439,10 @@ export class PiSessionService implements SessionRouteService {
private readonly logger: PiSessionLogger;
private readonly now: () => Date;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? effectiveAgentConfig().dir;
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
this.agentDir = deps.agentDir;
this.sessionManager = deps.sessionManager;
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
+3 -1
View File
@@ -9,6 +9,8 @@ import type { SessionRouteLookup, SessionRouteService } from "./sessionService.j
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
let app: FastifyInstance;
let service: PiSessionService;
let sessionManager: RejectingSessionManager;
@@ -18,7 +20,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});