Remove test type assertion suppressions

This commit is contained in:
Federico Jaramillo Martinez
2026-05-17 08:58:46 +02:00
parent 6a8f8b6ccf
commit c5c45195d7
7 changed files with 330 additions and 219 deletions
+110 -127
View File
@@ -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<PiSessionServiceDependencies["sessionManager"]>;
type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>;
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<ReturnType<CreateAgentSessionRuntimeFactory>>;
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<TestSession> = {}) {
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<typeof sessionRecord>[]): 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<string, { type: "api_key" | "oauth"; key?: string }>([["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<NonNullable<ConstructorParameters<typeof PiSessionService>[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,
});
+112 -36
View File
@@ -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<SessionArchiveStore, "list" | "archive" | "restore" | "isArchived">;
type SessionManagerGateway = Pick<typeof SessionManager, "list" | "create" | "listAll" | "open">;
type CreateAgentRuntime = typeof createAgentSessionRuntime;
type AgentModel = Model<Api>;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType<typeof ModelRegistry.create>): 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<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
abort(): Promise<void>;
clearQueue(): { steering: string[]; followUp: string[] };
getSteeringMessages(): readonly string[];
getFollowUpMessages(): readonly string[];
setModel(model: AgentModel): Promise<void>;
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>): void;
fork(entryId: string, options?: { position?: "before" | "at" }): Promise<{ cancelled: boolean }>;
dispose(): Promise<void>;
}
interface CreateAgentRuntimeOptions {
cwd: string;
agentDir: string;
sessionManager: PiSessionManager;
}
type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
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<typeof ModelRegistry.create>;
modelRegistry?: ModelRegistryInstance;
heartbeatIntervalMs?: number;
}
export class PiSessionService {
private readonly active = new Map<string, ActiveSession>();
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService;
private readonly commandService: SessionCommandService<PiAgentSession>;
private readonly authLossWarnings = new Set<string>();
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<typeof ModelRegistry.create>;
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<AgentSession> {
private async getOrOpen(sessionId: string): Promise<PiAgentSession> {
return (await this.getActive(sessionId)).runtime.session;
}
private async getActive(sessionId: string): Promise<ActiveSession> {
private async getActive(sessionId: string): Promise<ActiveSession<PiSessionRuntime>> {
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<ActiveSession> {
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
const active: ActiveSession = { runtime, unsubscribe: noop };
const active: ActiveSession<PiSessionRuntime> = { 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<PiSessionRuntime>): 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<void> {
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;
}
@@ -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<string, unknown> = {}) {
const session = {
interface TestCommandSession extends CommandSession {
sessionName: string | undefined;
}
function activeSession(overrides: Partial<TestCommandSession> = {}): CommandActiveSession<TestCommandSession> {
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<string, unknown> = {}) {
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<typeof activeSession>): Promise<never> {
async function getActive(active: CommandActiveSession<TestCommandSession>): Promise<CommandActiveSession> {
await Promise.resolve();
return active as never;
return active;
}
async function promptAccepted(): Promise<void> {
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<string, unknown>)["isStreaming"] = true;
active.runtime.session.isStreaming = true;
await expect(service.respond("s1", result.requestId, "m1")).resolves.toEqual({
type: "unsupported",
+57 -16
View File
@@ -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<TSession extends CommandSession = CommandSession> {
cwd: string;
session: TSession;
fork: (entryId: string, options?: { position?: "before" | "at" }) => Promise<{ cancelled: boolean }>;
}
export interface CommandActiveSession<TSession extends CommandSession = CommandSession> {
runtime: CommandRuntime<TSession>;
}
export type GetCommandActiveSession<TSession extends CommandSession = CommandSession> = (sessionId: string) => Promise<CommandActiveSession<TSession>>;
export interface CommandEventPublisher {
publish(sessionId: string, event: SessionUiEvent): void;
}
interface PendingCommandSelect {
sessionId: string;
command: "fork";
}
export class SessionCommandService {
export class SessionCommandService<TSession extends CommandSession = CommandSession> {
private readonly pendingSelects = new Map<string, PendingCommandSelect>();
constructor(
private readonly getActive: GetActiveSession,
private readonly getActive: GetCommandActiveSession<TSession>,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
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<TSession>, name: string): ClientCommandResult {
if (name === "") return { type: "unsupported", message: "Usage: /name <session 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<ClientCommandResult> {
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
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<TSession>): 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}`,
+3 -5
View File
@@ -1,8 +1,6 @@
import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
export interface ActiveSession {
runtime: AgentSessionRuntime;
export interface ActiveSession<TRuntime> {
runtime: TRuntime;
unsubscribe: () => void;
}
export type GetActiveSession = (sessionId: string) => Promise<ActiveSession>;
export type GetActiveSession<TRuntime> = (sessionId: string) => Promise<ActiveSession<TRuntime>>;