diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 65e3281bb4..ff021509c2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4039,12 +4039,16 @@ the authoritative analysis outcome. `/api/settings/ai/update` may save a selected Patrol provider/model even when that model is not ready for tool-backed Patrol execution, but it must echo `patrol_readiness` with stable `cause` metadata and execution routes - must continue to fail closed before model calls. Frontend Patrol settings + must continue to fail closed before model calls. Frontend settings consumers must surface that saved-but-not-ready response as a saved - configuration issue with the echoed provider, model, cause, and summary - instead of reporting the successful save as a failed save or hiding the - readiness blocker behind a generic notification; and the structured - investigation-record contract, so unified findings may + configuration issue rather than a failed save, and never as inline failure + state: every Pulse Intelligence settings page (Provider & Models, Patrol, + Assistant, Service context) raises a warning notification naming the echoed + summary, provider, and model, while the Patrol page's on/off save raises a + fixed not-ready warning and leaves the diagnosis to the page's own readiness + surfaces, such as the readiness banner that names the summary, provider, + and model while the header offers `Fix setup` when Patrol is active; and + the structured investigation-record contract, so unified findings may expose `investigation_record` only through the shared `aicontracts.InvestigationRecord` payload shape, with frontend API types and backend contract tests updated in the same slice as any field change. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0d45e31828..e78920a444 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3164,33 +3164,30 @@ Agent`), with the plain-language source phrase available through accessible operator action or selected Patrol context, uses distinct monitor labels, and does not become a nested card, generic dashboard strip, trust summary, or duplicate Patrol empty-work list. The - Patrol schedule and model drawer is part of that shared - feature-presentation boundary: it must stay viewport-bounded, expose an - accessible dialog label, keep the four-level control policy on the default - Patrol header, and keep provider model, schedule, trigger tuning, and - readiness validation inside the secondary disclosure. Backend save rejection reasons must pass - through as inline dialog state instead of being replaced with generic toast - copy, and that advanced disclosure must open when the inline state exists. - When the failure includes - Patrol readiness context, the inline state must expose the provider, model, - and readiness summary next to a direct provider-settings action instead of - hiding that diagnosis behind Assistant alone. The provider-model selector in - that popover must stay bound to the shared runtime settings/model catalog - even when the popover mounts after async catalog loading, but the full - catalog must stay behind an explicit change action so the default advanced - drawer leads with the current effective model summary rather than a raw - provider route list. A saved direct-provider Patrol model still renders as - that model instead of visually falling back to the default selection. - Successful provider-model saves that return a not-ready Patrol - readiness snapshot must use that same inline surface with `needs attention` - wording, and any Assistant handoff from that state describes a saved - configuration issue rather than a failed save. When governed fixes are locked, the same Patrol state - owner must clear stale full-mode unlock state before persisting the - monitor-only autonomy payload, so disabled paid controls cannot leak stale - permission into a save. If that inline state opens Assistant, the Patrol - feature must hand off - a source-named, model-only briefing and close the popover so the shared - Assistant drawer is not visually hidden behind feature chrome. When a + Patrol page keeps the four-level control policy on its header and has no + schedule and model drawer: schedule, trigger tuning, and the Patrol model + override live on Settings > Pulse Intelligence > Patrol, and providers and + the shared default model on Settings > Provider & Models. Those saves + report through the shared notification toasts, not inline state: a + rejected save raises an error toast carrying the backend's concrete reason + rather than generic fallback copy, and a saved response whose Patrol + readiness is not ready or degraded raises a warning toast naming the + readiness summary, provider, and model instead of a success toast. On the + Patrol page, an on/off save whose response reports Patrol not ready raises + a fixed warning toast, and a failed Patrol mode save raises an error toast + and restores the previous mode; neither renders inline save-failure state + or offers an Assistant handoff. If a Patrol settings popover or inline + save-failure state returns, it must stay viewport-bounded, expose an + accessible dialog label, bind any model selector to the shared runtime + settings and model catalog, show readiness context (provider, model, + summary) next to a direct provider-settings action rather than behind + Assistant alone, and describe a saved but not ready response as a saved + configuration issue rather than a failed save. An Assistant handoff from it + must send a source-named, model-only briefing and close the popover so the + shared Assistant drawer is not visually hidden behind feature chrome. When + governed fixes are locked, the Patrol state owner must clear stale + full-mode unlock state before persisting the monitor-only autonomy payload, + so disabled paid controls cannot leak stale permission into a save. When a restored Patrol assessment session is attached, the shared Assistant drawer empty state must stay aligned with that source-named briefing and must not render generic diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e5ad3e0b55..c6b2cb9fd7 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1581,17 +1581,18 @@ suppress stale healthy summary headlines such as `Health A ยท 100/100` even if the last summary payload still looks healthy. Legacy `/ai` entry points must redirect into that same Patrol-owned shell rather than preserving a second canonical route. -That same browser proof now covers the Patrol control and advanced-settings -split. The advanced Patrol settings drawer must stay within the desktop -viewport, avoid duplicating the inline Patrol control policy, expose -provider/model, schedule, trigger, and user-level model checks directly, and surface -the backend's concrete license/validation reason when a settings change is -rejected instead of replacing it with a generic `Failed to save advanced -settings` toast. That inline failure no longer -opens Assistant. If it does again, the handoff is model-only explanation -context: raw command, script, credential, and provider-detail payloads stay -redacted, Assistant opens with `autonomousMode:false`, and the Patrol control -panel closes so the operator is not left behind an overlapping popover. +That same browser proof now covers where Patrol settings are saved. The +Patrol page has no settings drawer or popover: Patrol schedule, triggers, and +the Patrol model override live on Settings > Pulse Intelligence > Patrol +(`Save Patrol settings`), and providers and the shared default model on +Settings > Provider & Models. A rejected save there raises an error +notification carrying the backend's concrete license/validation reason +instead of generic fallback copy, and it offers no Assistant handoff. If a +Patrol settings popover or an Assistant handoff from a settings failure +returns, the handoff is model-only explanation context: raw command, script, +credential, and provider-detail payloads stay redacted, Assistant opens with +`autonomousMode:false`, and the popover closes so the operator is not left +behind it. Patrol-control save-failure sessions saved by earlier builds keep the compatible `handoff_metadata.kind=patrol_configuration_failure` plus only the runtime-failure boolean needed for drawer/session presentation, so they restore @@ -1637,12 +1638,13 @@ ready raises a fixed warning notification, a failed Patrol mode save raises an e notification, and neither offers an Assistant handoff. A provider or model save in Settings > Provider & Models reports the readiness summary, provider and model in its own save message. -The Patrol mode selector in that header and configuration dialog must -compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` -instead of rebuilding a local active-button group. The wide default Patrol -header uses the segmented layout; the constrained configuration dialog may use -the shared prominent layout so all four mode labels remain readable without -inventing a Patrol-local selector. Patrol owns the default visible four-level +The Patrol mode selector in that header must compose the shared +`frontend-modern/src/components/shared/FilterButtonGroup.tsx` instead of +rebuilding a local active-button group. When the header shows Patrol mode +options, it renders the selector in the segmented layout inside its `Mode and +automation` disclosure; a constrained surface that hosts it may use the shared +prominent layout so all four mode labels remain readable without inventing a +Patrol-local selector. Patrol owns the default visible four-level policy presentation (`Watch only`, `Ask first`, `Safe auto-fix`, `Autopilot`), entitlement locks, and the rule that choosing the highest Autopilot level sends `full_mode_unlocked:true` while choosing any lower level clears that diff --git a/frontend-modern/browser-verification/0f6190e732d7b132.json b/frontend-modern/browser-verification/0f6190e732d7b132.json new file mode 100644 index 0000000000..bd6ecf244f --- /dev/null +++ b/frontend-modern/browser-verification/0f6190e732d7b132.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "421d1e5423e5b83b7923ea8c3b133ad520cd5f60", + "verified_at": "2026-10-07T15:20:38Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "f1e249c36f0f0f9810330a37cc9a80cb43baa1cd123db1defd3f27720d39912a", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "cb0a6962ed929238a974a3bbe5f69f4c89961ff30c88c2c94398073b9ef99a1c" + }, + "routes": [ + "/patrol", + "/patrol (Mode and automation disclosure)", + "/settings/pulse-intelligence/patrol", + "/settings/pulse-intelligence" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (333e51d2de; rebased onto 421d1e5423, which changes only contract docs) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both and produced identical results on all 31 observed fields at both widths.", + "Patrol on/off save whose PUT /api/settings/ai/update response (page.route) reports patrol_readiness not_ready: switching off and back on each raise only the warning toast 'Patrol setting was saved, but Patrol is not ready to run.' (role=alert inside the Notifications region, close button only); no role=alert outside the toast region, no 'needs attention' text, no readiness summary rendered inline, no Assistant action.", + "Failed Patrol mode save (Pro mode selector unlocked by adding ai_autofix to runtime-capabilities via page.route; PUT /api/ai/patrol/autonomy answered 500 {error}): only the error toast with the server reason; mode badge returns to Watch only; no inline error or 'needs attention' state.", + "Successful Patrol mode save (PUT 200, reconcile GET returns approval): Ask first becomes pressed and the badge reads Ask first; no new toast and no inline alert.", + "Settings > Pulse Intelligence > Patrol save (Schedule -> Every hour) and Settings > Provider & Models save with a not_ready response: only the warning toast ', but Patrol is not ready: The selected model cannot run Patrol tools. \u00b7 Provider: OpenRouter \u00b7 Model: openrouter:deepseek/deepseek-r1'; no inline alert.", + "Patrol status with readiness not_ready (page.route on /api/ai/patrol/status): the page's own 'Patrol model issue' banner names summary, provider and model, and the header shows Fix setup, at both widths.", + "Dist proof: 187 files each side; after normalising chunk hashes and SRI strings only assets/AIIntelligence.js differs (935 bytes smaller): the removed failure builder, details helper and surface callback replaced by one status check, toast string byte-identical; check-bundle-size.mjs passes." + ], + "interactions": [ + "Demo sign-in; on /patrol click Toggle Patrol twice (off, then on) with a not-ready settings response; open 'Mode and automation' and click Ask first with a rejected save, then again with an accepted save; desktop mouse and phone touch (isMobile) contexts.", + "On /settings/pulse-intelligence/patrol select Schedule 'Every hour' and click Save Patrol settings; on /settings/pulse-intelligence click Save provider settings; both with a not-ready response.", + "Screenshots of each toast state captured and inspected at both widths." + ] +} diff --git a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts index 4dcef31829..a7e54782cb 100644 --- a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { PATROL_MANUAL_SYNC_TIMEOUT_MS, PATROL_REFRESH_TIMEOUT_MS, - buildPatrolSettingsReadinessFailure, + getPatrolSavedReadinessWarning, openPatrolAssistantWorkflowHandoff, patrolStartFailureMessage, recordPatrolControlStarterActivity, @@ -338,24 +338,25 @@ describe('usePatrolIntelligenceState', () => { }); }); - describe('buildPatrolSettingsReadinessFailure', () => { - it('ignores settings snapshots that do not block Patrol readiness', () => { + describe('getPatrolSavedReadinessWarning', () => { + it('stays quiet when the saved settings do not block Patrol readiness', () => { + expect(getPatrolSavedReadinessWarning(null)).toBeNull(); expect( - buildPatrolSettingsReadinessFailure({ - settings: settingsWithReadiness({ + getPatrolSavedReadinessWarning( + settingsWithReadiness({ status: 'warning', ready: true, summary: 'Patrol can run with reduced confidence.', checks: [], }), - }), + ), ).toBeNull(); }); - it('builds a saved configuration issue from a not-ready settings response', () => { + it('warns that a not-ready Patrol setting was still saved', () => { expect( - buildPatrolSettingsReadinessFailure({ - settings: settingsWithReadiness({ + getPatrolSavedReadinessWarning( + settingsWithReadiness({ status: 'not_ready', ready: false, cause: 'model_unsupported_tools', @@ -364,40 +365,8 @@ describe('usePatrolIntelligenceState', () => { model: 'ollama:deepseek-r1:7b', checks: [], }), - autonomyLevel: 'monitor', - fullModeUnlocked: false, - investigationBudget: 15, - investigationTimeoutSec: 300, - runtimeState: 'blocked', - blockedReason: 'Connect a tool-capable Patrol model.', - }), - ).toEqual({ - message: 'The selected model cannot run Patrol tools.', - code: 'patrol_readiness_not_ready', - status: 409, - saved: true, - details: { - status: 'not_ready', - cause: 'model_unsupported_tools', - summary: 'The selected model cannot run Patrol tools.', - provider: 'ollama', - model: 'ollama:deepseek-r1:7b', - }, - autonomyLevel: 'monitor', - fullModeUnlocked: false, - investigationBudget: 15, - investigationTimeoutSec: 300, - readiness: { - status: 'not_ready', - cause: 'model_unsupported_tools', - summary: 'The selected model cannot run Patrol tools.', - provider: 'ollama', - model: 'ollama:deepseek-r1:7b', - }, - runtimeState: 'blocked', - blockedReason: 'Connect a tool-capable Patrol model.', - blockedCause: undefined, - }); + ), + ).toBe('Patrol setting was saved, but Patrol is not ready to run.'); }); }); @@ -410,6 +379,8 @@ describe('usePatrolIntelligenceState', () => { 'provider_not_configured', ); expect(resolvePatrolBlockedActionCause('', '')).toBeUndefined(); - expect(patrolIntelligenceStateSource).toContain('blockedCause: patrolStatus()?.blocked_cause'); + expect(patrolIntelligenceStateSource).toContain( + 'const blockedCause = createMemo(() => patrolStatus()?.blocked_cause);', + ); }); }); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index ab961032a5..ec242837ed 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -182,27 +182,6 @@ export interface PatrolRunAssistantHandoff { context: AIChatContext; } -export interface PatrolConfigurationFailureInput { - message: string; - code?: string; - status?: number; - saved?: boolean; - details?: Record; - autonomyLevel?: string; - fullModeUnlocked?: boolean; - investigationBudget?: number; - investigationTimeoutSec?: number; - readiness?: { - status?: string; - cause?: string; - summary?: string; - provider?: string; - model?: string; - } | null; - runtimeState?: string; - blockedReason?: string; - blockedCause?: string; -} const MAX_PATROL_RUN_HANDOFF_RESOURCES = 8; export function buildPatrolInvestigationRecordPresentation( diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 9bf1c0a20f..3b6f509208 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -62,7 +62,6 @@ import { patrolAssistantFindingHandoffRequiresApprovalMode, type PatrolAssistantApprovalBriefingInput, type PatrolAssistantFindingHandoff, - type PatrolConfigurationFailureInput, } from './patrolInvestigationContextModel'; import { schedulePatrolRunAcceptanceReconciliation } from './patrolRunAcceptance'; @@ -141,19 +140,6 @@ export const patrolStartFailureMessage = (error: unknown): string => ? `Could not reach Pulse to start Patrol: ${patrolErrorMessage(error, 'network request failed')}` : patrolErrorMessage(error, 'Pulse rejected the Patrol run'); -const buildReadinessDetails = ( - readiness: NonNullable, -): Record => { - const details: Record = { - status: readiness.status, - }; - if (readiness.cause?.trim()) details.cause = readiness.cause.trim(); - if (readiness.summary?.trim()) details.summary = readiness.summary.trim(); - if (readiness.provider?.trim()) details.provider = readiness.provider.trim(); - if (readiness.model?.trim()) details.model = readiness.model.trim(); - return details; -}; - export function resolvePatrolAutonomyLevelForSave( level: PatrolAutonomyLevel, fullModeUnlocked: boolean, @@ -184,52 +170,16 @@ export function resolvePatrolAutonomySettingsForSave({ }; } -export function buildPatrolSettingsReadinessFailure({ - settings, - message, - autonomyLevel, - fullModeUnlocked, - investigationBudget, - investigationTimeoutSec, - runtimeState, - blockedReason, - blockedCause, -}: { - settings: AISettings | null | undefined; - message?: string; - autonomyLevel?: string; - fullModeUnlocked?: boolean; - investigationBudget?: number; - investigationTimeoutSec?: number; - runtimeState?: string; - blockedReason?: string; - blockedCause?: string; -}): PatrolConfigurationFailureInput | null { - const readiness = settings?.patrol_readiness; - if (!readiness || readiness.status !== 'not_ready') return null; - - return { - message: - message || readiness.summary || 'Patrol settings were saved, but Patrol is not ready to run.', - code: 'patrol_readiness_not_ready', - status: 409, - saved: true, - details: buildReadinessDetails(readiness), - autonomyLevel, - fullModeUnlocked, - investigationBudget, - investigationTimeoutSec, - readiness: { - status: readiness.status, - cause: readiness.cause, - summary: readiness.summary, - provider: readiness.provider, - model: readiness.model, - }, - runtimeState, - blockedReason, - blockedCause, - }; +/** + * A Patrol on/off save whose response reports Patrol not ready was still + * saved, so it raises a warning rather than an error. + */ +export function getPatrolSavedReadinessWarning( + settings: AISettings | null | undefined, +): string | null { + return settings?.patrol_readiness?.status === 'not_ready' + ? 'Patrol setting was saved, but Patrol is not ready to run.' + : null; } /** @@ -380,26 +330,6 @@ export function usePatrolIntelligenceState() { applyPatrolAISettings(aiRuntimeSettings()); }); - const surfaceSavedPatrolReadinessIssue = ( - settings: AISettings | null | undefined, - message?: string, - ) => { - const failure = buildPatrolSettingsReadinessFailure({ - settings, - message, - autonomyLevel: autonomyLevel(), - fullModeUnlocked: fullModeUnlocked(), - investigationBudget: investigationBudget(), - investigationTimeoutSec: investigationTimeout(), - runtimeState: runtimeState(), - blockedReason: blockedReason(), - blockedCause: patrolStatus()?.blocked_cause, - }); - if (failure) { - notificationStore.warning(failure.message); - } - }; - async function handleTogglePatrol() { if (isTogglingPatrol()) return; setIsTogglingPatrol(true); @@ -419,10 +349,10 @@ export function usePatrolIntelligenceState() { } else { setPatrolEnabledLocal(newValue); } - surfaceSavedPatrolReadinessIssue( - data, - 'Patrol setting was saved, but Patrol is not ready to run.', - ); + const readinessWarning = getPatrolSavedReadinessWarning(data); + if (readinessWarning) { + notificationStore.warning(readinessWarning); + } if (refetchPatrolStatus) { refetchPatrolStatus(); } diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index f96ca80244..a2de774cb6 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3609,7 +3609,7 @@ describe('frontend resource type boundaries', () => { expect(patrolIntelligenceStateSource).toMatch( /import\s*\{[^}]*\bbuildPatrolAssistantFindingHandoffFromUnifiedFinding\b[^}]*\}\s*from '\.\/patrolInvestigationContextModel';/, ); - expect(patrolIntelligenceStateSource).toContain('buildPatrolSettingsReadinessFailure'); + expect(patrolIntelligenceStateSource).toContain('getPatrolSavedReadinessWarning'); expect(patrolIntelligenceStateSource).not.toContain('recent_changes?.length'); expect(patrolIntelligenceStateSource).not.toContain('governed resource${'); expect(patrolInvestigationContextModelSource).toContain(