diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts new file mode 100644 index 0000000..3116a66 --- /dev/null +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; + +describe("PiSessionService archive and cleanup", () => { + it("archives a session subtree within the root workspace", async () => { + const archivedInputs: string[] = []; + const root = sessionRecord("root"); + const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path }; + const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path }; + const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; + 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(), { + 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 }]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({ + archived: true, + sessionIds: ["root", "direct-child", "grandchild"], + archivedCount: 3, + skippedAlreadyArchivedCount: 1, + }); + expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); + + await service.dispose(); + }); + + it("permanently deletes archived sessions through the archive store", async () => { + const deletedSessionIds: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: (sessionId) => { + deletedSessionIds.push(sessionId); + return Promise.resolve(); + }, + }, + sessionManager: sessionGateway([sessionRecord("active")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.deleteArchived("arch")).resolves.toBeUndefined(); + await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); + + expect(deletedSessionIds).toEqual(["archived"]); + await service.dispose(); + }); + + it("bulk archives inactive sessions by cwd without opening runtimes", async () => { + const recordsByCwd = new Map([ + ["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]], + ["/two", [sessionRecord("c", "/two")]], + ]); + const listCalls: string[] = []; + 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(), { + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve(recordsByCwd.get(cwd) ?? []); + }, + open, + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]); + + expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] }); + expect(listCalls).toEqual(["/one", "/two"]); + expect(open).not.toHaveBeenCalled(); + expect(archiveMany).toHaveBeenCalledTimes(1); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); + await service.dispose(); + }); + + it("bulk archive reports per-session failures without aborting other archives", async () => { + const busy = fakeRuntime("busy", { isStreaming: true }); + 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(), { + createAgentRuntime: () => { + createCalls += 1; + return Promise.resolve(busy.runtime); + }, + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("busy")); + const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]); + + expect(createCalls).toBe(1); + expect(busy.calls.abort).toBe(0); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]); + expect(result.archivedSessionIds).toEqual(["ok"]); + expect(result.failures).toEqual([ + { sessionId: "busy", error: "Stop current session activity before archiving" }, + { sessionId: "missing", error: "Session not found" }, + ]); + await service.dispose(); + }); + + it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => { + const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; + const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; + const busy = fakeRuntime("busy-archived", { isStreaming: true }); + const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(busy.runtime), + archiveStore: { + list: () => Promise.resolve([busyRecord, idleRecord]), + get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), + archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.resolve(), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([sessionRecord("unarchived")]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("busy-archived")); + const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]); + + expect(busy.calls.abort).toBe(0); + expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]); + expect(result.deletedSessionIds).toEqual(["idle-archived"]); + expect(result.failures).toEqual([ + { sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }, + { sessionId: "unarchived", error: "Archived session not found" }, + ]); + await service.dispose(); + }); + + it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => { + 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", archivePath: `/archive/${input.sessionId}.jsonl` })))); + const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); + const listCalls: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([ + { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, + { sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, + { sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" }, + ]), + get: () => Promise.resolve(undefined), + archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.resolve(), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]); + + expect(listCalls).toEqual(["/workspace"]); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); + expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]); + expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]); + expect(result.failures).toEqual([]); + await service.dispose(); + }); + + it("previews session cleanup without mutating and executes a recomputed plan", async () => { + const archivedInputs: string[] = []; + const deletedSessionIds: string[] = []; + let listAllCalls = 0; + 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(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([archived, otherArchived]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("cleanup should use archiveMany")), + archiveMany: (inputs) => { + archivedInputs.push(...inputs.map((input) => input.sessionId)); + return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), + deleteArchivedMany: (sessionIds) => { + deletedSessionIds.push(...sessionIds); + return Promise.resolve([...sessionIds]); + }, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => { + listAllCalls += 1; + return Promise.resolve([ + listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), + listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), + ]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); + expect(archivedInputs).toEqual([]); + expect(deletedSessionIds).toEqual([]); + + const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(result.archivedSessionIds).toEqual(["execute-only"]); + expect(result.deletedSessionIds).toEqual(["archived-old"]); + expect(archivedInputs).toEqual(["execute-only"]); + expect(deletedSessionIds).toEqual(["archived-old"]); + + await service.dispose(); + }); + + it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => { + const listCalls: string[] = []; + 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(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([ + { sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, + { sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, + ]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("cleanup should use archiveMany")), + archiveMany, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), + deleteArchivedMany, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: (cwd) => { + listCalls.push(cwd); + return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]); + }, + listAll: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + + expect(listCalls).toEqual(["/old-project"]); + expect(archiveMany).toHaveBeenCalledTimes(1); + expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); + expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]); + expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]); + + await service.dispose(); + }); + + it("skips busy active sessions during cleanup execution", async () => { + 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(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + createAgentRuntime: runtimeCreator(fake.runtime), + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager("/old-project"), + list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + open: () => fakeSessionManager("/old-project"), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status("busy-open"); + const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); + + expect(result.archivedSessionIds).toEqual([]); + expect(result.skippedBusySessionIds).toEqual(["busy-open"]); + expect(archivedInputs).toEqual([]); + expect(fake.calls.abort).toBe(0); + + await service.dispose(); + }); + +}); diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts new file mode 100644 index 0000000..12afa09 --- /dev/null +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -0,0 +1,386 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +describe("PiSessionService lifecycle, listing, and reload", () => { + it("starts sessions through an injected runtime creator", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime(); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; + const service = new PiSessionService(hub, { + createAgentRuntime, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + const session = await service.start("/workspace"); + + expect(createCalls).toBe(1); + expect(fake.calls.bindExtensions).toHaveLength(1); + expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); + expect(service.activeCount()).toBe(1); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true); + expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true); + + await service.dispose(); + expect(fake.calls.abort).toBe(1); + expect(fake.calls.dispose).toBe(1); + }); + + it("reports persistence from actual session-file existence for fresh active sessions", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-")); + const sessionFile = join(dir, "new-session.jsonl"); + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("new-session", { sessionFile }); + let service: PiSessionService | undefined; + try { + service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + const session = await service.start("/workspace"); + const createdEvent = hub.globalEvents.find((event) => event.type === "session.created"); + + expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false }); + expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } }); + await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false }); + + await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8"); + + await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true }); + } finally { + await service?.dispose(); + await rm(dir, { recursive: true, force: true }); + } + }); + + it("opens legacy id-only lookups from the default session store gateway", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("legacy-session"); + const open = vi.fn(() => fakeSessionManager()); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([sessionRecord("legacy-session")]), + open, + }, + heartbeatIntervalMs: 60_000, + }); + + await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" }); + expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl"); + + await service.dispose(); + }); + + it("binds extensions again when the SDK runtime replaces the active session", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("session-1"); + const replacement = fakeRuntime("session-2"); + let rebindSession: ((session: PiAgentSession) => Promise) | undefined; + fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session }); + await rebindSession?.(replacement.session); + + expect(fake.calls.bindExtensions).toHaveLength(1); + expect(replacement.calls.bindExtensions).toHaveLength(1); + expect(service.activeCount()).toBe(1); + expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" }); + + await service.dispose(); + }); + + it("publishes extension errors reported while binding session extensions", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("extension-session", { + bindExtensions: (bindings) => { + bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" }); + return Promise.resolve(); + }, + }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + expect(hub.sessionEvents).toContainEqual({ + sessionId: "extension-session", + event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" }, + }); + const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session"); + expect(extensionErrorActivity).toMatchObject({ + type: "activity.update", + activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" }, + }); + + await service.dispose(); + }); + + it("clears stale active activity once a previously active session becomes idle", async () => { + vi.useFakeTimers(); + let service: PiSessionService | undefined; + try { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("idle-session", { + isStreaming: true, + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("idle-session")]), + heartbeatIntervalMs: 1_000, + }); + + await service.status(sessionRef("idle-session")); + hub.globalEvents.length = 0; + listener?.({ type: "agent_start" }); + + const activityPhases = () => hub.globalEvents + .filter((event) => event.type === "activity.update") + .map((event) => event.activity.phase); + expect(activityPhases()).toEqual(["active"]); + + fake.session.isStreaming = false; + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(1_000); + + expect(activityPhases()).toEqual(["active", "idle"]); + } finally { + await service?.dispose(); + vi.useRealTimers(); + } + }); + + it("publishes idle activity for SDK completion events", async () => { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("completion-session", { + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("completion-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("completion-session")); + hub.globalEvents.length = 0; + listener?.({ type: "tool_execution_end", toolName: "read", isError: false }); + + expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([ + { activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } }, + ]); + + await service.dispose(); + }); + + it("uses injected archive and session-manager gateways for listing", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), + get: () => Promise.resolve(undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([ + { ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, + { ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, + ]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ id: "active", persisted: true }); + expect(sessions[0]?.archived).toBeUndefined(); + expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" }); + + await service.dispose(); + }); + + it("lists archived records that have been moved out of the active session directory", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + 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), + archive: () => { throw new Error("archive should not be called for moved records"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ id: "active" }); + expect(sessions[0]?.archived).toBeUndefined(); + expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" }); + + await service.dispose(); + }); + + + it("runs /reload by refreshing the active runtime resources in place", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("runtime-reload-session"); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({ + type: "done", + message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.", + }); + + expect(fake.calls.reload).toBe(1); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true); + + await service.dispose(); + }); + + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { + const first = fakeRuntime("reload-session"); + const second = fakeRuntime("reload-session"); + const runtimes = [first.runtime, second.runtime]; + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = runtimes[createCalls]; + createCalls += 1; + if (runtime === undefined) throw new Error("unexpected runtime creation"); + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("reload-session")]), + heartbeatIntervalMs: 60_000, + }); + + // Open once so there is an active runtime to reload. + await service.status(sessionRef("reload-session")); + expect(createCalls).toBe(1); + + await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); + + // The original runtime was torn down and a fresh one opened from disk. + expect(first.calls.abort).toBe(1); + expect(first.calls.dispose).toBe(1); + expect(createCalls).toBe(2); + expect(service.activeCount()).toBe(1); + + await service.dispose(); + }); + + 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(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("busy-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); + expect(fake.calls.abort).toBe(0); + expect(fake.calls.dispose).toBe(0); + + await service.dispose(); + }); + + it("refuses to reload an archived session", async () => { + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) + ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } + : undefined), + archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(true), + }, + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); + + await service.dispose(); + }); + + it("reconciles workspace activity when listing only archived sessions", async () => { + const reconciliations: { cwd: string; sessionIds: string[] }[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + 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), + archive: () => { throw new Error("archive should not be called for moved records"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + workspaceActivity: { + applySessionStatus: () => undefined, + applySessionActivity: () => undefined, + removeSession: () => undefined, + reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); }, + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id: "archived", archived: true }); + expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]); + + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts new file mode 100644 index 0000000..ecad5cc --- /dev/null +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -0,0 +1,299 @@ +import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { PiSessionService } from "./piSessionService.js"; +import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; + +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(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("prompt-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("prompt-session"), "Build the thing"); + + expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); + await service.dispose(); + }); + + it("echoes the user message for direct prompts but not command-forwarded ones", async () => { + const fake = fakeRuntime("echo-session", { + resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) }, + }); + const hub = new CapturingSessionEventHub(); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("echo-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("echo-session"), "Build the thing"); + expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); + + // The client optimistically renders command-forwarded prompts (e.g. /skill:*), + // so the server must not publish a second copy via message.append. + await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator"); + expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); + expect(fake.calls.prompt).toEqual([ + { text: "Build the thing", options: undefined }, + { text: "/skill:skill-creator", options: undefined }, + ]); + + await service.dispose(); + }); + + it("rejects malformed prompt text before opening the runtime", async () => { + const fake = fakeRuntime("prompt-session"); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([sessionRecord("prompt-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); + + expect(createCalls).toBe(0); + expect(fake.calls.prompt).toEqual([]); + await service.dispose(); + }); + + it("generates a session name for the first prompt via the session's agent.streamFn", async () => { + const model = testModel(); + const streamCalls: unknown[] = []; + const streamFn: StreamFn = (streamModel, context, options) => { + streamCalls.push({ streamModel, context, options }); + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Fix login bug" }], + api: "anthropic-messages", + provider: "anthropic", + model: model.id, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("name-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("name-session"), "Please fix the login bug"); + await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); }); + + expect(streamCalls).toHaveLength(1); + expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true); + await service.dispose(); + }); + + it("includes queued message details in session status", async () => { + const fake = fakeRuntime("status-session", { + messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }], + pendingMessageCount: 2, + getSteeringMessages: () => ["adjust this turn"], + getFollowUpMessages: () => ["then do this"], + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("status-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({ + pendingMessageCount: 2, + queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }], + messageCount: 2, + }); + await service.dispose(); + }); + + it("does not enqueue duplicate queued message text", async () => { + const fake = fakeRuntime("dedupe-session", { + isStreaming: true, + pendingMessageCount: 1, + getFollowUpMessages: () => ["already queued"], + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("dedupe-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp"); + + expect(fake.calls.prompt).toEqual([]); + await service.dispose(); + }); + + it("does not append queued prompts to the transcript before delivery", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("queued-session", { isStreaming: true }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("queued-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp"); + + expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); + await service.dispose(); + }); + + it("holds prompts sent during compaction until compaction finishes", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("compacting-session", { isCompacting: true }); + let resolveFirstPrompt: (() => void) | undefined; + fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => { + fake.calls.prompt.push({ text, options }); + if (options === undefined) { + fake.session.isStreaming = true; + return new Promise((resolve) => { resolveFirstPrompt = resolve; }); + } + return Promise.resolve(); + }; + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("compacting-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp"); + await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp"); + + expect(fake.calls.prompt).toEqual([]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 2, + queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }], + }); + + fake.session.isCompacting = false; + fake.emit({ type: "compaction_end" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 1, + queuedMessages: [{ kind: "followUp", text: "Then task 2" }], + }); + + fake.emit({ type: "agent_start" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([ + { text: "Start task 1", options: undefined }, + { text: "Then task 2", options: { streamingBehavior: "followUp" } }, + ]); + await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ + pendingMessageCount: 0, + queuedMessages: [], + }); + resolveFirstPrompt?.(); + await service.dispose(); + }); + + it("clears queued messages when aborting active work", async () => { + const fake = fakeRuntime("abort-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("abort-session")); + await service.abort(sessionRef("abort-session")); + + expect(fake.calls.clearQueue).toBe(1); + expect(fake.calls.abort).toBe(1); + await service.dispose(); + }); + + 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(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp"); + await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 }); + await service.abort(sessionRef("abort-compaction-session")); + + expect(fake.calls.clearQueue).toBe(1); + expect(fake.calls.prompt).toEqual([]); + await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] }); + await service.dispose(); + }); + + it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { + const hub = new CapturingSessionEventHub(); + const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); + const modelRegistry = ModelRegistry.inMemory(authStorage); + const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022"); + if (model === undefined) throw new Error("Expected Anthropic model fixture"); + const fake = fakeRuntime("auth-session", { model, modelRegistry }); + + const service = new PiSessionService(hub, { + modelRegistry, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("auth-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("auth-session")); + hub.sessionEvents.length = 0; + hub.globalEvents.length = 0; + + authStorage.logout("anthropic"); + service.applyAuthChange({ removedProviderId: "anthropic" }); + service.applyAuthChange({ removedProviderId: "anthropic" }); + + const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length; + expect(warningCount()).toBe(1); + expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); + + authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); + service.applyAuthChange(); + authStorage.logout("anthropic"); + service.applyAuthChange({ removedProviderId: "anthropic" }); + expect(warningCount()).toBe(2); + + await service.dispose(); + }); + + it("clears queued messages when stopping a session runtime", async () => { + const fake = fakeRuntime("stop-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("stop-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("stop-session")); + service.stop(sessionRef("stop-session")); + + expect(fake.calls.clearQueue).toBe(1); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts new file mode 100644 index 0000000..8f3e51d --- /dev/null +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +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"; + +describe("PiSessionService", () => { + describe("spawnSession", () => { + function spawnService(decision: SpawnTargetDecision) { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + const log: { details: Record; message: string }[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, + logger: { info: (details, message) => { log.push({ details, message }); } }, + heartbeatIntervalMs: 60_000, + }); + return { fake, service, log }; + } + + it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => { + const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" }); + + const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" }); + expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]); + expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]); + await service.dispose(); + }); + + it("uses the dispatching session's model as the spawned session's initial model", async () => { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + const model = testModel(); + let initialModel: PiAgentSession["model"]; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModel = options.initialModel; + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); + + expect(initialModel).toBe(model); + await service.dispose(); + }); + + it("rejects an out-of-project target without starting a session", async () => { + const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" })) + .rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace"); + expect(fake.calls.prompt).toEqual([]); + expect(service.activeCount()).toBe(0); + await service.dispose(); + }); + + it("rejects when the spawning session is not in a registered project", async () => { + const { service } = spawnService({ allowed: false, reason: "not-registered" }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning session is not in a registered project"); + await service.dispose(); + }); + + it("is disabled when no spawn target resolver is configured", async () => { + const fake = fakeRuntime("spawned-x"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning sessions is disabled"); + await service.dispose(); + }); + }); +}); diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts new file mode 100644 index 0000000..b695529 --- /dev/null +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -0,0 +1,844 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +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"; + +describe("PiSessionService", () => { + describe("spawnSubsession", () => { + function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); + const created = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async () => { + await Promise.resolve(); + const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime; + index += 1; + return runtime; + }; + const archived = new Map(); + const archiveStore = { + list: () => Promise.resolve([...archived.values()]), + get: (sessionId: string) => Promise.resolve(archived.get(sessionId)), + archive: (input: { sessionId: string; cwd: string }) => { + const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" }; + archived.set(input.sessionId, record); + return Promise.resolve(record); + }, + restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); }, + isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore, + spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, + heartbeatIntervalMs, + }); + return { parent, child, service }; + } + + it("records the parent, delivers the prompt, and lists the tracked child", async () => { + const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); // bring the parent online so it can be notified + + const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" }); + expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + await service.dispose(); + }); + + it("uses the parent session's model as the tracked child's initial model", async () => { + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); + const model = testModel(); + const initialModels: PiAgentSession["model"][] = []; + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModels.push(options.initialModel); + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); + + expect(initialModels).toEqual([undefined, model]); + await service.dispose(); + }); + + it("persists tracked child links in the parent and child sessions", async () => { + const parentPersisted: { customType: string; data?: unknown }[] = []; + const childPersisted: { customType: string; data?: unknown }[] = []; + const parent = fakeRuntime("parent-1", { + sessionFile: "/tmp/parent-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + appendCustomEntry: (customType, data) => { + parentPersisted.push({ customType, data }); + return "parent-entry-1"; + }, + }), + }); + const child = fakeRuntime("child-1", { + sessionFile: "/tmp/child-1.jsonl", + sessionManager: fakeSessionManager("/workspace-feature", { + appendCustomEntry: (customType, data) => { + childPersisted.push({ customType, data }); + return "child-entry-1"; + }, + }), + }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(parentPersisted).toEqual([ + { + customType: "pi-web.subsession.link", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" }, + }, + ]); + expect(childPersisted).toEqual([ + { + customType: "pi-web.subsession.spawned", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" }, + }, + ]); + await service.dispose(); + }); + + it("hydrates persisted child links after a service restart so the parent can inspect them", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }], + }); + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "finished", + messageCount: 1, + }); + expect(open).toHaveBeenCalledWith(childFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("ignores stale persisted child links when the child no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not hydrate persisted links when the exact child file is unavailable", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not hydrate parent links without a child file", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not invent subsession links from existing child session headers", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile }; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not hydrate copied parent links when the opened parent has a different id", async () => { + const forkedParent = fakeRuntime("parent-fork-1", { + sessionFile: "/sessions/parent-fork-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(forkedParent.runtime), + sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("relinks a spawned child when the child session is opened after restart", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("notifies the validated parent file instead of an active prefix-matched parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const forkParentFile = join(tempDir, "parent-fork.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const forkManager = fakeSessionManager("/workspace"); + const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [fork.runtime, child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => { + if (path === parentFile) return parentManager; + if (path === forkParentFile) return forkManager; + return childManager; + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => forkManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("parent-1-fork", "/workspace")); + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(fork.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the verified child file instead of an active copied child with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const copiedManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }], + }); + const originalManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true }); + const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime); + if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === copiedChildFile) return copiedManager; + if (path === originalChildFile) return originalManager; + if (path === parentFile) return parentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => parentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + + copiedChild.session.isStreaming = true; + copiedChild.emit({ type: "agent_start" }); + copiedChild.session.isStreaming = false; + copiedChild.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "original child result", + messageCount: 1, + }); + const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile); + expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" }); + expect(open).toHaveBeenCalledWith(originalChildFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the verified parent file instead of an active copied parent with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const copiedParentFile = join(tempDir, "copied-parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === childManager) return Promise.resolve(child.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === childFile) return childManager; + if (path === parentFile) return parentManager; + if (path === copiedParentFile) return copiedParentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => copiedParentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.status(sessionRef("parent-1", "/workspace")); + + await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]); + await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions"); + await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions"); + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(copiedParent.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a child marker when the current child file header no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: { + ...emptyArchiveStore(), + get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a child marker when the child header points at a different parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); + const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); + const actualParentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: mismatchedParentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]), + listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(actualParentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink copied child markers when the opened child has a different id", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childFile = "/sessions/child-fork-1.jsonl"; + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(child.runtime), + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-fork-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(open).not.toHaveBeenCalledWith(parentFile); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("notifies the parent once when the tracked child stops working", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); // arm the notification + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); // fire once + child.emit({ type: "turn_end" }); // must not re-notify + await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion"); + expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); + expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message + await service.dispose(); + }); + + it("notifies via the heartbeat when the child settles without a further event", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + parent.calls.prompt.length = 0; + + // The child works, then settles silently: agent_end arrives while it still + // reports active work, so the event-driven latch does not fire here. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.emit({ type: "agent_end" }); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + // Once the session settles, the periodic heartbeat re-check notifies. + child.session.isStreaming = false; + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + await service.dispose(); + }); + + it("does not notify the parent when a tracked child is archived", async () => { + const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + // Arm the notification, as a real working child would. + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + parent.calls.sendCustomMessage.length = 0; + + await service.archive("child-1"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + }); + + it("reports a missing tracked child file as unknown in the subsession list", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await service.archive("child-1"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" }, + ]); + await service.dispose(); + }); + + it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => { + const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); + + await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions"); + await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions"); + await service.dispose(); + }); + + it("is disabled when no spawn target resolver is configured", async () => { + const fake = fakeRuntime("nope"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([]), + heartbeatIntervalMs: 60_000, + }); + await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined })) + .rejects.toThrow("Spawning sessions is disabled"); + await service.dispose(); + }); + }); +}); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts deleted file mode 100644 index f9b8f93..0000000 --- a/src/server/sessions/piSessionService.test.ts +++ /dev/null @@ -1,2105 +0,0 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; -import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; -import { SessionEventHub } from "../realtime/sessionEventHub.js"; -import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js"; -import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; - -class CapturingSessionEventHub extends SessionEventHub { - readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; - readonly globalEvents: GlobalSessionEvent[] = []; - - override publish(sessionId: string, event: SessionUiEvent): void { - this.sessionEvents.push({ sessionId, event }); - } - - override publishGlobal(event: GlobalSessionEvent): void { - this.globalEvents.push(event); - } -} - -type SessionGateway = NonNullable; -type RuntimeCreator = NonNullable; - -interface TestSession extends PiAgentSession { - sessionName: string | undefined; - model: PiAgentSession["model"]; - isStreaming: boolean; - isCompacting: boolean; - isBashRunning: boolean; - pendingMessageCount: number; - getSteeringMessages: () => readonly string[]; - getFollowUpMessages: () => readonly string[]; -} - -function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { - return { - getCwd: () => cwd, - getBranch: () => [], - getLeafId: () => "leaf-1", - ...patch, - }; -} - -function sessionRecord(id: string, cwd = "/workspace") { - return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }; -} - -function sessionRef(id: string, cwd = "/workspace") { - return { id, cwd }; -} - -function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022"); - if (model === undefined) throw new Error("test model not found"); - return model; -} - -function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { - const promptCalls: { text: string; options: unknown }[] = []; - const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; - const bindExtensionCalls: unknown[] = []; - const listeners: ((event: unknown) => void)[] = []; - const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; - const session: TestSession = { - sessionId, - sessionFile: `/tmp/${sessionId}.jsonl`, - messages: [], - sessionName: undefined, - model: undefined, - thinkingLevel: "off", - isStreaming: false, - isCompacting: false, - isBashRunning: false, - pendingMessageCount: 0, - sessionManager: fakeSessionManager(), - modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), - scopedModels: [], - extensionRunner: { getRegisteredCommands: () => [] }, - promptTemplates: [], - resourceLoader: { getSkills: () => ({ skills: [] }) }, - subscribe: (listener: (event: unknown) => void) => { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index !== -1) listeners.splice(index, 1); - }; - }, - bindExtensions: (bindings: unknown) => { - calls.bindExtensions.push(bindings); - return Promise.resolve(); - }, - getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), - getContextUsage: () => undefined, - reload: () => { - calls.reload += 1; - return Promise.resolve(); - }, - prompt: (text: string, options: unknown) => { - calls.prompt.push({ text, options }); - return Promise.resolve(); - }, - sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => { - calls.sendCustomMessage.push({ message, options }); - return Promise.resolve(); - }, - executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), - abort: () => { - calls.abort += 1; - return Promise.resolve(); - }, - clearQueue: () => { - calls.clearQueue += 1; - return { steering: [], followUp: [] }; - }, - getSteeringMessages: () => [], - getFollowUpMessages: () => [], - setModel: () => Promise.resolve(), - cycleModel: () => Promise.resolve(undefined), - getAvailableThinkingLevels: () => [], - setThinkingLevel: () => undefined, - cycleThinkingLevel: () => undefined, - setSessionName: (name: string) => { session.sessionName = name; }, - compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), - getUserMessagesForForking: () => [], - agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, - ...patch, - }; - const runtime: PiSessionRuntime = { - cwd: session.sessionManager.getCwd(), - session, - setRebindSession: () => undefined, - fork: () => Promise.resolve({ cancelled: false }), - dispose: () => { - calls.dispose += 1; - return Promise.resolve(); - }, - }; - return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } }; -} - -function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { - return async () => { - await Promise.resolve(); - return runtime; - }; -} - -function sessionGateway(records: ReturnType[]): SessionGateway { - return { - create: () => fakeSessionManager(), - list: () => Promise.resolve(records), - open: () => fakeSessionManager(), - }; -} - -function emptyArchiveStore(): NonNullable { - return { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("archive should not be called")), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }; -} - -describe("PiSessionService", () => { - it("starts sessions through an injected runtime creator", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime(); - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - createCalls += 1; - await Promise.resolve(); - return fake.runtime; - }; - const service = new PiSessionService(hub, { - createAgentRuntime, - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - const session = await service.start("/workspace"); - - expect(createCalls).toBe(1); - expect(fake.calls.bindExtensions).toHaveLength(1); - expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); - expect(service.activeCount()).toBe(1); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true); - expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true); - - await service.dispose(); - expect(fake.calls.abort).toBe(1); - expect(fake.calls.dispose).toBe(1); - }); - - it("reports persistence from actual session-file existence for fresh active sessions", async () => { - const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-")); - const sessionFile = join(dir, "new-session.jsonl"); - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("new-session", { sessionFile }); - let service: PiSessionService | undefined; - try { - service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - const session = await service.start("/workspace"); - const createdEvent = hub.globalEvents.find((event) => event.type === "session.created"); - - expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false }); - expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } }); - await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false }); - - await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8"); - - await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true }); - } finally { - await service?.dispose(); - await rm(dir, { recursive: true, force: true }); - } - }); - - it("opens legacy id-only lookups from the default session store gateway", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("legacy-session"); - const open = vi.fn(() => fakeSessionManager()); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => Promise.resolve([sessionRecord("legacy-session")]), - open, - }, - heartbeatIntervalMs: 60_000, - }); - - await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" }); - expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl"); - - await service.dispose(); - }); - - it("binds extensions again when the SDK runtime replaces the active session", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("session-1"); - const replacement = fakeRuntime("session-2"); - let rebindSession: ((session: PiAgentSession) => Promise) | undefined; - fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session }); - await rebindSession?.(replacement.session); - - expect(fake.calls.bindExtensions).toHaveLength(1); - expect(replacement.calls.bindExtensions).toHaveLength(1); - expect(service.activeCount()).toBe(1); - expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" }); - - await service.dispose(); - }); - - it("publishes extension errors reported while binding session extensions", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("extension-session", { - bindExtensions: (bindings) => { - bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" }); - return Promise.resolve(); - }, - }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - expect(hub.sessionEvents).toContainEqual({ - sessionId: "extension-session", - event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" }, - }); - const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session"); - expect(extensionErrorActivity).toMatchObject({ - type: "activity.update", - activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" }, - }); - - await service.dispose(); - }); - - it("clears stale active activity once a previously active session becomes idle", async () => { - vi.useFakeTimers(); - let service: PiSessionService | undefined; - try { - const hub = new CapturingSessionEventHub(); - let listener: ((event: unknown) => void) | undefined; - const fake = fakeRuntime("idle-session", { - isStreaming: true, - subscribe: (next) => { - listener = next; - return () => undefined; - }, - }); - service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("idle-session")]), - heartbeatIntervalMs: 1_000, - }); - - await service.status(sessionRef("idle-session")); - hub.globalEvents.length = 0; - listener?.({ type: "agent_start" }); - - const activityPhases = () => hub.globalEvents - .filter((event) => event.type === "activity.update") - .map((event) => event.activity.phase); - expect(activityPhases()).toEqual(["active"]); - - fake.session.isStreaming = false; - await vi.advanceTimersByTimeAsync(1_000); - await vi.advanceTimersByTimeAsync(1_000); - - expect(activityPhases()).toEqual(["active", "idle"]); - } finally { - await service?.dispose(); - vi.useRealTimers(); - } - }); - - it("publishes idle activity for SDK completion events", async () => { - const hub = new CapturingSessionEventHub(); - let listener: ((event: unknown) => void) | undefined; - const fake = fakeRuntime("completion-session", { - subscribe: (next) => { - listener = next; - return () => undefined; - }, - }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("completion-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("completion-session")); - hub.globalEvents.length = 0; - listener?.({ type: "tool_execution_end", toolName: "read", isError: false }); - - expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([ - { activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } }, - ]); - - await service.dispose(); - }); - - it("uses injected archive and session-manager gateways for listing", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), - get: () => Promise.resolve(undefined), - archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([ - { ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, - { ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, - ]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - expect(sessions).toHaveLength(2); - expect(sessions[0]).toMatchObject({ id: "active", persisted: true }); - expect(sessions[0]?.archived).toBeUndefined(); - expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" }); - - await service.dispose(); - }); - - it("lists archived records that have been moved out of the active session directory", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - 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), - archive: () => { throw new Error("archive should not be called for moved records"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - - expect(sessions).toHaveLength(2); - expect(sessions[0]).toMatchObject({ id: "active" }); - expect(sessions[0]?.archived).toBeUndefined(); - expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" }); - - await service.dispose(); - }); - - it("archives a session subtree within the root workspace", async () => { - const archivedInputs: string[] = []; - const root = sessionRecord("root"); - const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path }; - const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path }; - const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; - 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(), { - 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 }]), - get: () => Promise.resolve(undefined), - archive: (input) => { - archivedInputs.push(input.sessionId); - return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({ - archived: true, - sessionIds: ["root", "direct-child", "grandchild"], - archivedCount: 3, - skippedAlreadyArchivedCount: 1, - }); - expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); - - await service.dispose(); - }); - - it("permanently deletes archived sessions through the archive store", async () => { - const deletedSessionIds: string[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([]), - get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) - ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } - : undefined), - archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: (sessionId) => { - deletedSessionIds.push(sessionId); - return Promise.resolve(); - }, - }, - sessionManager: sessionGateway([sessionRecord("active")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.deleteArchived("arch")).resolves.toBeUndefined(); - await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); - - expect(deletedSessionIds).toEqual(["archived"]); - await service.dispose(); - }); - - it("bulk archives inactive sessions by cwd without opening runtimes", async () => { - const recordsByCwd = new Map([ - ["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]], - ["/two", [sessionRecord("c", "/two")]], - ]); - const listCalls: string[] = []; - 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(), { - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve(recordsByCwd.get(cwd) ?? []); - }, - open, - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]); - - expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] }); - expect(listCalls).toEqual(["/one", "/two"]); - expect(open).not.toHaveBeenCalled(); - expect(archiveMany).toHaveBeenCalledTimes(1); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); - await service.dispose(); - }); - - it("bulk archive reports per-session failures without aborting other archives", async () => { - const busy = fakeRuntime("busy", { isStreaming: true }); - 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(), { - createAgentRuntime: () => { - createCalls += 1; - return Promise.resolve(busy.runtime); - }, - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("busy")); - const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]); - - expect(createCalls).toBe(1); - expect(busy.calls.abort).toBe(0); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]); - expect(result.archivedSessionIds).toEqual(["ok"]); - expect(result.failures).toEqual([ - { sessionId: "busy", error: "Stop current session activity before archiving" }, - { sessionId: "missing", error: "Session not found" }, - ]); - await service.dispose(); - }); - - it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => { - const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; - const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; - const busy = fakeRuntime("busy-archived", { isStreaming: true }); - const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(busy.runtime), - archiveStore: { - list: () => Promise.resolve([busyRecord, idleRecord]), - get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), - archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.resolve(), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([sessionRecord("unarchived")]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("busy-archived")); - const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]); - - expect(busy.calls.abort).toBe(0); - expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]); - expect(result.deletedSessionIds).toEqual(["idle-archived"]); - expect(result.failures).toEqual([ - { sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }, - { sessionId: "unarchived", error: "Archived session not found" }, - ]); - await service.dispose(); - }); - - it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => { - 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", archivePath: `/archive/${input.sessionId}.jsonl` })))); - const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); - const listCalls: string[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([ - { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, - { sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, - { sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" }, - ]), - get: () => Promise.resolve(undefined), - archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.resolve(), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]); - }, - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]); - - expect(listCalls).toEqual(["/workspace"]); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); - expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]); - expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]); - expect(result.failures).toEqual([]); - await service.dispose(); - }); - - it("previews session cleanup without mutating and executes a recomputed plan", async () => { - const archivedInputs: string[] = []; - const deletedSessionIds: string[] = []; - let listAllCalls = 0; - 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(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - archiveStore: { - list: () => Promise.resolve([archived, otherArchived]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("cleanup should use archiveMany")), - archiveMany: (inputs) => { - archivedInputs.push(...inputs.map((input) => input.sessionId)); - return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), - deleteArchivedMany: (sessionIds) => { - deletedSessionIds.push(...sessionIds); - return Promise.resolve([...sessionIds]); - }, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => { - listAllCalls += 1; - return Promise.resolve([ - listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), - listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), - ]); - }, - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); - expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); - expect(archivedInputs).toEqual([]); - expect(deletedSessionIds).toEqual([]); - - const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - expect(result.archivedSessionIds).toEqual(["execute-only"]); - expect(result.deletedSessionIds).toEqual(["archived-old"]); - expect(archivedInputs).toEqual(["execute-only"]); - expect(deletedSessionIds).toEqual(["archived-old"]); - - await service.dispose(); - }); - - it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => { - const listCalls: string[] = []; - 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(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - archiveStore: { - list: () => Promise.resolve([ - { sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, - { sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, - ]), - get: () => Promise.resolve(undefined), - archive: () => Promise.reject(new Error("cleanup should use archiveMany")), - archiveMany, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), - deleteArchivedMany, - }, - sessionManager: { - create: () => fakeSessionManager(), - list: (cwd) => { - listCalls.push(cwd); - return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]); - }, - listAll: () => Promise.resolve([]), - open: () => fakeSessionManager(), - }, - heartbeatIntervalMs: 60_000, - }); - - const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); - - expect(listCalls).toEqual(["/old-project"]); - expect(archiveMany).toHaveBeenCalledTimes(1); - expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); - expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]); - expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]); - - await service.dispose(); - }); - - it("skips busy active sessions during cleanup execution", async () => { - 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(), { - now: () => new Date("2026-06-25T00:00:00.000Z"), - createAgentRuntime: runtimeCreator(fake.runtime), - archiveStore: { - list: () => Promise.resolve([]), - get: () => Promise.resolve(undefined), - archive: (input) => { - archivedInputs.push(input.sessionId); - return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); - }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager("/old-project"), - list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), - listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), - open: () => fakeSessionManager("/old-project"), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status("busy-open"); - const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); - - expect(result.archivedSessionIds).toEqual([]); - expect(result.skippedBusySessionIds).toEqual(["busy-open"]); - expect(archivedInputs).toEqual([]); - expect(fake.calls.abort).toBe(0); - - await service.dispose(); - }); - - it("runs /reload by refreshing the active runtime resources in place", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("runtime-reload-session"); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({ - type: "done", - message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.", - }); - - expect(fake.calls.reload).toBe(1); - expect(fake.calls.abort).toBe(0); - expect(fake.calls.dispose).toBe(0); - expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true); - - await service.dispose(); - }); - - it("reloads a session by closing the active runtime and re-opening it from disk", async () => { - const first = fakeRuntime("reload-session"); - const second = fakeRuntime("reload-session"); - const runtimes = [first.runtime, second.runtime]; - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - await Promise.resolve(); - const runtime = runtimes[createCalls]; - createCalls += 1; - if (runtime === undefined) throw new Error("unexpected runtime creation"); - return runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([sessionRecord("reload-session")]), - heartbeatIntervalMs: 60_000, - }); - - // Open once so there is an active runtime to reload. - await service.status(sessionRef("reload-session")); - expect(createCalls).toBe(1); - - await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined(); - - // The original runtime was torn down and a fresh one opened from disk. - expect(first.calls.abort).toBe(1); - expect(first.calls.dispose).toBe(1); - expect(createCalls).toBe(2); - expect(service.activeCount()).toBe(1); - - await service.dispose(); - }); - - 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(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("busy-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading"); - expect(fake.calls.abort).toBe(0); - expect(fake.calls.dispose).toBe(0); - - await service.dispose(); - }); - - it("refuses to reload an archived session", async () => { - const service = new PiSessionService(new CapturingSessionEventHub(), { - archiveStore: { - list: () => Promise.resolve([]), - get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) - ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } - : undefined), - archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }), - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(true), - }, - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only"); - - await service.dispose(); - }); - - it("reconciles workspace activity when listing only archived sessions", async () => { - const reconciliations: { cwd: string; sessionIds: string[] }[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - 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), - archive: () => { throw new Error("archive should not be called for moved records"); }, - restore: () => Promise.resolve(), - isArchived: () => Promise.resolve(false), - }, - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - open: () => fakeSessionManager(), - }, - workspaceActivity: { - applySessionStatus: () => undefined, - applySessionActivity: () => undefined, - removeSession: () => undefined, - reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); }, - }, - heartbeatIntervalMs: 60_000, - }); - - const sessions = await service.list("/workspace"); - - expect(sessions).toHaveLength(1); - expect(sessions[0]).toMatchObject({ id: "archived", archived: true }); - expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]); - - await service.dispose(); - }); - - it("sends prompts to an injected runtime without touching the SDK runtime", async () => { - const fake = fakeRuntime("prompt-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("prompt-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("prompt-session"), "Build the thing"); - - expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); - await service.dispose(); - }); - - it("echoes the user message for direct prompts but not command-forwarded ones", async () => { - const fake = fakeRuntime("echo-session", { - resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) }, - }); - const hub = new CapturingSessionEventHub(); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("echo-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("echo-session"), "Build the thing"); - expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); - - // The client optimistically renders command-forwarded prompts (e.g. /skill:*), - // so the server must not publish a second copy via message.append. - await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator"); - expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1); - expect(fake.calls.prompt).toEqual([ - { text: "Build the thing", options: undefined }, - { text: "/skill:skill-creator", options: undefined }, - ]); - - await service.dispose(); - }); - - it("rejects malformed prompt text before opening the runtime", async () => { - const fake = fakeRuntime("prompt-session"); - let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { - createCalls += 1; - await Promise.resolve(); - return fake.runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([sessionRecord("prompt-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); - - expect(createCalls).toBe(0); - expect(fake.calls.prompt).toEqual([]); - await service.dispose(); - }); - - it("generates a session name for the first prompt via the session's agent.streamFn", async () => { - const model = testModel(); - const streamCalls: unknown[] = []; - const streamFn: StreamFn = (streamModel, context, options) => { - streamCalls.push({ streamModel, context, options }); - const stream = createAssistantMessageEventStream(); - const message: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "Fix login bug" }], - api: "anthropic-messages", - provider: "anthropic", - model: model.id, - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, - stopReason: "stop", - timestamp: Date.now(), - }; - stream.push({ type: "done", reason: "stop", message }); - stream.end(message); - return stream; - }; - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("name-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("name-session"), "Please fix the login bug"); - await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); }); - - expect(streamCalls).toHaveLength(1); - expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true); - await service.dispose(); - }); - - it("includes queued message details in session status", async () => { - const fake = fakeRuntime("status-session", { - messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }], - pendingMessageCount: 2, - getSteeringMessages: () => ["adjust this turn"], - getFollowUpMessages: () => ["then do this"], - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("status-session")]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({ - pendingMessageCount: 2, - queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }], - messageCount: 2, - }); - await service.dispose(); - }); - - it("does not enqueue duplicate queued message text", async () => { - const fake = fakeRuntime("dedupe-session", { - isStreaming: true, - pendingMessageCount: 1, - getFollowUpMessages: () => ["already queued"], - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("dedupe-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp"); - - expect(fake.calls.prompt).toEqual([]); - await service.dispose(); - }); - - it("does not append queued prompts to the transcript before delivery", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("queued-session", { isStreaming: true }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("queued-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp"); - - expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); - await service.dispose(); - }); - - it("holds prompts sent during compaction until compaction finishes", async () => { - const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("compacting-session", { isCompacting: true }); - let resolveFirstPrompt: (() => void) | undefined; - fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => { - fake.calls.prompt.push({ text, options }); - if (options === undefined) { - fake.session.isStreaming = true; - return new Promise((resolve) => { resolveFirstPrompt = resolve; }); - } - return Promise.resolve(); - }; - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("compacting-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp"); - await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp"); - - expect(fake.calls.prompt).toEqual([]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 2, - queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }], - }); - - fake.session.isCompacting = false; - fake.emit({ type: "compaction_end" }); - await new Promise((resolve) => setTimeout(resolve, 5)); - - expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]); - expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 1, - queuedMessages: [{ kind: "followUp", text: "Then task 2" }], - }); - - fake.emit({ type: "agent_start" }); - await new Promise((resolve) => setTimeout(resolve, 5)); - - expect(fake.calls.prompt).toEqual([ - { text: "Start task 1", options: undefined }, - { text: "Then task 2", options: { streamingBehavior: "followUp" } }, - ]); - await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({ - pendingMessageCount: 0, - queuedMessages: [], - }); - resolveFirstPrompt?.(); - await service.dispose(); - }); - - it("clears queued messages when aborting active work", async () => { - const fake = fakeRuntime("abort-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("abort-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("abort-session")); - await service.abort(sessionRef("abort-session")); - - expect(fake.calls.clearQueue).toBe(1); - expect(fake.calls.abort).toBe(1); - await service.dispose(); - }); - - 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(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp"); - await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 }); - await service.abort(sessionRef("abort-compaction-session")); - - expect(fake.calls.clearQueue).toBe(1); - expect(fake.calls.prompt).toEqual([]); - await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] }); - await service.dispose(); - }); - - it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { - const hub = new CapturingSessionEventHub(); - const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022"); - if (model === undefined) throw new Error("Expected Anthropic model fixture"); - const fake = fakeRuntime("auth-session", { model, modelRegistry }); - - const service = new PiSessionService(hub, { - modelRegistry, - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("auth-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("auth-session")); - hub.sessionEvents.length = 0; - hub.globalEvents.length = 0; - - authStorage.logout("anthropic"); - service.applyAuthChange({ removedProviderId: "anthropic" }); - service.applyAuthChange({ removedProviderId: "anthropic" }); - - const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length; - expect(warningCount()).toBe(1); - expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); - - authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); - service.applyAuthChange(); - authStorage.logout("anthropic"); - service.applyAuthChange({ removedProviderId: "anthropic" }); - expect(warningCount()).toBe(2); - - await service.dispose(); - }); - - it("clears queued messages when stopping a session runtime", async () => { - const fake = fakeRuntime("stop-session"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([sessionRecord("stop-session")]), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("stop-session")); - service.stop(sessionRef("stop-session")); - - expect(fake.calls.clearQueue).toBe(1); - await service.dispose(); - }); - - describe("spawnSession", () => { - function spawnService(decision: SpawnTargetDecision) { - const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); - const log: { details: Record; message: string }[] = []; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, - logger: { info: (details, message) => { log.push({ details, message }); } }, - heartbeatIntervalMs: 60_000, - }); - return { fake, service, log }; - } - - it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => { - const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" }); - - const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" }); - - expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" }); - expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]); - expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]); - await service.dispose(); - }); - - it("uses the dispatching session's model as the spawned session's initial model", async () => { - const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); - const model = testModel(); - let initialModel: PiAgentSession["model"]; - const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { - await Promise.resolve(); - initialModel = options.initialModel; - return fake.runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); - - expect(initialModel).toBe(model); - await service.dispose(); - }); - - it("rejects an out-of-project target without starting a session", async () => { - const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" })) - .rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace"); - expect(fake.calls.prompt).toEqual([]); - expect(service.activeCount()).toBe(0); - await service.dispose(); - }); - - it("rejects when the spawning session is not in a registered project", async () => { - const { service } = spawnService({ allowed: false, reason: "not-registered" }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning session is not in a registered project"); - await service.dispose(); - }); - - it("is disabled when no spawn target resolver is configured", async () => { - const fake = fakeRuntime("spawned-x"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning sessions is disabled"); - await service.dispose(); - }); - }); - - describe("spawnSubsession", () => { - function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { - const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); - const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); - const created = [parent.runtime, child.runtime]; - let index = 0; - const createAgentRuntime: RuntimeCreator = async () => { - await Promise.resolve(); - const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime; - index += 1; - return runtime; - }; - const archived = new Map(); - const archiveStore = { - list: () => Promise.resolve([...archived.values()]), - get: (sessionId: string) => Promise.resolve(archived.get(sessionId)), - archive: (input: { sessionId: string; cwd: string }) => { - const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" }; - archived.set(input.sessionId, record); - return Promise.resolve(record); - }, - restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); }, - isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - archiveStore, - spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, - heartbeatIntervalMs, - }); - return { parent, child, service }; - } - - it("records the parent, delivers the prompt, and lists the tracked child", async () => { - const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); // bring the parent online so it can be notified - - const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); - - expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" }); - expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]); - await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, - ]); - await service.dispose(); - }); - - it("uses the parent session's model as the tracked child's initial model", async () => { - const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); - const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); - const model = testModel(); - const initialModels: PiAgentSession["model"][] = []; - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { - await Promise.resolve(); - initialModels.push(options.initialModel); - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return runtime; - }; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: sessionGateway([]), - archiveStore: emptyArchiveStore(), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); - - expect(initialModels).toEqual([undefined, model]); - await service.dispose(); - }); - - it("persists tracked child links in the parent and child sessions", async () => { - const parentPersisted: { customType: string; data?: unknown }[] = []; - const childPersisted: { customType: string; data?: unknown }[] = []; - const parent = fakeRuntime("parent-1", { - sessionFile: "/tmp/parent-1.jsonl", - sessionManager: fakeSessionManager("/workspace", { - appendCustomEntry: (customType, data) => { - parentPersisted.push({ customType, data }); - return "parent-entry-1"; - }, - }), - }); - const child = fakeRuntime("child-1", { - sessionFile: "/tmp/child-1.jsonl", - sessionManager: fakeSessionManager("/workspace-feature", { - appendCustomEntry: (customType, data) => { - childPersisted.push({ customType, data }); - return "child-entry-1"; - }, - }), - }); - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: sessionGateway([]), - archiveStore: emptyArchiveStore(), - spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); - - expect(parentPersisted).toEqual([ - { - customType: "pi-web.subsession.link", - data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" }, - }, - ]); - expect(childPersisted).toEqual([ - { - customType: "pi-web.subsession.spawned", - data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" }, - }, - ]); - await service.dispose(); - }); - - it("hydrates persisted child links after a service restart so the parent can inspect them", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }], - }); - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }), - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const runtimes = [parent.runtime, child.runtime]; - let index = 0; - const open = vi.fn(() => childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? child.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({ - sessionId: "child-1", - cwd: "/workspace-feature", - status: "idle", - finalText: "finished", - messageCount: 1, - }); - expect(open).toHaveBeenCalledWith(childFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("ignores stale persisted child links when the child no longer records the parent", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); - - try { - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not hydrate persisted links when the exact child file is unavailable", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not hydrate parent links without a child file", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not invent subsession links from existing child session headers", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile }; - const parent = fakeRuntime("parent-1", { - sessionFile: parentFile, - sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(parent.runtime), - sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("does not hydrate copied parent links when the opened parent has a different id", async () => { - const forkedParent = fakeRuntime("parent-fork-1", { - sessionFile: "/sessions/parent-fork-1.jsonl", - sessionManager: fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], - }), - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(forkedParent.runtime), - sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("relinks a spawned child when the child session is opened after restart", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("notifies the validated parent file instead of an active prefix-matched parent id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-")); - const parentFile = join(tempDir, "parent.jsonl"); - const forkParentFile = join(tempDir, "parent-fork.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const forkManager = fakeSessionManager("/workspace"); - const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [fork.runtime, child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => { - if (path === parentFile) return parentManager; - if (path === forkParentFile) return forkManager; - return childManager; - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => forkManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace" - ? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }] - : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("parent-1-fork", "/workspace")); - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(fork.calls.sendCustomMessage).toHaveLength(0); - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const originalChildFile = join(tempDir, "original-child.jsonl"); - const copiedChildFile = join(tempDir, "copied-child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("uses the verified child file instead of an active copied child with the same id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-")); - const parentFile = join(tempDir, "parent.jsonl"); - const originalChildFile = join(tempDir, "original-child.jsonl"); - const copiedChildFile = join(tempDir, "copied-child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const copiedManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }], - }); - const originalManager = fakeSessionManager("/workspace-feature", { - getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], - }); - const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true }); - const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { - if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime); - if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime); - if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); - throw new Error("unexpected session manager"); - }; - const open = vi.fn((path: string) => { - if (path === copiedChildFile) return copiedManager; - if (path === originalChildFile) return originalManager; - if (path === parentFile) return parentManager; - throw new Error(`unexpected open path ${path}`); - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: { - create: () => parentManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - await service.start("/workspace"); - - await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, - ]); - - copiedChild.session.isStreaming = true; - copiedChild.emit({ type: "agent_start" }); - copiedChild.session.isStreaming = false; - copiedChild.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(parent.calls.sendCustomMessage).toHaveLength(0); - - await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({ - sessionId: "child-1", - cwd: "/workspace-feature", - status: "idle", - finalText: "original child result", - messageCount: 1, - }); - const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile); - expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" }); - expect(open).toHaveBeenCalledWith(originalChildFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("uses the verified parent file instead of an active copied parent with the same id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-")); - const parentFile = join(tempDir, "parent.jsonl"); - const copiedParentFile = join(tempDir, "copied-parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager }); - const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { - if (options.sessionManager === childManager) return Promise.resolve(child.runtime); - if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); - if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime); - throw new Error("unexpected session manager"); - }; - const open = vi.fn((path: string) => { - if (path === childFile) return childManager; - if (path === parentFile) return parentManager; - if (path === copiedParentFile) return copiedParentManager; - throw new Error(`unexpected open path ${path}`); - }); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime, - sessionManager: { - create: () => copiedParentManager, - list: (cwd: string) => Promise.resolve(cwd === "/workspace" - ? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }] - : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - await service.status(sessionRef("parent-1", "/workspace")); - - await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]); - await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions"); - await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions"); - - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(copiedParent.calls.sendCustomMessage).toHaveLength(0); - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(open).toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a child marker when the current child file header no longer records the parent", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); - const parentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parentManager = fakeSessionManager("/workspace", { - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], - }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: { - ...emptyArchiveStore(), - get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), - }, - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - expect(open).not.toHaveBeenCalledWith(parentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink a child marker when the child header points at a different parent id", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); - const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); - const actualParentFile = join(tempDir, "parent.jsonl"); - const childFile = join(tempDir, "child.jsonl"); - await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); - await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8"); - - try { - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: mismatchedParentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") }); - const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); - const runtimes = [child.runtime, parent.runtime]; - let index = 0; - const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: () => { - const runtime = runtimes[index] ?? parent.runtime; - index += 1; - return Promise.resolve(runtime); - }, - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]), - listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - expect(open).not.toHaveBeenCalledWith(actualParentFile); - await service.dispose(); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it("does not relink copied child markers when the opened child has a different id", async () => { - const parentFile = "/sessions/parent-1.jsonl"; - const childFile = "/sessions/child-fork-1.jsonl"; - const childManager = fakeSessionManager("/workspace-feature", { - getHeader: () => ({ parentSession: parentFile }), - getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], - }); - const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); - const open = vi.fn(() => childManager); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(child.runtime), - sessionManager: { - create: () => childManager, - list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), - listAll: () => Promise.resolve([]), - open, - }, - archiveStore: emptyArchiveStore(), - heartbeatIntervalMs: 60_000, - }); - - await service.status(sessionRef("child-fork-1", "/workspace-feature")); - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(open).not.toHaveBeenCalledWith(parentFile); - await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); - await service.dispose(); - }); - - it("notifies the parent once when the tracked child stops working", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification - - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); // arm the notification - child.session.isStreaming = false; - child.emit({ type: "agent_end" }); // fire once - child.emit({ type: "turn_end" }); // must not re-notify - await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion"); - expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" }); - expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message - await service.dispose(); - }); - - it("notifies via the heartbeat when the child settles without a further event", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - parent.calls.prompt.length = 0; - - // The child works, then settles silently: agent_end arrives while it still - // reports active work, so the event-driven latch does not fire here. - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.emit({ type: "agent_end" }); - expect(parent.calls.sendCustomMessage).toHaveLength(0); - - // Once the session settles, the periodic heartbeat re-check notifies. - child.session.isStreaming = false; - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(parent.calls.sendCustomMessage).toHaveLength(1); - expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); - await service.dispose(); - }); - - it("does not notify the parent when a tracked child is archived", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - // Arm the notification, as a real working child would. - child.session.isStreaming = true; - child.emit({ type: "agent_start" }); - child.session.isStreaming = false; - parent.calls.sendCustomMessage.length = 0; - - await service.archive("child-1"); - await new Promise((resolve) => setTimeout(resolve, 20)); - - expect(parent.calls.sendCustomMessage).toHaveLength(0); - await service.dispose(); - }); - - it("reports a missing tracked child file as unknown in the subsession list", async () => { - const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - - await service.archive("child-1"); - - await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" }, - ]); - await service.dispose(); - }); - - it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => { - const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); - await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); - - await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions"); - await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions"); - await service.dispose(); - }); - - it("is disabled when no spawn target resolver is configured", async () => { - const fake = fakeRuntime("nope"); - const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined })) - .rejects.toThrow("Spawning sessions is disabled"); - await service.dispose(); - }); - }); -}); diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts new file mode 100644 index 0000000..d058a53 --- /dev/null +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -0,0 +1,162 @@ +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; +import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; + +export class CapturingSessionEventHub extends SessionEventHub { + readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; + readonly globalEvents: GlobalSessionEvent[] = []; + + override publish(sessionId: string, event: SessionUiEvent): void { + this.sessionEvents.push({ sessionId, event }); + } + + override publishGlobal(event: GlobalSessionEvent): void { + this.globalEvents.push(event); + } +} + +export type SessionGateway = NonNullable; +export type RuntimeCreator = NonNullable; + +export interface TestSession extends PiAgentSession { + sessionName: string | undefined; + model: PiAgentSession["model"]; + isStreaming: boolean; + isCompacting: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + getSteeringMessages: () => readonly string[]; + getFollowUpMessages: () => readonly string[]; +} + +export function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { + return { + getCwd: () => cwd, + getBranch: () => [], + getLeafId: () => "leaf-1", + ...patch, + }; +} + +export function sessionRecord(id: string, cwd = "/workspace") { + return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }; +} + +export function sessionRef(id: string, cwd = "/workspace") { + return { id, cwd }; +} + +export function testModel(): NonNullable { + const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022"); + if (model === undefined) throw new Error("test model not found"); + return model; +} + +export function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { + const promptCalls: { text: string; options: unknown }[] = []; + const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; + const bindExtensionCalls: unknown[] = []; + const listeners: ((event: unknown) => void)[] = []; + const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; + const session: TestSession = { + sessionId, + sessionFile: `/tmp/${sessionId}.jsonl`, + messages: [], + sessionName: undefined, + model: undefined, + thinkingLevel: "off", + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + sessionManager: fakeSessionManager(), + modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), + scopedModels: [], + extensionRunner: { getRegisteredCommands: () => [] }, + promptTemplates: [], + resourceLoader: { getSkills: () => ({ skills: [] }) }, + subscribe: (listener: (event: unknown) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index !== -1) listeners.splice(index, 1); + }; + }, + bindExtensions: (bindings: unknown) => { + calls.bindExtensions.push(bindings); + return Promise.resolve(); + }, + getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), + getContextUsage: () => undefined, + reload: () => { + calls.reload += 1; + return Promise.resolve(); + }, + prompt: (text: string, options: unknown) => { + calls.prompt.push({ text, options }); + return Promise.resolve(); + }, + sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => { + calls.sendCustomMessage.push({ message, options }); + return Promise.resolve(); + }, + executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), + abort: () => { + calls.abort += 1; + return Promise.resolve(); + }, + clearQueue: () => { + calls.clearQueue += 1; + return { steering: [], followUp: [] }; + }, + getSteeringMessages: () => [], + getFollowUpMessages: () => [], + setModel: () => Promise.resolve(), + cycleModel: () => Promise.resolve(undefined), + getAvailableThinkingLevels: () => [], + setThinkingLevel: () => undefined, + cycleThinkingLevel: () => undefined, + setSessionName: (name: string) => { session.sessionName = name; }, + compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), + getUserMessagesForForking: () => [], + agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } }, + ...patch, + }; + const runtime: PiSessionRuntime = { + cwd: session.sessionManager.getCwd(), + session, + setRebindSession: () => undefined, + fork: () => Promise.resolve({ cancelled: false }), + dispose: () => { + calls.dispose += 1; + return Promise.resolve(); + }, + }; + return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } }; +} + +export function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { + return async () => { + await Promise.resolve(); + return runtime; + }; +} + +export function sessionGateway(records: ReturnType[]): SessionGateway { + return { + create: () => fakeSessionManager(), + list: () => Promise.resolve(records), + open: () => fakeSessionManager(), + }; +} + +export function emptyArchiveStore(): NonNullable { + return { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("archive should not be called")), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }; +}