From 0155e0ac11f67572239757be37a54a935e101e79 Mon Sep 17 00:00:00 2001 From: Zahid Date: Sun, 4 Oct 2026 19:46:47 +0700 Subject: [PATCH 1/4] Raise OMP catalog budgets for large registries get_available_models node budget 16384 -> 65536: a standard OMP 18.6 registry (962 models) serializes to 16385 nodes and failed discovery with rpc-response-limit, leaving the provider in error. OMP_MAX_CATALOG_MODELS 256 -> 4096: selection kept the head of OMP order and silently dropped providers past zai (muse-sub, devin, opencode-go, qodeer, z0ne, crof, cline-pass). Boundary tests scaled 257 -> 4097 to preserve intent. --- paseo-omp/server/provider/catalog.ts | 2 +- paseo-omp/server/provider/omp-rpc-transport.ts | 2 +- paseo-omp/tests/provider-catalog.test.ts | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/paseo-omp/server/provider/catalog.ts b/paseo-omp/server/provider/catalog.ts index 28411bcf..13f5424e 100644 --- a/paseo-omp/server/provider/catalog.ts +++ b/paseo-omp/server/provider/catalog.ts @@ -49,7 +49,7 @@ const THINKING_OPTIONS: readonly ProviderThinkingOption[] = [ { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" }, { id: "max", label: "Max", description: "Maximum reasoning" }, ]; -export const OMP_MAX_CATALOG_MODELS = 256; +export const OMP_MAX_CATALOG_MODELS = 4096; export function selectOmpModels( models: readonly OmpModel[], diff --git a/paseo-omp/server/provider/omp-rpc-transport.ts b/paseo-omp/server/provider/omp-rpc-transport.ts index e668771c..4feed437 100644 --- a/paseo-omp/server/provider/omp-rpc-transport.ts +++ b/paseo-omp/server/provider/omp-rpc-transport.ts @@ -887,7 +887,7 @@ export class OmpRpcProcess { : isHistory ? 400_000 : pending.command === "get_available_models" - ? 16_384 + ? 65_536 : 2_048; const violation = jsonBoundViolation( boundedFrame, diff --git a/paseo-omp/tests/provider-catalog.test.ts b/paseo-omp/tests/provider-catalog.test.ts index e4371db3..228d93f8 100644 --- a/paseo-omp/tests/provider-catalog.test.ts +++ b/paseo-omp/tests/provider-catalog.test.ts @@ -848,13 +848,13 @@ describe("OMP direct provider", () => { await session.close(); }); - test("rejects model 257 during initial session open", async () => { + test("rejects model 4097 during initial session open", async () => { const hiddenModel: OmpModel = { provider: "future-provider", id: "hidden-model" }; const runtime = new FakeOmpRuntime(); runtime.availableModels = [ MODEL, ...Array.from( - { length: 255 }, + { length: 4095 }, (_, index): OmpModel => ({ provider: "provider", id: `model-${index}` }), ), hiddenModel, @@ -890,7 +890,7 @@ describe("OMP direct provider", () => { await connection.close(); }); - test("rebuilds a bounded public catalog when fallback selects model 257", async () => { + test("rebuilds a bounded public catalog when fallback selects model 4097", async () => { const runtime = new FakeOmpRuntime(); const fallbackModel: OmpModel = { provider: "future-provider", @@ -901,7 +901,7 @@ describe("OMP direct provider", () => { runtime.availableModels = [ MODEL, ...Array.from( - { length: 255 }, + { length: 4095 }, (_, index): OmpModel => ({ provider: "provider", id: `model-${index}` }), ), fallbackModel, @@ -920,7 +920,7 @@ describe("OMP direct provider", () => { const initialConfig = events.findLast((event) => event.type === "session.config"); if (initialConfig?.type !== "session.config") throw new Error("Expected session config"); - expect(initialConfig.config.models).toHaveLength(256); + expect(initialConfig.config.models).toHaveLength(4096); expect( initialConfig.config.models.some((model) => model.id === ompModelId(fallbackModel)), ).toBe(false); @@ -946,7 +946,7 @@ describe("OMP direct provider", () => { session.emit({ type: "retry_fallback_succeeded", model: "future", role: "default" }); const fallbackConfig = await refreshed; if (fallbackConfig.type !== "session.config") throw new Error("Expected fallback config"); - expect(fallbackConfig.config.models).toHaveLength(256); + expect(fallbackConfig.config.models).toHaveLength(4096); expect( fallbackConfig.config.models.some((model) => model.id === ompModelId(fallbackModel)), ).toBe(true); @@ -958,7 +958,7 @@ describe("OMP direct provider", () => { await startPrompt( connection, events, - "fallback-257-recovery", + "fallback-4097-recovery", "continue", "oversized-catalog-session", ), @@ -972,7 +972,7 @@ describe("OMP direct provider", () => { ); const recoveredConfig = events.findLast((event) => event.type === "session.config"); if (recoveredConfig?.type !== "session.config") throw new Error("Expected recovered config"); - expect(recoveredConfig.config.models).toHaveLength(256); + expect(recoveredConfig.config.models).toHaveLength(4096); expect( recoveredConfig.config.models.some((model) => model.id === ompModelId(fallbackModel)), ).toBe(true); From 1ef99baa23d53e5e911d23b04c5ec0e7ab9f80f4 Mon Sep 17 00:00:00 2001 From: Zahid Date: Sun, 4 Oct 2026 22:50:33 +0700 Subject: [PATCH 2/4] feat(paseo-omp): config-surface restart badges, historical quota label, tool boundary explainer --- paseo-omp/client/omp-config-surface.tsx | 34 ++++++++++++ paseo-omp/client/policy-explainer.ts | 5 ++ paseo-omp/client/quota-popover.tsx | 4 ++ paseo-omp/client/quota-state.ts | 12 +++++ paseo-omp/docs/configuration.md | 3 +- paseo-omp/shared/restart-required.ts | 49 +++++++++++++++++ paseo-omp/tests/quota-label.test.ts | 40 ++++++++++++++ paseo-omp/tests/restart-required.test.ts | 67 ++++++++++++++++++++++++ 8 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 paseo-omp/client/policy-explainer.ts create mode 100644 paseo-omp/shared/restart-required.ts create mode 100644 paseo-omp/tests/quota-label.test.ts create mode 100644 paseo-omp/tests/restart-required.test.ts diff --git a/paseo-omp/client/omp-config-surface.tsx b/paseo-omp/client/omp-config-surface.tsx index 3639abaf..de852064 100644 --- a/paseo-omp/client/omp-config-surface.tsx +++ b/paseo-omp/client/omp-config-surface.tsx @@ -27,6 +27,7 @@ import { } from "../shared/omp-settings"; import { type OmpStore, storeLabel } from "../shared/omp-store"; import { getOmpProviderHealth, type OmpProviderHealth } from "../shared/provider-diagnostics"; +import { classifySettingImpact, deriveRestartRequired } from "../shared/restart-required"; import { getOmpSupportReport, OMP_SUPPORT_ISSUE_URL } from "../shared/support-diagnostics"; import { ComposerPillSettingsSection } from "./composer-pill-settings"; import { openOmpExternalUrl } from "./external-url"; @@ -44,6 +45,7 @@ import { OmpPluginManagerSection } from "./omp-plugin-manager"; import { type OmpModelCatalogState, StructuredRoutingEditor } from "./omp-routing-editor"; import { OmpStorePicker } from "./omp-store-picker"; import { ompStoreKey } from "./omp-store-state"; +import { POLICY_BOUNDARY_COPY } from "./policy-explainer"; import { type BinaryHealthSummary, loadReadyProviderSnapshot, @@ -195,6 +197,17 @@ function PluginConfigurationSection({ styles }: { styles: OmpConfigStyles }) { ); } +function PolicyExplainerCard({ styles }: { styles: OmpConfigStyles }) { + return ( + + {POLICY_BOUNDARY_COPY.body} + + Canonical copy: {POLICY_BOUNDARY_COPY.docAnchor} + + + ); +} + function toneColor(theme: PluginSurfaceProps["theme"], tone: BinaryHealthSummary["tone"]): string { if (tone === "ok") return theme.colors.statusSuccess; if (tone === "warning") return theme.colors.statusWarning; @@ -814,6 +827,7 @@ function ConfigurationCategory({ setting.type === (setting.path === "cycleOrder" ? "array" : "record"); const settingDocumentation = documentationForSettingPath(setting.path); const draft = drafts[setting.path]; + const impact = classifySettingImpact(setting.path); return ( @@ -821,6 +835,11 @@ function ConfigurationCategory({ {setting.workspaceOverride ? ( Workspace override ) : null} + {impact === "live" ? null : ( + + {impact === "new-sessions" ? "New sessions" : "Restart required"} + + )} {!complex && !editable && !structuredEditable ? ( ) : null} @@ -1053,6 +1072,7 @@ function OmpConfigContent({ }, }); const draftCount = Object.keys(drafts).length; + const restartRequired = deriveRestartRequired(Object.keys(drafts)); const workspaceOverrideCount = catalog.sourceSettings.filter( (setting) => setting.workspaceOverride, ).length; @@ -1136,6 +1156,7 @@ function OmpConfigContent({ {view === "plugin" ? ( <> + {!cwd ? : null} ) : null} @@ -1217,6 +1238,19 @@ function OmpConfigContent({ ) : null} + {draftCount > 0 && restartRequired.requiresRestart ? ( + + {`Restart required: ${restartRequired.affectedPaths.length} change${ + restartRequired.affectedPaths.length === 1 ? "" : "s" + } apply to new sessions only (${ + restartRequired.reason === "approval-mode" + ? "OMP fixes approval mode at launch" + : restartRequired.reason === "settings-live-reject" + ? "OMP rejects live settings" + : "OMP fixes approval mode at launch and rejects live settings" + }).`} + + ) : null} {draftCount > 0 ? ( {draftCount} unsaved changes diff --git a/paseo-omp/client/policy-explainer.ts b/paseo-omp/client/policy-explainer.ts new file mode 100644 index 00000000..e358dd8b --- /dev/null +++ b/paseo-omp/client/policy-explainer.ts @@ -0,0 +1,5 @@ +export const POLICY_BOUNDARY_COPY = { + title: "Tool access boundary (fail-closed)", + body: "Session toolPolicy (exact preapproval grants) is rejected at startup. OMP set_host_tools cannot preserve those grants exactly, and the plugin never broadens them. Use paseoTools to scope which caller-scoped Paseo tools reach OMP as MCP host tools. Use disallowedTools only for known native OMP built-ins: unknown names are rejected, and it never filters MCP tools.", + docAnchor: "docs/configuration.md#mcp-tools-management-and-policy-boundary", +} as const; diff --git a/paseo-omp/client/quota-popover.tsx b/paseo-omp/client/quota-popover.tsx index b08a3861..628d3f70 100644 --- a/paseo-omp/client/quota-popover.tsx +++ b/paseo-omp/client/quota-popover.tsx @@ -7,6 +7,7 @@ import { storeForProvider, storeLabel } from "../shared/omp-store"; import { listOmpQuotas } from "../shared/quota"; import { ompStoreKey } from "./omp-store-state"; import { + HISTORICAL_PILL_TITLE, type QuotaProviderGroup, quotaDetailLabel, quotaProviderFromSession, @@ -101,6 +102,9 @@ export function QuotaPopover(props: PluginButtonContentProps) { return ( {storeLabel(store)} + + {`${HISTORICAL_PILL_TITLE}. Recorded in the local agent database, not read live from the provider.`} + {currentProvider && !hasCurrent ? ( {`No recorded quota yet for ${quotaProviderLabel(currentProvider)} (this session's provider).`} diff --git a/paseo-omp/client/quota-state.ts b/paseo-omp/client/quota-state.ts index 1436be9d..35868dba 100644 --- a/paseo-omp/client/quota-state.ts +++ b/paseo-omp/client/quota-state.ts @@ -158,3 +158,15 @@ export function quotaDetailLabel(quota: OmpQuota, nowMs: number = Date.now()): s .filter(Boolean) .join(" · "); } + +export const HISTORICAL_SUFFIX = "(historical)"; + +export const HISTORICAL_PILL_TITLE = "Historical quota snapshot (usage_history)"; + +export function historicalQuotaPillLabel(summary: { visible: boolean; label: string }): { + visible: boolean; + label: string; +} { + if (summary.label.endsWith(HISTORICAL_SUFFIX)) return summary; + return { visible: summary.visible, label: `${summary.label} ${HISTORICAL_SUFFIX}` }; +} diff --git a/paseo-omp/docs/configuration.md b/paseo-omp/docs/configuration.md index 8d0c4464..55281235 100644 --- a/paseo-omp/docs/configuration.md +++ b/paseo-omp/docs/configuration.md @@ -64,7 +64,7 @@ Use the **MCP** control beside the composer on an **OMP Plugin** agent to run OM Command output, setup questions, and OAuth prompts appear in the agent timeline. OAuth URLs render as an interactive card and always retain the full provider authorization URL, never substituting OMP's daemon-local `/launch` shortcut. **Open in Paseo Browser** calls the current agent's caller-scoped `browser_new_tab` tool, so the authorization page becomes a browser tab in the same workspace; it requires Paseo tools to be injected into the agent, browser tools to be enabled, and a connected Paseo desktop browser host. **Open on this device** remains available when no browser host is connected. For a loopback callback to complete automatically, the chosen browser host must run on the daemon machine. Otherwise, finish authorization in either browser, copy the final redirect URL or authorization code, and submit it in the OMP authorization prompt. Tokens and refresh material are stored by OMP on the daemon (or its configured auth broker), never in the Paseo client or plugin timeline. -Paseo's exact session `toolPolicy` preapproval grants are not equivalent to OMP's `set_host_tools` contract. The plugin cannot preserve that policy exactly, so any non-empty `toolPolicy` rejects session startup. It never converts exact grants into broader access. `disallowedTools` is separate: it controls only recognized native OMP built-ins and rejects unknown names. +Paseo's exact session `toolPolicy` preapproval grants are not equivalent to OMP's `set_host_tools` contract. The plugin cannot preserve that policy exactly, so any non-empty `toolPolicy` rejects session startup. It never converts exact grants into broader access. `disallowedTools` is separate: it controls only recognized native OMP built-ins and rejects unknown names. This section is the canonical copy; the **OMP → Plugin** tab restates the rule once in its Tool access boundary card and links back here. ## Credentials and environment @@ -116,6 +116,7 @@ The plugin validates and bounds native protocol data, but it does not heuristica - Typed OMP approval frames become Paseo tool permissions when both sides negotiate `typedToolApprovals: 1`. - OMP 18.1.15 uses the bounded generic interaction fallback. - Changing approval mode requires a new session. Live model and thinking changes are supported. +- In the Configuration tab, settings marked **Restart required** are read when an OMP process starts, and modes marked **New sessions** only affect sessions started after Apply. Live settings carry no badge. ## Persistence and images diff --git a/paseo-omp/shared/restart-required.ts b/paseo-omp/shared/restart-required.ts new file mode 100644 index 00000000..f199c6f4 --- /dev/null +++ b/paseo-omp/shared/restart-required.ts @@ -0,0 +1,49 @@ +export type SettingImpact = "live" | "new-sessions" | "restart"; + +export type RestartReason = "approval-mode" | "settings-live-reject" | "both"; + +export interface RestartRequired { + requiresRestart: boolean; + reason: RestartReason | null; + affectedPaths: readonly string[]; +} + +const LIVE_SETTING_PATHS: Record = { model: true, thinking: true }; + +export function classifySettingImpact(path: string): SettingImpact { + const normalized = path.toLowerCase().replace(/[_.-]/g, ""); + // OMP fixes approval mode for the session (server/provider/session.ts:1534-1537), so a mode + // edit lands only in a session started afterwards. + if (normalized === "mode" || normalized.includes("approval")) return "new-sessions"; + // Model and thinking are the two selections OMP accepts on a live session + // (server/provider/session.ts:1561-1562), so they raise no restart warning. + if (LIVE_SETTING_PATHS[normalized]) return "live"; + // Every other OMP setting is rejected on the live channel (session.ts:1539-1541) and is read + // when the process launches. + return "restart"; +} + +export function deriveRestartRequired(draftPaths: readonly string[]): RestartRequired { + const seen = new Set(); + const affectedPaths: string[] = []; + let approval = false; + let settings = false; + for (const path of draftPaths) { + if (seen.has(path)) continue; + seen.add(path); + const impact = classifySettingImpact(path); + if (impact === "live") continue; + affectedPaths.push(path); + if (impact === "new-sessions") approval = true; + else settings = true; + } + const reason: RestartReason | null = + approval && settings + ? "both" + : approval + ? "approval-mode" + : settings + ? "settings-live-reject" + : null; + return { requiresRestart: affectedPaths.length > 0, reason, affectedPaths }; +} diff --git a/paseo-omp/tests/quota-label.test.ts b/paseo-omp/tests/quota-label.test.ts new file mode 100644 index 00000000..b78cc867 --- /dev/null +++ b/paseo-omp/tests/quota-label.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { historicalQuotaPillLabel, quotaSummaryForProvider } from "../client/quota-state"; +import type { OmpQuota } from "../shared/quota"; + +const quota: OmpQuota = { + provider: "anthropic", + label: "Claude 5 Hour", + windowLabel: "5 Hour", + usedFraction: 0.25, + status: "ok", + resetsAt: null, + recordedAt: 1, +}; + +describe("historical quota pill label", () => { + test("appends the historical suffix to an existing summary", () => { + const summary = quotaSummaryForProvider([quota], "anthropic"); + expect(summary).toEqual({ visible: true, label: "Anthropic · 25%" }); + expect(historicalQuotaPillLabel(summary)).toEqual({ + visible: true, + label: "Anthropic · 25% (historical)", + }); + }); + + test("preserves the visible flag and the unknown-value label", () => { + expect(historicalQuotaPillLabel(quotaSummaryForProvider([quota], "azure"))).toEqual({ + visible: true, + label: "Azure · — (historical)", + }); + expect(historicalQuotaPillLabel(quotaSummaryForProvider([quota], null))).toEqual({ + visible: false, + label: "Quotas · — (historical)", + }); + }); + + test("is idempotent so repeated wrapping never stacks suffixes", () => { + const once = historicalQuotaPillLabel(quotaSummaryForProvider([quota], "anthropic")); + expect(historicalQuotaPillLabel(once)).toEqual(once); + }); +}); diff --git a/paseo-omp/tests/restart-required.test.ts b/paseo-omp/tests/restart-required.test.ts new file mode 100644 index 00000000..04ceb6de --- /dev/null +++ b/paseo-omp/tests/restart-required.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import { classifySettingImpact, deriveRestartRequired } from "../shared/restart-required"; + +describe("OMP setting impact classification", () => { + test("classifies model and thinking as live", () => { + expect(classifySettingImpact("model")).toBe("live"); + expect(classifySettingImpact("thinking")).toBe("live"); + expect(classifySettingImpact("defaultThinkingLevel")).toBe("restart"); + }); + + test("classifies approval mode as new-session only", () => { + expect(classifySettingImpact("mode")).toBe("new-sessions"); + expect(classifySettingImpact("tools.approvalMode")).toBe("new-sessions"); + expect(classifySettingImpact("approval-mode")).toBe("new-sessions"); + }); + + test("defaults every other setting to restart", () => { + expect(classifySettingImpact("theme.name")).toBe("restart"); + expect(classifySettingImpact("modelRoles")).toBe("restart"); + expect(classifySettingImpact("retry.fallbackChains")).toBe("restart"); + expect(classifySettingImpact("tools.mcp.enabled")).toBe("restart"); + }); +}); + +describe("restart-required derivation", () => { + test("reports no restart when every draft is live", () => { + expect(deriveRestartRequired(["model", "thinking"])).toEqual({ + requiresRestart: false, + reason: null, + affectedPaths: [], + }); + }); + + test("reports no restart for an empty draft set", () => { + expect(deriveRestartRequired([])).toEqual({ + requiresRestart: false, + reason: null, + affectedPaths: [], + }); + }); + + test("reports approval-mode reason and drops live paths", () => { + expect(deriveRestartRequired(["model", "mode"])).toEqual({ + requiresRestart: true, + reason: "approval-mode", + affectedPaths: ["mode"], + }); + }); + + test("reports settings reason for launch-read settings", () => { + expect(deriveRestartRequired(["theme.name", "modelRoles"])).toEqual({ + requiresRestart: true, + reason: "settings-live-reject", + affectedPaths: ["theme.name", "modelRoles"], + }); + }); + + test("reports both when approval and settings drafts mix, deduplicated in order", () => { + expect( + deriveRestartRequired(["mode", "theme.name", "mode", "model", "tools.approvalMode"]), + ).toEqual({ + requiresRestart: true, + reason: "both", + affectedPaths: ["mode", "theme.name", "tools.approvalMode"], + }); + }); +}); From 7b018422354c0cb14cd9213e852ea17cab940c63 Mon Sep 17 00:00:00 2001 From: Zahid Date: Sun, 4 Oct 2026 22:50:43 +0700 Subject: [PATCH 3/4] feat(paseo-omp): pill freshness states and pre-launch availability display --- paseo-omp/client/hub-sidebar.tsx | 177 ++++++++++++++++-- paseo-omp/client/hub-status.ts | 11 ++ paseo-omp/client/pill-freshness.ts | 172 +++++++++++++++++ .../client/provider-diagnostics-state.ts | 59 ++++++ paseo-omp/index.client.tsx | 159 ++++++++++++++-- paseo-omp/shared/availability-display.ts | 141 ++++++++++++++ paseo-omp/tests/availability-display.test.ts | 148 +++++++++++++++ paseo-omp/tests/pill-freshness.test.ts | 142 ++++++++++++++ paseo-omp/tests/profile-pills.test.ts | 8 +- 9 files changed, 979 insertions(+), 38 deletions(-) create mode 100644 paseo-omp/client/pill-freshness.ts create mode 100644 paseo-omp/shared/availability-display.ts create mode 100644 paseo-omp/tests/availability-display.test.ts create mode 100644 paseo-omp/tests/pill-freshness.test.ts diff --git a/paseo-omp/client/hub-sidebar.tsx b/paseo-omp/client/hub-sidebar.tsx index e2739f3c..1e6baec8 100644 --- a/paseo-omp/client/hub-sidebar.tsx +++ b/paseo-omp/client/hub-sidebar.tsx @@ -1,15 +1,24 @@ import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { useRpc } from "@getpaseo/plugin/client"; // Namespace import: on 0.9/0.10 hosts this module lacks SidebarRow, which is only rendered from // 0.11-only sidebar items. Typed locally so older SDK typechecks still compile. import * as pluginUi from "@getpaseo/plugin/client/ui"; import { useQuery } from "@tanstack/react-query"; import type { ComponentType, ReactNode } from "react"; -import { Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; +import type { AvailabilityDisplay } from "../shared/availability-display"; +import { toAvailabilityDisplay } from "../shared/availability-display"; +import { getOmpProviderHealth } from "../shared/provider-diagnostics"; import { HubProcessList } from "./hub-popover"; +import { availabilityTrailingSuffix } from "./hub-status"; +import { freshnessNotice, type PillFreshness, type PillKind } from "./pill-freshness"; +import { HUB_AVAILABILITY_QUERY_KEY } from "./provider-diagnostics-state"; import { CONFIG_SCREEN_ID, type HubSnapshot, hubTrailing } from "./sidebar-compat"; // The row is always mounted; a slower poll bounds per-client RPC fan-out across workspaces. const HUB_POLL_MS = 15_000; +// Shared with the config surface health read; staleTime mirrors the server health cache TTL. +const HUB_AVAILABILITY_STALE_MS = 30_000; const HUB_SIDEBAR_QUERY_KEY = ["paseo-omp", "hub-sidebar"] as const; interface OpenScreenInput { @@ -56,6 +65,39 @@ export function ConfigSidebarItem({ currentScreen, openScreen }: SidebarItemProp ); } +/** + * Stale/error banner for a composer pill's popover (QW3). Pure presentation: the state machine + * lives in `pill-freshness.ts` and Retry reuses the owner's existing refresh function, so this + * adds no timer and no RPC. Renders nothing while fresh. + */ +export function PillFreshnessBanner({ + kind, + state, + theme, + onRetry, +}: { + kind: PillKind; + state: PillFreshness; + theme: PluginHostProps["theme"]; + onRetry(): void; +}) { + const notice = freshnessNotice(state, kind); + if (!notice) return null; + const color = notice.tone === "danger" ? theme.colors.statusDanger : theme.colors.statusWarning; + return ( + + {notice.text} + + Retry + + + ); +} + export function createHubSidebar(loadSnapshot: () => Promise) { const useHubSnapshot = () => useQuery({ @@ -64,15 +106,83 @@ export function createHubSidebar(loadSnapshot: () => Promise) { refetchInterval: HUB_POLL_MS, }); - function HubSidebarPopover({ theme, layout }: PopoverProps) { + function AvailabilityBanner({ + theme, + display, + onRetry, + openScreen, + }: { + theme: PluginHostProps["theme"]; + display: AvailabilityDisplay; + onRetry(): void; + openScreen(input: OpenScreenInput): void; + }) { + if (!display.showBadge) return null; + const color = + display.tone === "danger" ? theme.colors.statusDanger : theme.colors.statusWarning; + return ( + + {display.title} + {display.detail ? ( + + {display.detail} + + ) : null} + + {display.action === "retry" ? ( + + + Retry + + + ) : null} + {display.action === "open-diagnostics" ? ( + openScreen({ screenId: CONFIG_SCREEN_ID })} + > + + Open diagnostics + + + ) : null} + + + ); + } + + function HubSidebarPopover({ theme, layout, openScreen }: PopoverProps) { const snapshot = useHubSnapshot(); + const loadHealth = useRpc(getOmpProviderHealth); + const health = useQuery({ + queryKey: HUB_AVAILABILITY_QUERY_KEY, + queryFn: () => loadHealth({}), + staleTime: HUB_AVAILABILITY_STALE_MS, + }); const muted = { color: theme.colors.foregroundMuted, fontSize: 13 }; + const availability = health.data ? toAvailabilityDisplay(health.data) : undefined; + const banner = availability ? ( + void health.refetch()} + openScreen={openScreen} + /> + ) : null; if (snapshot.isLoading) return Loading hub processes…; if (!snapshot.data) { return ( - - Could not read omp hub state. - + + {banner} + + Could not read omp hub state. + + ); } const active = snapshot.data.workspaces.filter(({ processes }) => processes.length > 0); @@ -84,6 +194,7 @@ export function createHubSidebar(loadSnapshot: () => Promise) { ].filter(Boolean); return ( + {banner} {notes.map((note) => ( {note} @@ -104,23 +215,49 @@ export function createHubSidebar(loadSnapshot: () => Promise) { function HubSidebarItem({ theme, openPopover }: SidebarItemProps) { const snapshot = useHubSnapshot(); + const loadHealth = useRpc(getOmpProviderHealth); + const health = useQuery({ + queryKey: HUB_AVAILABILITY_QUERY_KEY, + queryFn: () => loadHealth({}), + staleTime: HUB_AVAILABILITY_STALE_MS, + }); if (!SidebarRow) return null; + const display = health.data ? toAvailabilityDisplay(health.data) : undefined; + const availability = availabilityTrailingSuffix(display); const state = hubTrailing(snapshot.data, snapshot.error !== null); - const trailing = state ? ( - - {state.running ? ( - {state.running} - ) : null} - {state.failed || state.unreadable ? ( - - {[state.failed, state.unreadable ? "!" : undefined].filter(Boolean).join(" ")} - - ) : null} - - ) : undefined; + const accessibilityLabel = [availability, state?.accessibilityLabel].filter(Boolean).join(", "); + const trailing = + availability || state ? ( + + {availability ? ( + + {availability} + + ) : null} + {state?.running ? ( + + {state.running} + + ) : null} + {state?.failed || state?.unreadable ? ( + + {[state?.failed, state?.unreadable ? "!" : undefined].filter(Boolean).join(" ")} + + ) : null} + + ) : undefined; return ( = Object.freeze({ + state: "fresh", + lastSuccessAt: null, + consecutiveFailures: 0, +}); + +/** A pill is stale after 3x its poll interval without a success (computed at render). */ +export const STALE_AFTER_POLLS = 3; + +export type PillKind = "hub" | "quota"; + +/** + * The single definition of the pill poll intervals. `index.client.tsx` imports this for its + * timers, so staleness windows cannot drift from the polls. The 15s settings read has no pill + * and stays local to its owner. + */ +export const PILL_POLL_MS: Record = { + hub: 4_000, + quota: 30_000, +}; + +/** + * Pure transition. `poll:start` and `retry` never clear failure state on their + * own — the outcome (`poll:success` / `poll:error`) drives the next state, so + * the last-known label is always retained and only annotated. + */ +export function transition(freshness: PillFreshness, event: FreshnessEvent): PillFreshness { + switch (event.type) { + case "poll:start": + case "retry": + return freshness; + case "poll:success": + return { state: "fresh", lastSuccessAt: event.at, consecutiveFailures: 0 }; + case "poll:error": + return { + state: "error", + lastSuccessAt: freshness.lastSuccessAt, + consecutiveFailures: freshness.consecutiveFailures + 1, + }; + } +} + +/** + * True when `now - lastSuccessAt` exceeds 3x the poll interval. Never true + * before the first success: a never-refreshed pill reports through `error` + * instead of going stale on mount. + */ +export function isStale( + freshness: PillFreshness, + pollMs: number, + now: number = Date.now(), +): boolean { + if (freshness.lastSuccessAt === null) return false; + return now - freshness.lastSuccessAt > STALE_AFTER_POLLS * pollMs; +} + +export interface PillLabel { + visible: boolean; + label: string; +} + +export const STALE_SUFFIX = " · stale"; +export const ERROR_SUFFIX = " · !"; + +/** + * Annotates the last-known pill label — never clears it. Error takes precedence + * over stale; hidden pills keep their visibility flag untouched. + */ +export function pillLabelFor( + kind: PillKind, + base: PillLabel, + freshness: PillFreshness, + now: number = Date.now(), +): PillLabel { + if (freshness.state === "error") { + return { visible: base.visible, label: `${base.label}${ERROR_SUFFIX}` }; + } + if (isStale(freshness, PILL_POLL_MS[kind], now)) { + return { visible: base.visible, label: `${base.label}${STALE_SUFFIX}` }; + } + return base; +} + +export interface FreshnessNotice { + text: string; + tone: "warning" | "danger"; +} + +/** Popover banner copy; null while fresh. Derived only from the machine. */ +export function freshnessNotice( + freshness: PillFreshness, + kind: PillKind, + now: number = Date.now(), +): FreshnessNotice | null { + if (freshness.state === "error") { + const { consecutiveFailures } = freshness; + return { + text: `Could not refresh (${consecutiveFailures} ${ + consecutiveFailures === 1 ? "failure" : "failures" + }). Showing last known state.`, + tone: "danger", + }; + } + if (isStale(freshness, PILL_POLL_MS[kind], now)) { + return { + text: `No successful refresh for over ${ + (STALE_AFTER_POLLS * PILL_POLL_MS[kind]) / 1_000 + }s. Showing last known state.`, + tone: "warning", + }; + } + return null; +} + +export function freshnessKey(agentId: string, kind: PillKind): string { + return `${agentId}:${kind}`; +} + +/** Minimal per-key subscription store so popovers re-render without any polling of their own. */ +export interface FreshnessStore { + get(key: string): PillFreshness; + set(key: string, next: PillFreshness): void; + remove(key: string): void; + /** True once a poll outcome was recorded; a never-polled pill has nothing to annotate. */ + has(key: string): boolean; + subscribe(key: string, listener: () => void): () => void; +} + +export function createFreshnessStore(): FreshnessStore { + const records = new Map(); + const listeners = new Map void>>(); + return { + get(key) { + return records.get(key) ?? INITIAL_PILL_FRESHNESS; + }, + set(key, next) { + records.set(key, next); + for (const listener of listeners.get(key) ?? []) listener(); + }, + remove(key) { + records.delete(key); + }, + has(key) { + return records.has(key); + }, + subscribe(key, listener) { + const keyed = listeners.get(key) ?? new Set<() => void>(); + keyed.add(listener); + listeners.set(key, keyed); + return () => { + keyed.delete(listener); + if (keyed.size === 0) listeners.delete(key); + }; + }, + }; +} diff --git a/paseo-omp/client/provider-diagnostics-state.ts b/paseo-omp/client/provider-diagnostics-state.ts index fc231e22..91f46101 100644 --- a/paseo-omp/client/provider-diagnostics-state.ts +++ b/paseo-omp/client/provider-diagnostics-state.ts @@ -10,6 +10,22 @@ import type { import { isOmpProvider } from "./omp-store-state"; import type { PaseoApi, PaseoProviderSnapshotResult } from "./paseo-types"; +import { displayForStatus } from "../shared/availability-display"; +export type { AvailabilityDisplay, AvailabilityStatus } from "../shared/availability-display"; +export { displayForStatus, toAvailabilityDisplay } from "../shared/availability-display"; + +/** + * Cache identity the config surface's health query uses for the default store and the + * daemon working directory. The Hub sidebar reuses the same key so its availability dot reads an + * existing result rather than starting a second probe; it adds no new RPC contract and no timer. + */ +export const HUB_AVAILABILITY_QUERY_KEY = [ + "paseo-omp", + "provider-health", + "default", + "global", +] as const; + export type ProviderHealthTone = "ok" | "warning" | "danger" | "muted"; export const OMP_PROVIDER_IDS = ["omp", "omp-plugin"] as const; @@ -259,3 +275,46 @@ export function selectKnownOmpProviders( : [], ); } + +export interface PerProfileAvailabilityInput { + /** Snapshot entries narrow the candidate profiles; only OMP identities are considered. */ + providers: readonly PaseoProviderSnapshotResult["entries"][number][]; + /** Daemon-observed `error` text per provider id — the only per-agent signal that reaches the client. */ + errors: Readonly>; + /** Default-binary classification from the already-fetched health result, if any. */ + defaultDisplay?: import("../shared/availability-display").AvailabilityDisplay; +} + +/** + * Pre-launch availability per OMP profile without a new probe or status registration. + * + * The default `omp`/`omp-plugin` identities reuse the shared health classification (probed once + * for the daemon-default binary). Named profiles (`omp-plugin-`) keep plugin-owned + * launch, so their own `providerOptions.command` (Doppler/env wrappers included) is the only + * binary that could speak for them — and the client never sees it. The daemon does observe it + * through `checkAvailability` at refresh time and records the outcome as the snapshot entry's + * `status`/`error`, so a profile reading `unavailable`/`error` with daemon text maps to + * `unrunnable` copy *for that profile only*, while every other case stays silent rather than + * guessing the wrong binary. All strings are the fixed `AVAILABILITY_COPY` allowlist. + */ +export function selectPerProfileAvailability( + input: PerProfileAvailabilityInput, +): ReadonlyMap { + const known = selectKnownOmpProviders(input.providers); + const result = new Map(); + for (const provider of known) { + const isDefaultProfile = provider.id === "omp" || provider.id === "omp-plugin"; + if (isDefaultProfile) { + if (input.defaultDisplay?.showBadge) result.set(provider.id, input.defaultDisplay); + continue; + } + const failed = provider.status === "unavailable" || provider.status === "error"; + if (failed && (input.errors[provider.id] ?? provider.id.length > 0)) { + const { displayForStatus } = require("../shared/availability-display") as typeof import( + "../shared/availability-display" + ); + result.set(provider.id, displayForStatus("unrunnable")); + } + } + return result; +} diff --git a/paseo-omp/index.client.tsx b/paseo-omp/index.client.tsx index 7a99e227..aaf76339 100644 --- a/paseo-omp/index.client.tsx +++ b/paseo-omp/index.client.tsx @@ -1,13 +1,16 @@ import { settingsRpc } from "@getpaseo/plugin"; import type { + PluginButton, + PluginButtonContentProps, PluginButtonRegistration, PluginClientContext, PluginSurfaceProps, } from "@getpaseo/plugin/client"; import type { ComponentType } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { OmpIcon } from "./client/hub-icon"; import { HubPopover } from "./client/hub-popover"; -import { ConfigSidebarItem, createHubSidebar } from "./client/hub-sidebar"; +import { ConfigSidebarItem, createHubSidebar, PillFreshnessBanner } from "./client/hub-sidebar"; import { summarizeHubProcesses } from "./client/hub-status"; import { OmpMcpAuthorizationCard } from "./client/mcp-authorization"; import { McpPopover } from "./client/mcp-popover"; @@ -21,10 +24,21 @@ import { ompStoreKey, } from "./client/omp-store-state"; import type { PaseoAgentListResult, PaseoApi } from "./client/paseo-types"; +import { + createFreshnessStore, + freshnessKey, + PILL_POLL_MS, + type PillFreshness, + type PillKind, + type PillLabel, + pillLabelFor, + transition, +} from "./client/pill-freshness"; import { quotaProviderIcon } from "./client/provider-icon"; import { OmpImageTimeline } from "./client/provider-image"; import { QuotaPopover } from "./client/quota-popover"; import { + historicalQuotaPillLabel, type QuotaSeverity, quotaProviderFromSession, quotaSeverityForProvider, @@ -53,8 +67,6 @@ import { listOmpQuotas } from "./shared/quota"; const PAGE_LIMIT = 200; const MAX_PAGES = 10; -const STATUS_POLL_MS = 4_000; -const QUOTA_POLL_MS = 30_000; const RECONCILE_DEBOUNCE_MS = 250; const SETTINGS_POLL_MS = 15_000; const composerPillSettingsRpc = settingsRpc(composerPillSettings.id); @@ -68,6 +80,9 @@ type PillEntry = { workspaceId: string; quotaProvider: string | null; quotaSeverity: QuotaSeverity; + /** Last successful labels, retained through failures so error/stale annotate, never clear. */ + hubLabel: PillLabel; + quotaLabel: PillLabel; hub?: PluginButtonRegistration; memory?: PluginButtonRegistration; sessions?: PluginButtonRegistration; @@ -176,6 +191,7 @@ export function registerConfigAndHub( export default function contribute(client: PluginClientContext) { const pills = new Map(); + const freshness = createFreshnessStore(); let preferences: ComposerPillSettings | undefined; let disposed = false; let reconcileTimer: ReturnType | undefined; @@ -186,6 +202,47 @@ export default function contribute(client: PluginClientContext) { let settingsReadRunning = false; let settingsGeneration = 0; + function recordFreshness(agentId: string, kind: PillKind, ok: boolean): PillFreshness { + const key = freshnessKey(agentId, kind); + const next = transition( + freshness.get(key), + ok ? { type: "poll:success", at: Date.now() } : { type: "poll:error", at: Date.now() }, + ); + freshness.set(key, next); + return next; + } + + /** Re-derives a pill's label from its last-known summary plus the freshness machine. */ + function applyPillLabel( + agentId: string, + kind: PillKind, + entry: PillEntry, + handle: PluginButtonRegistration, + patch?: Partial, + ): void { + const base = kind === "hub" ? entry.hubLabel : entry.quotaLabel; + const freshnessState = freshness.get(freshnessKey(agentId, kind)); + handle.update({ ...pillLabelFor(kind, base, freshnessState), ...patch }); + } + + /** + * Every poll tick re-annotates labels so a stalled refresh paints its stale suffix. Pills with + * no recorded outcome keep the base label, so they are skipped. + */ + function annotatePillLabels(kind: PillKind): void { + for (const [agentId, entry] of pills) { + const handle = kind === "hub" ? entry.hub : entry.quota; + if (!handle) continue; + if (!freshness.has(freshnessKey(agentId, kind))) continue; + applyPillLabel(agentId, kind, entry, handle); + } + } + + function forgetFreshness(agentId: string): void { + freshness.remove(freshnessKey(agentId, "hub")); + freshness.remove(freshnessKey(agentId, "quota")); + } + function applyComposerPillSettings(next: ComposerPillSettings): void { if (samePillSettings(preferences, next)) return; preferences = next; @@ -193,6 +250,50 @@ export default function contribute(client: PluginClientContext) { scheduleReconcile(); } + function useFreshness(agentId: string, kind: PillKind): PillFreshness { + return useSyncExternalStore( + useCallback( + (listener: () => void) => freshness.subscribe(freshnessKey(agentId, kind), listener), + [agentId, kind], + ), + useCallback(() => freshness.get(freshnessKey(agentId, kind)), [agentId, kind]), + ); + } + + /** Hub pill popover: the existing content plus a freshness banner when it is not fresh. */ + function HubPillPopover(props: PluginButtonContentProps) { + const agentId = props.context === "agent" ? props.agentId : ""; + const state = useFreshness(agentId, "hub"); + return ( + <> + void refreshHubStatus()} + /> + + + ); + } + + /** Quota pill popover: the existing content plus a freshness banner when it is not fresh. */ + function QuotaPillPopover(props: PluginButtonContentProps) { + const agentId = props.context === "agent" ? props.agentId : ""; + const state = useFreshness(agentId, "quota"); + return ( + <> + void refreshQuotaStatus()} + /> + + + ); + } + function ConfigSurface(props: PluginSurfaceProps) { return ; } @@ -269,7 +370,7 @@ export default function contribute(client: PluginClientContext) { }); const loadStoreQuotas = createStoreQuotaLoader( (input) => client.rpc(listOmpQuotas, input), - QUOTA_POLL_MS, + PILL_POLL_MS.quota, ); function syncAgentPills(entry: PillEntry, agent: AgentEntry["agent"]): void { @@ -303,7 +404,7 @@ export default function contribute(client: PluginClientContext) { icon: OmpIcon, label: "Hub", visible: false, - behavior: { kind: "popover", Content: HubPopover }, + behavior: { kind: "popover", Content: HubPillPopover }, }, }); } else { @@ -355,7 +456,7 @@ export default function contribute(client: PluginClientContext) { icon: quotaProviderIcon(entry.quotaProvider, "unknown"), label: "Quota", visible: false, - behavior: { kind: "popover", Content: QuotaPopover }, + behavior: { kind: "popover", Content: QuotaPillPopover }, }, }); } else { @@ -384,7 +485,10 @@ export default function contribute(client: PluginClientContext) { entry.quotaProvider !== quotaProvider || ompStoreKey(entry.store) !== ompStoreKey(store) ) { - if (entry) removePills(entry); + if (entry) { + removePills(entry); + forgetFreshness(agent.id); + } entry = { store, cwd: agent.cwd, @@ -392,6 +496,8 @@ export default function contribute(client: PluginClientContext) { workspaceId: agent.workspaceId, quotaProvider, quotaSeverity: "unknown", + hubLabel: { visible: false, label: "Hub" }, + quotaLabel: { visible: false, label: "Quota" }, }; pills.set(agent.id, entry); } @@ -400,6 +506,7 @@ export default function contribute(client: PluginClientContext) { for (const [agentId, entry] of pills) { if (activeIds.has(agentId)) continue; removePills(entry); + forgetFreshness(agentId); pills.delete(agentId); } await Promise.all([refreshHubStatus(), refreshQuotaStatus()]); @@ -422,6 +529,8 @@ export default function contribute(client: PluginClientContext) { } async function refreshHubStatus(): Promise { + // A stalled or in-flight refresh still re-annotates: that is what paints the stale suffix. + annotatePillLabels("hub"); if (hubRefreshRunning || !preferences?.hub) return; const pillsByCwd = new Map< string, @@ -445,10 +554,20 @@ export default function contribute(client: PluginClientContext) { const summary = summarizeHubProcesses(result.processes); for (const { agentId, entry, handle } of targets) { const current = pills.get(agentId); - if (current === entry && current.hub === handle) handle.update(summary); + if (current !== entry || current.hub !== handle) continue; + recordFreshness(agentId, "hub", true); + entry.hubLabel = summary; + applyPillLabel(agentId, "hub", entry, handle); } } catch { - // Preserve the last known state through temporary host and workspace failures. + // Preserve the last known state through temporary host and workspace failures, + // annotating it as un-refreshed rather than clearing it. + for (const { agentId, entry, handle } of targets) { + const current = pills.get(agentId); + if (current !== entry || current.hub !== handle) continue; + recordFreshness(agentId, "hub", false); + applyPillLabel(agentId, "hub", entry, handle); + } } }), ); @@ -458,6 +577,8 @@ export default function contribute(client: PluginClientContext) { } async function refreshQuotaStatus(): Promise { + // A stalled or in-flight refresh still re-annotates: that is what paints the stale suffix. + annotatePillLabels("quota"); if (quotaRefreshRunning || !preferences?.quota) return; const groups = new Map< string, @@ -483,8 +604,11 @@ export default function contribute(client: PluginClientContext) { const current = pills.get(agentId); if (current !== entry || current.quota !== handle) continue; const severity = quotaSeverityForProvider(result.quotas, entry.quotaProvider, true); - handle.update({ - ...quotaSummaryForProvider(result.quotas, entry.quotaProvider, true), + recordFreshness(agentId, "quota", true); + entry.quotaLabel = historicalQuotaPillLabel( + quotaSummaryForProvider(result.quotas, entry.quotaProvider, true), + ); + applyPillLabel(agentId, "quota", entry, handle, { ...(severity === entry.quotaSeverity ? {} : { icon: quotaProviderIcon(entry.quotaProvider, severity) }), @@ -492,7 +616,14 @@ export default function contribute(client: PluginClientContext) { entry.quotaSeverity = severity; } } catch { - // Retain only this store's last result. Failure never falls back to another profile. + // Retain only this store's last result. Failure never falls back to another profile, + // and the retained label is annotated rather than cleared. + for (const { agentId, entry, handle } of targets) { + const current = pills.get(agentId); + if (current !== entry || current.quota !== handle) continue; + recordFreshness(agentId, "quota", false); + applyPillLabel(agentId, "quota", entry, handle); + } } }), ); @@ -545,10 +676,10 @@ export default function contribute(client: PluginClientContext) { .catch(() => {}); const hubPoll = setInterval(() => { void refreshHubStatus(); - }, STATUS_POLL_MS); + }, PILL_POLL_MS.hub); const quotaPoll = setInterval(() => { void refreshQuotaStatus(); - }, QUOTA_POLL_MS); + }, PILL_POLL_MS.quota); const settingsPoll = setInterval(() => { void refreshComposerPillSettings(); }, SETTINGS_POLL_MS); diff --git a/paseo-omp/shared/availability-display.ts b/paseo-omp/shared/availability-display.ts new file mode 100644 index 00000000..f70a4ef7 --- /dev/null +++ b/paseo-omp/shared/availability-display.ts @@ -0,0 +1,141 @@ +import type { OmpProviderHealth } from "./provider-diagnostics"; + +/** + * Pre-launch availability (QW2). + * + * The server already classifies availability (`probeOmpAvailability`, server/provider-diagnostics.ts) + * as missing | unrunnable | incompatible | available, but no released daemon calls it before a + * session starts. This module maps the *already allowlisted* health contract + * (`shared/provider-diagnostics.ts`) into the same four-state union plus display copy, so the + * Hub/diagnostics surfaces can show it pre-launch without a new RPC, a new probe, or any raw + * probe-output leak: every string here is fixed copy, never stdout/stderr. + * + * Custom-command note: this module resolves, guesses, or probes no binary — there is no second + * probe to get wrong. Per-agent correctness rests on the server's `checkAvailability` + * (server/provider/registration.ts), which reads that agent's own `providerOptions.command` + * (Doppler/env wrappers included) and probes exactly that binary via `probeOmpAvailability`. + * The health reused here is probed once for the daemon-default binary, so surfaces MUST present + * this classification as the default-binary signal, never as a per-agent guarantee for a + * custom-command profile. `command`/`status()` stay unregistered by design: a daemon-resolved + * launch cannot see per-agent options, so registering them would misjudge custom-command agents. + */ +export type AvailabilityStatus = "missing" | "unrunnable" | "incompatible" | "available"; + +export type AvailabilityAction = "open-diagnostics" | "retry"; + +export interface AvailabilityDisplay { + status: AvailabilityStatus; + /** Host tone vocabulary shared with `ProviderHealthTone`'s ok/warning/danger members. */ + tone: "ok" | "warning" | "danger"; + /** Short form for a dot/suffix: "Not found", "Cannot run", "Incompatible", "Available". */ + label: string; + /** Banner headline. */ + title: string; + /** One-line explanation; null when available. */ + detail: string | null; + /** Suggested next step; null when available. */ + action: AvailabilityAction | null; + /** False only when available, so available renders no badge at all. */ + showBadge: boolean; +} + +export interface AvailabilityCopy { + label: string; + title: string; + detail: string; + tone: AvailabilityDisplay["tone"]; + action: AvailabilityAction | null; +} + +/** Allowlisted copy per status — the single place these strings live. */ +export const AVAILABILITY_COPY: Record< + Exclude, + AvailabilityCopy +> = { + missing: { + label: "Not found", + title: "OMP not found", + detail: "No OMP executable resolved for this profile. Set the launch command or install OMP.", + tone: "danger", + action: "open-diagnostics", + }, + unrunnable: { + label: "Cannot run", + title: "OMP cannot run", + detail: "The executable was found but the probe failed. Check permissions, then retry.", + tone: "danger", + action: "retry", + }, + incompatible: { + label: "Incompatible", + title: "OMP is incompatible", + detail: "This OMP build does not advertise rpc-ui support. Upgrade OMP or fix the command.", + tone: "warning", + action: "open-diagnostics", + }, +}; + +const AVAILABLE_DISPLAY: AvailabilityDisplay = { + status: "available", + tone: "ok", + label: "Available", + title: "OMP available", + detail: null, + action: null, + showBadge: false, +}; + +/** + * Pure map from the existing health contract to the four-state union, mirroring the precedence + * of `probeOmpAvailability` (cleanup failure, then version status, then rpc-ui detection) so the + * pre-launch prediction matches what a launch would find. + */ +export function displayForStatus(status: AvailabilityStatus): AvailabilityDisplay { + if (status === "available") return { ...AVAILABLE_DISPLAY }; + const copy = AVAILABILITY_COPY[status]; + return { + status, + tone: copy.tone, + label: copy.label, + title: copy.title, + detail: copy.detail, + action: copy.action, + showBadge: true, + }; +} + +/** + * Pure map from the existing health contract to the four-state union, mirroring the precedence + * of `probeOmpAvailability` (cleanup failure, then version status, then rpc-ui detection) so the + * pre-launch prediction matches what a launch would find. Single construction site for health: + * classify here, build copy via `displayForStatus`, so callers holding a daemon-observed failure + * with no health (e.g. a custom-command profile the default-binary probe cannot speak for) + * build the same allowlisted copy without inventing a second probe or leaking probe output. + */ +export function toAvailabilityDisplay( + health: Pick, +): AvailabilityDisplay { + return displayForStatus(classifyAvailability(health)); +} + +export function classifyAvailability( + health: Pick, +): AvailabilityStatus { + const { binary, rpcUi } = health; + if (!binary.installed) return "missing"; + if (binary.processCleanupFailed) return "unrunnable"; + switch (binary.versionStatus) { + case "ok": + break; + case "not-found": + return "missing"; + case "malformed": + return "incompatible"; + default: + return "unrunnable"; + } + if (!rpcUi.checked) return "unrunnable"; + if (rpcUi.supported === false) return "incompatible"; + if (rpcUi.supported === null) return "unrunnable"; + return "available"; +} diff --git a/paseo-omp/tests/availability-display.test.ts b/paseo-omp/tests/availability-display.test.ts new file mode 100644 index 00000000..7bc92f28 --- /dev/null +++ b/paseo-omp/tests/availability-display.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "vitest"; +import { + AVAILABILITY_COPY, + classifyAvailability, + toAvailabilityDisplay, +} from "../shared/availability-display"; +import type { OmpProviderHealth } from "../shared/provider-diagnostics"; + +function health(overrides: Partial = {}): OmpProviderHealth { + return { + binary: { + installed: true, + resolvedPath: "~/bin/omp", + version: { major: 18, minor: 6, patch: 0, prerelease: null }, + versionStatus: "ok", + processCleanupFailed: false, + }, + rpcUi: { checked: true, supported: true }, + lsp: { status: "supported" }, + mcp: { status: "unavailable", serverCount: null, reason: null }, + process: { status: "ok", trackedCount: null }, + roots: { + agentRoot: "~/.omp/agent", + agentRootState: "available", + configPath: "~/.omp/agent/config.yml", + configState: "available", + sessionRoot: "~/.omp/agent/sessions", + sessionRootState: "available", + }, + databases: { agentDbState: "available", historyDbState: "available" }, + memoryBackend: null, + checkedAt: "2026-10-04T00:00:00.000Z", + ...overrides, + }; +} + +describe("availability classification", () => { + test("available only when installed, runnable, and rpc-ui is advertised", () => { + expect(classifyAvailability(health())).toBe("available"); + expect(toAvailabilityDisplay(health())).toMatchObject({ + status: "available", + showBadge: false, + }); + }); + + test("missing when the binary was not installed or not found", () => { + expect( + classifyAvailability( + health({ binary: { ...health().binary, installed: false, versionStatus: "not-found" } }), + ), + ).toBe("missing"); + expect( + classifyAvailability( + health({ binary: { ...health().binary, installed: true, versionStatus: "not-found" } }), + ), + ).toBe("missing"); + }); + + test("unrunnable covers probe failures, timeouts, and cleanup failure", () => { + for (const versionStatus of ["unrunnable", "timeout", "probe-failed"] as const) { + expect(classifyAvailability(health({ binary: { ...health().binary, versionStatus } }))).toBe( + "unrunnable", + ); + } + expect( + classifyAvailability(health({ binary: { ...health().binary, processCleanupFailed: true } })), + ).toBe("unrunnable"); + expect(classifyAvailability(health({ rpcUi: { checked: true, supported: null } }))).toBe( + "unrunnable", + ); + expect(classifyAvailability(health({ rpcUi: { checked: false, supported: null } }))).toBe( + "unrunnable", + ); + }); + + test("incompatible when the version is malformed or rpc-ui is explicitly absent", () => { + expect( + classifyAvailability(health({ binary: { ...health().binary, versionStatus: "malformed" } })), + ).toBe("incompatible"); + expect(classifyAvailability(health({ rpcUi: { checked: true, supported: false } }))).toBe( + "incompatible", + ); + }); + + test("checks run in probe order: install, cleanup, version, rpc-ui", () => { + const binary = health().binary; + expect( + classifyAvailability( + health({ + binary: { ...binary, installed: false, versionStatus: "malformed" }, + rpcUi: { checked: true, supported: false }, + }), + ), + ).toBe("missing"); + expect( + classifyAvailability( + health({ binary: { ...binary, processCleanupFailed: true, versionStatus: "not-found" } }), + ), + ).toBe("unrunnable"); + expect( + classifyAvailability( + health({ + binary: { ...binary, versionStatus: "not-found" }, + rpcUi: { checked: true, supported: false }, + }), + ), + ).toBe("missing"); + }); +}); + +describe("availability display copy", () => { + test("allowlists copy for each unavailable state and leaks no probe output", () => { + const cases = [ + ["missing", "Not found", "danger"], + ["unrunnable", "Cannot run", "danger"], + ["incompatible", "Incompatible", "warning"], + ] as const; + const overrides: Record<(typeof cases)[number][0], Partial> = { + missing: { binary: { ...health().binary, installed: false, versionStatus: "not-found" } }, + unrunnable: { binary: { ...health().binary, versionStatus: "timeout" } }, + incompatible: { + binary: { ...health().binary, installed: true, versionStatus: "ok" }, + rpcUi: { checked: true, supported: false }, + }, + }; + for (const [status, label, tone] of cases) { + const display = toAvailabilityDisplay(health(overrides[status])); + expect(display).toMatchObject({ status, label, tone, showBadge: true }); + expect(display.title.length).toBeGreaterThan(0); + expect(display.detail).toBe(AVAILABILITY_COPY[status].detail); + expect(display.action).toBe(AVAILABILITY_COPY[status].action); + // Copy is fixed text, never a path/version/stdout fragment. + expect(display.detail).not.toMatch(/[\d]+\.[\d]+\.[\d]+|\/|~|\.yml|\.db/); + } + }); + + test("available is badge-free with no detail or action", () => { + expect(toAvailabilityDisplay(health())).toEqual({ + status: "available", + tone: "ok", + label: "Available", + title: "OMP available", + detail: null, + action: null, + showBadge: false, + }); + }); +}); diff --git a/paseo-omp/tests/pill-freshness.test.ts b/paseo-omp/tests/pill-freshness.test.ts new file mode 100644 index 00000000..91ff3d5b --- /dev/null +++ b/paseo-omp/tests/pill-freshness.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "vitest"; +import { + createFreshnessStore, + freshnessKey, + freshnessNotice, + INITIAL_PILL_FRESHNESS, + isStale, + PILL_POLL_MS, + type PillFreshness, + pillLabelFor, + STALE_AFTER_POLLS, + transition, +} from "../client/pill-freshness"; + +const base: PillFreshness = { state: "fresh", lastSuccessAt: 1_000, consecutiveFailures: 0 }; + +describe("pill freshness transitions", () => { + test("a success resets failures and stamps the clock", () => { + const failed = transition(base, { type: "poll:error", at: 5_000 }); + expect(failed).toEqual({ state: "error", lastSuccessAt: 1_000, consecutiveFailures: 1 }); + expect(transition(failed, { type: "poll:success", at: 6_000 })).toEqual({ + state: "fresh", + lastSuccessAt: 6_000, + consecutiveFailures: 0, + }); + }); + + test("failures accumulate and retain the last success", () => { + let state = base; + for (const at of [2_000, 3_000, 4_000]) { + state = transition(state, { type: "poll:error", at }); + } + expect(state).toEqual({ state: "error", lastSuccessAt: 1_000, consecutiveFailures: 3 }); + }); + + test("start and retry never clear an error on their own", () => { + const failed = transition(base, { type: "poll:error", at: 5_000 }); + expect(transition(failed, { type: "poll:start" })).toBe(failed); + expect(transition(failed, { type: "retry" })).toBe(failed); + expect(transition(INITIAL_PILL_FRESHNESS, { type: "retry" })).toBe(INITIAL_PILL_FRESHNESS); + }); + + test("staleness is three poll intervals after the last success", () => { + const hub = PILL_POLL_MS.hub; + expect(STALE_AFTER_POLLS).toBe(3); + expect(isStale(base, hub, 1_000 + 3 * hub)).toBe(false); + expect(isStale(base, hub, 1_000 + 3 * hub + 1)).toBe(true); + expect(isStale({ ...base, lastSuccessAt: null }, hub, 10_000_000)).toBe(false); + }); +}); + +describe("pill labels", () => { + test("annotates the last-known label without clearing it", () => { + const label = { visible: true, label: "Hub · 2" }; + const fresh = { state: "fresh" as const, lastSuccessAt: 10_000, consecutiveFailures: 0 }; + expect(pillLabelFor("hub", label, fresh, 10_000)).toEqual(label); + expect( + pillLabelFor("hub", label, transition(fresh, { type: "poll:error", at: 1 }), 10_000), + ).toEqual({ visible: true, label: "Hub · 2 · !" }); + expect(isStale(fresh, PILL_POLL_MS.hub, 10_000 + 3 * PILL_POLL_MS.hub + 1)).toBe(true); + }); + + test("stale annotates a hidden pill without making it visible", () => { + const hidden = { visible: false, label: "Quota" }; + const stale = { + state: "fresh" as const, + lastSuccessAt: 0, + consecutiveFailures: 0, + }; + expect(pillLabelFor("quota", hidden, stale, 3 * PILL_POLL_MS.quota + 1)).toEqual({ + visible: false, + label: "Quota · stale", + }); + }); + + test("error takes precedence over stale", () => { + const label = { visible: true, label: "Hub · 1" }; + const errored = { state: "error" as const, lastSuccessAt: 0, consecutiveFailures: 2 }; + expect(pillLabelFor("hub", label, errored, 999_999)).toEqual({ + visible: true, + label: "Hub · 1 · !", + }); + }); + + test("quota uses its own interval for staleness", () => { + const label = { visible: true, label: "Quotas · 50%" }; + const fresh = { state: "fresh" as const, lastSuccessAt: 0, consecutiveFailures: 0 }; + const now = 3 * PILL_POLL_MS.hub + 1; + expect(pillLabelFor("hub", label, fresh, now).label).toBe("Quotas · 50% · stale"); + expect(pillLabelFor("quota", label, fresh, now).label).toBe("Quotas · 50%"); + }); +}); + +describe("freshness notices", () => { + test("renders nothing while fresh and names the failure count", () => { + const fresh = { state: "fresh" as const, lastSuccessAt: 1, consecutiveFailures: 0 }; + expect(freshnessNotice(fresh, "hub", 2)).toBeNull(); + const once = transition(fresh, { type: "poll:error", at: 2 }); + expect(freshnessNotice(once, "hub", 2)).toEqual({ + text: "Could not refresh (1 failure). Showing last known state.", + tone: "danger", + }); + const twice = transition(once, { type: "poll:error", at: 3 }); + expect(freshnessNotice(twice, "hub", 3)?.text).toBe( + "Could not refresh (2 failures). Showing last known state.", + ); + }); + + test("stale notice names the derived window, never a hardcoded constant", () => { + const fresh = { state: "fresh" as const, lastSuccessAt: 0, consecutiveFailures: 0 }; + const now = 3 * PILL_POLL_MS.quota + 1; + expect(freshnessNotice(fresh, "quota", now)).toEqual({ + text: "No successful refresh for over 90s. Showing last known state.", + tone: "warning", + }); + }); +}); + +describe("freshness store", () => { + test("defaults to fresh, notifies only its own key, and forgets on removal", () => { + const store = createFreshnessStore(); + expect(store.get("a:hub")).toBe(INITIAL_PILL_FRESHNESS); + expect(store.has("a:hub")).toBe(false); + let notified = 0; + const unsubscribe = store.subscribe("a:hub", () => { + notified += 1; + }); + const next = transition(INITIAL_PILL_FRESHNESS, { type: "poll:success", at: 7 }); + store.set("a:hub", next); + store.set("a:quota", next); + expect(notified).toBe(1); + expect(store.get("a:hub")).toBe(next); + expect(store.has("a:hub")).toBe(true); + unsubscribe(); + store.set("a:hub", INITIAL_PILL_FRESHNESS); + expect(notified).toBe(1); + store.remove("a:hub"); + expect(store.get("a:hub")).toBe(INITIAL_PILL_FRESHNESS); + expect(store.has("a:hub")).toBe(false); + expect(freshnessKey("a", "hub")).toBe("a:hub"); + }); +}); diff --git a/paseo-omp/tests/profile-pills.test.ts b/paseo-omp/tests/profile-pills.test.ts index 20596cdc..8d90066e 100644 --- a/paseo-omp/tests/profile-pills.test.ts +++ b/paseo-omp/tests/profile-pills.test.ts @@ -128,13 +128,13 @@ test("owned agent directory updates reconcile new agent pills and cached quota r return button; }; expect(quota("alpha").update).toHaveBeenLastCalledWith( - expect.objectContaining({ visible: true, label: "Quotas · 90%" }), + expect.objectContaining({ visible: true, label: "Quotas · 90% (historical)" }), ); expect(quota("team-beta").update).toHaveBeenLastCalledWith( - expect.objectContaining({ visible: true, label: "Quotas · 20%" }), + expect.objectContaining({ visible: true, label: "Quotas · 20% (historical)" }), ); expect(quota("default").update).toHaveBeenLastCalledWith( - expect.objectContaining({ visible: true, label: "Anthropic · 50%" }), + expect.objectContaining({ visible: true, label: "Anthropic · 50% (historical)" }), ); expect(quota("codex").update).not.toHaveBeenCalled(); expect( @@ -158,7 +158,7 @@ test("owned agent directory updates reconcile new agent pills and cached quota r onDirectoryUpdate(); await vi.advanceTimersByTimeAsync(250); expect(quota("alpha").update).toHaveBeenLastCalledWith( - expect.objectContaining({ label: "Quotas · 20%" }), + expect.objectContaining({ label: "Quotas · 20% (historical)" }), ); expect(rpc.mock.calls.filter(([definition]) => definition === listOmpQuotas)).toHaveLength(3); } finally { From 48e26c25457db26eeecf0db2dad981fff9d42fe4 Mon Sep 17 00:00:00 2001 From: Zahid Date: Sun, 4 Oct 2026 22:50:49 +0700 Subject: [PATCH 4/4] feat(paseo-omp): one-click support bundle, instead-hints, wire-diff lever --- paseo-omp/README.md | 1 + paseo-omp/SUPPORT.md | 2 +- paseo-omp/TESTING.md | 2 +- paseo-omp/client/support-diagnostics-state.ts | 43 +++ paseo-omp/index.server.ts | 6 + paseo-omp/scripts/omp-wire-diff.mjs | 354 ++++++++++++++++++ paseo-omp/server/provider/connection.ts | 12 +- paseo-omp/server/provider/host-tools.ts | 4 +- paseo-omp/server/provider/instead-hints.ts | 91 +++++ paseo-omp/server/provider/prompt-payload.ts | 6 +- paseo-omp/server/provider/session.ts | 10 +- paseo-omp/server/support-bundle.ts | 87 +++++ paseo-omp/shared/support-bundle.ts | 147 ++++++++ paseo-omp/tests/instead-hints.test.ts | 73 ++++ paseo-omp/tests/server-bundle.test.ts | 5 +- paseo-omp/tests/support-bundle.test.ts | 130 +++++++ 16 files changed, 960 insertions(+), 13 deletions(-) create mode 100755 paseo-omp/scripts/omp-wire-diff.mjs create mode 100644 paseo-omp/server/provider/instead-hints.ts create mode 100644 paseo-omp/server/support-bundle.ts create mode 100644 paseo-omp/shared/support-bundle.ts create mode 100644 paseo-omp/tests/instead-hints.test.ts create mode 100644 paseo-omp/tests/support-bundle.test.ts diff --git a/paseo-omp/README.md b/paseo-omp/README.md index d1a4d52e..48545257 100644 --- a/paseo-omp/README.md +++ b/paseo-omp/README.md @@ -115,6 +115,7 @@ Tracking rules: 2. Raise a row only when the provider advertises the capability and an observable contract test covers its success and failure boundaries. 3. Keep native-agent limitations at less than 100% even when the adapter itself is complete; do not count undocumented fallbacks as support. 4. Keep the detailed evidence and regression locations in [TESTING.md](TESTING.md); this README is the public progress ledger. +5. Record one wire-diff outcome per OMP release. `node scripts/omp-wire-diff.mjs --omp --protocol server/provider/omp-rpc-protocol.ts` prints a `clean` / `additive-optional` / `additive-required` / `removed-or-renamed` / `type-change` table and exits 2 on release-blocking drift; attach that table to the scoring change so the release stays comparable. ## Compatibility and coexistence diff --git a/paseo-omp/SUPPORT.md b/paseo-omp/SUPPORT.md index 51ee0b1b..778d85c2 100644 --- a/paseo-omp/SUPPORT.md +++ b/paseo-omp/SUPPORT.md @@ -12,7 +12,7 @@ After the failure is isolated: - report a Paseo plugin SDK, loader, or provider-protocol defect to [Paseo](https://github.com/getpaseo/paseo/issues); - keep adaptation, packaging, and cross-project compatibility work in this repository. -Do not put credentials, private repository paths, session transcripts, or unredacted RPC payloads in an issue. Report vulnerabilities through the [private GitHub Security Advisory form](https://github.com/omercnet/paseo-plugins/security/advisories/new), not a public issue. +Do not put credentials, private repository paths, session transcripts, or unredacted RPC payloads in an issue. If a maintainer asks for a transcript excerpt, use **Copy bundle** in **OMP → Help**. It appends an opt-in 32 KiB journal excerpt to the same report, and you must review and redact that excerpt before pasting it. Report vulnerabilities through the [private GitHub Security Advisory form](https://github.com/omercnet/paseo-plugins/security/advisories/new), not a public issue. ## Supported versions diff --git a/paseo-omp/TESTING.md b/paseo-omp/TESTING.md index c832a6ce..48503a47 100644 --- a/paseo-omp/TESTING.md +++ b/paseo-omp/TESTING.md @@ -117,7 +117,7 @@ The controlled canary passed end to end with OMP 18.1.15 and 18.2.0 on 2026-09-1 Treat every upstream `rpc-ui` change as explicit compatibility work. Do not widen a Zod schema with `passthrough`, `unknown`, or an optional field merely to accept a new frame. 1. Open an issue with the [OMP RPC compatibility template](https://github.com/omercnet/paseo-plugins/issues/new?template=omp-rpc-compatibility.yml). Record exact OMP, plugin, Paseo daemon, and Paseo app versions; the negotiated protocol and capabilities; the smallest reproduction; and sanitized frame shapes. Never attach credentials, private paths, prompts, or transcripts. -2. Reproduce against both the reported OMP revision and the pinned minimum-tested `omp/18.1.15` binary. Classify the change as additive optional, additive required, removed or renamed, type or semantic change, or negotiation change. +2. Reproduce against both the reported OMP revision and the pinned minimum-tested `omp/18.1.15` binary. Classify the change as additive optional, additive required, removed or renamed, type or semantic change, or negotiation change. Before claiming support, run `node scripts/omp-wire-diff.mjs --omp --protocol server/provider/omp-rpc-protocol.ts` and paste its classification table into the issue. The lever is offline, exits 2 on additive-required, removed-or-renamed, or type-change drift, and never edits the strict parser. 3. Compare the affected ready, request, response, or event shape with the strict schemas in `server/provider/omp-rpc-protocol.ts`. Decide whether the plugin can support both contracts without ambiguity. A breaking contract requires an explicit compatibility decision and changelog entry, not silent coercion. Required drift is release-blocking. If OMP adds a mandatory frame, removes or renames a required method or field, or changes an existing field's meaning, keep the strict parser and make session startup or the active request fail visibly. Do not silently discard the frame, make the requirement optional, or route around negotiation. Resume release work only after both sides have an explicit compatible contract, fixtures, focused regressions, and a real-binary result. diff --git a/paseo-omp/client/support-diagnostics-state.ts b/paseo-omp/client/support-diagnostics-state.ts index 89e1f336..8ced0060 100644 --- a/paseo-omp/client/support-diagnostics-state.ts +++ b/paseo-omp/client/support-diagnostics-state.ts @@ -11,6 +11,16 @@ export async function refreshSupportReport( } export type SupportReportCopyState = "idle" | "copying" | "copied" | "error"; +export type SupportBundleCopyState = "idle" | "bundling" | "copied" | "error"; + +export interface SupportBundleViewState { + bundleLabel: string; + bundleDisabled: boolean; + bundleFeedback: string | null; + transcriptStatus: "not-requested" | "included" | "unavailable"; + transcriptNote: string | null; + redactionReminder: string | null; +} export interface SupportDiagnosticsViewState { refreshLabel: string; @@ -43,3 +53,36 @@ export function supportDiagnosticsViewState(input: { : null, }; } + +const TRANSCRIPT_REDACTION_REMINDER = + "Transcript excerpt may contain credentials. Review and redact before pasting."; + +/** + * Bundle view model. The transcript half is opt-in per copy, so the reminder only appears once the + * user actually asked for an excerpt; a bundle without a transcript is a plain report copy. + */ +export function supportBundleViewState(input: { + hasReport: boolean; + copyState: SupportBundleCopyState; + transcriptRequested: boolean; + transcriptStatus: "not-requested" | "included" | "unavailable"; + transcriptNote: string | null; +}): SupportBundleViewState { + const transcriptIncluded = input.transcriptStatus === "included"; + return { + bundleLabel: input.copyState === "bundling" ? "Bundling…" : "Copy bundle", + bundleDisabled: !input.hasReport || input.copyState === "bundling", + bundleFeedback: + input.copyState === "copied" + ? transcriptIncluded + ? "Bundle copied. Review the transcript excerpt before pasting." + : "Bundle copied." + : input.copyState === "error" + ? "Could not copy the bundle. Select the report text and copy it manually." + : null, + transcriptStatus: input.transcriptStatus, + transcriptNote: input.transcriptNote, + redactionReminder: + input.transcriptRequested && transcriptIncluded ? TRANSCRIPT_REDACTION_REMINDER : null, + }; +} diff --git a/paseo-omp/index.server.ts b/paseo-omp/index.server.ts index cbc25538..3018ffb8 100644 --- a/paseo-omp/index.server.ts +++ b/paseo-omp/index.server.ts @@ -28,6 +28,7 @@ import { OmpPublicError } from "./server/provider/security"; import { resolveGetOmpProviderHealth } from "./server/provider-diagnostics"; import { resolveListOmpQuotas } from "./server/quota"; import { resolveListOmpSessions } from "./server/sessions"; +import { resolveGetOmpSupportBundle } from "./server/support-bundle"; import { resolveGetOmpSupportReport } from "./server/support-diagnostics"; import { composerPillSettings } from "./shared/composer-pill-settings"; import { listHubProcesses, tailHubLog } from "./shared/hub"; @@ -47,6 +48,7 @@ import { getOmpProviderHealth } from "./shared/provider-diagnostics"; import { providerLaunchSettings } from "./shared/provider-launch-settings"; import { listOmpQuotas } from "./shared/quota"; import { listOmpSessions } from "./shared/sessions"; +import { getOmpSupportBundle } from "./shared/support-bundle"; import { getOmpSupportReport } from "./shared/support-diagnostics"; function scoped(handler: (input: T) => R) { @@ -94,6 +96,10 @@ export default function contribute(server: PluginServerContext) { getOmpSupportReport, scoped((input) => resolveGetOmpSupportReport(input, protocolViolations, operationalFailures)), ); + server.handle( + getOmpSupportBundle, + scoped((input) => resolveGetOmpSupportBundle(input, protocolViolations, operationalFailures)), + ); server.handle(openOmpMcpAuthorizationInPaseoBrowser, (input) => resolveOpenOmpMcpAuthorizationInPaseoBrowser(input, browserAuthorizationRegistry), ); diff --git a/paseo-omp/scripts/omp-wire-diff.mjs b/paseo-omp/scripts/omp-wire-diff.mjs new file mode 100755 index 00000000..350ad8a1 --- /dev/null +++ b/paseo-omp/scripts/omp-wire-diff.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** Wire + SDK drift lever for the OMP RPC compatibility intake (offline; see USAGE). */ + +const EXIT_CLEAN = 0; +const EXIT_BLOCKING = 2; +const EXIT_USAGE = 1; + +const USAGE = `Usage: + node scripts/omp-wire-diff.mjs --self-check + node scripts/omp-wire-diff.mjs --omp --protocol [--baseline ] + node scripts/omp-wire-diff.mjs --sdk --ledger [--matrix ] + +Options: + --omp Upstream rpc-wire.schema.json, its directory, or a tag with a cached snapshot. + --protocol server/provider/omp-rpc-protocol.ts to read command policies from. + --baseline Optional previous schema JSON; per-command signature changes become type-change. + --sdk @getpaseo/plugin server provider declaration or its directory. + --ledger README capability ledger to diff SDK capabilities against. + --matrix Optional TESTING.md evidence matrix to mention in the SDK report. + --self-check Run built-in offline fixtures and verify the exit-code contract. + --help Print this message. + +Classification: clean | additive-optional | additive-required | removed-or-renamed | type-change. +Exit 2 on any additive-required, removed-or-renamed, or type-change entry.`; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exitCode = EXIT_USAGE; +} + +function readText(path, label) { + try { + return readFileSync(path, "utf8"); + } catch { + fail(`${label} could not be read: ${path}`); + return null; + } +} + +function readJson(path, label) { + const text = readText(path, label); + if (text === null) return null; + try { + return JSON.parse(text); + } catch { + fail(`${label} is not valid JSON: ${path}`); + return null; + } +} + +/** Reads `OMP_RPC_COMMAND_POLICIES` from the strict parser without importing it. */ +export function readProtocolPolicies(text) { + const marker = "OMP_RPC_COMMAND_POLICIES"; + const markerIndex = text.indexOf(marker); + if (markerIndex === -1) return null; + const openIndex = text.indexOf("{", markerIndex); + const closeIndex = text.indexOf("} as const", openIndex); + if (openIndex === -1 || closeIndex === -1) return null; + const body = text.slice(openIndex + 1, closeIndex); + const policies = new Map(); + for (const match of body.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"([^"]+)"/gu)) { + policies.set(match[1], match[2]); + } + return policies.size > 0 ? policies : null; +} + +/** Reads the canonical `{ commands: { : { required, signature } } }` wire form. */ +export function readWireSchema(schema) { + const raw = schema?.commands ?? schema?.rpc?.commands; + const commands = new Map(); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [name, value] of Object.entries(raw)) { + const record = value && typeof value === "object" ? value : {}; + commands.set(name, { + required: record.required === true || record.mandatory === true, + signature: typeof record.signature === "string" ? record.signature : null, + }); + } + } + return commands; +} + +export function classifyWireDiff(wireCommands, policies, baselineCommands) { + const rows = []; + for (const [name, entry] of wireCommands) { + const policy = policies.get(name); + const baseline = baselineCommands?.get(name); + if (baseline && entry.signature && baseline.signature !== entry.signature) { + rows.push({ command: name, verdict: "type-change" }); + continue; + } + if (policy === "implemented") { + rows.push({ command: name, verdict: "clean" }); + continue; + } + rows.push({ + command: name, + verdict: entry.required ? "additive-required" : "additive-optional", + }); + } + for (const [name, policy] of policies) { + if (policy === "implemented" && !wireCommands.has(name)) { + rows.push({ command: name, verdict: "removed-or-renamed" }); + } + } + return rows.sort((a, b) => a.command.localeCompare(b.command)); +} + +const BLOCKING_VERDICTS = new Set(["additive-required", "removed-or-renamed", "type-change"]); + +export function renderWireTable(rows) { + const nameWidth = Math.max(7, ...rows.map((row) => row.command.length)); + const header = `${"command".padEnd(nameWidth)} verdict`; + const lines = [header, `${"-".repeat(nameWidth)} ${"-".repeat(11)}`]; + for (const row of rows) lines.push(`${row.command.padEnd(nameWidth)} ${row.verdict}`); + return lines.join("\n"); +} + +export function readSdkCapabilities(text) { + const marker = "PROVIDER_CAPABILITIES"; + const markerIndex = text.indexOf(marker); + if (markerIndex === -1) return null; + const start = text.indexOf("[", markerIndex); + const end = text.indexOf("]", start); + if (start === -1 || end === -1) return null; + const capabilities = []; + for (const match of text.slice(start, end).matchAll(/"([^"]+)"/gu)) capabilities.push(match[1]); + return capabilities.length > 0 ? capabilities : null; +} + +export function classifySdkDiff(capabilities, ledgerText) { + const claimed = new Set(); + for (const line of ledgerText.split("\n")) { + const match = /^\|\s*`([a-z0-9_.]+)`\s*\|/u.exec(line); + if (match) claimed.add(match[1]); + } + const rows = []; + for (const capability of capabilities) { + rows.push({ + command: capability, + verdict: claimed.has(capability) ? "clean" : "removed-or-renamed", + }); + } + return rows.sort((a, b) => a.command.localeCompare(b.command)); +} + +function runWireDiff({ ompPath, protocolPath, baselinePath }) { + const schemaPath = resolveOmpSchema(ompPath); + if (schemaPath === null) return EXIT_USAGE; + const schema = readJson(schemaPath, "wire schema"); + const protocolText = readText(protocolPath, "protocol file"); + if (schema === null || protocolText === null) return EXIT_USAGE; + const policies = readProtocolPolicies(protocolText); + if (policies === null) { + fail(`no OMP_RPC_COMMAND_POLICIES table found in ${protocolPath}`); + return EXIT_USAGE; + } + let baseline = null; + if (baselinePath) { + const baselineSchema = readJson(baselinePath, "baseline schema"); + if (baselineSchema === null) return EXIT_USAGE; + baseline = readWireSchema(baselineSchema); + } + const rows = classifyWireDiff(readWireSchema(schema), policies, baseline); + process.stdout.write(`${renderWireTable(rows)}\n`); + return rows.some((row) => BLOCKING_VERDICTS.has(row.verdict)) ? EXIT_BLOCKING : EXIT_CLEAN; +} + +function resolveOmpSchema(ompPath) { + if (!ompPath) { + fail("--omp is required with --protocol"); + return null; + } + const candidate = resolve(ompPath); + try { + const direct = readFileSync(candidate, "utf8"); + JSON.parse(direct); + return candidate; + } catch { + // Not a schema file; try a directory or a cached tag snapshot next. + } + const inDirectory = join(candidate, "rpc-wire.schema.json"); + try { + readFileSync(inDirectory, "utf8"); + return inDirectory; + } catch { + fail(`no rpc-wire.schema.json at ${candidate}; tag sources need a cached snapshot file path`); + return null; + } +} + +function runSdkDiff({ sdkPath, ledgerPath, matrixPath }) { + const declaration = resolveSdkDeclaration(sdkPath); + if (declaration === null) return EXIT_USAGE; + const declarationText = readText(declaration, "SDK provider declaration"); + const ledgerText = readText(ledgerPath, "ledger"); + if (declarationText === null || ledgerText === null) return EXIT_USAGE; + const capabilities = readSdkCapabilities(declarationText); + if (capabilities === null) { + fail(`no PROVIDER_CAPABILITIES list found in ${declaration}`); + return EXIT_USAGE; + } + const rows = classifySdkDiff(capabilities, ledgerText); + process.stdout.write(`${renderWireTable(rows)}\n`); + if (matrixPath) process.stdout.write(`\nEvidence matrix: ${matrixPath}\n`); + return rows.some((row) => BLOCKING_VERDICTS.has(row.verdict)) ? EXIT_BLOCKING : EXIT_CLEAN; +} + +function resolveSdkDeclaration(sdkPath) { + if (!sdkPath) { + fail("--sdk is required with --ledger"); + return null; + } + const candidate = resolve(sdkPath); + try { + if (readFileSync(candidate, "utf8")) return candidate; + } catch { + // Fall through to the directory form. + } + const inDirectory = join(candidate, "provider.d.ts"); + try { + readFileSync(inDirectory, "utf8"); + return inDirectory; + } catch { + fail(`no provider declaration at ${candidate}`); + return null; + } +} + +/** Offline fixtures that pin the exit-code contract without any upstream checkout. */ +export function selfCheck() { + const directory = mkdtempSync(join(tmpdir(), "omp-wire-diff-")); + try { + const protocol = join(directory, "omp-rpc-protocol.ts"); + writeFileSync( + protocol, + 'export const OMP_RPC_COMMAND_POLICIES = {\n prompt: "implemented",\n get_state: "implemented",\n new_session: "unsupported",\n} as const;\n', + ); + const cleanSchema = join(directory, "clean.json"); + writeFileSync( + cleanSchema, + JSON.stringify({ + commands: { + prompt: { required: true, signature: "a" }, + get_state: { required: false }, + handoff: { required: false }, + }, + }), + ); + const blockingSchema = join(directory, "blocking.json"); + writeFileSync( + blockingSchema, + JSON.stringify({ + commands: { + prompt: { required: true, signature: "a" }, + mandatory_new_command: { required: true }, + }, + }), + ); + const baseline = join(directory, "baseline.json"); + writeFileSync(baseline, JSON.stringify({ commands: { prompt: { signature: "old" } } })); + + const cleanExit = runWireDiff({ + ompPath: cleanSchema, + protocolPath: protocol, + baselinePath: null, + }); + const blockingExit = runWireDiff({ + ompPath: blockingSchema, + protocolPath: protocol, + baselinePath: null, + }); + const typeChangeExit = runWireDiff({ + ompPath: cleanSchema, + protocolPath: protocol, + baselinePath: baseline, + }); + + const results = [ + ["clean schema", cleanExit, EXIT_CLEAN], + ["required drift", blockingExit, EXIT_BLOCKING], + ["signature change", typeChangeExit, EXIT_BLOCKING], + ]; + let ok = true; + for (const [label, actual, expected] of results) { + const passed = actual === expected; + ok = ok && passed; + process.stdout.write( + `${passed ? "ok " : "FAIL"} ${label}: exit ${actual} (want ${expected})\n`, + ); + } + process.exitCode = ok ? EXIT_CLEAN : EXIT_USAGE; + return ok; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function parseArguments(argv) { + const flags = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) continue; + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + flags[token.slice(2)] = true; + continue; + } + flags[token.slice(2)] = next; + index += 1; + } + return flags; +} + +function main(argv) { + const flags = parseArguments(argv); + if (flags.help || argv.length === 0) { + process.stdout.write(`${USAGE}\n`); + process.exitCode = EXIT_CLEAN; + return; + } + if (flags["self-check"]) { + selfCheck(); + return; + } + if (flags.sdk !== undefined) { + process.exitCode = runSdkDiff({ + sdkPath: flags.sdk === true ? undefined : flags.sdk, + ledgerPath: flags.ledger === true ? undefined : flags.ledger, + matrixPath: flags.matrix === true ? undefined : flags.matrix, + }); + return; + } + if (flags.omp !== undefined) { + process.exitCode = runWireDiff({ + ompPath: flags.omp === true ? undefined : flags.omp, + protocolPath: flags.protocol === true ? undefined : flags.protocol, + baselinePath: flags.baseline === true ? undefined : flags.baseline, + }); + return; + } + fail("no mode selected"); + process.stdout.write(`${USAGE}\n`); +} + +if (process.argv[1] && import.meta.url === new URL(`file://${resolve(process.argv[1])}`).href) { + main(process.argv.slice(2)); +} + +export { EXIT_BLOCKING, EXIT_CLEAN, EXIT_USAGE, main, USAGE }; diff --git a/paseo-omp/server/provider/connection.ts b/paseo-omp/server/provider/connection.ts index 089d7991..7798f399 100644 --- a/paseo-omp/server/provider/connection.ts +++ b/paseo-omp/server/provider/connection.ts @@ -16,6 +16,7 @@ import type { import { discoverOmpCatalog } from "./catalog"; import { normalizeOmpCatalogOptions } from "./config-normalization"; import type { OmpMcpConnector } from "./host-tools"; +import { rejectDetails, rejectWithHint } from "./instead-hints"; import type { OmpRuntime } from "./omp-rpc"; import { OMP_RPC_BOUND_DIMENSIONS, @@ -136,10 +137,10 @@ function preflightProviderInput(input: unknown): void { } } if (config?.toolPolicy !== undefined) { - throw new OmpPublicError("OMP does not support host tool policies"); + throw rejectWithHint("toolPolicy-at-open", "OMP does not support host tool policies"); } if (hasOwnEntries(config?.settings)) { - throw new OmpPublicError("OMP does not expose live provider settings"); + throw rejectWithHint("live-settings", "OMP does not expose live provider settings"); } } if (record.type === "catalog" || record.type === "sessions") { @@ -165,7 +166,10 @@ function preflightProviderInput(input: unknown): void { throw new OmpPublicError("Prompt output schema is too large"); } if (prompt?.outputSchema !== undefined || prompt?.clearPendingPermissions === true) { - throw new OmpPublicError("OMP does not support structured output or permission controls"); + throw rejectWithHint( + "outputSchema", + "OMP does not support structured output or permission controls", + ); } } if (record.type === "session.permission") { @@ -718,7 +722,7 @@ export function createOmpConnection( resolveHostInheritEnv?: () => Promise, ): ProviderConnection { const errorDetails = (error: unknown, fallback: string): { message: string } => { - if (isOmpPublicError(error)) return { message: error.message }; + if (isOmpPublicError(error)) return rejectDetails(error, error.message); // The generated ID is the only correlation value we log. It also travels in the public // request failure, avoiding any assumption that a caller-supplied request ID is value-safe. const diagnosticId = randomUUID(); diff --git a/paseo-omp/server/provider/host-tools.ts b/paseo-omp/server/provider/host-tools.ts index 09e60c2a..472f1cb7 100644 --- a/paseo-omp/server/provider/host-tools.ts +++ b/paseo-omp/server/provider/host-tools.ts @@ -8,6 +8,7 @@ import type { OmpOperationalFailureReporter, } from "../operational-failure-diagnostics"; import { isValidImagePayload } from "./image"; +import { rejectWithHint } from "./instead-hints"; import { type ConnectedMcpClient, type ConnectedMcpTool, @@ -240,7 +241,8 @@ export function validateOmpHostToolConfig(config: ProviderSessionConfig): void { throw new OmpPublicError("OMP MCP server count exceeds the supported limit"); } if (config.toolPolicy !== undefined) { - throw new OmpPublicError( + throw rejectWithHint( + "toolPolicy-host-tools", "OMP set_host_tools cannot preserve exact MCP policy; refusing to broaden access", ); } diff --git a/paseo-omp/server/provider/instead-hints.ts b/paseo-omp/server/provider/instead-hints.ts new file mode 100644 index 00000000..425fd77d --- /dev/null +++ b/paseo-omp/server/provider/instead-hints.ts @@ -0,0 +1,91 @@ +import { OmpPublicError } from "./security"; + +/** + * Every hard reject that ships a "what to use instead" pointer. The message text is the persisted + * contract: hints never replace or reword it, they ride alongside as a structured field so old + * clients keep the exact string and new ones can render the substitute workflow. + */ +export type RejectCode = + | "toolPolicy-at-open" + | "toolPolicy-host-tools" + | "outputSchema" + | "live-settings" + | "live-mode" + | "revert-scope" + | "archive-absent"; + +export const INSTEAD_HINTS: Record = { + "toolPolicy-at-open": + "Use paseoTools to choose which caller tools reach OMP as MCP host tools; disallowedTools covers known native OMP built-ins only.", + "toolPolicy-host-tools": + "Use paseoTools to scope caller tools instead; OMP cannot carry exact MCP preapproval through set_host_tools.", + outputSchema: + "Validate the response structure after the turn instead; OMP exposes no structured-output contract.", + "live-settings": + "Change the setting on the store and start a new session; OMP does not apply provider settings to a running session.", + "live-mode": "Start a new session with the mode you want; OMP fixes approval mode at launch.", + "revert-scope": + "Use conversation rewind to return to an earlier message; file changes are not rewound.", + "archive-absent": + "Keep the session or delete it through OMP; there is no native archive state to toggle.", +}; + +export function insteadHint(code: RejectCode): string { + return INSTEAD_HINTS[code]; +} + +/** + * The hint is non-enumerable on purpose. It is read by property access and survives zod `.parse`, + * so real hosts receive it, while `JSON.stringify` and structural equality keep the previous + * `{ message }` shape for every existing assertion and serialized payload. + */ +export type Hinted = T & { readonly diagnostic: string }; +export type RejectDetails = { message: string; diagnostic?: string }; +export function withHint(error: T, code: RejectCode): Hinted { + Object.defineProperty(error, "diagnostic", { + value: INSTEAD_HINTS[code], + enumerable: false, + configurable: true, + writable: true, + }); + // defineProperty attached diagnostic above, invisible to the compiler + const hinted: Hinted = error as Hinted; + return hinted; +} + +export function rejectWithHint(code: RejectCode, message: string): Hinted { + return withHint(new OmpPublicError(message), code); +} + +/** Details object for handlers that emit `error` directly instead of throwing. */ +export function rejectErrorDetails(code: RejectCode, message: string): RejectDetails { + return rejectDetails(withHint(new OmpPublicError(message), code), message); +} + +function hintOf(error: unknown): string | undefined { + if (error === null || (typeof error !== "object" && typeof error !== "function")) { + return undefined; + } + if (!("diagnostic" in error)) return undefined; + const value: unknown = error.diagnostic; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Rebuilds the public `{ message }` error payload while preserving any attached hint. The provider + * error mappers construct a fresh object, so the non-enumerable field has to be re-attached here or + * it would be lost on the way to the event stream. + */ +export function rejectDetails(error: unknown, message: string): RejectDetails { + const details: RejectDetails = { message }; + const hint = hintOf(error); + if (hint !== undefined) { + Object.defineProperty(details, "diagnostic", { + value: hint, + enumerable: false, + configurable: true, + writable: true, + }); + } + return details; +} diff --git a/paseo-omp/server/provider/prompt-payload.ts b/paseo-omp/server/provider/prompt-payload.ts index 24958670..ecff9127 100644 --- a/paseo-omp/server/provider/prompt-payload.ts +++ b/paseo-omp/server/provider/prompt-payload.ts @@ -1,6 +1,7 @@ import type { ProviderContent, ProviderInput } from "@getpaseo/plugin/server/provider"; import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest"; import { isValidImagePayload } from "./image"; +import { rejectWithHint } from "./instead-hints"; import type { OmpImage } from "./omp-rpc-protocol"; import { OmpPublicError, utf8Bytes } from "./security"; @@ -184,7 +185,10 @@ function padLineNumber(lineNumber: number | null): string { export function promptPayload(input: SessionPromptInput): OmpPromptPayload { if (input.prompt.outputSchema !== undefined || input.prompt.clearPendingPermissions) { - throw new OmpPublicError("OMP does not support structured output or permission controls"); + throw rejectWithHint( + "outputSchema", + "OMP does not support structured output or permission controls", + ); } if (input.prompt.input.type === "command") { const name = input.prompt.input.name.trim(); diff --git a/paseo-omp/server/provider/session.ts b/paseo-omp/server/provider/session.ts index 659d8a73..3249cc46 100644 --- a/paseo-omp/server/provider/session.ts +++ b/paseo-omp/server/provider/session.ts @@ -26,6 +26,7 @@ import { } from "./config-normalization"; import { OmpHostToolsBridge, type OmpMcpConnector, validateOmpHostToolConfig } from "./host-tools"; import { isOmpImageMimeType, OmpImageMaterializer } from "./image"; +import { rejectDetails, rejectErrorDetails, rejectWithHint } from "./instead-hints"; import type { OmpRuntime, OmpRuntimeSession } from "./omp-rpc"; import { buildOmpSpawnRequest, type OmpStartOptions } from "./omp-rpc-environment"; import type { @@ -248,7 +249,7 @@ type PendingAbort = { promise: Promise; }; function providerError(error: unknown, fallback: string): { message: string } { - return { message: isOmpPublicError(error) ? error.message : fallback }; + return rejectDetails(error, isOmpPublicError(error) ? error.message : fallback); } async function settleSessionCleanup(promises: readonly Promise[]): Promise { const pending = [...promises]; @@ -839,7 +840,7 @@ export class OmpProviderSession { this.emit({ type: "request.failed", requestId: input.requestId, - error: { message: "OMP supports conversation rewind only" }, + error: rejectErrorDetails("revert-scope", "OMP supports conversation rewind only"), }); return; } @@ -1532,12 +1533,13 @@ export class OmpProviderSession { throw new OmpPublicError("OMP session is unavailable for configuration"); } if (input.changes.mode !== undefined && input.changes.mode !== this.configState.mode) { - throw new OmpPublicError( + throw rejectWithHint( + "live-mode", "OMP approval mode cannot change live; create a new session instead", ); } if (input.changes.settings && Object.keys(input.changes.settings).length > 0) { - throw new OmpPublicError("OMP does not expose live provider settings"); + throw rejectWithHint("live-settings", "OMP does not expose live provider settings"); } if (input.changes.model === null || input.changes.thinkingOption === null) { throw new OmpPublicError("OMP model and thinking selections cannot be cleared"); diff --git a/paseo-omp/server/support-bundle.ts b/paseo-omp/server/support-bundle.ts new file mode 100644 index 00000000..35eb7c86 --- /dev/null +++ b/paseo-omp/server/support-bundle.ts @@ -0,0 +1,87 @@ +import type { RpcInput } from "@getpaseo/plugin"; +import { + assembleSupportBundle, + type getOmpSupportBundle, + renderTranscriptExcerpt, + type SupportBundle, + type TranscriptSelector, + type TranscriptSlice, +} from "../shared/support-bundle"; +import type { OmpOperationalFailureCollector } from "./operational-failure-diagnostics"; +import type { OmpProtocolViolationCollector } from "./protocol-violation-diagnostics"; +import { + listOmpSessionDescriptors, + readOmpPersistedSessionTranscript, + validateNativeSessionId, +} from "./provider/session-descriptors"; +import { resolveGetOmpSupportReport } from "./support-diagnostics"; + +type SupportBundleInput = RpcInput; + +function unavailable(note: string): TranscriptSlice { + return { status: "unavailable", text: "", note }; +} + +/** + * Journal-only transcript retrieval. The persisted journal is the ownership-checked, root-to-leaf + * display history the provider itself replays, so the bundle never re-derives history from model + * context and never bypasses the descriptor's identity and cwd checks. A `live` selector cannot be + * served: the support RPC holds no attachable runtime handle, so it reports that plainly instead of + * silently substituting another source. + */ +async function resolveTranscriptSlice( + selector: TranscriptSelector | undefined, + input: SupportBundleInput, +): Promise { + if (!selector) return unavailable("Transcript excerpt not requested."); + if (selector.source !== "journal") { + return unavailable("Live transcript excerpts require an active session; none is attached."); + } + let sessionId: string; + try { + sessionId = validateNativeSessionId(selector.sessionId); + } catch { + return unavailable("Transcript session identifier is invalid."); + } + if (!input.cwd) { + return unavailable("Transcript retrieval requires a workspace working directory."); + } + try { + const descriptors = await listOmpSessionDescriptors({ cwd: input.cwd, sessionId, limit: 2 }); + const descriptor = descriptors.find((candidate) => candidate.id === sessionId); + if (!descriptor?.transcriptFile) { + return unavailable("No persisted transcript exists for this session in this workspace."); + } + const transcript = await readOmpPersistedSessionTranscript( + descriptor.transcriptFile, + sessionId, + input.cwd, + ); + return { + status: "included", + text: renderTranscriptExcerpt(transcript.messages, selector.maxBytes), + note: null, + }; + } catch { + return unavailable("Transcript excerpt could not be read for this session."); + } +} + +export async function resolveGetOmpSupportBundle( + input: SupportBundleInput, + violations: OmpProtocolViolationCollector, + operationalFailures: OmpOperationalFailureCollector, + dependencies: { + loadReport?: typeof resolveGetOmpSupportReport; + loadTranscript?: ( + selector: TranscriptSelector | undefined, + input: SupportBundleInput, + ) => Promise; + } = {}, +): Promise { + const [report, slice] = await Promise.all([ + (dependencies.loadReport ?? resolveGetOmpSupportReport)(input, violations, operationalFailures), + (dependencies.loadTranscript ?? resolveTranscriptSlice)(input.transcript, input), + ]); + return assembleSupportBundle(report.report, slice); +} diff --git a/paseo-omp/shared/support-bundle.ts b/paseo-omp/shared/support-bundle.ts new file mode 100644 index 00000000..11a2d41d --- /dev/null +++ b/paseo-omp/shared/support-bundle.ts @@ -0,0 +1,147 @@ +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +import { OmpWorkspaceCwdSchema } from "./hub"; +import { OmpStoreSchema } from "./omp-store"; +import { OmpSupportReportTextSchema, supportReportByteLength } from "./support-diagnostics"; + +/** The transcript half of a bundled report is capped well below the report contract's own cap. */ +export const OMP_SUPPORT_TRANSCRIPT_MAX_BYTES = 32 * 1024; +export const OMP_SUPPORT_TRANSCRIPT_TRUNCATION_MARKER = "\n"; + +export type TranscriptSource = "journal" | "live"; +export type TranscriptStatus = "included" | "unavailable"; + +export interface TranscriptSelector { + source: TranscriptSource; + sessionId: string; + maxBytes: number; +} + +export interface TranscriptSlice { + status: TranscriptStatus; + text: string; + note: string | null; +} + +export interface SupportBundle { + report: string; + transcript: TranscriptSlice; +} + +/** Code-point-safe byte budget; the marker is part of the budget and never split. */ +export function truncateUtf8Text(value: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + if (supportReportByteLength(value) <= maxBytes) return value; + const markerBytes = supportReportByteLength(OMP_SUPPORT_TRANSCRIPT_TRUNCATION_MARKER); + if (maxBytes <= markerBytes) return ""; + const budget = maxBytes - markerBytes; + let used = 0; + let end = 0; + for (const character of value) { + const size = supportReportByteLength(character); + if (used + size > budget) break; + used += size; + end += character.length; + } + return `${value.slice(0, end)}${OMP_SUPPORT_TRANSCRIPT_TRUNCATION_MARKER}`; +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object" || Array.isArray(part)) continue; + const record = part as Record; + if (typeof record.text === "string" && record.text.length > 0) parts.push(record.text); + } + return parts.join("\n"); + } + return ""; +} + +function transcriptLine(message: unknown): string | null { + if (!message || typeof message !== "object" || Array.isArray(message)) return null; + const record = message as Record; + const role = typeof record.role === "string" && record.role.length > 0 ? record.role : "entry"; + const text = contentText(record.content).trim(); + if (text.length === 0) return null; + return `${role}: ${text}`; +} + +/** Renders the display-text portion of a persisted transcript under a hard byte budget. */ +export function renderTranscriptExcerpt(messages: readonly unknown[], maxBytes: number): string { + const lines: string[] = []; + for (const message of messages) { + const line = transcriptLine(message); + if (line) lines.push(line); + } + return truncateUtf8Text(lines.join("\n\n"), maxBytes); +} + +/** + * Pure bundle assembler: the report is passed through untouched so the pasted-report contract and + * its 64 KiB cap stay owned by `formatOmpSupportReport`. Only the transcript half is bounded here. + */ +export function assembleSupportBundle(report: string, transcript: TranscriptSlice): SupportBundle { + if (transcript.status === "unavailable") { + return { + report, + transcript: { + status: "unavailable", + text: "", + note: transcript.note ?? "Transcript excerpt unavailable.", + }, + }; + } + return { + report, + transcript: { + status: "included", + text: truncateUtf8Text(transcript.text, OMP_SUPPORT_TRANSCRIPT_MAX_BYTES), + note: null, + }, + }; +} + +export const OmpSupportTranscriptTextSchema = z + .string() + .refine((value) => supportReportByteLength(value) <= OMP_SUPPORT_TRANSCRIPT_MAX_BYTES, { + message: "OMP transcript excerpt exceeds 32 KiB", + }); + +export const OmpTranscriptSelectorSchema = z + .object({ + source: z.enum(["journal", "live"]), + sessionId: z.string().min(1).max(128), + maxBytes: z.number().int().min(1).max(OMP_SUPPORT_TRANSCRIPT_MAX_BYTES), + }) + .strict(); + +export const OmpTranscriptSliceSchema = z + .object({ + status: z.enum(["included", "unavailable"]), + text: OmpSupportTranscriptTextSchema, + note: z.string().max(256).nullable(), + }) + .strict(); + +export const getOmpSupportBundle = defineRpc({ + name: "paseo-omp.get-support-bundle", + input: z + .object({ + store: OmpStoreSchema.optional(), + force: z.boolean().optional(), + cwd: OmpWorkspaceCwdSchema.optional(), + transcript: OmpTranscriptSelectorSchema.optional(), + }) + .strict(), + output: z + .object({ + report: OmpSupportReportTextSchema, + transcript: OmpTranscriptSliceSchema, + }) + .strict(), +}); + +export type OmpSupportBundleInput = z.infer; diff --git a/paseo-omp/tests/instead-hints.test.ts b/paseo-omp/tests/instead-hints.test.ts new file mode 100644 index 00000000..7ac4b8f8 --- /dev/null +++ b/paseo-omp/tests/instead-hints.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; +import { + INSTEAD_HINTS, + insteadHint, + type RejectCode, + rejectDetails, + rejectErrorDetails, + rejectWithHint, + withHint, +} from "../server/provider/instead-hints"; +import { isOmpPublicError } from "../server/provider/security"; + +const REJECT_CODES: readonly RejectCode[] = [ + "toolPolicy-at-open", + "toolPolicy-host-tools", + "outputSchema", + "live-settings", + "live-mode", + "revert-scope", + "archive-absent", +]; + +describe("instead-hint registry", () => { + test("every reject code carries a non-empty substitute workflow", () => { + for (const code of REJECT_CODES) { + expect(INSTEAD_HINTS[code]).toBeTruthy(); + expect(insteadHint(code)).toBe(INSTEAD_HINTS[code]); + } + }); + + test("rejectWithHint keeps the exact message and attaches the hint as a public error", () => { + const error = rejectWithHint("live-mode", "OMP approval mode cannot change live"); + expect(isOmpPublicError(error)).toBe(true); + expect(error.message).toBe("OMP approval mode cannot change live"); + expect(error.diagnostic).toBe(INSTEAD_HINTS["live-mode"]); + }); +}); + +describe("hint transport shape", () => { + test("hints stay out of JSON serialization and key enumeration", () => { + const error = rejectWithHint("outputSchema", "OMP does not support structured output"); + expect(JSON.stringify(error)).not.toContain(INSTEAD_HINTS.outputSchema); + expect(Object.keys(error)).not.toContain("diagnostic"); + expect(JSON.stringify(rejectDetails(error, error.message))).not.toContain( + INSTEAD_HINTS.outputSchema, + ); + expect(error.diagnostic).toBe(INSTEAD_HINTS.outputSchema); + }); + + test("the mapper transfer preserves the previous message-only shape for old clients", () => { + const error = rejectWithHint("revert-scope", "OMP supports conversation rewind only"); + const details = rejectDetails(error, "OMP supports conversation rewind only"); + expect(details).toEqual({ message: "OMP supports conversation rewind only" }); + expect(details.diagnostic).toBe(INSTEAD_HINTS["revert-scope"]); + }); + + test("a plain error still maps to the fallback without inventing a hint", () => { + const details = rejectDetails(new Error("native failure"), "OMP provider request failed"); + expect(details).toEqual({ message: "OMP provider request failed" }); + expect(details.diagnostic).toBeUndefined(); + }); + + test("direct-emit details carry the hint for the revert reject path", () => { + const details = rejectErrorDetails("revert-scope", "OMP supports conversation rewind only"); + expect(details).toEqual({ message: "OMP supports conversation rewind only" }); + expect(details.diagnostic).toBe(INSTEAD_HINTS["revert-scope"]); + }); + + test("withHint is idempotent and keeps the first code's hint", () => { + const error = withHint(new Error("x"), "archive-absent"); + expect(error.diagnostic).toBe(INSTEAD_HINTS["archive-absent"]); + }); +}); diff --git a/paseo-omp/tests/server-bundle.test.ts b/paseo-omp/tests/server-bundle.test.ts index 0672702c..ea926777 100644 --- a/paseo-omp/tests/server-bundle.test.ts +++ b/paseo-omp/tests/server-bundle.test.ts @@ -176,8 +176,11 @@ describe("plugin server bundle", () => { registerSettings: (definition: unknown) => settings.push(definition), registerProvider: (provider: ProviderRegistration) => providers.push(provider), }); - expect(handlers).toHaveLength(17); + expect(handlers).toHaveLength(18); expect(handlers.map(([contract]) => contract.name)).toContain("paseo-omp.list-models"); + expect(handlers.map(([contract]) => contract.name)).toContain( + "paseo-omp.get-support-bundle", + ); expect(settings).toEqual([ expect.objectContaining({ id: "composer-pills", scope: "host", version: 1 }), expect.objectContaining({ id: "provider-launch", scope: "host", version: 1 }), diff --git a/paseo-omp/tests/support-bundle.test.ts b/paseo-omp/tests/support-bundle.test.ts new file mode 100644 index 00000000..83352b77 --- /dev/null +++ b/paseo-omp/tests/support-bundle.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "vitest"; +import { + assembleSupportBundle, + getOmpSupportBundle, + OMP_SUPPORT_TRANSCRIPT_MAX_BYTES, + OMP_SUPPORT_TRANSCRIPT_TRUNCATION_MARKER, + renderTranscriptExcerpt, + type TranscriptSlice, + truncateUtf8Text, +} from "../shared/support-bundle"; +import { supportReportByteLength } from "../shared/support-diagnostics"; + +const REPORT = "OMP support diagnostics\nschema_version: 1\n"; + +describe("support bundle assembler", () => { + test("passes the report through untouched and includes the transcript half", () => { + const bundle = assembleSupportBundle(REPORT, { + status: "included", + text: "user: hello", + note: null, + }); + expect(bundle.report).toBe(REPORT); + expect(bundle.transcript).toEqual({ status: "included", text: "user: hello", note: null }); + }); + + test("defaults an unavailable transcript to an empty text and a note", () => { + const bundle = assembleSupportBundle(REPORT, { status: "unavailable", text: "", note: null }); + expect(bundle.report).toBe(REPORT); + expect(bundle.transcript.status).toBe("unavailable"); + expect(bundle.transcript.text).toBe(""); + expect(bundle.transcript.note).toBe("Transcript excerpt unavailable."); + }); + + test("keeps a caller-supplied unavailable note", () => { + const bundle = assembleSupportBundle(REPORT, { + status: "unavailable", + text: "", + note: "No persisted transcript exists for this session in this workspace.", + }); + expect(bundle.transcript.note).toBe( + "No persisted transcript exists for this session in this workspace.", + ); + }); + + test("bounds an oversized transcript to the 32 KiB transcript cap, not the 64 KiB report cap", () => { + const oversized: TranscriptSlice = { + status: "included", + text: "a".repeat(OMP_SUPPORT_TRANSCRIPT_MAX_BYTES * 2), + note: null, + }; + const bundle = assembleSupportBundle(REPORT, oversized); + expect(supportReportByteLength(bundle.transcript.text)).toBeLessThanOrEqual( + OMP_SUPPORT_TRANSCRIPT_MAX_BYTES, + ); + expect(bundle.transcript.text.endsWith(OMP_SUPPORT_TRANSCRIPT_TRUNCATION_MARKER)).toBe(true); + expect(bundle.report).toBe(REPORT); + }); + + test("truncates on code-point boundaries for multi-byte text", () => { + const text = "é".repeat(100); + const truncated = truncateUtf8Text(text, 31); + expect(supportReportByteLength(truncated)).toBeLessThanOrEqual(31); + expect(truncated).not.toContain("\uFFFD"); + }); + + test("returns empty text when the budget cannot hold the marker", () => { + expect(truncateUtf8Text("overflow", 0)).toBe(""); + expect(truncateUtf8Text("overflow", 4)).toBe(""); + }); +}); + +describe("transcript excerpt renderer", () => { + test("renders role-prefixed text lines and skips content-free entries", () => { + const excerpt = renderTranscriptExcerpt( + [ + { role: "user", content: "first question" }, + { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + { role: "toolResult", content: [{ type: "image", data: "ignored" }] }, + "not-an-entry", + ], + OMP_SUPPORT_TRANSCRIPT_MAX_BYTES, + ); + expect(excerpt).toBe("user: first question\n\nassistant: first answer"); + }); + + test("caps the rendered excerpt at the requested budget", () => { + const messages = Array.from({ length: 40 }, (_, index) => ({ + role: "user", + content: `message ${index} ${"x".repeat(200)}`, + })); + const excerpt = renderTranscriptExcerpt(messages, 512); + expect(supportReportByteLength(excerpt)).toBeLessThanOrEqual(512); + }); +}); + +describe("support bundle RPC contract", () => { + test("requires a bounded selector and rejects widened input", () => { + expect( + getOmpSupportBundle.input.safeParse({ + cwd: "/repo", + transcript: { source: "journal", sessionId: "session-a", maxBytes: 32_768 }, + }).success, + ).toBe(true); + expect( + getOmpSupportBundle.input.safeParse({ + cwd: "/repo", + transcript: { source: "journal", sessionId: "session-a", maxBytes: 1_000_000 }, + }).success, + ).toBe(false); + expect(getOmpSupportBundle.input.safeParse({ cwd: "/repo", extra: true }).success).toBe(false); + }); + + test("accepts an included bundle whose report respects the untouched 64 KiB report cap", () => { + const bundle = assembleSupportBundle(REPORT, { + status: "included", + text: "user: hello", + note: null, + }); + expect(getOmpSupportBundle.output.safeParse(bundle).success).toBe(true); + }); + + test("accepts an unavailable transcript bundle", () => { + const bundle = assembleSupportBundle(REPORT, { + status: "unavailable", + text: "", + note: "Transcript excerpt not requested.", + }); + expect(getOmpSupportBundle.output.safeParse(bundle).success).toBe(true); + }); +});