diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index f3e3707..2180f68 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -24,8 +24,8 @@ describe("auth provider options", () => { expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); }); - it("builds login options for OAuth-only, dual-auth, and API-key providers", async () => { - const options = await getLoginProviderOptions(runtime()); + it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { + const options = getLoginProviderOptions(runtime()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), expect.objectContaining({ id: "anthropic", authType: "api_key" }), diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 34b7cdb..94cc528 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -27,7 +27,7 @@ export interface AuthProviderRuntime { getProviderAuthStatus(providerId: string): AuthProviderStatus; } -export async function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): Promise { +export function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): AuthProviderOption[] { const providers = runtime.getProviders(); const oauthProviderIds = new Set(providers.filter((provider) => provider.auth.oauth !== undefined).map((provider) => provider.id)); diff --git a/src/server/sessions/authRoutes.ts b/src/server/sessions/authRoutes.ts index 1f000f3..a8f516c 100644 --- a/src/server/sessions/authRoutes.ts +++ b/src/server/sessions/authRoutes.ts @@ -4,7 +4,7 @@ import type { AuthService } from "./authService.js"; export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, prefix = ""): void { app.get<{ Querystring: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" } }>(`${prefix}/auth/providers`, async (request, reply) => { try { - return auth.authProviders(request.query.mode ?? "login", request.query.authType); + return await auth.authProviders(request.query.mode ?? "login", request.query.authType); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -12,7 +12,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string; key: string } }>(`${prefix}/auth/api-key`, async (request, reply) => { try { - return auth.saveApiKey(request.body.providerId, request.body.key); + return await auth.saveApiKey(request.body.providerId, request.body.key); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -20,7 +20,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string } }>(`${prefix}/auth/logout`, async (request, reply) => { try { - return auth.logoutProvider(request.body.providerId); + return await auth.logoutProvider(request.body.providerId); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -28,7 +28,7 @@ export function registerAuthRoutes(app: FastifyInstance, auth: AuthService, pref app.post<{ Body: { providerId: string } }>(`${prefix}/auth/oauth`, async (request, reply) => { try { - return auth.startOAuthLogin(request.body.providerId); + return await auth.startOAuthLogin(request.body.providerId); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 685295f..64fce77 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,7 +1,8 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { InMemoryCredentialStore, type Credential } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; @@ -14,85 +15,84 @@ afterEach(async () => { }); describe("AuthService", () => { - it("saves API keys and emits a global auth change", () => { - const { auth, authStorage, changes } = createAuthService(); + it("saves API keys and emits a global auth change", async () => { + const { auth, credentials, changes } = await createAuthService(); - expect(auth.saveApiKey("anthropic", "sk-test")).toEqual({ accepted: true }); + await expect(auth.saveApiKey("anthropic", "sk-test")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-test" }); + await expect(credentials.read("anthropic")).resolves.toEqual({ type: "api_key", key: "sk-test" }); expect(changes).toEqual([{}]); auth.dispose(); }); - it("logs out providers and emits the removed provider id", () => { - const { auth, authStorage, changes } = createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); + it("logs out providers and emits the removed provider id", async () => { + const { auth, credentials, changes } = await createAuthService({ anthropic: { type: "api_key", key: "sk-test" } }); - expect(auth.logoutProvider("anthropic")).toEqual({ accepted: true }); + await expect(auth.logoutProvider("anthropic")).resolves.toEqual({ accepted: true }); - expect(authStorage.get("anthropic")).toBeUndefined(); + await expect(credentials.read("anthropic")).resolves.toBeUndefined(); expect(changes).toEqual([{ removedProviderId: "anthropic" }]); auth.dispose(); }); - it("rejects blank API keys", () => { - const { auth, changes } = createAuthService(); + it("rejects blank API keys", async () => { + const { auth, changes } = await createAuthService(); - expect(() => { auth.saveApiKey("anthropic", " "); }).toThrow("API key is required"); + await expect(auth.saveApiKey("anthropic", " ")).rejects.toThrow("API key is required"); expect(changes).toEqual([]); auth.dispose(); }); it("stores credentials in the configured agent directory", async () => { const agentDir = await tempAgentDir(); - const auth = new AuthService({ agentDir }); + const auth = await AuthService.create({ agentDir }); - auth.saveApiKey("anthropic", "sk-test"); + await auth.saveApiKey("anthropic", "sk-test"); await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test"); auth.dispose(); }); - it("refreshes auth state after OAuth login completes", () => { - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.create(authStorage); + it("refreshes auth state after OAuth login completes", async () => { + const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore() }); const authFlows = new CapturingOAuthLoginFlowService(); - const auth = new AuthService({ modelRegistry, authFlows }); + const auth = await AuthService.create({ runtime, authFlows }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - const reload = vi.spyOn(authStorage, "reload"); - const refresh = vi.spyOn(modelRegistry, "refresh"); - const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic"); + const refresh = vi.spyOn(runtime, "refresh"); + const provider = runtime.getProviders().find((option) => option.id === "anthropic" && option.auth.oauth !== undefined); if (provider === undefined) throw new Error("Expected built-in OAuth provider"); - expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); + await expect(auth.startOAuthLogin(provider.id)).resolves.toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); const startOptions = authFlows.startCalls.at(0); if (startOptions === undefined) throw new Error("Expected OAuth flow to start"); expect(startOptions.providerId).toBe(provider.id); expect(startOptions.providerName).toBe(provider.name); - expect(startOptions.authStorage).toBe(authStorage); + expect(startOptions.runtime).toBe(runtime); expect(changes).toEqual([]); - reload.mockClear(); refresh.mockClear(); if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); startOptions.onComplete(); + await vi.waitFor(() => { expect(changes).toEqual([{}]); }); - expect(reload).toHaveBeenCalledOnce(); expect(refresh).toHaveBeenCalledOnce(); - expect(changes).toEqual([{}]); auth.dispose(); expect(authFlows.disposed).toBe(true); }); }); -function createAuthService(data: Parameters[0] = {}) { - const authStorage = AuthStorage.inMemory(data); - const modelRegistry = ModelRegistry.create(authStorage); - const auth = new AuthService({ modelRegistry }); +async function createAuthService(seed: Record = {}) { + const credentials = new InMemoryCredentialStore(); + for (const [providerId, credential] of Object.entries(seed)) { + await credentials.modify(providerId, () => Promise.resolve(credential)); + } + const runtime = await ModelRuntime.create({ credentials }); + const auth = await AuthService.create({ runtime }); const changes: AuthChange[] = []; auth.subscribe((change) => { changes.push(change); }); - return { auth, authStorage, changes }; + return { auth, credentials, changes }; } async function tempAgentDir(): Promise { diff --git a/src/server/sessions/authService.ts b/src/server/sessions/authService.ts index 3dab648..5d0679c 100644 --- a/src/server/sessions/authService.ts +++ b/src/server/sessions/authService.ts @@ -51,7 +51,7 @@ export class AuthService { async authProviders(mode: "login" | "logout", authType?: AuthType): Promise { await this.runtime.refresh(); - const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : await getLoginProviderOptions(this.runtime, authType); + const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : getLoginProviderOptions(this.runtime, authType); return { providers }; } @@ -61,8 +61,8 @@ export class AuthService { // credential through the runtime's credential store; feed the key back via a // non-interactive AuthInteraction. const interaction: AuthInteraction = { - prompt: async () => key, - notify: () => {}, + prompt: () => Promise.resolve(key), + notify: () => undefined, }; await this.runtime.login(providerId, "api_key", interaction); await this.refreshAuthState(); @@ -110,7 +110,7 @@ export class AuthService { private async requireOAuthLoginProvider(providerId: string) { await this.runtime.refresh(); - const provider = (await getLoginProviderOptions(this.runtime, "oauth")).find((option) => option.id === providerId); + const provider = getLoginProviderOptions(this.runtime, "oauth").find((option) => option.id === providerId); if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); return provider; } diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts index 969ff15..22433e8 100644 --- a/src/server/sessions/piSessionService.archiveCleanup.test.ts +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -15,6 +15,7 @@ describe("PiSessionService archive and cleanup", () => { const fake = fakeRuntime("root", { sessionFile: root.path }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), @@ -49,6 +50,7 @@ describe("PiSessionService archive and cleanup", () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -83,6 +85,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), @@ -118,6 +121,7 @@ describe("PiSessionService archive and cleanup", () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { createCalls += 1; return Promise.resolve(busy.runtime); @@ -159,6 +163,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(busy.runtime), archiveStore: { list: () => Promise.resolve([busyRecord, idleRecord]), @@ -196,6 +201,7 @@ describe("PiSessionService archive and cleanup", () => { const listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([ { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, @@ -239,6 +245,7 @@ describe("PiSessionService archive and cleanup", () => { const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([archived, otherArchived]), @@ -292,6 +299,7 @@ describe("PiSessionService archive and cleanup", () => { const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([ @@ -334,6 +342,7 @@ describe("PiSessionService archive and cleanup", () => { const archivedInputs: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, now: () => new Date("2026-06-25T00:00:00.000Z"), createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 51d5b08..bf33407 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; -import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -31,6 +31,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -60,6 +61,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { try { service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -87,6 +89,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const open = vi.fn(() => fakeSessionManager()); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: { create: () => fakeSessionManager(), @@ -136,6 +139,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const open = vi.spyOn(gateway, "open"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: gateway, @@ -190,6 +194,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: sessionGateway([sessionRecord(sessionId)]), @@ -230,6 +235,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime(sessionId); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: emptyArchiveStore(), createAgentRuntime: () => { createStarted.resolve(); @@ -264,6 +270,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -291,6 +298,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -326,6 +334,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("idle-session")]), heartbeatIntervalMs: 1_000, @@ -362,6 +371,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("completion-session")]), heartbeatIntervalMs: 60_000, @@ -381,6 +391,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), get: () => Promise.resolve(undefined), @@ -411,6 +422,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("lists archived records that have been moved out of the active session directory", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), @@ -442,6 +454,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("runtime-reload-session"); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), heartbeatIntervalMs: 60_000, @@ -475,6 +488,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("reload-session")]), heartbeatIntervalMs: 60_000, @@ -499,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("busy-session", { isStreaming: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("busy-session")]), heartbeatIntervalMs: 60_000, @@ -514,6 +529,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload an archived session", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) @@ -536,6 +552,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), get: () => Promise.resolve(undefined), @@ -573,6 +590,7 @@ describe("PiSessionService.streamSnapshot", () => { const fake = fakeRuntime("snap-idle"); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -601,6 +619,7 @@ describe("PiSessionService.streamSnapshot", () => { const fake = fakeRuntime("snap-live", { state: { streamingMessage } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index be44884..c4d4514 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -1,9 +1,8 @@ -import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai"; import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; -import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -12,6 +11,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("prompt-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -30,6 +30,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("echo-session")]), heartbeatIntervalMs: 60_000, @@ -60,6 +61,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -96,6 +98,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("name-session")]), heartbeatIntervalMs: 60_000, @@ -118,6 +121,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("status-session")]), heartbeatIntervalMs: 60_000, @@ -139,6 +143,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("dedupe-session")]), heartbeatIntervalMs: 60_000, @@ -155,6 +160,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("queued-session", { isStreaming: true }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("queued-session")]), heartbeatIntervalMs: 60_000, @@ -181,6 +187,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("compacting-session")]), heartbeatIntervalMs: 60_000, @@ -245,6 +252,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { fake.session.clearQueue = clearRuntimeQueue; const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("clear-queue-session")]), heartbeatIntervalMs: 60_000, @@ -285,6 +293,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("clear-empty-queue-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]), heartbeatIntervalMs: 60_000, @@ -304,6 +313,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("abort-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-session")]), heartbeatIntervalMs: 60_000, @@ -321,6 +331,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), heartbeatIntervalMs: 60_000, @@ -338,15 +349,20 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); - const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); + // The shared model runtime reads a live credential store; auth changes are + // simulated by mutating the store and refreshing the runtime (the same + // sequence AuthService performs before emitting an AuthChange), then + // notifying the service via applyAuthChange. + const credentials = new InMemoryCredentialStore(); + await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" }); + const modelRuntime = await createTestModelRuntime(credentials); + const model = modelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("Expected Anthropic model fixture"); - const fake = fakeRuntime("auth-session", { model, modelRegistry }); + const fake = fakeRuntime("auth-session", { model, modelRuntime }); const service = new PiSessionService(hub, { agentDir: TEST_AGENT_DIR, - modelRegistry, + modelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("auth-session")]), heartbeatIntervalMs: 60_000, @@ -356,7 +372,8 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { hub.sessionEvents.length = 0; hub.globalEvents.length = 0; - authStorage.logout("anthropic"); + await credentials.delete("anthropic"); + await modelRuntime.refresh(); service.applyAuthChange({ removedProviderId: "anthropic" }); service.applyAuthChange({ removedProviderId: "anthropic" }); @@ -364,9 +381,11 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { expect(warningCount()).toBe(1); expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true); - authStorage.set("anthropic", { type: "api_key", key: "sk-new" }); + await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-new" }); + await modelRuntime.refresh(); service.applyAuthChange(); - authStorage.logout("anthropic"); + await credentials.delete("anthropic"); + await modelRuntime.refresh(); service.applyAuthChange({ removedProviderId: "anthropic" }); expect(warningCount()).toBe(2); @@ -377,6 +396,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("stop-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("stop-session")]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 29346ee..0d10106 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; -import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -12,6 +12,7 @@ describe("PiSessionService", () => { const log: { details: Record; message: string }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, @@ -45,6 +46,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, @@ -80,6 +82,7 @@ describe("PiSessionService", () => { const fake = fakeRuntime("spawned-x"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 1eb11b3..b28c178 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; -import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; +import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; @@ -40,6 +40,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore, @@ -81,6 +82,7 @@ describe("PiSessionService", () => { }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore: emptyArchiveStore(), @@ -121,6 +123,7 @@ describe("PiSessionService", () => { let index = 0; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -173,6 +176,7 @@ describe("PiSessionService", () => { const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -215,6 +219,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -240,6 +245,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -262,6 +268,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -283,6 +290,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -304,6 +312,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(forkedParent.runtime), sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -343,6 +352,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: (_createRuntime, options) => { delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? parent.runtime; @@ -406,6 +416,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -464,6 +475,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -529,6 +541,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: { create: () => parentManager, @@ -605,6 +618,7 @@ describe("PiSessionService", () => { }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime, sessionManager: { create: () => copiedParentManager, @@ -663,6 +677,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -716,6 +731,7 @@ describe("PiSessionService", () => { const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -757,6 +773,7 @@ describe("PiSessionService", () => { const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(child.runtime), sessionManager: { create: () => childManager, @@ -929,6 +946,7 @@ describe("PiSessionService", () => { const fake = fakeRuntime("nope"); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index 84a7247..e1d338d 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -1,4 +1,5 @@ -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js"; @@ -62,8 +63,34 @@ export function sessionRef(id: string, cwd = "/workspace") { export const TEST_MODEL_PROVIDER = "anthropic"; export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929"; +/** + * Seed a credential into an {@link InMemoryCredentialStore}. `modify` is the + * only write path on the pi-ai `CredentialStore` contract, so tests that need a + * pre-populated store go through it rather than mutating internals. + */ +export async function seedCredential(store: InMemoryCredentialStore, providerId: string, credential: Credential): Promise { + await store.modify(providerId, () => Promise.resolve(credential)); +} + +/** + * Build a real {@link ModelRuntime} over an in-memory credential store — the + * async test seam that replaces the removed `ModelRegistry.create(AuthStorage + * .inMemory())`. Pass a pre-seeded store to exercise credential-dependent + * behavior (e.g. auth-loss warnings). + */ +export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise { + return ModelRuntime.create({ credentials }); +} + +/** + * Shared runtime for the common case where a test only needs model catalog + * reads and no configured auth. Built once so the many `fakeRuntime` sessions + * and `PiSessionService` constructions can inject it synchronously. + */ +export const testModelRuntime = await createTestModelRuntime(); + export function testModel(): NonNullable { - const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID); + const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID); if (model === undefined) throw new Error("test model not found"); return model; } @@ -88,7 +115,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial pendingMessageCount: 0, sessionManager: fakeSessionManager(), settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined }, - modelRegistry: ModelRegistry.create(AuthStorage.inMemory()), + modelRuntime: testModelRuntime, scopedModels: [], extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [], diff --git a/src/server/sessions/piSessionService.warnings.test.ts b/src/server/sessions/piSessionService.warnings.test.ts index c4ec0a7..502f810 100644 --- a/src/server/sessions/piSessionService.warnings.test.ts +++ b/src/server/sessions/piSessionService.warnings.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js"; +import { testModel } from "./piSessionService.testSupport.js"; import type { PiAgentSession } from "./piSessionService.js"; import type { SessionWarning } from "../../shared/apiTypes.js"; @@ -83,47 +87,55 @@ describe("collectRuntimeWarnings", () => { const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; -type SubscriptionSession = Pick; +type SubscriptionSession = Pick; function anthropicModel(provider: string): PiAgentSession["model"] { - const registry = ModelRegistry.inMemory(AuthStorage.inMemory()); - const model = registry.getAll().find((candidate) => candidate.provider === provider) ?? registry.getAll()[0]; - if (model === undefined) throw new Error("expected at least one built-in model"); - return { ...model, provider }; + // anthropicSubscriptionWarning only reads `model.provider`, so any built-in + // model re-tagged with the desired provider is a sufficient fixture. + return { ...testModel(), provider }; } function subscriptionSession(options: { provider?: string; anthropicExtraUsage?: boolean; - credential?: AuthStorage; }): SubscriptionSession { - const authStorage = options.credential ?? AuthStorage.inMemory(); return { model: options.provider === undefined ? undefined : anthropicModel(options.provider), settingsManager: { getWarnings: () => (options.anthropicExtraUsage === undefined ? {} : { anthropicExtraUsage: options.anthropicExtraUsage }), setWarnings: () => undefined, }, - modelRegistry: ModelRegistry.create(authStorage), }; } -function anthropicAuth(credential: { type: "oauth" } | { type: "api_key"; key: string }): AuthStorage { - const authStorage = AuthStorage.inMemory(); - if (credential.type === "oauth") { - authStorage.set("anthropic", { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 }); - } else { - authStorage.set("anthropic", { type: "api_key", key: credential.key }); - } - return authStorage; +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** + * Write an `auth.json` holding a single anthropic credential and return its + * path. `anthropicSubscriptionWarning` reads it via `readStoredCredential`, so + * the credential seam is the on-disk auth file rather than an in-memory store. + */ +async function anthropicAuthPath(credential: { type: "oauth" } | { type: "api_key"; key: string }): Promise { + const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-")); + tempDirs.push(dir); + const authPath = join(dir, "auth.json"); + const stored = credential.type === "oauth" + ? { type: "oauth", access: "a", refresh: "r", expires: Date.now() + 3_600_000 } + : { type: "api_key", key: credential.key }; + await writeFile(authPath, JSON.stringify({ anthropic: stored })); + return authPath; } describe("anthropicSubscriptionWarning", () => { - it("warns with the verbatim SDK wording for a stored oauth credential", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "oauth" }), - }))).toEqual({ + it("warns with the verbatim SDK wording for a stored oauth credential", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "oauth" }), + )).toEqual({ severity: "warning", message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING, source: "anthropic", @@ -131,37 +143,41 @@ describe("anthropicSubscriptionWarning", () => { } satisfies SessionWarning); }); - it("warns for an sk-ant-oat subscription API key", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "api_key", key: "sk-ant-oat-abc123" }), - }))?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + it("warns for an sk-ant-oat subscription API key", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "api_key", key: "sk-ant-oat-abc123" }), + )?.message).toBe(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); }); - it("does not warn for a standard anthropic API key", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - credential: anthropicAuth({ type: "api_key", key: "sk-ant-api-abc123" }), - }))).toBeUndefined(); + it("does not warn for a standard anthropic API key", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + await anthropicAuthPath({ type: "api_key", key: "sk-ant-api-abc123" }), + )).toBeUndefined(); }); - it("respects the anthropicExtraUsage suppression gate", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "anthropic", - anthropicExtraUsage: false, - credential: anthropicAuth({ type: "oauth" }), - }))).toBeUndefined(); + it("respects the anthropicExtraUsage suppression gate", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic", anthropicExtraUsage: false }), + await anthropicAuthPath({ type: "oauth" }), + )).toBeUndefined(); }); - it("does not warn when the active provider is not anthropic", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ - provider: "openai", - credential: anthropicAuth({ type: "oauth" }), - }))).toBeUndefined(); + it("does not warn when the active provider is not anthropic", async () => { + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "openai" }), + await anthropicAuthPath({ type: "oauth" }), + )).toBeUndefined(); }); - it("does not warn when no anthropic credential is stored", () => { - expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined(); + it("does not warn when no anthropic credential is stored", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-web-warnings-")); + tempDirs.push(dir); + expect(anthropicSubscriptionWarning( + subscriptionSession({ provider: "anthropic" }), + join(dir, "auth.json"), + )).toBeUndefined(); }); }); diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 54dd4b3..2507177 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus, SessionStreamSnapshot } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import { testModelRuntime } from "./piSessionService.testSupport.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; @@ -20,7 +21,7 @@ beforeEach(async () => { await app.register(fastifyWebsocket); sessionManager = new RejectingSessionManager(); const eventHub = new SessionEventHub(); - service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 }); + service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, modelRuntime: testModelRuntime, sessionManager, heartbeatIntervalMs: 60_000 }); registerSessionRoutes(app, service, eventHub); });