From c5c45195d79491decfc8fe46ab05760efef84113 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 17 May 2026 08:58:46 +0200 Subject: [PATCH] Remove test type assertion suppressions --- src/server/realtime/sessionEventHub.test.ts | 17 +- src/server/realtime/sessionEventHub.ts | 16 +- src/server/sessions/piSessionService.test.ts | 237 ++++++++---------- src/server/sessions/piSessionService.ts | 148 ++++++++--- .../sessions/sessionCommandService.test.ts | 50 ++-- src/server/sessions/sessionCommandService.ts | 73 ++++-- src/server/sessions/sessionRuntimeStore.ts | 8 +- 7 files changed, 330 insertions(+), 219 deletions(-) diff --git a/src/server/realtime/sessionEventHub.test.ts b/src/server/realtime/sessionEventHub.test.ts index b5a4761..a3e3182 100644 --- a/src/server/realtime/sessionEventHub.test.ts +++ b/src/server/realtime/sessionEventHub.test.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; -import { SessionEventHub } from "./sessionEventHub.js"; +import { SessionEventHub, type RealtimeSocket } from "./sessionEventHub.js"; -class FakeSocket extends EventEmitter { +class FakeSocket extends EventEmitter implements RealtimeSocket { readonly OPEN = 1; readyState = this.OPEN; send = vi.fn(); @@ -14,8 +13,8 @@ describe("SessionEventHub", () => { const hub = new SessionEventHub(); const sessionSocket = new FakeSocket(); const otherSocket = new FakeSocket(); - hub.add("s1", sessionSocket as never); - hub.add("s2", otherSocket as never); + hub.add("s1", sessionSocket); + hub.add("s2", otherSocket); hub.publish("s1", { type: "assistant.delta", text: "hello" }); @@ -28,8 +27,8 @@ describe("SessionEventHub", () => { const closed = new FakeSocket(); const removed = new FakeSocket(); closed.readyState = 3; - hub.add("s1", closed as never); - hub.add("s1", removed as never); + hub.add("s1", closed); + hub.add("s1", removed); removed.emit("close"); hub.publish("s1", { type: "session.error", message: "boom" }); @@ -42,8 +41,8 @@ describe("SessionEventHub", () => { const hub = new SessionEventHub(); const globalSocket = new FakeSocket(); const sessionSocket = new FakeSocket(); - hub.addGlobal(globalSocket as never); - hub.add("s1", sessionSocket as never); + hub.addGlobal(globalSocket); + hub.add("s1", sessionSocket); const status = { sessionId: "s1", diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index afdb96b..77ca5df 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -1,11 +1,17 @@ import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js"; -import type { WebSocket } from "ws"; + +export interface RealtimeSocket { + readonly OPEN: number; + readyState: number; + send(payload: string): void; + on(event: "close", listener: () => void): unknown; +} export class SessionEventHub { - private readonly socketsBySession = new Map>(); - private readonly globalSockets = new Set(); + private readonly socketsBySession = new Map>(); + private readonly globalSockets = new Set(); - add(sessionId: string, socket: WebSocket): void { + add(sessionId: string, socket: RealtimeSocket): void { let sockets = this.socketsBySession.get(sessionId); if (!sockets) { sockets = new Set(); @@ -17,7 +23,7 @@ export class SessionEventHub { }); } - addGlobal(socket: WebSocket): void { + addGlobal(socket: RealtimeSocket): void { this.globalSockets.add(socket); socket.on("close", () => this.globalSockets.delete(socket)); } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index adc2b52..0e4f6a1 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -import type { AgentSession, AgentSessionRuntime, CreateAgentSessionRuntimeFactory, SessionManager } from "@earendil-works/pi-coding-agent"; +import { AuthStorage, ModelRegistry } 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"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; +import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js"; class CapturingSessionEventHub extends SessionEventHub { readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = []; @@ -18,41 +17,60 @@ class CapturingSessionEventHub extends SessionEventHub { } } -function fakeSessionManager(cwd = "/workspace"): SessionManager { +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"): PiSessionManager { return { getCwd: () => cwd, getBranch: () => [], - } as unknown as SessionManager; + getLeafId: () => "leaf-1", + }; } -type RuntimeFactoryResult = Awaited>; - -function asRuntimeFactoryResult(runtime: AgentSessionRuntime): RuntimeFactoryResult { - return runtime as unknown as RuntimeFactoryResult; +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 fakeRuntime(sessionId = "session-1") { +function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { const promptCalls: { text: string; options: unknown }[] = []; const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls }; - const session = { + const session: TestSession = { sessionId, sessionFile: `/tmp/${sessionId}.jsonl`, messages: [], sessionName: undefined, model: undefined, - thinkingLevel: 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: () => () => undefined, - getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: 0 }), + 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, prompt: (text: string, options: unknown) => { calls.prompt.push({ text, options }); return Promise.resolve(); }, + executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }), abort: () => { calls.abort += 1; return Promise.resolve(); @@ -63,37 +81,64 @@ function fakeRuntime(sessionId = "session-1") { }, getSteeringMessages: () => [], getFollowUpMessages: () => [], - } as unknown as AgentSession; - const runtime = { + 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: () => [], + ...patch, + }; + const runtime: PiSessionRuntime = { + cwd: session.sessionManager.getCwd(), session, setRebindSession: () => undefined, + fork: () => Promise.resolve({ cancelled: false }), dispose: () => { calls.dispose += 1; return Promise.resolve(); }, - } as unknown as AgentSessionRuntime; - return { runtime, calls }; + }; + return { runtime, session, calls }; +} + +function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { + return async () => { + await Promise.resolve(); + return runtime; + }; +} + +function sessionGateway(records: ReturnType[]): SessionGateway { + return { + create: () => fakeSessionManager(), + list: () => Promise.resolve(records), + listAll: () => Promise.resolve(records), + open: () => fakeSessionManager(), + }; } describe("PiSessionService", () => { - it("starts sessions through an injected runtime factory", async () => { + it("starts sessions through an injected runtime creator", async () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); - const createRuntime: CreateAgentSessionRuntimeFactory = () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; const service = new PiSessionService(hub, { - createRuntime, - createAgentRuntime: () => Promise.resolve(fake.runtime), - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => Promise.resolve([]), - open: () => fakeSessionManager(), - }, + createAgentRuntime, + sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, }); const session = await service.start("/workspace"); + expect(createCalls).toBe(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); @@ -114,8 +159,8 @@ describe("PiSessionService", () => { 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" }, + { ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }, + { ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" }, ]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager(), @@ -135,14 +180,8 @@ describe("PiSessionService", () => { 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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, }); @@ -153,19 +192,14 @@ describe("PiSessionService", () => { }); it("includes queued message details in session status", async () => { - const fake = fakeRuntime("status-session"); - (fake.runtime.session as unknown as { pendingMessageCount: number; getSteeringMessages: () => string[]; getFollowUpMessages: () => string[] }).pendingMessageCount = 2; - (fake.runtime.session as unknown as { getSteeringMessages: () => string[] }).getSteeringMessages = () => ["adjust this turn"]; - (fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["then do this"]; + const fake = fakeRuntime("status-session", { + pendingMessageCount: 2, + getSteeringMessages: () => ["adjust this turn"], + getFollowUpMessages: () => ["then do this"], + }); 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: "status-session", path: "/sessions/status-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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("status-session")]), heartbeatIntervalMs: 60_000, }); @@ -177,19 +211,14 @@ describe("PiSessionService", () => { }); it("does not enqueue duplicate queued message text", async () => { - const fake = fakeRuntime("dedupe-session"); - (fake.runtime.session as unknown as { isStreaming: boolean; pendingMessageCount: number; getFollowUpMessages: () => string[] }).isStreaming = true; - (fake.runtime.session as unknown as { pendingMessageCount: number }).pendingMessageCount = 1; - (fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["already queued"]; + const fake = fakeRuntime("dedupe-session", { + isStreaming: true, + pendingMessageCount: 1, + getFollowUpMessages: () => ["already queued"], + }); 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: "dedupe-session", path: "/sessions/dedupe-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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("dedupe-session")]), heartbeatIntervalMs: 60_000, }); @@ -201,17 +230,10 @@ describe("PiSessionService", () => { it("does not append queued prompts to the transcript before delivery", async () => { const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("queued-session"); - (fake.runtime.session as unknown as { isStreaming: boolean }).isStreaming = true; + const fake = fakeRuntime("queued-session", { isStreaming: true }); const service = new PiSessionService(hub, { - createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)), - createAgentRuntime: () => Promise.resolve(fake.runtime), - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => Promise.resolve([{ id: "queued-session", path: "/sessions/queued-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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("queued-session")]), heartbeatIntervalMs: 60_000, }); @@ -225,14 +247,8 @@ describe("PiSessionService", () => { it("clears queued messages when aborting active work", async () => { const fake = fakeRuntime("abort-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: "abort-session", path: "/sessions/abort-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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-session")]), heartbeatIntervalMs: 60_000, }); @@ -246,61 +262,34 @@ describe("PiSessionService", () => { it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); - const fake = fakeRuntime("auth-session"); - (fake.runtime.session as unknown as { model: { provider: string; id: string } }).model = { provider: "anthropic", id: "claude-3-5-sonnet" }; - - const credentials = new Map([["anthropic", { type: "api_key", key: "sk-test" }]]); - const authStorage = { - get(provider: string) { return credentials.get(provider); }, - list(): string[] { return Array.from(credentials.keys()); }, - getOAuthProviders: () => [], - hasAuth(provider: string): boolean { return credentials.has(provider); }, - getAuthStatus(provider: string) { return credentials.has(provider) ? { configured: true, source: "stored" as const } : { configured: false }; }, - }; - let refreshCalls = 0; - const knownModels = [{ provider: "anthropic", id: "claude-3-5-sonnet" }]; - const modelRegistry = { - authStorage, - refresh(): void { refreshCalls += 1; }, - getAll: () => knownModels, - getAvailable: () => credentials.has("anthropic") ? knownModels : [], - find: (provider: string, id: string) => knownModels.find((model) => model.provider === provider && model.id === id), - getProviderDisplayName: (provider: string) => provider, - getProviderAuthStatus: (provider: string) => authStorage.getAuthStatus(provider), - hasConfiguredAuth: (model: { provider: string }) => credentials.has(model.provider), - }; - (fake.runtime.session as unknown as { modelRegistry: typeof modelRegistry }).modelRegistry = modelRegistry; + const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); + const modelRegistry = ModelRegistry.create(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: modelRegistry as unknown as NonNullable[1]>["modelRegistry"]>, - createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)), - createAgentRuntime: () => Promise.resolve(fake.runtime), - sessionManager: { - create: () => fakeSessionManager(), - list: () => Promise.resolve([]), - listAll: () => Promise.resolve([{ id: "auth-session", path: "/sessions/auth-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(), - }, + modelRegistry, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("auth-session")]), heartbeatIntervalMs: 60_000, }); await service.status("auth-session"); hub.sessionEvents.length = 0; hub.globalEvents.length = 0; - const refreshBefore = refreshCalls; - credentials.delete("anthropic"); + 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")).length; - expect(refreshCalls).toBeGreaterThan(refreshBefore); + 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); - credentials.set("anthropic", { type: "api_key", key: "sk-new" }); + authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); service.applyAuthChange(); - credentials.delete("anthropic"); + authStorage.logout("anthropic"); service.applyAuthChange({ removedProviderId: "anthropic" }); expect(warningCount()).toBe(2); @@ -310,14 +299,8 @@ describe("PiSessionService", () => { it("clears queued messages when stopping a session runtime", async () => { const fake = fakeRuntime("stop-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: "stop-session", path: "/sessions/stop-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(), - }, + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("stop-session")]), heartbeatIntervalMs: 60_000, }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 282da67..73bf1ab 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1,4 +1,5 @@ import { readFile, writeFile } from "node:fs/promises"; +import type { Api, Model } from "@earendil-works/pi-ai"; import { AuthStorage, createAgentSessionFromServices, @@ -7,7 +8,6 @@ import { getAgentDir, ModelRegistry, SessionManager, - type AgentSession, type CreateAgentSessionRuntimeFactory, } from "@earendil-works/pi-coding-agent"; import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; @@ -29,10 +29,81 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string } type SessionArchiveRepository = Pick; -type SessionManagerGateway = Pick; -type CreateAgentRuntime = typeof createAgentSessionRuntime; +type AgentModel = Model; +type ModelRegistryInstance = ReturnType; -function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType): CreateAgentSessionRuntimeFactory { +export interface PiSessionManager { + getCwd(): string; + getBranch(): unknown[]; + getLeafId(): string | null; + getHeader?(): { parentSession?: string } | null | undefined; +} + +export interface PiSessionManagerGateway { + list(cwd: string): Promise<{ id: string; path: string; cwd: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string; name?: string; parentSessionPath?: string }[]>; + create(cwd: string): PiSessionManager; + listAll(): Promise<{ id: string; path: string; cwd: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string }[]>; + open(path: string): PiSessionManager; +} + +export interface PiAgentSession { + modelRegistry: ModelRegistryInstance; + sessionManager: PiSessionManager; + scopedModels: readonly { model: AgentModel; thinkingLevel?: ClientThinkingLevel }[]; + sessionId: string; + sessionFile: string | undefined; + sessionName: string | undefined; + messages: readonly unknown[]; + model: AgentModel | undefined; + thinkingLevel: ClientThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + isBashRunning: boolean; + pendingMessageCount: number; + extensionRunner: { getRegisteredCommands(): readonly { invocationName: string; description?: string }[] }; + promptTemplates: readonly { name: string; description?: string }[]; + resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } }; + subscribe(listener: (event: unknown) => void): () => void; + compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>; + getUserMessagesForForking(): readonly { entryId: string; text: string }[]; + getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number }; + getContextUsage(): ClientSessionStatus["contextUsage"] | undefined; + prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp" }): Promise; + executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>; + abort(): Promise; + clearQueue(): { steering: string[]; followUp: string[] }; + getSteeringMessages(): readonly string[]; + getFollowUpMessages(): readonly string[]; + setModel(model: AgentModel): Promise; + cycleModel(direction?: "forward" | "backward"): Promise<{ model: AgentModel } | undefined>; + getAvailableThinkingLevels(): ClientThinkingLevel[]; + setThinkingLevel(level: ClientThinkingLevel): void; + cycleThinkingLevel(): ClientThinkingLevel | undefined; + setSessionName(name: string): void; +} + +export interface PiSessionRuntime { + readonly cwd: string; + readonly session: PiAgentSession; + setRebindSession(rebindSession?: (session: PiAgentSession) => Promise): void; + fork(entryId: string, options?: { position?: "before" | "at" }): Promise<{ cancelled: boolean }>; + dispose(): Promise; +} + +interface CreateAgentRuntimeOptions { + cwd: string; + agentDir: string; + sessionManager: PiSessionManager; +} + +type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise; + +function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise { + if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager"); + return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager }); +} + +function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry }); const options = sessionStartEvent === undefined @@ -46,25 +117,25 @@ function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: Re export interface PiSessionServiceDependencies { archiveStore?: SessionArchiveRepository; agentDir?: string; - sessionManager?: SessionManagerGateway; + sessionManager?: PiSessionManagerGateway; createRuntime?: CreateAgentSessionRuntimeFactory; createAgentRuntime?: CreateAgentRuntime; - modelRegistry?: ReturnType; + modelRegistry?: ModelRegistryInstance; heartbeatIntervalMs?: number; } export class PiSessionService { - private readonly active = new Map(); + private readonly active = new Map>(); private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; - private readonly commandService: SessionCommandService; + private readonly commandService: SessionCommandService; private readonly authLossWarnings = new Set(); private readonly archiveStore: SessionArchiveRepository; private readonly agentDir: string; - private readonly sessionManager: SessionManagerGateway; + private readonly sessionManager: PiSessionManagerGateway; private readonly createRuntime: CreateAgentSessionRuntimeFactory; private readonly createAgentRuntime: CreateAgentRuntime; - private readonly modelRegistry: ReturnType; + private readonly modelRegistry: ModelRegistryInstance; constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); @@ -72,7 +143,7 @@ export class PiSessionService { this.sessionManager = deps.sessionManager ?? SessionManager; this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry); - this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime; + this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000); this.commandService = new SessionCommandService( (sessionId) => this.getActive(sessionId), @@ -217,10 +288,10 @@ export class PiSessionService { commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" }); } for (const template of session.promptTemplates) { - commands.push({ name: template.name, description: template.description, source: "prompt" }); + commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" }); } for (const skill of session.resourceLoader.getSkills().skills) { - commands.push({ name: `skill:${skill.name}`, description: skill.description, source: "skill" }); + commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" }); } return commands.sort((a, b) => a.name.localeCompare(b.name)); } @@ -332,11 +403,11 @@ export class PiSessionService { if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue."); } - private async getOrOpen(sessionId: string): Promise { + private async getOrOpen(sessionId: string): Promise { return (await this.getActive(sessionId)).runtime.session; } - private async getActive(sessionId: string): Promise { + private async getActive(sessionId: string): Promise> { const active = this.active.get(sessionId); if (active) return active; @@ -345,9 +416,9 @@ export class PiSessionService { return this.create(this.sessionManager.open(match.path), match.cwd); } - private async create(sessionManager: SessionManager, cwd: string): Promise { + private async create(sessionManager: PiSessionManager, cwd: string): Promise> { const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); - const active: ActiveSession = { runtime, unsubscribe: noop }; + const active: ActiveSession = { runtime, unsubscribe: noop }; this.bindRuntime(active); runtime.setRebindSession(() => { this.bindRuntime(active); @@ -358,7 +429,7 @@ export class PiSessionService { return active; } - private bindRuntime(active: ActiveSession): void { + private bindRuntime(active: ActiveSession): void { active.unsubscribe(); for (const [sessionId, candidate] of this.active.entries()) { if (candidate === active) this.active.delete(sessionId); @@ -372,7 +443,7 @@ export class PiSessionService { this.active.set(session.sessionId, active); } - private maybeGenerateSessionName(session: AgentSession, firstMessage: string): void { + private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void { if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return; const model = session.model; if (model === undefined) return; @@ -384,7 +455,7 @@ export class PiSessionService { }); } - private applyGeneratedSessionName(session: AgentSession, name: string | undefined): void { + private applyGeneratedSessionName(session: PiAgentSession, name: string | undefined): void { if (name === undefined || session.sessionName !== undefined) return; session.setSessionName(name); this.publishSessionName(session); @@ -400,7 +471,7 @@ export class PiSessionService { } } - private syncCurrentModelAuthWarning(session: AgentSession, removedProviderId: string | undefined): void { + private syncCurrentModelAuthWarning(session: PiAgentSession, removedProviderId: string | undefined): void { const model = session.model; if (model === undefined) return; if (model.provider === "unknown" && model.id === "unknown") return; @@ -427,7 +498,7 @@ export class PiSessionService { } } - private publishSessionName(session: AgentSession): void { + private publishSessionName(session: PiAgentSession): void { const event = session.sessionName === undefined ? { type: "session.name", sessionId: session.sessionId } as const : { type: "session.name", sessionId: session.sessionId, name: session.sessionName } as const; @@ -447,7 +518,7 @@ export class PiSessionService { } } - private activityLabelFromStatus(session: AgentSession): string { + private activityLabelFromStatus(session: PiAgentSession): string { if (session.isCompacting) return "compacting"; if (session.isBashRunning) return "running bash"; if (session.isStreaming) return "agent running"; @@ -455,7 +526,7 @@ export class PiSessionService { return "active"; } - private publishActivityForEvent(session: AgentSession, event: unknown): void { + private publishActivityForEvent(session: PiAgentSession, event: unknown): void { const eventType = getString(event, "type"); if (eventType === undefined) return; if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; } @@ -482,7 +553,7 @@ export class PiSessionService { this.publishActivity(session, eventType.replaceAll("_", " "), "active"); } - private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { + private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { const at = new Date().toISOString(); const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at }; this.activities.set(session.sessionId, stored); @@ -491,13 +562,13 @@ export class PiSessionService { this.events.publishGlobal({ type: "activity.update", activity }); } - private publishStatus(session: AgentSession): void { + private publishStatus(session: PiAgentSession): void { const status = this.statusFromSession(session); this.events.publish(session.sessionId, { type: "status.update", status }); this.events.publishGlobal({ type: "status.update", status }); } - private statusFromSession(session: AgentSession): ClientSessionStatus { + private statusFromSession(session: PiAgentSession): ClientSessionStatus { const stats = session.getSessionStats(); const model = session.model === undefined ? undefined : modelToClientModel(session.model); const contextUsage = session.getContextUsage(); @@ -517,7 +588,7 @@ export class PiSessionService { } } -function modelToClientModel(model: AgentSession["model"]): ClientSessionModel { +function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel { if (model === undefined) return {}; const name = getString(model, "name"); const reasoning = getProperty(model, "reasoning"); @@ -542,15 +613,15 @@ async function clearParentSession(sessionFile: string): Promise { await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8"); } -function clearSessionQueue(session: AgentSession): void { +function clearSessionQueue(session: PiAgentSession): void { session.clearQueue(); } -function hasQueuedMessageText(session: AgentSession, text: string): boolean { +function hasQueuedMessageText(session: PiAgentSession, text: string): boolean { return queuedMessagesFromSession(session).some((message) => message.text === text); } -function queuedMessagesFromSession(session: AgentSession): { kind: "steer" | "followUp"; text: string }[] { +function queuedMessagesFromSession(session: PiAgentSession): { kind: "steer" | "followUp"; text: string }[] { return [ ...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })), ...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })), @@ -561,13 +632,18 @@ function userTextMessage(text: string): { role: "user"; content: string } { return { role: "user", content: text }; } -function historyMessages(session: AgentSession): unknown[] { +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function historyMessages(session: PiAgentSession): unknown[] { const messages: unknown[] = []; for (const entry of session.sessionManager.getBranch()) { - if (entry.type === "message") messages.push(entry.message); - else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details }); - else if (entry.type === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${entry.summary}` }); - else if (entry.type === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${entry.summary}` }); + if (!isRecord(entry)) continue; + if (entry["type"] === "message") messages.push(entry["message"]); + else if (entry["type"] === "custom_message" && entry["display"] === true) messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] }); + else if (entry["type"] === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` }); + else if (entry["type"] === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` }); } return messages; } diff --git a/src/server/sessions/sessionCommandService.test.ts b/src/server/sessions/sessionCommandService.test.ts index 1bff313..eff4ec0 100644 --- a/src/server/sessions/sessionCommandService.test.ts +++ b/src/server/sessions/sessionCommandService.test.ts @@ -1,13 +1,21 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ import { describe, expect, it, vi } from "vitest"; -import { SessionCommandService } from "./sessionCommandService.js"; +import type { SessionUiEvent } from "../../shared/apiTypes.js"; +import { SessionCommandService, type CommandActiveSession, type CommandSession } from "./sessionCommandService.js"; -function activeSession(overrides: Record = {}) { - const session = { +interface TestCommandSession extends CommandSession { + sessionName: string | undefined; +} + +function activeSession(overrides: Partial = {}): CommandActiveSession { + const session: TestCommandSession = { sessionId: "s1", sessionFile: "/tmp/s1.jsonl", - sessionName: undefined as string | undefined, + sessionName: undefined, messages: [{}, {}], + isStreaming: false, + isBashRunning: false, + isCompacting: false, + pendingMessageCount: 0, promptTemplates: [{ name: "template" }], extensionRunner: { getRegisteredCommands: () => [{ invocationName: "ext" }] }, resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-a" }] }) }, @@ -29,27 +37,27 @@ function activeSession(overrides: Record = {}) { getUserMessagesForForking: vi.fn(() => [{ entryId: "m1", text: "hello ".repeat(40) }]), ...overrides, }; - const runtime = { cwd: "/work", session, fork: vi.fn(async () => { - await Promise.resolve(); - return { cancelled: false }; - }) }; - return { runtime }; + return { runtime: { cwd: "/work", session, fork: vi.fn(() => Promise.resolve({ cancelled: false })) } }; } -async function getActive(active: ReturnType): Promise { +async function getActive(active: CommandActiveSession): Promise { await Promise.resolve(); - return active as never; + return active; } async function promptAccepted(): Promise { await Promise.resolve(); } +function eventPublisher() { + return { publish: vi.fn<(sessionId: string, event: SessionUiEvent) => void>() }; +} + describe("SessionCommandService", () => { it("rejects unknown commands and forwards runtime commands as prompts", async () => { const active = activeSession(); const prompt = vi.fn(promptAccepted); - const service = new SessionCommandService(() => getActive(active), prompt, { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), prompt, eventPublisher()); await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" }); await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" }); @@ -60,7 +68,7 @@ describe("SessionCommandService", () => { it("renames sessions and returns updated client session metadata", async () => { const active = activeSession(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({ type: "done", @@ -72,7 +80,7 @@ describe("SessionCommandService", () => { it("formats session stats", async () => { const active = activeSession(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); await expect(service.run("s1", "/session")).resolves.toEqual({ type: "done", @@ -82,8 +90,8 @@ describe("SessionCommandService", () => { it("starts compaction and publishes completion", async () => { const active = activeSession(); - const events = { publish: vi.fn() }; - const service = new SessionCommandService(() => getActive(active), vi.fn(), events as never); + const events = eventPublisher(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events); await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" }); await vi.waitFor(() => { @@ -104,7 +112,7 @@ describe("SessionCommandService", () => { { entryId: "newest", text: "newest message" }, ]), }); - const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); const result = await service.run("s1", "/fork"); @@ -117,7 +125,7 @@ describe("SessionCommandService", () => { it("rejects fork and clone while the session has active work", async () => { const active = activeSession({ isStreaming: true }); - const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); await expect(service.run("s1", "/fork")).resolves.toEqual({ type: "unsupported", @@ -132,11 +140,11 @@ describe("SessionCommandService", () => { it("rejects fork responses if the session becomes active after choosing fork", async () => { const active = activeSession(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); const result = await service.run("s1", "/fork"); if (result.type !== "select") throw new Error("Expected select result"); - (active.runtime.session as Record)["isStreaming"] = true; + active.runtime.session.isStreaming = true; await expect(service.respond("s1", result.requestId, "m1")).resolves.toEqual({ type: "unsupported", diff --git a/src/server/sessions/sessionCommandService.ts b/src/server/sessions/sessionCommandService.ts index 73e85a7..4f97b59 100644 --- a/src/server/sessions/sessionCommandService.ts +++ b/src/server/sessions/sessionCommandService.ts @@ -1,25 +1,66 @@ import crypto from "node:crypto"; -import type { AgentSession, AgentSessionRuntime } from "@earendil-works/pi-coding-agent"; -import type { SessionEventHub } from "../realtime/sessionEventHub.js"; +import type { SessionUiEvent } from "../../shared/apiTypes.js"; import type { ClientCommandResult, ClientSession } from "../types.js"; import { isBuiltinCommand } from "./builtinCommands.js"; -import type { ActiveSession, GetActiveSession } from "./sessionRuntimeStore.js"; + +export interface CommandSession { + sessionId: string; + sessionFile: string | undefined; + sessionName: string | undefined; + messages: readonly unknown[]; + isStreaming: boolean; + isBashRunning: boolean; + isCompacting: boolean; + pendingMessageCount: number; + promptTemplates: readonly { name: string }[]; + extensionRunner: { getRegisteredCommands(): readonly { invocationName: string }[] }; + resourceLoader: { getSkills(): { skills: readonly { name: string }[] } }; + sessionManager: { getLeafId(): string | null; getHeader?: () => { parentSession?: string } | null | undefined }; + setSessionName: (name: string) => void; + compact: (instructions?: string) => Promise<{ summary: string; tokensBefore: number }>; + getSessionStats: () => { + sessionId: string; + totalMessages: number; + userMessages: number; + assistantMessages: number; + toolCalls: number; + tokens: { input: number; output: number; total: number }; + cost: number; + }; + getUserMessagesForForking: () => readonly { entryId: string; text: string }[]; +} + +export interface CommandRuntime { + cwd: string; + session: TSession; + fork: (entryId: string, options?: { position?: "before" | "at" }) => Promise<{ cancelled: boolean }>; +} + +export interface CommandActiveSession { + runtime: CommandRuntime; +} + +export type GetCommandActiveSession = (sessionId: string) => Promise>; + +export interface CommandEventPublisher { + publish(sessionId: string, event: SessionUiEvent): void; +} interface PendingCommandSelect { sessionId: string; command: "fork"; } -export class SessionCommandService { +export class SessionCommandService { private readonly pendingSelects = new Map(); constructor( - private readonly getActive: GetActiveSession, + private readonly getActive: GetCommandActiveSession, private readonly prompt: (sessionId: string, text: string) => Promise, - private readonly events: SessionEventHub, + private readonly events: CommandEventPublisher, private readonly lifecycle: { - onCompactionStart?: (session: AgentSession) => void; - onCompactionEnd?: (session: AgentSession, result: "success" | "error", detail?: string) => void; + onCompactionStart?: (session: TSession) => void; + onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void; } = {}, ) {} @@ -58,13 +99,13 @@ export class SessionCommandService { return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) }; } - private nameSession(active: ActiveSession, name: string): ClientCommandResult { + private nameSession(active: CommandActiveSession, name: string): ClientCommandResult { if (name === "") return { type: "unsupported", message: "Usage: /name " }; active.runtime.session.setSessionName(name); return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) }; } - private compact(session: AgentSession, instructions: string): ClientCommandResult { + private compact(session: TSession, instructions: string): ClientCommandResult { this.lifecycle.onCompactionStart?.(session); void session.compact(instructions === "" ? undefined : instructions) .then((result) => { @@ -84,7 +125,7 @@ export class SessionCommandService { return { type: "done", message: "Compaction started…" }; } - private async clone(active: ActiveSession): Promise { + private async clone(active: CommandActiveSession): Promise { if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone"); const leafId = active.runtime.session.sessionManager.getLeafId(); if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" }; @@ -93,7 +134,7 @@ export class SessionCommandService { return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) }; } - private fork(active: ActiveSession): ClientCommandResult { + private fork(active: CommandActiveSession): ClientCommandResult { if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork"); const messages = active.runtime.session.getUserMessagesForForking(); if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" }; @@ -107,14 +148,14 @@ export class SessionCommandService { }; } - private isRuntimeCommand(session: AgentSession, name: string): boolean { + private isRuntimeCommand(session: TSession, name: string): boolean { return session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === name) || session.promptTemplates.some((template) => template.name === name) || session.resourceLoader.getSkills().skills.some((skill) => `skill:${skill.name}` === name); } } -function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession { +function clientSessionFromRuntime(runtime: CommandRuntime): ClientSession { const session = runtime.session; const parentSessionPath = typeof session.sessionManager.getHeader === "function" ? session.sessionManager.getHeader()?.parentSession : undefined; return { @@ -130,7 +171,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession { }; } -function sessionHasActiveWork(session: AgentSession): boolean { +function sessionHasActiveWork(session: CommandSession): boolean { return session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0; } @@ -138,7 +179,7 @@ function forkActiveUnsupported(command: "fork" | "clone"): ClientCommandResult { return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` }; } -function formatSessionStats(session: AgentSession): string { +function formatSessionStats(session: CommandSession): string { const stats = session.getSessionStats(); return [ `Session: ${stats.sessionId}`, diff --git a/src/server/sessions/sessionRuntimeStore.ts b/src/server/sessions/sessionRuntimeStore.ts index 310ea95..d6a4c4b 100644 --- a/src/server/sessions/sessionRuntimeStore.ts +++ b/src/server/sessions/sessionRuntimeStore.ts @@ -1,8 +1,6 @@ -import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent"; - -export interface ActiveSession { - runtime: AgentSessionRuntime; +export interface ActiveSession { + runtime: TRuntime; unsubscribe: () => void; } -export type GetActiveSession = (sessionId: string) => Promise; +export type GetActiveSession = (sessionId: string) => Promise>;