Archived
feat(sessions): surface live session startup warnings in the web UI
Show a pinned banner at the top of the session view with resource and runtime diagnostics (skills, prompts, themes, extension load errors) plus the Anthropic subscription-auth billing notice, recomputed live from the current runtime so they stay accurate across browser reloads. Warnings carry an optional dismiss capability; the Anthropic notice is dismissable and durably suppressed through pi's own anthropicExtraUsage warning setting. Also fixes the testing-guide skill frontmatter so it loads.
This commit is contained in:
@@ -87,6 +87,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
sessionManager: fakeSessionManager(),
|
||||
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
|
||||
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
|
||||
scopedModels: [],
|
||||
extensionRunner: { getRegisteredCommands: () => [] },
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
defineTool,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
type AgentSessionRuntimeDiagnostic,
|
||||
type AgentSessionServices,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
type ResourceDiagnostic,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionStreamSnapshot, SessionUiEvent } from "../types.js";
|
||||
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
||||
@@ -28,7 +31,7 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
|
||||
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
|
||||
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
@@ -206,6 +209,16 @@ interface PiExtensionBindings {
|
||||
|
||||
export interface PiAgentSession {
|
||||
modelRegistry: ModelRegistryInstance;
|
||||
/**
|
||||
* Narrow read/write of the SDK `SettingsManager`, exposing only the warning
|
||||
* suppression flags consumed here (e.g. `anthropicExtraUsage`). Used to gate
|
||||
* the Anthropic subscription-auth billing warning the same way the TUI does,
|
||||
* and to durably suppress it when the user dismisses the warning.
|
||||
*/
|
||||
settingsManager: {
|
||||
getWarnings(): { anthropicExtraUsage?: boolean };
|
||||
setWarnings(warnings: { anthropicExtraUsage?: boolean }): void;
|
||||
};
|
||||
sessionManager: PiSessionManager;
|
||||
scopedModels: readonly { model: AgentModel; thinkingLevel?: ClientThinkingLevel }[];
|
||||
sessionId: string;
|
||||
@@ -263,6 +276,15 @@ export interface PiAgentSession {
|
||||
export interface PiSessionRuntime {
|
||||
readonly cwd: string;
|
||||
readonly session: PiAgentSession;
|
||||
/**
|
||||
* Live, runtime-scoped diagnostics/services used to compute session warnings.
|
||||
*
|
||||
* These mirror the SDK runtime and are recomputed whenever the runtime is
|
||||
* (re)built. `undefined` on lightweight/test runtimes that do not carry SDK
|
||||
* services; callers must treat missing sources as "no warnings".
|
||||
*/
|
||||
readonly diagnostics?: readonly AgentSessionRuntimeDiagnostic[];
|
||||
readonly services?: AgentSessionServices;
|
||||
setRebindSession(rebindSession?: (session: PiAgentSession) => Promise<void>): void;
|
||||
fork(entryId: string, options?: { position?: "before" | "at" }): Promise<{ cancelled: boolean; selectedText?: string }>;
|
||||
dispose(): Promise<void>;
|
||||
@@ -273,6 +295,131 @@ interface PendingSessionOpen {
|
||||
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
||||
}
|
||||
|
||||
function resourceDiagnosticToWarning(diagnostic: ResourceDiagnostic, source: string): SessionWarning {
|
||||
return {
|
||||
severity: diagnostic.type === "error" ? "error" : "warning",
|
||||
message: diagnostic.message,
|
||||
source,
|
||||
...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeDiagnosticToWarning(diagnostic: AgentSessionRuntimeDiagnostic): SessionWarning {
|
||||
return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural view of a runtime's warning sources: the runtime setup
|
||||
* diagnostics plus the resource loader's per-collection diagnostics and
|
||||
* extension load errors. Narrowed to just what {@link collectRuntimeWarnings}
|
||||
* reads so the real SDK runtime and lightweight test doubles both satisfy it.
|
||||
*/
|
||||
export interface RuntimeWarningSources {
|
||||
readonly diagnostics?: readonly AgentSessionRuntimeDiagnostic[];
|
||||
readonly services?: {
|
||||
resourceLoader: {
|
||||
getSkills(): { diagnostics: readonly ResourceDiagnostic[] };
|
||||
getPrompts(): { diagnostics: readonly ResourceDiagnostic[] };
|
||||
getThemes(): { diagnostics: readonly ResourceDiagnostic[] };
|
||||
getExtensions(): { errors: readonly { path: string; error: string }[] };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the live warnings for a runtime by re-reading its current resource
|
||||
* loader diagnostics, extension load errors, and runtime setup diagnostics.
|
||||
*
|
||||
* This mimics the TUI recomputing warnings on every (re)bind: it reads the
|
||||
* runtime's current state rather than a cached snapshot, so a rebuilt runtime
|
||||
* yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
|
||||
* contribute no warnings.
|
||||
*/
|
||||
export function collectRuntimeWarnings(runtime: RuntimeWarningSources): SessionWarning[] {
|
||||
const warnings: SessionWarning[] = [];
|
||||
for (const diagnostic of runtime.diagnostics ?? []) warnings.push(runtimeDiagnosticToWarning(diagnostic));
|
||||
const resourceLoader = runtime.services?.resourceLoader;
|
||||
if (resourceLoader !== undefined) {
|
||||
for (const diagnostic of resourceLoader.getSkills().diagnostics) warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
|
||||
for (const diagnostic of resourceLoader.getPrompts().diagnostics) warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
|
||||
for (const diagnostic of resourceLoader.getThemes().diagnostics) warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
|
||||
for (const error of resourceLoader.getExtensions().errors) {
|
||||
warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
|
||||
* character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
|
||||
* the SDK's interactive mode so the browser shows the same message the TUI does.
|
||||
*/
|
||||
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.";
|
||||
|
||||
/** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
|
||||
function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
|
||||
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
|
||||
* own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
|
||||
* coupling `pi` already defines rather than inventing a parallel vocabulary, and
|
||||
* {@link dismissSessionWarning} maps it back to `setWarnings`.
|
||||
*/
|
||||
const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
|
||||
|
||||
/**
|
||||
* Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
|
||||
* live from the session's current model, stored Anthropic credential, and warning
|
||||
* settings. Returns the billing warning when the active provider is `anthropic`
|
||||
* and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
|
||||
* key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
|
||||
*
|
||||
* The stored credential is read synchronously (matching the TUI's `oauth` branch
|
||||
* and the documented `sk-ant-oat` key trigger) so warnings stay part of the
|
||||
* synchronous live status computation.
|
||||
*/
|
||||
export function anthropicSubscriptionWarning(
|
||||
session: Pick<PiAgentSession, "model" | "modelRegistry" | "settingsManager">,
|
||||
): SessionWarning | undefined {
|
||||
if (session.settingsManager.getWarnings().anthropicExtraUsage === false) return undefined;
|
||||
if (session.model?.provider !== "anthropic") return undefined;
|
||||
const credential = session.modelRegistry.authStorage.get("anthropic");
|
||||
if (credential === undefined) return undefined;
|
||||
const isSubscriptionAuth = credential.type === "oauth"
|
||||
? true
|
||||
: isAnthropicSubscriptionAuthKey(credential.key);
|
||||
if (!isSubscriptionAuth) return undefined;
|
||||
return {
|
||||
severity: "warning",
|
||||
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
||||
source: "anthropic",
|
||||
dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Durably suppress a dismissable session warning by mapping its opaque dismiss
|
||||
* id back to the concrete `pi` suppression it represents. Only known ids are
|
||||
* honored; unknown ids throw so a stale/forged client cannot silently no-op.
|
||||
*
|
||||
* This is the single place provider-specific suppression lives: the wire type,
|
||||
* parser, and UI stay agnostic. Adding a future dismissable warning is a
|
||||
* server-only change here plus a `dismiss` id on its producer.
|
||||
*/
|
||||
export function dismissSessionWarning(
|
||||
session: Pick<PiAgentSession, "settingsManager">,
|
||||
dismissId: string,
|
||||
): void {
|
||||
if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
|
||||
throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
|
||||
}
|
||||
session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
|
||||
}
|
||||
|
||||
interface CreateAgentRuntimeOptions {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
@@ -1399,6 +1546,13 @@ export class PiSessionService implements SessionRouteService {
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async dismissWarning(ref: PiSessionLookup, dismissId: string): Promise<ClientSessionStatus> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
dismissSessionWarning(session, dismissId);
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async abort(ref: PiSessionLookup): Promise<void> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
@@ -2000,6 +2154,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
const stats = session.getSessionStats();
|
||||
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
||||
const contextUsage = session.getContextUsage();
|
||||
const warnings = this.warningsForSession(session);
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
@@ -2014,9 +2169,24 @@ export class PiSessionService implements SessionRouteService {
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||
...(warnings.length === 0 ? {} : { warnings }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the live warning set for a session: runtime/resource diagnostics from
|
||||
* the active runtime (if any) plus the Anthropic subscription-auth notice. Read
|
||||
* fresh on each status publish so a rebuilt runtime or an auth/model change is
|
||||
* reflected without caching a stale snapshot.
|
||||
*/
|
||||
private warningsForSession(session: PiAgentSession): SessionWarning[] {
|
||||
const runtime = this.active.get(session.sessionId)?.runtime;
|
||||
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
|
||||
const anthropic = anthropicSubscriptionWarning(session);
|
||||
if (anthropic !== undefined) warnings.push(anthropic);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private pendingMessageCount(session: PiAgentSession): number {
|
||||
return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AuthStorage, ModelRegistry, type AgentSessionRuntimeDiagnostic, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
|
||||
import { anthropicSubscriptionWarning, collectRuntimeWarnings, dismissSessionWarning, type RuntimeWarningSources } from "./piSessionService.js";
|
||||
import type { PiAgentSession } from "./piSessionService.js";
|
||||
import type { SessionWarning } from "../../shared/apiTypes.js";
|
||||
|
||||
function runtimeWith(options: {
|
||||
diagnostics?: readonly AgentSessionRuntimeDiagnostic[];
|
||||
skills?: readonly ResourceDiagnostic[];
|
||||
prompts?: readonly ResourceDiagnostic[];
|
||||
themes?: readonly ResourceDiagnostic[];
|
||||
extensionErrors?: readonly { path: string; error: string }[];
|
||||
withServices?: boolean;
|
||||
}): RuntimeWarningSources {
|
||||
const services: NonNullable<RuntimeWarningSources["services"]> = {
|
||||
resourceLoader: {
|
||||
getSkills: () => ({ diagnostics: options.skills ?? [] }),
|
||||
getPrompts: () => ({ diagnostics: options.prompts ?? [] }),
|
||||
getThemes: () => ({ diagnostics: options.themes ?? [] }),
|
||||
getExtensions: () => ({ errors: options.extensionErrors ?? [] }),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...(options.diagnostics === undefined ? {} : { diagnostics: options.diagnostics }),
|
||||
...(options.withServices === false ? {} : { services }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectRuntimeWarnings", () => {
|
||||
it("returns no warnings for a runtime without SDK services", () => {
|
||||
expect(collectRuntimeWarnings({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps runtime diagnostics preserving severity and tagging the runtime source", () => {
|
||||
const diagnostics: AgentSessionRuntimeDiagnostic[] = [
|
||||
{ type: "warning", message: "runtime warned" },
|
||||
{ type: "error", message: "runtime failed" },
|
||||
{ type: "info", message: "runtime noted" },
|
||||
];
|
||||
|
||||
expect(collectRuntimeWarnings(runtimeWith({ diagnostics, withServices: false }))).toEqual([
|
||||
{ severity: "warning", message: "runtime warned", source: "runtime" },
|
||||
{ severity: "error", message: "runtime failed", source: "runtime" },
|
||||
{ severity: "info", message: "runtime noted", source: "runtime" },
|
||||
] satisfies SessionWarning[]);
|
||||
});
|
||||
|
||||
it("maps resource diagnostics to their source labels and carries an optional path", () => {
|
||||
const warnings = collectRuntimeWarnings(runtimeWith({
|
||||
skills: [{ type: "error", message: "bad skill", path: "/skills/a.md" }],
|
||||
prompts: [{ type: "warning", message: "odd prompt" }],
|
||||
themes: [{ type: "warning", message: "odd theme" }],
|
||||
}));
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{ severity: "error", message: "bad skill", source: "skill", path: "/skills/a.md" },
|
||||
{ severity: "warning", message: "odd prompt", source: "prompt" },
|
||||
{ severity: "warning", message: "odd theme", source: "theme" },
|
||||
] satisfies SessionWarning[]);
|
||||
});
|
||||
|
||||
it("treats non-error resource diagnostics as warning severity", () => {
|
||||
const [warning] = collectRuntimeWarnings(runtimeWith({ skills: [{ type: "warning", message: "hmm" }] }));
|
||||
expect(warning?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("surfaces extension load errors with the failing path", () => {
|
||||
expect(collectRuntimeWarnings(runtimeWith({ extensionErrors: [{ path: "/ext/x.js", error: "boom" }] }))).toEqual([
|
||||
{ severity: "error", message: "/ext/x.js: boom", source: "extension", path: "/ext/x.js" },
|
||||
] satisfies SessionWarning[]);
|
||||
});
|
||||
|
||||
it("orders runtime diagnostics before resource diagnostics", () => {
|
||||
const warnings = collectRuntimeWarnings(runtimeWith({
|
||||
diagnostics: [{ type: "warning", message: "runtime first" }],
|
||||
skills: [{ type: "error", message: "skill second" }],
|
||||
}));
|
||||
|
||||
expect(warnings.map((warning) => warning.message)).toEqual(["runtime first", "skill second"]);
|
||||
});
|
||||
});
|
||||
|
||||
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">;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe("anthropicSubscriptionWarning", () => {
|
||||
it("warns with the verbatim SDK wording for a stored oauth credential", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
credential: anthropicAuth({ type: "oauth" }),
|
||||
}))).toEqual({
|
||||
severity: "warning",
|
||||
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
||||
source: "anthropic",
|
||||
dismiss: { id: "anthropicExtraUsage" },
|
||||
} 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("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("respects the anthropicExtraUsage suppression gate", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({
|
||||
provider: "anthropic",
|
||||
anthropicExtraUsage: false,
|
||||
credential: anthropicAuth({ 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 no anthropic credential is stored", () => {
|
||||
expect(anthropicSubscriptionWarning(subscriptionSession({ provider: "anthropic" }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissSessionWarning", () => {
|
||||
it("durably suppresses the anthropic notice via pi's WarningSettings key", () => {
|
||||
const calls: { anthropicExtraUsage?: boolean }[] = [];
|
||||
dismissSessionWarning({
|
||||
settingsManager: {
|
||||
getWarnings: () => ({}),
|
||||
setWarnings: (warnings) => { calls.push(warnings); },
|
||||
},
|
||||
}, "anthropicExtraUsage");
|
||||
|
||||
expect(calls).toEqual([{ anthropicExtraUsage: false }]);
|
||||
});
|
||||
|
||||
it("preserves other warning settings when suppressing", () => {
|
||||
const calls: { anthropicExtraUsage?: boolean }[] = [];
|
||||
dismissSessionWarning({
|
||||
settingsManager: {
|
||||
getWarnings: () => ({ anthropicExtraUsage: true }),
|
||||
setWarnings: (warnings) => { calls.push(warnings); },
|
||||
},
|
||||
}, "anthropicExtraUsage");
|
||||
|
||||
expect(calls).toEqual([{ anthropicExtraUsage: false }]);
|
||||
});
|
||||
|
||||
it("rejects an unknown dismiss id instead of silently no-opping", () => {
|
||||
let called = false;
|
||||
expect(() => { dismissSessionWarning({
|
||||
settingsManager: {
|
||||
getWarnings: () => ({}),
|
||||
setWarnings: () => { called = true; },
|
||||
},
|
||||
}, "somethingElse"); }).toThrow("Unknown session warning dismiss id: somethingElse");
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -237,6 +237,44 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses a session warning with workspace context and returns fresh status", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const requestCwd = resolve("/repo");
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/warnings/dismiss", payload: { cwd: requestCwd, dismissId: "anthropicExtraUsage" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ sessionId: "session-1" });
|
||||
expect(routeService.dismissWarningCalls).toEqual([{ lookup: { id: "session-1", cwd: requestCwd }, dismissId: "anthropicExtraUsage" }]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a warning dismiss without a dismissId", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/warnings/dismiss", payload: {} });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(routeService.dismissWarningCalls).toEqual([]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps archived queue-clear failures to a mutation error without requiring a body", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
@@ -345,6 +383,8 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
readonly calls: unknown[] = [];
|
||||
readonly reloadCalls: SessionRouteLookup[] = [];
|
||||
readonly clearQueueCalls: SessionRouteLookup[] = [];
|
||||
readonly dismissWarningCalls: { lookup: SessionRouteLookup; dismissId: string }[] = [];
|
||||
dismissWarningError: Error | undefined;
|
||||
messagesResponse: unknown[] | MessagePage = [];
|
||||
streamSnapshotResponse: SessionStreamSnapshot = { seq: 0, partial: null };
|
||||
readonly streamSnapshotCalls: SessionRouteLookup[] = [];
|
||||
@@ -388,6 +428,21 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
list(): never { throw unusedRouteMethod("list"); }
|
||||
start(): never { throw unusedRouteMethod("start"); }
|
||||
|
||||
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
|
||||
this.dismissWarningCalls.push({ lookup, dismissId });
|
||||
if (this.dismissWarningError !== undefined) return Promise.reject(this.dismissWarningError);
|
||||
return Promise.resolve({
|
||||
sessionId: sessionIdFromLookup(lookup),
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
});
|
||||
}
|
||||
|
||||
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
|
||||
this.clearQueueCalls.push(lookup);
|
||||
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
|
||||
|
||||
@@ -189,6 +189,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
return await sessions.dismissWarning(sessionLookupFromBody(request.params.sessionId, body), requireString(body, "dismissId"));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SessionRouteService {
|
||||
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
|
||||
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
|
||||
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
|
||||
|
||||
Reference in New Issue
Block a user