diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md new file mode 100644 index 000000000..08dce799d --- /dev/null +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -0,0 +1,97 @@ +# Patrol and Assistant customer journey qualification + +The customer job is: "Tell me what needs my attention, explain why, and help me +deal with it without creating more work." Patrol owns the issue and investigation. +Assistant explains that same issue and uses existing governed action contracts. + +## Implemented interaction + +`Explain with Assistant` on an attention item or active alert starts an explicit +explanation using the selected evidence. Opening the drawer without choosing a +task remains context only. The request preserves the composer draft and current +conversation, uses normal send/queue/retry handling, and does not enable autonomy. +Pending initialization is cancelled when the organisation changes or the drawer +component is disposed. + +Attention handoffs use the canonical finding builder when exactly one finding +is linked. Otherwise they pass bounded attention evidence and typed resource and +action references without inventing a finding identity. Server-refreshed findings, +permissions, approval records, and execution policy remain authoritative. + +The drawer names the selected issue, hides unrelated workflow starters while +context is attached, and explains that discovery adds service detail. Discovery +being off does not mean inventory, metrics, and alerts are unavailable. + +## Browser interaction matrix + +Run against the current authenticated local development build: + +```sh +node scripts/check-patrol-assistant-journey.mjs +``` + +The script uses synthetic attention evidence and model responses, with other +non-GET application requests blocked. It writes screenshots and a result receipt +under `tmp/patrol-assistant-journey/`. It neither qualifies model reasoning nor +executes infrastructure actions. Authentication stays in browser memory. + +| Surface | States and interactions | +|---|---| +| `/patrol`, 1440, 900 and 390 pixels wide | Start review, expand evidence, focus and activate Explain with Enter, inspect issue title and response, verify selected evidence/resource and one request | +| Assistant drawer | Draft preserved across close/reopen, no inference on ordinary open, no competing workflow starters, normal failed-request display and retry, reload without resubmission | +| `/alerts`, desktop and narrow | Open secondary action menu, dismiss with Escape and outside click, reopen, choose explanation, verify selected alert context and no Patrol trigger | +| Initialization regression tests | Newer context cannot be cleared by an earlier send, rejected send retains context, preparation failure reports error, organisation switch cancels old evidence | + +Inspect screenshots for clipping, wrapping, readable responses, reachable input, +menu placement, and visible error/retry controls. The browser verification receipt +binds the pass to exact source hashes. Rerun affected states after source edits. + +## Real outcome qualification still required + +A scripted response passing the interaction matrix is not evidence that the +features deliver repeatable customer value. Qualify the following jobs on a +disposable environment with known ground truth before expanding claims or autonomy. + +| Job | Useful result | Controls | +|---|---|---| +| Unhealthy service | Current evidence identifies the failed service and likely cause, then gives a supported next step | Healthy service, intentional stop, missing access, correlated dependency failure | +| Backup or capacity risk | Explains actual coverage or growth risk, what is uncertain, and a concrete next step | Healthy protection, stale evidence, unavailable backup source, capacity with no safe automatic fix | +| Supported VM/LXC change | Correct canonical targets, reviewable plan, approval before execution, independent result verification attached to the original issue | Rejected approval, unsupported operation, stale target, partial failure | + +Use the existing `cmd/patrol-qualify` scenario runner and +`tests/qualification/patrol/scenarios/`, with explicit live-fault and remediation +authority for the disposable lab. Validate the catalog with: + +```sh +go run ./cmd/patrol-qualify -mode validate +``` + +The existing Docker watch/investigation/remediation catalog provides service +cases. Backup/capacity cases and the complete VM/LXC customer journey need separate +ground-truth fixtures. The existing `ProxmoxBulkLifecycleActionScenario` checks +canonical planning but does not by itself prove execution and verification. + +Record source SHA, runtime version, exact provider/model, effective permissions, +scenario ground truth, observed evidence, proposed action, approval and result, +useful/incorrect/missed diagnosis, latency and tokens/cost. A correct evidenced +"no action needed" or manual hardware replacement is a useful outcome. A tool +call or an action plan alone is not. + +Compare model configurations on the same cases. Establish repeatability across +independent volunteered Pro environments before treating one successful install +as a product-wide result. Keep customer content out of default telemetry. + +## Measurement boundaries + +Use eligible paid cohorts and latest valid installation reports. Keep current +enablement separate from historical rolling usage. Do not divide findings by +investigations or actions when their retention and provenance differ. Do not +interpret Assistant/Patrol activity cooccurrence as a linked completed task. + +Provider, cost and outcome telemetry requires schema adoption before it can be +used for assessment. Link outcomes locally to canonical findings/actions, export +only content-free aggregates, and distinguish useful diagnosis, justified no-op, +blocked access, rejected action, executed action and verified resolution. + +The owning governance gap is `patrol-assistant-customer-outcome-qualification`. +This interaction repair does not close that gap or authorize release publication. diff --git a/docs/release-control/control_plane.json b/docs/release-control/control_plane.json index ebef138a8..888734ae0 100644 --- a/docs/release-control/control_plane.json +++ b/docs/release-control/control_plane.json @@ -79,7 +79,7 @@ "profile_id": "v6", "kind": "feature", "status": "active", - "summary": "Post-GA active development runs on main and promotes proactive Pulse Intelligence, policy-aware data governance, resource-change intelligence, action-governance, fleet-governance, agent-operable infrastructure onboarding, continuous discovery reconciliation, and agent-ready operations contract proof after the monitoring-first v6 floor; Patrol is the detection and investigation engine, Assistant is the contextual explanation, approval, and governed action surface, and agent-ready proof is API/CLI-first with MCP only as an adapter over canonical contracts.", + "summary": "Post-GA active development runs on main and promotes proactive Pulse Intelligence, policy-aware data governance, resource-change intelligence, action-governance, fleet-governance, agent-operable infrastructure onboarding, continuous discovery reconciliation, and agent-ready operations contract proof after the monitoring-first v6 floor; Patrol is the detection and investigation engine, Assistant is the contextual explanation, approval, and governed action surface, and agent-ready proof is API/CLI-first with MCP only as an adapter over canonical contracts. The current product priority is a coherent Patrol-to-Assistant customer journey: explain the selected issue from current evidence, preserve governed approval and verification, and qualify useful outcomes for unhealthy services, backup or capacity risks, and supported VM or LXC actions before claiming broad Pulse Pro value.", "completion_rule": "manual", "proof_scope": "none" } diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index f3ef64e74..99f0962d4 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10240,6 +10240,40 @@ "kind": "file" } ] + }, + { + "id": "patrol-assistant-customer-outcome-qualification", + "summary": "The 2026-09-05 product review found that Explain with Assistant opened a blank generic conversation and the attention workbench discarded richer canonical finding context. The explicit explanation dispatcher and canonical handoff repair address that frontend break, with scripted browser proof and regression tests. They do not qualify model reasoning, real infrastructure actions, or repeated customer value. Latest-report, monitoring-active, multi-ping telemetry excluding dev and deployment proof contained 127 paid installs, 71 with Patrol enabled and 23 with Assistant calls. Fourteen reported verified resolutions were concentrated in one install. Paid includes all non-free tiers, and activity cooccurrence is not a linked successful journey. Schema 17 outcome/provider/cost fields had no adoption in that review. Three real jobs remain to qualify across named provider/model/version configurations: unhealthy service diagnosis, backup or capacity risk, and supported VM/LXC plan, approval and verified result. Repeatability must be established across independent paid customer environments without pooling free-tier or ineligible installs.", + "owner": "project-owner", + "status": "planned", + "recorded_at": "2026-09-05", + "lane_ids": [ + "L6" + ], + "subsystem_ids": [ + "ai-runtime", + "api-contracts", + "patrol-intelligence" + ], + "proposed_resolution": "lane-expansion", + "coverage_impact": 4, + "evidence": [ + { + "repo": "pulse", + "path": "docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/qualification", + "kind": "dir" + }, + { + "repo": "pulse", + "path": "scripts/check-patrol-assistant-journey.mjs", + "kind": "file" + } + ] } ], "candidate_lanes": [ @@ -10357,36 +10391,32 @@ "product-demand: pulse-pro/FEATURE_REQUESTS.md#patrol-weekly-digest-what-patrol-did-for-you", "design-note: docs/PATROL_WEEKLY_DIGEST.md" ] - } - ], - "work_claims": [ - { - "id": "claude-ai-provider-guided-setup-candidate-lane-ai-provider-guided-setup", - "agent_id": "claude-ai-provider-guided-setup", - "summary": "Guided AI provider setup with cost preview", - "target_id": "v6-product-lane-expansion", - "claimed_at": "2026-09-02T05:31:42Z", - "heartbeat_at": "2026-09-02T05:31:42Z", - "expires_at": "2026-09-02T09:31:42Z", - "work_item": { - "kind": "candidate-lane", - "id": "ai-provider-guided-setup" - } }, { - "id": "claude-patrol-digest-candidate-lane-patrol-weekly-digest", - "agent_id": "claude-patrol-digest", - "summary": "Patrol weekly digest: aggregation endpoint and in-app This week card", + "id": "patrol-assistant-customer-outcomes", + "name": "Patrol and Assistant Customer Outcomes", + "summary": "Qualify unhealthy service diagnosis, backup or capacity risk, and supported VM/LXC planning through approval and independent verification on disposable infrastructure. Compare named model configurations for useful outcomes, missed faults, false positives, latency and cost, then establish repeated value across independent volunteered Pro environments. The explicit explanation handoff is the completed frontend foundation, not completion of this outcome qualification.", + "status": "proposed", + "recorded_at": "2026-09-05", "target_id": "v6-product-lane-expansion", - "claimed_at": "2026-09-02T05:37:43Z", - "heartbeat_at": "2026-09-02T05:37:43Z", - "expires_at": "2026-09-02T11:37:43Z", - "work_item": { - "kind": "candidate-lane", - "id": "patrol-weekly-digest" - } + "current_lane_ids": [ + "L6" + ], + "coverage_gap_ids": [ + "patrol-assistant-customer-outcome-qualification" + ], + "subsystem_ids": [ + "ai-runtime", + "api-contracts", + "patrol-intelligence" + ], + "demand_evidence": [ + "user-direction: 2026-09-05 prioritise dependable Patrol and Assistant outcomes as core Pulse Pro value", + "qualification-contract: docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md" + ] } ], + "work_claims": [], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 580755dba..60e6551cc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3364,7 +3364,14 @@ query...`, and `Reading storage...` before streamed tool arguments are timelines must render as source-named investigation handoffs in the drawer instead of generic dashboard briefs. Source-owned handoff helpers may attach bounded model-only context, resources, action references, and metadata, but - they must not synthesize, prefill, or auto-submit a user prompt. The drawer + ordinary context attachment must not synthesize, prefill, or submit a user + prompt. An explicit labelled Explain action is a user-selected task and + dispatches the shared evidence-first explanation request through + `frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts`. + It preserves drafts and current work, captures the selected context before + asynchronous initialization, and uses normal send, queue, and retry handling + with request-local `autonomousMode:false`. It must not prescribe a diagnosis + or a tool sequence or grant action authority. The drawer presentation must stay compact: source, status, one primary subject, and an optional safe route link. It must not render Patrol-authored remediation steps, evidence chips, command summaries, recommendations, or suggested-prompt @@ -7949,3 +7956,9 @@ returns the typed `PatrolDigest` payload unchanged. Presentation code must not recompute digest counts from run history, findings, or cost events, and must not call the endpoint through any other client. Proof: `frontend-modern/src/api/__tests__/patrol.test.ts`. + +The explicit issue explanation journey is qualified separately from provider +reasoning and real remediation in +`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. The repeatable browser +proof is `scripts/check-patrol-assistant-journey.mjs`. A passing scripted +response does not establish a useful customer outcome or model qualification. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index b2587c3e4..5ee9d1ff9 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -512,14 +512,16 @@ transition references recovery evidence separate from its trigger evidence. On resource-backed active alert cards, Patrol is the primary doer: the visible primary action must run a manual scoped Patrol trigger such as "Have Patrol investigate" through the `ai-runtime` manual Patrol route - contract. Pulse Assistant remains a secondary context-only explanation path: + contract. Pulse Assistant remains a secondary explicit explanation path: Assistant handoffs must preserve alert context, force request-scoped approval mode, send bounded model-only handoff context plus structured resource references through the shared Assistant chat transport, and render a compact Alerts-owned briefing in the Assistant drawer without transferring - raw command payloads or synthesizing, pre-filling, or auto-submitting a chat - prompt. The Patrol trigger and the context-only Assistant open path must stay - distinct. + raw command payloads. The labelled Explain action dispatches the shared + explanation request through `aiChatStore.explain`, retaining selected alert + evidence through normal send and retry without replacing the composer draft. + Ordinary context-only opens remain free of inference. The Patrol trigger + and the Assistant explanation request must stay distinct. 8. Add or change Pulse Assistant incident timeline handoffs through `frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx` and `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`; @@ -2529,3 +2531,9 @@ Qualification additionally injects admission HTTP 503 on reconnect with populated inventory and checks incident access on desktop and the 390px mobile rail. Enabled acknowledgement controls prove access only, not persisted acknowledgement or external notification receipt. + +The explicit issue explanation journey is qualified separately from provider +reasoning and real remediation in +`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. The repeatable browser +proof is `scripts/check-patrol-assistant-journey.mjs`. A passing scripted +response does not establish a useful customer outcome or model qualification. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a729fcffb..729ff2fd8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -6642,8 +6642,12 @@ decisions from the structured handoff metadata and bounded chat context. Frontend handoff builders may send these fields for owned alert, incident, Patrol assessment, Patrol finding, or Patrol run-history context, but the backend must not persist them as user-authored message text, and the frontend -must not prefill or auto-submit a product-authored user prompt from them. They -are context only and must be treated as explanation/review context only. When a +must not turn their evidence or proposed remedies into user-authored message +text. Ordinary attachment is context only. A labelled Explain action may +submit the shared user-selected explanation request over the existing chat +transport, with the evidence kept in model-only handoff fields and +`autonomous_mode:false`. This starts explanation/review and grants no new +approval or execution authority. When a Patrol `finding_id` resolves, backend-refreshed durable finding context remains canonical; the handler may merge only recognized same-finding Patrol product handoff text and same-finding @@ -10590,3 +10594,9 @@ service, Patrol, or an email destination is missing; nothing is written to disk. The advanced-reporting entitlement gates both kinds. Proofs: `internal/api/report_schedules_test.go`, `internal/api/patrol_digest_email_test.go`, `internal/api/ai_handlers_more_test.go`. + +The explicit issue explanation journey is qualified separately from provider +reasoning and real remediation in +`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. The repeatable browser +proof is `scripts/check-patrol-assistant-journey.mjs`. A passing scripted +response does not establish a useful customer outcome or model qualification. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1193511ad..4add9dde7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2694,9 +2694,13 @@ production table, router and styles; it does not qualify full-app scrolling. Scoped Assistant handoffs must keep request-local execution overrides in drawer context. Dashboard and other route-owned entry points may open the Assistant drawer with source context and `autonomousMode:false`, but they - must not pre-fill or auto-submit a prompt, mutate persistent AI control-level - settings or trigger background Assistant settings/model bootstrap before - the drawer is open. Patrol finding handoffs that add structured + must not infer a user task from an ordinary context-only open. Explicit + labelled explanation actions use `aiChatStore.explain` and the shared + explanation dispatcher, which captures the request context, waits for open + initialization, preserves drafts, and acknowledges each request once. + Older request completion must not clear newer handoff context. Neither + path may mutate persistent AI control-level settings or trigger background + Assistant settings/model bootstrap before the drawer is open. Patrol finding handoffs that add structured investigation-record framing must derive that context through `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` so shared drawer primitives stay shell-owned rather than becoming a @@ -7083,3 +7087,9 @@ Patrol-origin fixes are waiting for approval. Loading, failed-load, no-runs, and truncated-history states carry distinct copy, and a failed load never renders zero counts as if the week were quiet. Browser proof covers the desktop and narrow Activity tab in `frontend-modern/browser-verification.json`. + +The explicit issue explanation journey is qualified separately from provider +reasoning and real remediation in +`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. The repeatable browser +proof is `scripts/check-patrol-assistant-journey.mjs`. A passing scripted +response does not establish a useful customer outcome or model qualification. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 819d3616c..4091672ce 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -868,8 +868,13 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or probe, context assembler, and execution-governance owner; the configured LLM is the diagnostic and remediation-reasoning owner. Patrol handoffs may provide system context, resource posture, action posture, and governed tools, - but must not synthesize, pre-fill, or auto-submit chat prompts, force active - tool use, name a required tool path, show suggested prompt chips, or present + but ordinary context attachment must not synthesize, pre-fill, or submit + chat prompts. The explicitly labelled Explain action starts the shared + evidence-first Assistant request with the selected attention record, bounded + evidence, resource references, and the canonical finding handoff when exactly + one finding is linked. Ambiguous links must not select an arbitrary finding. + Neither path may force active tool use, name a required tool path, show + suggested prompt chips, or present a Patrol-authored remediation answer for the LLM to execute. Patrol runs must still call the configured model when deterministic triage is quiet, and unmatched deterministic signals may be returned as context for another @@ -2578,3 +2583,9 @@ is unavailable rather than showing zeros. Proofs: `frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx`, `frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx` (card ordering), and the browser receipt in `frontend-modern/browser-verification.json`. + +The explicit issue explanation journey is qualified separately from provider +reasoning and real remediation in +`docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md`. The repeatable browser +proof is `scripts/check-patrol-assistant-journey.mjs`. A passing scripted +response does not establish a useful customer outcome or model qualification. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index b46cd46cb..c9b39b4a4 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,38 +1,53 @@ { "version": 1, - "base_sha": "58413ebdf6af81018bce15ff4023ca9824c0cdae", - "verified_at": "2026-09-05T09:32:59.247Z", + "base_sha": "75976c60be5e156be46fca344a02b7df5f573416", + "verified_at": "2026-09-05T17:45:39.915245Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/useAppRuntimeState.ts" + "frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts", + "frontend-modern/src/components/AI/Chat/index.tsx", + "frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", + "frontend-modern/src/stores/aiChat.ts", + "frontend-modern/src/utils/aiChatPresentation.ts" ], "content_sha256": { - "frontend-modern/src/useAppRuntimeState.ts": "d1de019dad5bff7fcb78116a8cf53663f1ddcd4c631bdbf0260b68580ebe0eb7" + "frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts": "8606c79c11b0197ed5a55ed1d2ec83e0cd7dd0adda182937e5aad9144571cb17", + "frontend-modern/src/components/AI/Chat/index.tsx": "6b1d6243d9a99d688229dea9e954ae6aa770a8b6e07e7f0ce75316b486090f09", + "frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx": "f373ff21a1c54304d63b92498c39fa45f5ffb036a2b02e93de64fafbbf8ee52e", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "f098cddd58823d2df7687f0db7774363fc9c81a7a8845a917e73415a7f5aae10", + "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "4152a3bec5412b4d744436128e8e038be349d9b6419fc58d3b7527908ffc982f", + "frontend-modern/src/stores/aiChat.ts": "d0b1ef69bcb454e6f0ce17a157c98bdd34d68d90f607b76081e9cd1bd6778e4c", + "frontend-modern/src/utils/aiChatPresentation.ts": "4b05f663110ec6c789f8ff050993a945e3e9d41c6e3fee4047500e979a48531d" }, "routes": [ - "/alerts", - "/settings", - "/docker" + "/patrol", + "/alerts" ], "viewports": [ { "width": 1440, - "height": 900 + "height": 1000 + }, + { + "width": 900, + "height": 1000 }, { "width": 390, - "height": 900 + "height": 1000 } ], "states": [ - "Full application served by isolated local Vite; synthetic HTTP, two synthetic organisations and cold socket; Chromium 141.0.7390.37, fresh contexts, default zoom.", - "Six cases: incoming admission fails, succeeds all-false, or completes after outgoing response. Superseded outgoing response cannot restore platform navigation.", - "Backend tenancy, populated resource rendering, production build, external notification delivery and candidate soak not qualified." + "Current hot-dev frontend with mock monitoring backend. Scripted attention evidence and inference responses. Other non-GET requests blocked. No real model or infrastructure outcome qualification.", + "Patrol selected item, expanded evidence, scoped explanation, streamed response, failed provider request and successful retry. Draft preserved across Escape and context-only reopen.", + "Active alert secondary menu at desktop and narrow widths. Open, Escape dismissal, outside click dismissal, reopen, select explanation, menu closes and alert context sent. Screenshots inspected for menu placement, readable response, reachable composer and error controls." ], "interactions": [ - "pulse-heavy-run -- bash -c 'for w in 390 1440; do for mode in failure success reverse; do PULSE_PROOF_WIDTH=$w PULSE_PROOF_MODE=$mode node scripts/check-navigation-admission-race.mjs || exit; done; done'", - "Production reconnect subscriber invoked; switch organisation via real selector; release held outgoing response; assert no platform navigation and exact organisation request sequence.", - "390px: More open, Escape, reopen, Settings; subsequent successful organisation switch restores platform selector; choose Docker and verify route without reload.", - "1440px: Settings remains visible; subsequent successful switch restores Proxmox destination without reload. Desktop and narrow screenshots inspected." + "node scripts/check-patrol-assistant-journey.mjs", + "Patrol at 1440/900/390: keyboard focus and Enter on Explain, exactly one selected-issue request, correct evidence/resource, autonomous false, no generic workflow starters, close/reopen preserves draft without inference.", + "Alerts at 1440/390: More alert actions, Escape, reopen, outside click, reopen, Explain. Selected alert evidence and resource attached with autonomous false. No Patrol trigger or configuration mutation.", + "Provider failure, Try again retains original handoff, reload does not resubmit. No page errors or unexpected non-GET requests." ] } diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 14db322d6..ce78df075 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, afterEach, beforeAll, beforeEach } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { Show, createSignal } from 'solid-js'; +import type { AIChatExplanationRequest } from '@/stores/aiChat'; import type { ChatMessage, ModelInfo, ModelRouteRecoveryOption } from '../types'; import type { QueuedFollowUp } from '../hooks/useChat'; import { WORKFLOW_STATUS_PACE_MS } from '../workflowStatusDisplay'; @@ -179,6 +180,8 @@ const { const mockAiChatStore = { isOpenSignal: vi.fn(() => true), + explanationRequestSignal: vi.fn((): AIChatExplanationRequest | null => null), + ackExplanationRequest: vi.fn(), commandRequestSignal: vi.fn( (): { id: number; @@ -549,6 +552,8 @@ beforeEach(() => { resetAIRuntimeState(); mockAiChatStore.isOpenSignal.mockReturnValue(true); mockAiChatStore.commandRequestSignal.mockReturnValue(null); + mockAiChatStore.explanationRequestSignal.mockReturnValue(null); + mockAiChatStore.ackExplanationRequest.mockReset(); mockAiChatStore.context = { findingId: undefined, autonomousMode: undefined, @@ -1760,6 +1765,7 @@ describe('AIChat', () => { renderChat(); expect(screen.getByLabelText('Assistant context')).toBeInTheDocument(); + expect(screen.queryByTestId('assistant-workflow-starters')).not.toBeInTheDocument(); expect(screen.getByText('Pulse Patrol')).toBeInTheDocument(); expect(screen.getByText('High CPU usage on web-server')).toBeInTheDocument(); expect(screen.queryByText('Backup job saturated CPU.')).not.toBeInTheDocument(); @@ -2691,6 +2697,34 @@ describe('AIChat', () => { expect(mockChat.sendMessage).not.toHaveBeenCalled(); }); + it('dispatches an explicit issue request without replacing a draft or interrupting the current session', async () => { + const [request, setRequest] = createSignal(null); + mockAiChatStore.explanationRequestSignal.mockImplementation(request); + mockAiChatStore.ackExplanationRequest.mockImplementation(() => setRequest(null)); + renderChat(); + const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); + fireEvent.input(textarea, { target: { value: 'My unfinished question' } }); + setRequest({ + id: 1, + signal: new AbortController().signal, + context: { + findingId: 'finding-1', + handoffContext: 'Current evidence', + autonomousMode: false, + }, + }); + await waitFor(() => expect(mockChat.sendMessage).toHaveBeenCalledOnce()); + expect(mockChat.sendMessage).toHaveBeenCalledWith( + expect.stringContaining('Explain this issue'), + undefined, + 'finding-1', + expect.objectContaining({ autonomousMode: false, handoffContext: 'Current evidence' }), + ); + expect(textarea).toHaveValue('My unfinished question'); + expect(mockChat.newSession).not.toHaveBeenCalled(); + expect(mockChat.stop).not.toHaveBeenCalled(); + }); + it('consumes pending command palette model requests without sending a provider prompt', async () => { mockAiChatStore.commandRequestSignal.mockReturnValue({ id: 42, action: 'models' }); renderChat(); @@ -5946,7 +5980,7 @@ describe('AIChat', () => { }); expect( screen.getByText( - /Enable it in Settings so Pulse Assistant can reference real services, versions, and commands instead of generic guidance\./, + /Inventory, metrics, and alerts are available. Enable discovery in Settings for additional service details\./, ), ).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useExplanationRequest.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/useExplanationRequest.test.tsx new file mode 100644 index 000000000..0a5444aec --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/useExplanationRequest.test.tsx @@ -0,0 +1,122 @@ +import { cleanup, render, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { aiChatStore } from '@/stores/aiChat'; +import { eventBus } from '@/stores/events'; +import { + EXPLAIN_SELECTED_ISSUE_PROMPT, + useExplanationRequest, +} from '../hooks/useExplanationRequest'; + +describe('explicit issue explanations', () => { + beforeEach(() => { + const request = aiChatStore.explanationRequestSignal(); + if (request) aiChatStore.ackExplanationRequest(request.id); + aiChatStore.close(); + aiChatStore.clearContext(); + }); + afterEach(cleanup); + + const mount = ( + send = vi.fn().mockResolvedValue(true), + prepare = vi.fn().mockResolvedValue(undefined), + ) => { + const onError = vi.fn(); + render(() => { + useExplanationRequest({ isOpen: aiChatStore.isOpenSignal, send, prepare, onError }); + return
; + }); + return { send, prepare, onError }; + }; + + it('only attaches context for ordinary opens', async () => { + const { send } = mount(); + aiChatStore.open({ targetId: 'vm-1' }); + await Promise.resolve(); + expect(send).not.toHaveBeenCalled(); + }); + + it('sends the explicit request once with finding and action context', async () => { + const { send } = mount(); + const resources = [{ id: 'vm-1', type: 'vm' }]; + const actions = [{ actionId: 'action-1', approvalId: 'approval-1' }]; + aiChatStore.explain({ + targetId: 'vm-1', + findingId: 'finding-1', + autonomousMode: true, + handoffContext: 'Current finding evidence', + handoffResources: resources, + handoffActions: actions, + handoffMetadata: { kind: 'patrol_finding' }, + }); + await waitFor(() => expect(send).toHaveBeenCalledTimes(1)); + expect(send).toHaveBeenCalledWith(EXPLAIN_SELECTED_ISSUE_PROMPT, 'finding-1', { + autonomousMode: false, + handoffContext: 'Current finding evidence', + handoffResources: resources, + handoffActions: actions, + handoffMetadata: { kind: 'patrol_finding' }, + }); + aiChatStore.close(); + aiChatStore.open(); + await Promise.resolve(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('captures the selected issue while initialization is pending and never clears a newer handoff', async () => { + let ready!: () => void; + const prepare = vi.fn( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const { send } = mount(vi.fn().mockResolvedValue(true), prepare); + aiChatStore.explain({ findingId: 'finding-a', handoffContext: 'Evidence A' }); + await waitFor(() => expect(prepare).toHaveBeenCalledOnce()); + aiChatStore.open({ findingId: 'finding-b', handoffContext: 'Evidence B' }); + ready(); + await waitFor(() => expect(send).toHaveBeenCalledOnce()); + expect(send.mock.calls[0][1]).toBe('finding-a'); + expect(send.mock.calls[0][2].handoffContext).toBe('Evidence A'); + expect(aiChatStore.context.handoffContext).toBe('Evidence B'); + }); + + it('retains context after a rejected send and allows an explicit retry', async () => { + const { send } = mount(vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true)); + aiChatStore.explain({ findingId: 'finding-1', handoffContext: 'Evidence' }); + await waitFor(() => expect(send).toHaveBeenCalledOnce()); + expect(aiChatStore.context.handoffContext).toBe('Evidence'); + aiChatStore.explain(aiChatStore.context); + await waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(aiChatStore.context.handoffContext).toBeUndefined()); + }); + + it('does not dispatch old tenant evidence after an organisation switch during initialization', async () => { + let ready!: () => void; + const prepare = vi.fn( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const { send } = mount(vi.fn().mockResolvedValue(true), prepare); + aiChatStore.explain({ handoffContext: 'Private evidence for org A' }); + await waitFor(() => expect(prepare).toHaveBeenCalledOnce()); + eventBus.emit('org_switched', 'org-b'); + ready(); + await Promise.resolve(); + await Promise.resolve(); + expect(send).not.toHaveBeenCalled(); + expect(aiChatStore.explanationRequestSignal()).toBeNull(); + expect(aiChatStore.context.handoffContext).toBeUndefined(); + }); + + it('reports preparation failure without sending or silently retrying', async () => { + const error = new Error('offline'); + const { send, onError } = mount(vi.fn(), vi.fn().mockRejectedValue(error)); + aiChatStore.explain({ handoffContext: 'Evidence' }); + await waitFor(() => expect(onError).toHaveBeenCalledWith(error)); + expect(send).not.toHaveBeenCalled(); + expect(aiChatStore.context.handoffContext).toBe('Evidence'); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts b/frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts new file mode 100644 index 000000000..e6896ecce --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts @@ -0,0 +1,56 @@ +import { createEffect, onCleanup, untrack, type Accessor } from 'solid-js'; +import { aiChatStore, type AIChatContext } from '@/stores/aiChat'; +import type { SendMessageOptions } from './useChat'; + +export const EXPLAIN_SELECTED_ISSUE_PROMPT = + 'Explain this issue using current evidence. What needs my attention, and what is the next useful step? Do not make changes.'; + +export function explanationSendOptions(context: AIChatContext): SendMessageOptions { + return { + autonomousMode: false, + handoffContext: context.handoffContext, + handoffResources: context.handoffResources, + handoffActions: context.handoffActions, + handoffMetadata: context.handoffMetadata, + }; +} + +// Use the normal send/queue/retry path. A contextual request must not erase an +// unsent draft, stop another response, or bypass action approval. +export function useExplanationRequest(options: { + isOpen: Accessor; + prepare: () => Promise; + send: ( + prompt: string, + findingId: string | undefined, + options: SendMessageOptions, + ) => Promise; + onError: (error: unknown) => void; +}) { + let disposed = false; + onCleanup(() => { + disposed = true; + }); + createEffect(() => { + const request = aiChatStore.explanationRequestSignal?.(); + if (!request || !options.isOpen()) return; + aiChatStore.ackExplanationRequest(request.id); + untrack(() => { + void options + .prepare() + .then(() => + disposed || request.signal.aborted + ? false + : options.send( + EXPLAIN_SELECTED_ISSUE_PROMPT, + request.context.findingId, + explanationSendOptions(request.context), + ), + ) + .then((accepted) => { + if (accepted) aiChatStore.clearRequestHandoffPayload(request.context); + }) + .catch(options.onError); + }); + }); +} diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 291cc810f..7482f70bb 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -55,6 +55,7 @@ import { ActionIconButton } from '@/components/shared/Button'; import { SearchField } from '@/components/shared/SearchField'; import { notificationStore } from '@/stores/notifications'; import { aiChatStore, type AIChatContext } from '@/stores/aiChat'; +import { useExplanationRequest } from './hooks/useExplanationRequest'; import { aiRuntimeModels, aiRuntimeModelsError, @@ -2814,12 +2815,24 @@ export const AIChat: Component = (props) => { queueMicrotask(resizeTextarea); }); + let openInitialization: Promise = Promise.resolve(); createEffect(() => { if (!isOpen()) { setShowCommandHelp(false); return; } - void initializeWhenOpen(); + openInitialization = initializeWhenOpen(); + }); + + useExplanationRequest({ + isOpen, + prepare: () => openInitialization, + send: (prompt, findingId, sendOptions) => + chat.sendMessage(prompt, undefined, findingId, sendOptions), + onError: (error) => { + logger.warn('[AIChat] Failed to start issue explanation:', error); + notificationStore.error('Could not start the explanation. Try Explain with Assistant again.'); + }, }); createEffect(() => { @@ -5267,7 +5280,7 @@ export const AIChat: Component = (props) => {
- 0}> + 0}>