diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts new file mode 100644 index 0000000..6867ab0 --- /dev/null +++ b/src/server/sessions/piSessionService.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import type { AgentSession, AgentSessionRuntime, CreateAgentSessionRuntimeFactory, SessionManager } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; +import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; +import { PiSessionService } from "./piSessionService.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); + } +} + +function fakeSessionManager(cwd = "/workspace"): SessionManager { + return { + getCwd: () => cwd, + getBranch: () => [], + } as unknown as SessionManager; +} + +type RuntimeFactoryResult = Awaited>; + +function asRuntimeFactoryResult(runtime: AgentSessionRuntime): RuntimeFactoryResult { + return runtime as unknown as RuntimeFactoryResult; +} + +function fakeRuntime(sessionId = "session-1") { + const promptCalls: { text: string; options: unknown }[] = []; + const calls = { abort: 0, dispose: 0, prompt: promptCalls }; + const session = { + sessionId, + sessionFile: `/tmp/${sessionId}.jsonl`, + messages: [], + sessionName: undefined, + model: undefined, + thinkingLevel: undefined, + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + sessionManager: fakeSessionManager(), + subscribe: () => () => undefined, + getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: 0 }), + getContextUsage: () => undefined, + prompt: (text: string, options: unknown) => { + calls.prompt.push({ text, options }); + return Promise.resolve(); + }, + abort: () => { + calls.abort += 1; + return Promise.resolve(); + }, + } as unknown as AgentSession; + const runtime = { + session, + setRebindSession: () => undefined, + dispose: () => { + calls.dispose += 1; + return Promise.resolve(); + }, + } as unknown as AgentSessionRuntime; + return { runtime, calls }; +} + +describe("PiSessionService", () => { + it("starts sessions through an injected runtime factory", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime(); + const createRuntime: CreateAgentSessionRuntimeFactory = () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)); + const service = new PiSessionService(hub, { + createRuntime, + createAgentRuntime: () => Promise.resolve(fake.runtime), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const session = await service.start("/workspace"); + + 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); + + await service.dispose(); + expect(fake.calls.abort).toBe(1); + expect(fake.calls.dispose).toBe(1); + }); + + 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" }]), + 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([ + { id: "active", path: "/sessions/active.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, + { id: "archived", path: "/sessions/archived.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, + ]), + listAll: () => Promise.resolve([]), + 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", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" }); + + 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(), { + createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)), + createAgentRuntime: () => Promise.resolve(fake.runtime), + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([{ id: "prompt-session", path: "/sessions/prompt-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]), + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.prompt("prompt-session", "Build the thing"); + + expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index e41896a..8d3f412 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -23,6 +23,7 @@ function noop(): void { type SessionArchiveRepository = Pick; type SessionManagerGateway = Pick; +type CreateAgentRuntime = typeof createAgentSessionRuntime; function createDefaultRuntimeFactory(): CreateAgentSessionRuntimeFactory { const authStorage = AuthStorage.create(); @@ -42,6 +43,7 @@ export interface PiSessionServiceDependencies { agentDir?: string; sessionManager?: SessionManagerGateway; createRuntime?: CreateAgentSessionRuntimeFactory; + createAgentRuntime?: CreateAgentRuntime; modelRegistry?: ReturnType; heartbeatIntervalMs?: number; } @@ -55,6 +57,7 @@ export class PiSessionService { private readonly agentDir: string; private readonly sessionManager: SessionManagerGateway; private readonly createRuntime: CreateAgentSessionRuntimeFactory; + private readonly createAgentRuntime: CreateAgentRuntime; private readonly modelRegistry: ReturnType; constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { @@ -62,6 +65,7 @@ export class PiSessionService { this.agentDir = deps.agentDir ?? getAgentDir(); this.sessionManager = deps.sessionManager ?? SessionManager; this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(); + this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime; this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000); this.commandService = new SessionCommandService( @@ -254,7 +258,7 @@ export class PiSessionService { } private async create(sessionManager: SessionManager, cwd: string): Promise { - const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); + const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); const active: ActiveSession = { runtime, unsubscribe: noop }; this.bindRuntime(active); runtime.setRebindSession(() => {