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
@@ -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" },