mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1911 from rcourtman/pulse/scoped-issue-explanations
Start scoped explanations from Patrol and alert actions
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<AIChatExplanationRequest | null>(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();
|
||||
});
|
||||
|
||||
@@ -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 <div />;
|
||||
});
|
||||
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<void>((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<void>((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');
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
prepare: () => Promise<void>;
|
||||
send: (
|
||||
prompt: string,
|
||||
findingId: string | undefined,
|
||||
options: SendMessageOptions,
|
||||
) => Promise<boolean>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<AIChatProps> = (props) => {
|
||||
queueMicrotask(resizeTextarea);
|
||||
});
|
||||
|
||||
let openInitialization: Promise<void> = 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<AIChatProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantWorkflowStarters().length > 0}>
|
||||
<Show when={!contextBriefing() && assistantWorkflowStarters().length > 0}>
|
||||
<div
|
||||
class="mb-2 flex min-h-7 min-w-0 flex-wrap items-center gap-1.5"
|
||||
aria-label="Assistant workflow starters"
|
||||
|
||||
@@ -24,7 +24,7 @@ interface InvestigateAlertButtonProps {
|
||||
* When true (and the alert has a resource), the text/full variants render as
|
||||
* a split investigation button: the primary action runs a manual targeted
|
||||
* Patrol check scoped to this alert's resource, while a small menu keeps
|
||||
* Pulse Assistant available for context-only explanation. The icon variant is
|
||||
* Pulse Assistant available for scoped explanation. The icon variant is
|
||||
* single-purpose regardless.
|
||||
*/
|
||||
patrolOption?: boolean;
|
||||
@@ -32,7 +32,7 @@ interface InvestigateAlertButtonProps {
|
||||
|
||||
/**
|
||||
* Investigation button for one-click alert follow-up.
|
||||
* Plain Assistant opens remain context-only. On alert cards that opt into
|
||||
* The explicit Assistant explanation action starts a scoped read-only request. On alert cards that opt into
|
||||
* Patrol routing, resource-backed alerts make Patrol the primary doer and keep
|
||||
* Assistant as the secondary explanation path.
|
||||
* Hidden entirely when AI is not configured.
|
||||
@@ -92,7 +92,7 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
|
||||
vmid: props.vmid,
|
||||
});
|
||||
|
||||
aiChatStore.open(handoff.context);
|
||||
aiChatStore.explain(handoff.context);
|
||||
};
|
||||
|
||||
const startPatrolInvestigation = async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getPublicPricingUrl } from '@/utils/pricingHandoff';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
openMock,
|
||||
explainMock,
|
||||
openUpgradeDestinationMock,
|
||||
formatAlertValueMock,
|
||||
mockAiChatStore,
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
triggerPatrolRunMock,
|
||||
notificationStoreMock,
|
||||
} = vi.hoisted(() => {
|
||||
const openMock = vi.fn();
|
||||
const explainMock = vi.fn();
|
||||
const openUpgradeDestinationMock = vi.fn();
|
||||
const formatAlertValueMock = vi.fn((value?: number, _type?: string) =>
|
||||
value !== undefined ? `${value.toFixed(1)}%` : 'N/A',
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
|
||||
const mockAiChatStore = {
|
||||
enabled: true as boolean | null,
|
||||
open: (...args: unknown[]) => openMock(...args),
|
||||
explain: (...args: unknown[]) => explainMock(...args),
|
||||
};
|
||||
const triggerPatrolRunMock = vi.fn();
|
||||
const notificationStoreMock = {
|
||||
@@ -39,7 +39,7 @@ const {
|
||||
info: vi.fn(),
|
||||
};
|
||||
return {
|
||||
openMock,
|
||||
explainMock,
|
||||
openUpgradeDestinationMock,
|
||||
formatAlertValueMock,
|
||||
mockAiChatStore,
|
||||
@@ -107,7 +107,7 @@ function makeAlert(overrides: Partial<Alert> = {}): Alert {
|
||||
}
|
||||
|
||||
function openedContext(): Record<string, unknown> {
|
||||
return openMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
return explainMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function openedBriefing(): { statusLabel?: string; detailLines?: string[]; subject?: string } {
|
||||
@@ -130,7 +130,7 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActiveLocale(DEFAULT_LOCALE);
|
||||
openMock.mockReset();
|
||||
explainMock.mockReset();
|
||||
formatAlertValueMock.mockClear();
|
||||
openUpgradeDestinationMock.mockReset();
|
||||
getUpgradeActionDestinationMock.mockReset();
|
||||
@@ -271,7 +271,7 @@ describe('InvestigateAlertButton', () => {
|
||||
href: getPublicPricingUrl('ai_alerts'),
|
||||
external: true,
|
||||
});
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(explainMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the locked state non-promotional when upgrade prompts are hidden', async () => {
|
||||
@@ -288,7 +288,7 @@ describe('InvestigateAlertButton', () => {
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(openUpgradeDestinationMock).not.toHaveBeenCalled();
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(explainMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still stops propagation when locked and clicked', () => {
|
||||
@@ -316,7 +316,7 @@ describe('InvestigateAlertButton', () => {
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(openMock).toHaveBeenCalledTimes(1);
|
||||
expect(explainMock).toHaveBeenCalledTimes(1);
|
||||
const context = openedContext();
|
||||
|
||||
// Verify context
|
||||
@@ -780,7 +780,7 @@ describe('InvestigateAlertButton patrolOption', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Have Patrol investigate/i }));
|
||||
|
||||
await waitFor(() => expect(triggerPatrolRunMock).toHaveBeenCalled());
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(explainMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the Assistant only from the secondary explanation item', async () => {
|
||||
@@ -790,7 +790,7 @@ describe('InvestigateAlertButton patrolOption', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'More alert actions' }));
|
||||
await fireEvent.click(await screen.findByRole('menuitem', { name: /Explain with Assistant/i }));
|
||||
|
||||
expect(openMock).toHaveBeenCalledTimes(1);
|
||||
expect(explainMock).toHaveBeenCalledTimes(1);
|
||||
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
getPatrolProtectionProviderLabels,
|
||||
} from '@/features/patrol/patrolControlPresentation';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { buildPatrolAttentionAssistantHandoff } from './patrolInvestigationContextModel';
|
||||
import { aiIntelligenceStore, type UnifiedFinding } from '@/stores/aiIntelligence';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import {
|
||||
@@ -863,60 +864,8 @@ function AttentionDetail(props: {
|
||||
const openAssistant = () => {
|
||||
const value = detail();
|
||||
if (!value) return;
|
||||
const current = value.item;
|
||||
const evidence = value.evidence.map(
|
||||
(entry) =>
|
||||
`${entry.source.provider}/${entry.source.collector}: ${entry.completeness}, ${entry.confidence}, observed ${entry.observedAt}`,
|
||||
);
|
||||
aiChatStore.open({
|
||||
targetType: current.subjectResourceType || 'resource',
|
||||
targetId: current.subjectResourceId,
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: current.subjectResourceId,
|
||||
name: current.subjectResourceName,
|
||||
type: current.subjectResourceType,
|
||||
},
|
||||
],
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Selected attention item',
|
||||
subject: current.title,
|
||||
statusLabel: `${formatLabel(current.severity)} · ${formatLabel(current.state)}`,
|
||||
detailLines: [
|
||||
current.plainLanguageSummary,
|
||||
current.impact ? `Impact: ${current.impact}` : undefined,
|
||||
current.recommendedNextStep ? `Next step: ${current.recommendedNextStep}` : undefined,
|
||||
].filter((line): line is string => Boolean(line)),
|
||||
evidence: evidence.slice(0, 5),
|
||||
actionLabel: `Explain ${current.title}`,
|
||||
safetyNote:
|
||||
'This context explains evidence only. It does not grant approval or action authority.',
|
||||
},
|
||||
handoffContext: [
|
||||
`Attention Item: ${current.id}`,
|
||||
`Operational Record: ${current.operationalRecordId}`,
|
||||
`Resource: ${current.subjectResourceName} (${current.subjectResourceId})`,
|
||||
`State: ${current.state}`,
|
||||
`Severity: ${current.severity}`,
|
||||
`Summary: ${current.plainLanguageSummary}`,
|
||||
`Evidence: ${current.evidenceFreshness}/${current.evidenceCompleteness}`,
|
||||
current.impact ? `Impact: ${current.impact}` : '',
|
||||
current.recommendedNextStep ? `Recommended Next Step: ${current.recommendedNextStep}` : '',
|
||||
'Authority Boundary: Explain selected evidence only. Do not infer capabilities or bypass approval.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
context: {
|
||||
attentionItemId: current.id,
|
||||
operationalRecordId: current.operationalRecordId,
|
||||
lifecycleState: current.state,
|
||||
evidenceFreshness: current.evidenceFreshness,
|
||||
evidenceCompleteness: current.evidenceCompleteness,
|
||||
protectionPosture: current.protectionPosture,
|
||||
},
|
||||
});
|
||||
const handoff = buildPatrolAttentionAssistantHandoff(value, linkedFindings());
|
||||
aiChatStore.explain(handoff.context);
|
||||
};
|
||||
const copyResourceId = async (value: string) => {
|
||||
if (await copyToClipboard(value)) setCopiedResourceId(value);
|
||||
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
sortPatrolAttentionDecisions,
|
||||
} from '../PatrolAttentionWorkbench';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import { buildPatrolAttentionAssistantHandoff } from '../patrolInvestigationContextModel';
|
||||
import type { UnifiedFinding } from '@/stores/aiIntelligence';
|
||||
|
||||
const evaluatedAt = '2026-07-19T08:00:00Z';
|
||||
@@ -1031,3 +1032,68 @@ describe('PatrolAttentionWorkbench', () => {
|
||||
await waitFor(() => expect(currentTrigger).toHaveFocus());
|
||||
});
|
||||
});
|
||||
|
||||
describe('attention explanation context', () => {
|
||||
it('preserves canonical finding and investigation identity when the issue has one linked finding', () => {
|
||||
const selected = detail(item());
|
||||
const handoff = buildPatrolAttentionAssistantHandoff(selected, [mirroredFinding()]);
|
||||
expect(handoff.context.findingId).toBe('finding-1');
|
||||
expect(handoff.context.handoffMetadata?.kind).toBe('patrol_finding');
|
||||
expect(handoff.context.briefing?.title).toBe('Disk pressure on Database VM');
|
||||
expect(handoff.context.handoffContext).toContain(
|
||||
'The root volume has been filling for a week.',
|
||||
);
|
||||
expect(handoff.context.handoffContext).toContain('Operational Record: record-1');
|
||||
expect(handoff.context.handoffContext).toContain('Evidence evidence-1:');
|
||||
expect(handoff.context.autonomousMode).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps unlinked attention evidence and existing governed action references without inventing a finding', () => {
|
||||
const selected = detail(
|
||||
item({
|
||||
availableActions: [
|
||||
{
|
||||
actionId: 'action-1',
|
||||
targetResourceId: 'pve:vm:101',
|
||||
capability: 'reboot',
|
||||
kind: 'resource',
|
||||
label: 'Reboot',
|
||||
mode: 'plan',
|
||||
risk: 'medium',
|
||||
approval: 'required',
|
||||
eligibility: 'eligible',
|
||||
reasons: [],
|
||||
evidenceIds: ['evidence-1'],
|
||||
expectedPostcondition: 'running',
|
||||
verificationPolicy: 'independent',
|
||||
requiresApproval: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const handoff = buildPatrolAttentionAssistantHandoff(selected);
|
||||
expect(handoff.context.findingId).toBeUndefined();
|
||||
expect(handoff.context.handoffResources).toEqual([
|
||||
{ id: 'pve:vm:101', name: 'Database VM', type: 'vm' },
|
||||
]);
|
||||
expect(handoff.context.handoffActions).toEqual([
|
||||
{
|
||||
actionId: 'action-1',
|
||||
targetResourceId: 'pve:vm:101',
|
||||
actionCapability: 'reboot',
|
||||
actionRequiresApproval: true,
|
||||
},
|
||||
]);
|
||||
expect(handoff.context.handoffContext).toContain('Evidence: fresh/complete');
|
||||
expect(handoff.context.handoffContext).toContain('observed');
|
||||
});
|
||||
|
||||
it('does not bind the request to an arbitrary finding when several findings explain an issue', () => {
|
||||
const handoff = buildPatrolAttentionAssistantHandoff(detail(item()), [
|
||||
mirroredFinding(),
|
||||
mirroredFinding({ id: 'finding-2' }),
|
||||
]);
|
||||
expect(handoff.context.findingId).toBeUndefined();
|
||||
expect(handoff.context.context?.operationalRecordId).toBe('record-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@/types/aiIntelligence';
|
||||
import type { ApprovalRequest, InvestigationRecord, RemediationPlan } from '@/api/ai';
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import type { AttentionItemDetail } from '@/api/patrolAttention';
|
||||
import type { UnifiedFinding } from '@/stores/aiIntelligence';
|
||||
import type {
|
||||
AIChatContext,
|
||||
@@ -508,6 +509,90 @@ export function buildPatrolAssistantFindingHandoffFromUnifiedFinding(
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep the main attention journey on the same finding/action handoff as records. */
|
||||
export function buildPatrolAttentionAssistantHandoff(
|
||||
detail: AttentionItemDetail,
|
||||
linkedFindings: readonly UnifiedFinding[] = [],
|
||||
): PatrolAssistantFindingHandoff {
|
||||
const item = detail.item;
|
||||
// Never choose an arbitrary finding when several findings explain one issue.
|
||||
const finding = linkedFindings.length === 1 ? linkedFindings[0] : undefined;
|
||||
const canonical = finding
|
||||
? buildPatrolAssistantFindingHandoffFromUnifiedFinding(finding).context
|
||||
: {};
|
||||
const evidence = [...detail.evidence]
|
||||
.sort((a, b) => Date.parse(b.observedAt) - Date.parse(a.observedAt))
|
||||
.slice(0, 5)
|
||||
.map((entry) =>
|
||||
[
|
||||
`Evidence ${entry.id}: ${entry.source.provider}/${entry.source.collector}`,
|
||||
`${entry.completeness}, ${entry.confidence}, permissions ${entry.permissions}`,
|
||||
`observed ${entry.observedAt}`,
|
||||
entry.reason?.message || entry.reason?.code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
);
|
||||
const actionReferences: AIChatHandoffAction[] = item.availableActions
|
||||
.filter((action) => Boolean(action.actionId))
|
||||
.slice(0, 5)
|
||||
.map((action) => ({
|
||||
actionId: action.actionId,
|
||||
targetResourceId: action.targetResourceId,
|
||||
actionCapability: action.capability,
|
||||
actionRequiresApproval: action.requiresApproval,
|
||||
}));
|
||||
return {
|
||||
context: {
|
||||
...canonical,
|
||||
targetType: item.subjectResourceType || 'resource',
|
||||
targetId: item.subjectResourceId,
|
||||
autonomousMode: false,
|
||||
handoffResources: canonical.handoffResources ?? [
|
||||
{
|
||||
id: item.subjectResourceId,
|
||||
name: item.subjectResourceName,
|
||||
type: item.subjectResourceType,
|
||||
},
|
||||
],
|
||||
handoffActions: canonical.handoffActions?.length
|
||||
? canonical.handoffActions
|
||||
: actionReferences,
|
||||
handoffContext: [
|
||||
canonical.handoffContext,
|
||||
'[Patrol Attention Context]',
|
||||
`Attention Item: ${item.id}`,
|
||||
`Operational Record: ${item.operationalRecordId}`,
|
||||
`Resource: ${item.subjectResourceName} (${item.subjectResourceId})`,
|
||||
`State: ${item.state}`,
|
||||
`Severity: ${item.severity}`,
|
||||
`Summary: ${item.plainLanguageSummary}`,
|
||||
`Evidence: ${item.evidenceFreshness}/${item.evidenceCompleteness}`,
|
||||
item.impact ? `Impact: ${item.impact}` : '',
|
||||
item.recommendedNextStep ? `Recorded next step: ${item.recommendedNextStep}` : '',
|
||||
...evidence,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: item.title,
|
||||
subject: item.subjectResourceName,
|
||||
statusLabel: `${item.severity} · ${item.state}`,
|
||||
},
|
||||
context: {
|
||||
...canonical.context,
|
||||
attentionItemId: item.id,
|
||||
operationalRecordId: item.operationalRecordId,
|
||||
lifecycleState: item.state,
|
||||
evidenceFreshness: item.evidenceFreshness,
|
||||
evidenceCompleteness: item.evidenceCompleteness,
|
||||
protectionPosture: item.protectionPosture,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPatrolAssessmentAssistantHandoff(
|
||||
input: PatrolAssessmentAssistantHandoffInput,
|
||||
): PatrolAssessmentAssistantHandoff {
|
||||
|
||||
@@ -77,6 +77,22 @@ describe('aiChatStore', () => {
|
||||
expect(aiChatStore.commandRequest).toMatchObject({ action: 'providers' });
|
||||
});
|
||||
|
||||
it('starts only explicit explanations and ignores stale acknowledgements', () => {
|
||||
aiChatStore.open({ targetId: 'vm-a' });
|
||||
expect(aiChatStore.explanationRequestSignal()).toBeNull();
|
||||
aiChatStore.explain({ targetId: 'vm-a', autonomousMode: true });
|
||||
const first = aiChatStore.explanationRequestSignal()!;
|
||||
expect(first.context.autonomousMode).toBe(false);
|
||||
aiChatStore.explain({ targetId: 'vm-b', handoffContext: 'Evidence B' });
|
||||
const second = aiChatStore.explanationRequestSignal()!;
|
||||
aiChatStore.ackExplanationRequest(first.id);
|
||||
aiChatStore.clearRequestHandoffPayload(first.context);
|
||||
expect(aiChatStore.explanationRequestSignal()).toBe(second);
|
||||
expect(aiChatStore.context.handoffContext).toBe('Evidence B');
|
||||
aiChatStore.ackExplanationRequest(second.id);
|
||||
expect(aiChatStore.explanationRequestSignal()).toBeNull();
|
||||
});
|
||||
|
||||
it('re-derives enabled from the session assistantEnabled capability', async () => {
|
||||
getSecurityStatusMock.mockResolvedValue({
|
||||
sessionCapabilities: { assistantEnabled: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { batch, createSignal } from 'solid-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { eventBus } from '@/stores/events';
|
||||
// NOTE: AIAPI import removed - session management is handled by Pulse Assistant's embedded UI
|
||||
@@ -87,6 +87,12 @@ export interface AIChatCommandRequest {
|
||||
action: AIChatCommandRequestAction;
|
||||
}
|
||||
|
||||
export interface AIChatExplanationRequest {
|
||||
id: number;
|
||||
context: AIChatContext;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
// A single context item that can be accumulated
|
||||
interface ContextItem {
|
||||
id: string; // unique identifier (e.g., "vm-pve-node-101")
|
||||
@@ -168,6 +174,10 @@ const saveMessagesToStorage = (msgs: Message[]) => {
|
||||
// Global state for the AI chat drawer
|
||||
const [isAIChatOpen, setIsAIChatOpen] = createSignal(false);
|
||||
const [aiChatContext, setAIChatContext] = createSignal<AIChatContext>({});
|
||||
let explanationScope = new AbortController();
|
||||
const [explanationRequest, setExplanationRequest] = createSignal<AIChatExplanationRequest | null>(
|
||||
null,
|
||||
);
|
||||
const [aiChatCommandRequest, setAIChatCommandRequest] = createSignal<AIChatCommandRequest | null>(
|
||||
null,
|
||||
);
|
||||
@@ -186,6 +196,7 @@ const [_isSyncing, _setIsSyncing] = createSignal<boolean>(false);
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 2000; // Save 2 seconds after last change
|
||||
let nextAIChatCommandRequestId = 1;
|
||||
let nextExplanationRequestId = 1;
|
||||
|
||||
// Store reference to AI input for focusing from keyboard shortcuts
|
||||
let aiInputRef: HTMLTextAreaElement | null = null;
|
||||
@@ -222,6 +233,7 @@ export const aiChatStore = {
|
||||
isOpenSignal: isAIChatOpen,
|
||||
|
||||
commandRequestSignal: aiChatCommandRequest,
|
||||
explanationRequestSignal: explanationRequest,
|
||||
|
||||
get commandRequest() {
|
||||
return aiChatCommandRequest();
|
||||
@@ -403,6 +415,25 @@ export const aiChatStore = {
|
||||
setIsAIChatOpen(true);
|
||||
},
|
||||
|
||||
// A labelled Explain action is a user request. Merely opening the drawer
|
||||
// or attaching context never starts inference.
|
||||
explain(context: AIChatContext) {
|
||||
const scopedContext = { ...context, autonomousMode: false };
|
||||
batch(() => {
|
||||
setAIChatContext(scopedContext);
|
||||
setExplanationRequest({
|
||||
id: nextExplanationRequestId++,
|
||||
context: scopedContext,
|
||||
signal: explanationScope.signal,
|
||||
});
|
||||
setIsAIChatOpen(true);
|
||||
});
|
||||
},
|
||||
|
||||
ackExplanationRequest(id: number) {
|
||||
setExplanationRequest((request) => (request?.id === id ? null : request));
|
||||
},
|
||||
|
||||
requestCommand(action: AIChatCommandRequestAction) {
|
||||
setAIChatCommandRequest({
|
||||
id: nextAIChatCommandRequestId++,
|
||||
@@ -515,8 +546,10 @@ export const aiChatStore = {
|
||||
|
||||
// Clear one-shot request handoff payloads after the backend has persisted
|
||||
// them as model-only session context.
|
||||
clearRequestHandoffPayload() {
|
||||
clearRequestHandoffPayload(expectedContext?: AIChatContext) {
|
||||
setAIChatContext((prev) => {
|
||||
// A completed request must not consume a newer issue's handoff.
|
||||
if (expectedContext && prev !== expectedContext) return prev;
|
||||
if (
|
||||
!prev.handoffContext &&
|
||||
!prev.handoffResources?.length &&
|
||||
@@ -564,6 +597,10 @@ export const aiChatStore = {
|
||||
|
||||
// Clear AI chat state on org switch to prevent cross-org data leakage
|
||||
eventBus.on('org_switched', () => {
|
||||
// Invalidate requests waiting for drawer initialization before the tenant changes.
|
||||
explanationScope.abort();
|
||||
explanationScope = new AbortController();
|
||||
setExplanationRequest(null);
|
||||
// Clear all messages
|
||||
setMessages([]);
|
||||
saveMessagesToStorage([]);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('aiChatPresentation', () => {
|
||||
);
|
||||
expect(AI_CHAT_DISCOVERY_HINT_TITLE).toBe('Discovery is off.');
|
||||
expect(AI_CHAT_DISCOVERY_HINT_BODY).toBe(
|
||||
'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.',
|
||||
);
|
||||
expect(AI_CHAT_NEW_SESSION_SHORT_LABEL).toBe('New');
|
||||
expect(AI_CHAT_NEW_SESSION_BUTTON_TITLE).toBe('Start new Assistant session');
|
||||
|
||||
@@ -63,7 +63,7 @@ export const AI_CHAT_SESSION_SEARCH_ERROR_STATE = 'Failed to search assistant se
|
||||
export const AI_CHAT_MODEL_SELECTOR_EMPTY_STATE = 'No matching models.';
|
||||
export const AI_CHAT_DISCOVERY_HINT_TITLE = 'Discovery is off.';
|
||||
export const AI_CHAT_DISCOVERY_HINT_BODY =
|
||||
'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.';
|
||||
export const AI_CHAT_INPUT_PLACEHOLDER = 'Ask about your infrastructure...';
|
||||
export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input';
|
||||
export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...';
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// Browser contract proof against the current hot-dev build. Inference is scripted.
|
||||
// This does not qualify provider reasoning or real infrastructure actions.
|
||||
import {
|
||||
chromium,
|
||||
expect,
|
||||
} from "../tests/integration/node_modules/@playwright/test/index.mjs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
const base = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5173";
|
||||
const output = new URL("../tmp/patrol-assistant-journey/", import.meta.url);
|
||||
await mkdir(output, { recursive: true });
|
||||
const now = new Date().toISOString();
|
||||
const resourceId = "pve:vm:101";
|
||||
const makeItem = (id, title) => ({
|
||||
id,
|
||||
operationalRecordId: id,
|
||||
subjectResourceId: resourceId,
|
||||
subjectResourceName: "Database VM",
|
||||
subjectResourceType: "vm",
|
||||
kind: "disk",
|
||||
title,
|
||||
plainLanguageSummary: "The database disk is nearly full.",
|
||||
severity: "warning",
|
||||
state: "open",
|
||||
firstObservedAt: now,
|
||||
lastObservedAt: now,
|
||||
evidenceFreshness: "fresh",
|
||||
evidenceCompleteness: "complete",
|
||||
impact: "Writes may fail.",
|
||||
relatedResources: [],
|
||||
availableActions: [],
|
||||
verificationState: "not_available",
|
||||
});
|
||||
const items = [
|
||||
makeItem("issue-a", "Database disk pressure"),
|
||||
makeItem("issue-b", "Backup protection missing"),
|
||||
];
|
||||
const summary = {
|
||||
activeCount: 2,
|
||||
openCount: 2,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 0,
|
||||
calm: false,
|
||||
coverageState: "current",
|
||||
evaluatedAt: now,
|
||||
};
|
||||
const detail = (item) => ({
|
||||
item,
|
||||
operationalRecord: {
|
||||
...item,
|
||||
canonicalSpecId: "disk-pressure",
|
||||
stateChangedAt: now,
|
||||
evidenceIds: ["evidence-1"],
|
||||
causeKey: "capacity",
|
||||
relatedResourceIds: [],
|
||||
},
|
||||
timeline: [],
|
||||
evidence: [
|
||||
{
|
||||
id: "evidence-1",
|
||||
source: { provider: "proxmox", collector: "metrics" },
|
||||
subject: { resourceId },
|
||||
observedAt: now,
|
||||
ingestedAt: now,
|
||||
completeness: "complete",
|
||||
confidence: "confirmed",
|
||||
permissions: "sufficient",
|
||||
reason: { code: "capacity", message: "Disk usage is 95 percent." },
|
||||
},
|
||||
],
|
||||
});
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const mutations = [];
|
||||
const errors = [];
|
||||
let failNext = false;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const reply = async (route, failure = false) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: (failure
|
||||
? [
|
||||
{
|
||||
type: "error",
|
||||
data: { message: "Qualification provider unavailable" },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ type: "session", data: { id: "explanation-proof" } },
|
||||
{
|
||||
type: "content",
|
||||
data: "The database disk is 95 percent full. Check the recent growth before choosing a change. No changes were made.",
|
||||
},
|
||||
{ type: "done", data: { session_id: "explanation-proof" } },
|
||||
]
|
||||
)
|
||||
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
||||
.join(""),
|
||||
});
|
||||
try {
|
||||
await page.goto(base, { waitUntil: "domcontentloaded" });
|
||||
await page
|
||||
.getByLabel("Username", { exact: true })
|
||||
.fill(process.env.PULSE_E2E_USERNAME || "admin");
|
||||
await page
|
||||
.getByLabel("Password", { exact: true })
|
||||
.fill(process.env.PULSE_E2E_PASSWORD || "adminadminadmin");
|
||||
await page.getByRole("button", { name: "Sign in to Pulse" }).click();
|
||||
await expect(page.getByLabel("Password", { exact: true })).toHaveCount(0);
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === "/api/ai/chat") {
|
||||
requests.push(route.request().postDataJSON());
|
||||
const failure = failNext;
|
||||
failNext = false;
|
||||
return reply(route, failure);
|
||||
}
|
||||
if (path.startsWith("/api/ai/test/"))
|
||||
return route.fulfill({
|
||||
json: { success: true, message: "Scripted provider readiness" },
|
||||
});
|
||||
if (route.request().method() !== "GET") {
|
||||
mutations.push(path);
|
||||
return route.fulfill({
|
||||
status: 403,
|
||||
json: { error: "Writes disabled in journey proof" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/ai/status")
|
||||
return route.fulfill({ json: { running: true } });
|
||||
if (path === "/api/ai/settings")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
enabled: true,
|
||||
model: "ollama:qualification",
|
||||
chat_model: "ollama:qualification",
|
||||
control_level: "read_only",
|
||||
discovery_enabled: false,
|
||||
autonomous_mode: false,
|
||||
},
|
||||
});
|
||||
if (path === "/api/ai/models")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
models: [
|
||||
{
|
||||
id: "ollama:qualification",
|
||||
name: "Qualification",
|
||||
provider: "ollama",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (path === "/api/ai/sessions") return route.fulfill({ json: [] });
|
||||
if (path === "/api/ai/patrol/attention/summary")
|
||||
return route.fulfill({ json: summary });
|
||||
if (path === "/api/ai/patrol/attention")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: items,
|
||||
summary,
|
||||
meta: { page: 1, limit: 50, total: 2, totalPages: 1 },
|
||||
},
|
||||
});
|
||||
const item = items.find(
|
||||
(item) => path === `/api/ai/patrol/attention/${item.id}`,
|
||||
);
|
||||
if (item) return route.fulfill({ json: detail(item) });
|
||||
return route.continue();
|
||||
});
|
||||
const results = [];
|
||||
for (const width of [1440, 900, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.goto(`${base}/patrol`, { waitUntil: "domcontentloaded" });
|
||||
await page
|
||||
.getByRole("button", { name: "Start review", exact: true })
|
||||
.click();
|
||||
await page.getByText("Evidence and history", { exact: true }).click();
|
||||
const explain = page.getByRole("button", {
|
||||
name: "Explain with Assistant",
|
||||
exact: true,
|
||||
});
|
||||
await explain.scrollIntoViewIfNeeded();
|
||||
await explain.focus();
|
||||
await expect(explain).toBeFocused();
|
||||
const before = requests.length;
|
||||
await explain.press("Enter");
|
||||
await expect.poll(() => requests.length).toBe(before + 1);
|
||||
expect(requests.at(-1).prompt).toContain("Explain this issue");
|
||||
expect(requests.at(-1).handoff_context).toContain(
|
||||
"Attention Item: issue-a",
|
||||
);
|
||||
expect(requests.at(-1).handoff_context).toContain(
|
||||
"Disk usage is 95 percent.",
|
||||
);
|
||||
expect(requests.at(-1).handoff_resources[0].id).toBe(resourceId);
|
||||
expect(requests.at(-1).autonomous_mode).toBe(false);
|
||||
await expect(
|
||||
page.getByText("No changes were made.", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Database disk pressure", { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-workflow-starters")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: new URL(`explanation-${width}.png`, output).pathname,
|
||||
});
|
||||
const composer = page.getByPlaceholder("Ask about your infrastructure...");
|
||||
await composer.fill("My unfinished question");
|
||||
await page.keyboard.press("Escape");
|
||||
await page
|
||||
.getByRole("button", { name: /Ask Pulse Assistant about Patrol/ })
|
||||
.click();
|
||||
await expect(composer).toHaveValue("My unfinished question");
|
||||
expect(requests.length).toBe(before + 1);
|
||||
await page.keyboard.press("Escape");
|
||||
results.push({
|
||||
width,
|
||||
state:
|
||||
"selected issue, evidence expansion, keyboard Explain, streamed result, close/reopen, draft preserved",
|
||||
});
|
||||
}
|
||||
// A failed explicit request uses the normal error and retry surface.
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto(`${base}/patrol`, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Start review", exact: true }).click();
|
||||
failNext = true;
|
||||
await page
|
||||
.getByRole("button", { name: "Explain with Assistant", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText("Qualification provider unavailable", { exact: false }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: new URL("provider-error.png", output).pathname,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Try again", exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText("No changes were made.", { exact: false }),
|
||||
).toBeVisible();
|
||||
expect(requests.at(-1).handoff_context).toContain("Attention Item: issue-a");
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.goto(`${base}/alerts`, { waitUntil: "domcontentloaded" });
|
||||
const menu = page.getByRole("button", { name: "More alert actions", exact: true }).first();
|
||||
await expect(menu).toBeVisible({ timeout: 20000 });
|
||||
await menu.click();
|
||||
await expect(page.getByRole("menuitem")).toBeVisible();
|
||||
await page.screenshot({ path: new URL(`alert-menu-${width}.png`, output).pathname });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||
await menu.click();
|
||||
await page.getByText("Active Alerts", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||
await menu.click();
|
||||
const before = requests.length;
|
||||
await page.getByRole("menuitem").click();
|
||||
await expect.poll(() => requests.length).toBe(before + 1);
|
||||
expect(requests.at(-1).handoff_context).toContain("Source: Pulse Alerts active alert");
|
||||
expect(requests.at(-1).handoff_resources.length).toBeGreaterThan(0);
|
||||
expect(requests.at(-1).autonomous_mode).toBe(false);
|
||||
await expect(page.getByText("No changes were made.", { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||
await page.screenshot({ path: new URL(`alert-explanation-${width}.png`, output).pathname });
|
||||
results.push({ width, state: "alert menu, Escape, outside click, selected alert explanation" });
|
||||
}
|
||||
// No background inference after a reload or context-only drawer open.
|
||||
const beforeReload = requests.length;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(500);
|
||||
expect(requests.length).toBe(beforeReload);
|
||||
expect(mutations).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
await writeFile(
|
||||
new URL("result.json", output),
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
base,
|
||||
results,
|
||||
requestCount: requests.length,
|
||||
mutations,
|
||||
errors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ passed: true, results, requestCount: requests.length }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(await page.locator("body").innerText());
|
||||
console.error("Page errors:", errors);
|
||||
await page.screenshot({ path: new URL("failure.png", output).pathname });
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user