Migrate test doubles + testSupport to ModelRuntime/InMemoryCredentialStore (slice 5)

Replace AuthStorage.inMemory / ModelRegistry.create|inMemory across all test
and support code with the pi-ai InMemoryCredentialStore + async
ModelRuntime.create({ credentials }). Add shared test-runtime seams
(createTestModelRuntime, testModelRuntime, seedCredential) in testSupport.ts
and thread modelRuntime into fakeRuntime and every PiSessionService
construction (now a required dependency). Rework the anthropic subscription
warning tests onto a temp auth.json seam read via readStoredCredential, and
the auth-loss warning test onto a live credential store + runtime refresh.
Make getLoginProviderOptions synchronous and fix associated await/lint sites.

npm run verify green (typecheck + lint + knip + 1390 tests).
This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 21:56:53 +02:00
parent 0706cc26a5
commit d0cc55cce3
13 changed files with 220 additions and 107 deletions
@@ -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" }),
+1 -1
View File
@@ -27,7 +27,7 @@ export interface AuthProviderRuntime {
getProviderAuthStatus(providerId: string): AuthProviderStatus;
}
export async function getLoginProviderOptions(runtime: AuthProviderRuntime, authType?: AuthType): Promise<AuthProviderOption[]> {
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));
+4 -4
View File
@@ -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) });
}
+31 -31
View File
@@ -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<typeof AuthStorage.inMemory>[0] = {}) {
const authStorage = AuthStorage.inMemory(data);
const modelRegistry = ModelRegistry.create(authStorage);
const auth = new AuthService({ modelRegistry });
async function createAuthService(seed: Record<string, Credential> = {}) {
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<string> {
+4 -4
View File
@@ -51,7 +51,7 @@ export class AuthService {
async authProviders(mode: "login" | "logout", authType?: AuthType): Promise<AuthProvidersResponse> {
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;
}
@@ -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: {
@@ -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,
@@ -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,
@@ -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<string, unknown>; 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,
@@ -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,
@@ -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<void> {
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<ModelRuntime> {
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<PiAgentSession["model"]> {
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<TestSession>
pendingMessageCount: 0,
sessionManager: fakeSessionManager(),
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
modelRuntime: testModelRuntime,
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
@@ -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<PiAgentSession, "model" | "modelRegistry" | "settingsManager">;
type SubscriptionSession = Pick<PiAgentSession, "model" | "settingsManager">;
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<string> {
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();
});
});
+2 -1
View File
@@ -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);
});