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:
Federico Jaramillo Martinez
2026-07-17 19:16:58 +02:00
parent 2b17145291
commit aedcbf885e
20 changed files with 820 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: testing-guide
description: Repository-specific testing guide. Use for any test work: planning coverage, writing/fixing/reviewing Vitest tests, test helpers/fakes, failure triage, choosing test layers, and Lit UI tests, including TemplateResult handler extraction rules.
description: "Repository-specific testing guide. Use for any test work: planning coverage, writing/fixing/reviewing Vitest tests, test helpers/fakes, failure triage, choosing test layers, and Lit UI tests, including TemplateResult handler extraction rules."
---
# Testing guide
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Surface live session startup warnings in the web UI. A pinned banner at the top of the session view now shows resource and runtime diagnostics (skills, prompts, themes, and extension load errors) plus the Anthropic subscription-auth billing notice, recomputed from the current runtime so they stay accurate across browser reloads. The Anthropic billing notice can be dismissed, which durably suppresses it through the underlying agent's own warning setting.
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+1
View File
@@ -212,6 +212,7 @@ export const sessionsApi = {
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
streamSnapshot: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "stream-snapshot", machineId), parseSessionStreamSnapshot),
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
+53
View File
@@ -268,6 +268,59 @@ describe("API parsers", () => {
});
});
it("parses live session warnings including optional source and path", () => {
const parsed = parseSessionStatus({
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
warnings: [
{ severity: "error", message: "bad skill", source: "skill", path: "/skills/a.md" },
{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
{ severity: "info", message: "heads up", source: "runtime" },
],
});
expect(parsed.warnings).toEqual([
{ severity: "error", message: "bad skill", source: "skill", path: "/skills/a.md" },
{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
{ severity: "info", message: "heads up", source: "runtime" },
]);
});
it("omits warnings entirely when the field is absent", () => {
const parsed = parseSessionStatus({
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
expect(parsed.warnings).toBeUndefined();
});
it("rejects a warning with an invalid severity", () => {
expect(() => parseSessionStatus({
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
warnings: [{ severity: "fatal", message: "nope" }],
})).toThrow("Invalid session warning severity");
});
it("parses workspace effective upload config when present", () => {
expect(parseWorkspace({
id: "w1",
+30 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -177,6 +177,34 @@ export function parseSessionInfo(value: unknown): SessionInfo {
};
}
function parseSessionWarningSeverity(value: unknown): SessionWarningSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid session warning severity");
return value;
}
function parseSessionWarningDismiss(value: unknown): { id: string } | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return { id: requireString(record, "id") };
}
function parseSessionWarning(value: unknown): SessionWarning {
const record = requireRecord(value);
const dismiss = parseSessionWarningDismiss(record["dismiss"]);
return {
severity: parseSessionWarningSeverity(record["severity"]),
message: requireString(record, "message"),
...optionalField("source", optionalString(record, "source")),
...optionalField("path", optionalString(record, "path")),
...(dismiss === undefined ? {} : { dismiss }),
};
}
function optionalWarnings(value: unknown): Pick<SessionStatus, "warnings"> | object {
if (value === undefined) return {};
return { warnings: arrayOf(parseSessionWarning)(value) };
}
export function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value);
return {
@@ -193,6 +221,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
...optionalModel(record["model"]),
...optionalContextUsage(record["contextUsage"]),
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
...optionalWarnings(record["warnings"]),
};
}
+106 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus } from "../api";
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
import type { ChatLine } from "./shared";
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
@@ -68,6 +68,73 @@ describe("ChatView queued-message clear action", () => {
});
});
describe("ChatView session warnings banner", () => {
it("renders one severity-tagged row per warning with optional path and source", () => {
const view = new ChatView();
view.status = warningStatus([
{ severity: "error", message: "skill failed to load", source: "skill", path: "/skills/a.md" },
{ severity: "warning", message: "subscription auth is active" },
{ severity: "info", message: "heads up", source: "runtime" },
]);
const rendered = renderWarnings(view);
if (rendered === null) throw new Error("expected a warnings banner");
const markup = templateStaticMarkup(rendered);
const values = collectStringValues(rendered);
expect(markup).toContain('class="session-warnings"');
expect(markup).toContain('role="alert"');
expect(values).toContain("skill failed to load");
expect(values).toContain("/skills/a.md");
expect(values).toContain("subscription auth is active");
expect(values).toContain("heads up");
expect(values).toContain("skill");
const severityClasses = values.filter((value) => value.startsWith("session-warning "));
expect(severityClasses).toEqual(["session-warning error", "session-warning warning", "session-warning info"]);
});
it("renders nothing when there are no warnings", () => {
const view = new ChatView();
view.status = warningStatus([]);
expect(renderWarnings(view)).toBeNull();
});
it("renders nothing when status is unset", () => {
expect(renderWarnings(new ChatView())).toBeNull();
});
it("renders a dismiss control only for warnings carrying a dismiss capability", () => {
const view = new ChatView();
view.status = warningStatus([
{ severity: "error", message: "skill failed to load", source: "skill" },
{ severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
]);
const rendered = renderWarnings(view);
if (rendered === null) throw new Error("expected a warnings banner");
const markup = templateStaticMarkup(rendered);
// One dismiss button total: only the warning with a dismiss capability gets one.
expect(markup.match(/session-warning-dismiss/g)?.length).toBe(1);
});
// Direct handler extraction keeps this node-environment test focused on the
// dismiss button wiring without a component-wide DOM shim.
it("invokes onDismissWarning with the warning's dismiss id", () => {
const view = new ChatView();
const onDismissWarning = vi.fn();
view.onDismissWarning = onDismissWarning;
view.status = warningStatus([
{ severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
]);
const rendered = renderWarnings(view);
if (rendered === null) throw new Error("expected a warnings banner");
templateEventHandler(rendered, "session-warning-dismiss")(new Event("click"));
expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage");
});
});
describe("chatMessageMetadataLabel", () => {
it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
@@ -293,6 +360,44 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function collectStringValues(template: TemplateResult): string[] {
const found: string[] = [];
visit(template);
return found;
function visit(value: unknown): void {
if (typeof value === "string") {
found.push(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
for (const child of templateValues(value)) visit(child);
}
}
type RenderWarnings = (this: ChatView) => TemplateResult | null;
function isRenderWarnings(value: unknown): value is RenderWarnings {
return typeof value === "function";
}
function renderWarnings(view: ChatView): TemplateResult | null {
const method: unknown = Reflect.get(view, "renderWarnings");
if (!isRenderWarnings(method)) throw new Error("ChatView.renderWarnings is not callable");
return method.call(view);
}
function warningStatus(warnings: SessionWarning[]): SessionStatus {
return {
...queuedStatus([]),
...(warnings.length === 0 ? {} : { warnings }),
};
}
function queuedStatus(queuedMessages: QueuedSessionMessage[]): SessionStatus {
return {
sessionId: "session-1",
+42 -1
View File
@@ -7,7 +7,7 @@ import { writeClipboardText } from "../clipboard";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./ConversationMeter";
@@ -16,6 +16,12 @@ import "./ToolExecutionView";
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
function warningSeverityIcon(severity: SessionWarningSeverity): string {
if (severity === "error") return "⛔";
if (severity === "info") return "️";
return "⚠️";
}
function clampPercent(value: number): number {
return clampNumber(value, 0, 100);
}
@@ -78,6 +84,7 @@ export class ChatView extends LitElement {
@property({ attribute: false }) activity?: SessionActivity;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onDismissWarning?: (dismissId: string) => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
@@ -208,6 +215,7 @@ export class ChatView extends LitElement {
override render() {
const groups = this.groupedMessages();
return html`
${this.renderWarnings()}
<div class="chat-wrap">
${this.renderConversationRail()}
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
@@ -230,6 +238,39 @@ export class ChatView extends LitElement {
`;
}
private renderWarnings() {
const warnings = this.status?.warnings ?? [];
if (warnings.length === 0) return null;
return html`
<aside class="session-warnings" role="alert" aria-live="polite">
${warnings.map((warning) => {
const dismiss = warning.dismiss;
return html`
<div class=${`session-warning ${warning.severity}`}>
<div class="session-warning-head">
<span class="session-warning-icon" aria-hidden="true">${warningSeverityIcon(warning.severity)}</span>
${warning.source === undefined ? null : html`<span class="session-warning-source">${warning.source}</span>`}
</div>
<div class="session-warning-body">
<p class="session-warning-message">${warning.message}</p>
${warning.path === undefined ? null : html`<p class="session-warning-path">${warning.path}</p>`}
</div>
${dismiss === undefined ? null : html`
<button
type="button"
class="session-warning-dismiss"
title="Don't show this warning again"
aria-label="Dismiss warning"
@click=${() => { this.onDismissWarning?.(dismiss.id); }}
>×</button>
`}
</div>
`;
})}
</aside>
`;
}
private renderImageZoom() {
return html`
<dialog class="image-zoom" @click=${this.onImageZoomDialogClick} @close=${this.closeImageZoom} @cancel=${this.closeImageZoom}>
+5 -1
View File
@@ -1874,6 +1874,10 @@ export class PiWebApp extends LitElement {
void this.sessions.clearServerQueue();
};
private readonly handleDismissWarning = (dismissId: string): void => {
void this.sessions.dismissWarning(dismissId);
};
private readonly handleSelectModel = (): void => {
void this.openModelDialog();
};
@@ -1884,7 +1888,7 @@ export class PiWebApp extends LitElement {
private renderChatView(state: AppState, session: SessionInfo) {
return html`
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
`;
}
+13
View File
@@ -273,6 +273,19 @@ export const listStyles = css`
export const chatStyles = css`
:host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.session-warnings { flex: 0 0 auto; display: grid; gap: 8px; max-height: 40%; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); }
.session-warning { position: relative; display: grid; gap: 4px; box-sizing: border-box; padding: 10px 34px 10px 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); }
.session-warning.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); }
.session-warning.info { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
.session-warning-head { display: flex; align-items: center; gap: 8px; min-height: 16px; }
.session-warning-icon { flex: 0 0 auto; font-size: 14px; line-height: 1.4; }
.session-warning-body { min-width: 0; display: grid; gap: 3px; }
.session-warning-message { margin: 0; overflow-wrap: anywhere; }
.session-warning-path { margin: 0; color: var(--pi-muted); font-size: 12px; font-family: var(--pi-mono, ui-monospace, monospace); overflow-wrap: anywhere; }
.session-warning-source { color: var(--pi-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.session-warning-dismiss { position: absolute; top: 6px; right: 6px; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); font: 15px/1 system-ui, sans-serif; cursor: pointer; }
.session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
.session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
function machine(id: string): NonNullable<AppState["selectedMachine"]> {
return { id, name: id, kind: "remote", createdAt: "now", updatedAt: "now" };
}
function warningStatus(sessionId: string): SessionStatus {
return {
...status(sessionId),
warnings: [{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } }],
};
}
describe("SessionController warning dismissal", () => {
it("dismisses via the API and applies the recomputed status", async () => {
const withWarning = warningStatus(oldSession.id);
const withoutWarning: SessionStatus = { ...status(oldSession.id), warnings: [] };
const dismissCalls: { sessionId: string; dismissId: string; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedMachine: machine("remote-a"),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: withWarning,
sessionStatuses: { [oldSession.id]: withWarning },
};
const api: typeof defaultApi = {
...defaultApi,
dismissWarning: (session, dismissId, machineId) => {
dismissCalls.push({ sessionId: sessionLookupId(session), dismissId, machineId: machineId ?? "local" });
return Promise.resolve(withoutWarning);
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.dismissWarning("anthropicExtraUsage");
expect(dismissCalls).toEqual([{ sessionId: oldSession.id, dismissId: "anthropicExtraUsage", machineId: "remote-a" }]);
expect(state.status).toEqual(withoutWarning);
expect(state.sessionStatuses[oldSession.id]).toEqual(withoutWarning);
});
it("reports dismissal failures through the application error state", async () => {
const withWarning = warningStatus(oldSession.id);
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: withWarning,
sessionStatuses: { [oldSession.id]: withWarning },
};
const api: typeof defaultApi = { ...defaultApi, dismissWarning: () => Promise.reject(new Error("dismiss failed")) };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.dismissWarning("anthropicExtraUsage");
expect(state.error).toBe("Error: dismiss failed");
expect(state.status).toBe(withWarning);
});
});
@@ -741,6 +741,20 @@ export class SessionController {
}
}
async dismissWarning(dismissId: string) {
const state = this.getState();
const session = state.selectedSession;
if (session === undefined || isClientPendingStartSessionInfo(session)) return;
const machineId = selectedMachineId(state);
const selectionSeq = this.selectionSeq;
try {
const status = await this.api.dismissWarning(session, dismissId, machineId);
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(status);
} catch (error) {
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
}
}
async stopActiveWork() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -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: () => [] },
+171 -1
View File
@@ -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);
});
});
+55
View File
@@ -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);
+9
View File
@@ -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);
+1
View File
@@ -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>;
+31
View File
@@ -399,6 +399,30 @@ export interface ThinkingLevelsResponse {
levels: string[];
}
export type SessionWarningSeverity = "info" | "warning" | "error";
/**
* A live, runtime-scoped warning surfaced to the browser (skill/resource
* diagnostics, extension load errors, subscription-auth billing notice, etc.).
*
* Warnings are recomputed whenever the runtime is (re)built inside sessiond and
* are not persisted chat messages. `source` is an optional short origin label
* (e.g. `"skill"`, `"extension"`, `"anthropic"`); `path` carries a related file
* path when the warning came from a resource diagnostic.
*
* `dismiss` is present only when the warning has a durable, first-class
* off-switch in the underlying `pi` agent (not a UI-only hide). Its `id` is the
* opaque token the server maps back to that suppression; the client renders a
* dismiss control for any warning carrying it, without knowing what it means.
*/
export interface SessionWarning {
severity: SessionWarningSeverity;
message: string;
source?: string;
path?: string;
dismiss?: { id: string };
}
export interface SessionStatus {
sessionId: string;
/** True when the server has verified a backing session file exists; false when known transient. */
@@ -414,6 +438,13 @@ export interface SessionStatus {
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
cost: number;
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
/**
* Live, runtime-scoped warnings for this session (skill/resource diagnostics,
* extension load errors, Anthropic subscription-auth billing notice, etc.).
* Recomputed on each status read from the current runtime; absent/empty when
* there are none. See {@link SessionWarning}.
*/
warnings?: SessionWarning[];
}
export interface WorkspaceActivity {
+1
View File
@@ -61,6 +61,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "GET", path: "/sessions/:sessionId/commands" },
{ method: "POST", path: "/sessions/:sessionId/prompt" },
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
{ method: "POST", path: "/sessions/:sessionId/attachments" },
{ method: "POST", path: "/sessions/:sessionId/shell" },
{ method: "POST", path: "/sessions/:sessionId/commands/run" },