diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 50ecd7acab..31c6b7be3f 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -14,9 +14,9 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces. ### Anonymous outbound telemetry -Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use. +Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted. -No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below. +No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, or personally identifiable information is ever sent. See the full field list below. #### How to disable @@ -80,11 +80,69 @@ Every field is listed below with the reason it exists — nothing else leaves yo | Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used | | Paid license | `true`/`false` | Distinguish free from paid posture without sending the exact commercial tier | | Has API tokens | `true`/`false` | See whether token-based automation/integration is being used without sending token counts | +| Pulse Intelligence configured | `true`/`false` | See whether Assistant, Patrol, governed actions, or external-agent access is configured so adoption can be measured without sending configuration details | +| Pulse Intelligence active 30d | `true`/`false` | See whether Assistant, Patrol, external-agent, or governed-action activity occurred in the current 30-day telemetry window | +| Pulse Intelligence governed decision 30d | `true`/`false` | See whether Patrol issue activity reached an approved or rejected governed-action decision without sending prompts, findings, resource identifiers, command text, or action output | +| Pulse Intelligence approved action attempt 30d | `true`/`false` | See whether Patrol issue activity reached an approved governed action attempt without sending action details | +| Pulse Intelligence resolved issue 30d | `true`/`false` | See whether Patrol issue activity reached resolution with an approved action success without sending prompts, findings, resource identifiers, command text, or action output | +| Pulse Intelligence Patrol mode decision 30d | `true`/`false` | See whether a Patrol mode starter, Patrol issue activity, contextual Assistant or external-agent collaboration, and either a rejected governed decision or an approved governed decision with a verified outcome occurred without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence Patrol mode resolved issue 30d | `true`/`false` | See whether a Patrol mode starter, Patrol issue activity, contextual Assistant or external-agent collaboration, an approved governed decision, and a verified outcome occurred without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence paid Patrol mode decision 30d | `true`/`false` | See whether the install currently has a paid license and Patrol mode reached a governed decision without sending the exact tier, checkout details, account links, prompts, token details, resource identifiers, command text, or action output | +| Pulse Intelligence paid Patrol mode resolved issue 30d | `true`/`false` | See whether the install currently has a paid license and Patrol mode reached a resolved issue without sending the exact tier, checkout details, account links, prompts, token details, resource identifiers, command text, or action output | +| Pulse Intelligence historical Patrol mode decision mirror 30d | `true`/`false` | Compatibility mirror of the Patrol mode decision field for historical aggregate reporting without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence historical Patrol mode resolved mirror 30d | `true`/`false` | Compatibility mirror of the Patrol mode resolved field for historical aggregate reporting without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence paid historical Patrol mode decision mirror 30d | `true`/`false` | Compatibility mirror of the paid Patrol mode decision field for historical aggregate reporting without sending exact tier, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence paid historical Patrol mode resolved mirror 30d | `true`/`false` | Compatibility mirror of the paid Patrol mode resolved field for historical aggregate reporting without sending exact tier, checkout details, account links, token details, resource identifiers, command text, or action output | +| Pulse Intelligence governed action active 30d | `true`/`false` | See whether Patrol or Assistant reached action planning, approval, approve/reject decision, or approved-action depth in the current 30-day telemetry window without sending action details | +| Pulse Intelligence Assistant governed decision 30d | `true`/`false` | See whether Assistant collaboration specifically reached the Patrol issue plus approved/rejected governed-action path without sending prompts, chat messages, tool details, findings, command text, or action output | +| Pulse Intelligence Assistant approved action attempt 30d | `true`/`false` | See whether Assistant collaboration specifically reached approved governed-action execution in the 30-day window without sending prompts, chat messages, command text, actors, or action output | +| Pulse Intelligence Assistant approved action success 30d | `true`/`false` | See whether Assistant collaboration specifically reached approved action success in the 30-day window without sending prompts, chat messages, verification details, command text, or action output | +| Pulse Intelligence Assistant resolved issue 30d | `true`/`false` | See whether Assistant collaboration specifically reached Patrol resolution plus approved action success in the 30-day window without sending findings, prompts, chat messages, verification details, command text, or action output | +| Pulse Intelligence external-agent governed decision 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached the Patrol issue plus approved/rejected governed-action path without sending route parameters, prompts, resource identifiers, command text, or action output | +| Pulse Intelligence external-agent approved action attempt 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached approved governed-action execution in the 30-day window without sending route parameters, command text, actors, or action output | +| Pulse Intelligence external-agent approved action success 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached approved action success in the 30-day window without sending route parameters, command text, verification details, or action output | +| Pulse Intelligence external-agent resolved issue 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached Patrol resolution plus approved action success in the 30-day window without sending findings, route parameters, command text, verification details, or action output | +| Pulse Intelligence `pulse-mcp` governed decision 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached the Patrol issue plus approved/rejected governed-action path without sending prompts, tool inputs, route parameters, resource identifiers, command text, or action output | +| Pulse Intelligence `pulse-mcp` approved action attempt 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached approved governed-action execution in the 30-day window without sending prompts, tool inputs, command text, actors, or action output | +| Pulse Intelligence `pulse-mcp` approved action success 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached approved action success in the 30-day window without sending prompts, tool inputs, verification details, command text, or action output | +| Pulse Intelligence `pulse-mcp` resolved issue 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached Patrol resolution plus approved action success in the 30-day window without sending findings, prompts, tool inputs, verification details, command text, or action output | +| Pulse Intelligence starter requests 30d | `3` | Count shared Patrol-work starter requests in the current 30-day telemetry window without sending prompt text, chat messages, tool inputs, resource IDs, or request details | +| Pulse Intelligence Assistant starter requests 30d | `2` | Count Assistant requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, chat messages, resource IDs, or request details | +| Pulse Intelligence Patrol starter requests 30d | `1` | Count Patrol work starter requests in the current 30-day telemetry window without sending prompt text, findings, resource IDs, or request details | +| Pulse Intelligence Patrol mode starter requests 30d | `1` | Count Patrol work, Patrol-mode, and historical entry-point requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, checkout details, account links, resource IDs, or request details | +| Pulse Intelligence historical Patrol mode starter mirror 30d | `1` | Count historical entry-point requests for Patrol mode in the current 30-day telemetry window without sending prompt text, checkout details, account links, resource IDs, or request details | +| Pulse Intelligence `pulse-mcp` starter requests 30d | `1` | Count `pulse-mcp` requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, tool inputs, resource IDs, route parameters, or request details | +| Pulse Intelligence Assistant AI calls 30d | `18` | Count Assistant model calls in the current 30-day telemetry window without sending prompts, responses, session IDs, or chat text | +| Pulse Intelligence Assistant context AI calls 30d | `7` | Count Assistant model calls tied to a governed resource, finding, handoff, or action context in the current 30-day telemetry window without sending prompts, responses, session IDs, resource IDs, finding IDs, or chat text | +| Pulse Intelligence Assistant tool calls 30d | `11` | Count Assistant tool calls in the current 30-day telemetry window without sending tool names, tool inputs, tool outputs, prompts, responses, session IDs, resource IDs, finding IDs, command text, or chat text | +| Pulse Intelligence Patrol AI calls 30d | `6` | Count Patrol model calls in the current 30-day telemetry window without sending provider-bound context or findings text | +| Pulse Intelligence Patrol runs 30d | `12` | Count Patrol investigations in the current 30-day telemetry window | +| Pulse Intelligence Patrol new findings 30d | `5` | Count new findings produced by Patrol in the current 30-day telemetry window without sending finding IDs or details | +| Pulse Intelligence Patrol investigations 30d | `3` | Count findings investigated by Patrol in the current 30-day telemetry window without sending finding IDs, resource IDs, or details | +| Pulse Intelligence Patrol resolved findings 30d | `2` | Count findings resolved or fix-verified in the current 30-day telemetry window without sending finding IDs, resource IDs, fix details, or verification detail | +| Pulse Intelligence Patrol autofixes 30d | `1` | Count Patrol autofix records in the current 30-day telemetry window without sending target resources or fix content | +| Pulse Intelligence external agent enabled | `true`/`false` | See whether at least one token can use the external Pulse Intelligence agent/MCP surface without sending token counts, names, scopes, or values | +| Pulse Intelligence external agent used 30d | `true`/`false` | See whether an external-agent-capable API token reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, or request details | +| Pulse Intelligence MCP adapter used 30d | `true`/`false` | See whether the `pulse-mcp` adapter reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, prompts, or request details | +| Pulse Intelligence external agent context requests 30d | `8` | Count external-agent/MCP resource-context and fleet-context requests in the current 30-day telemetry window without sending resource IDs, route parameters, or request details | +| Pulse Intelligence external agent event stream requests 30d | `3` | Count external-agent/MCP event-stream requests in the current 30-day telemetry window without sending event content, route parameters, or request details | +| Pulse Intelligence external agent provisioning requests 30d | `2` | Count external-agent/MCP provisioning requests in the current 30-day telemetry window without sending discovered resources, credentials, route parameters, or request details | +| Pulse Intelligence external agent operator state requests 30d | `5` | Count external-agent/MCP operator-state requests in the current 30-day telemetry window without sending state payloads, route parameters, or request details | +| Pulse Intelligence external agent finding requests 30d | `4` | Count external-agent/MCP finding-list and finding-decision requests in the current 30-day telemetry window without sending finding IDs, finding text, route parameters, or request details | +| Pulse Intelligence external agent action requests 30d | `1` | Count external-agent/MCP action-plan, action-decision, and action-execution requests in the current 30-day telemetry window without sending command text, action output, route parameters, or request details | +| Pulse Intelligence action plans 30d | `4` | Count governed action plans in the current 30-day telemetry window without sending command text, resource IDs, or plan details | +| Pulse Intelligence approval requests 30d | `2` | Count approval-gated action requests in the current 30-day telemetry window without sending approvers, reasons, command text, or targets | +| Pulse Intelligence rejected action decisions 30d | `1` | Count rejected governed action decisions in the current 30-day telemetry window without sending approvers, reasons, command text, targets, or action IDs | +| Pulse Intelligence approved action decisions 30d | `1` | Count approved governed action decisions in the current 30-day telemetry window without sending approvers, reasons, command text, targets, or action IDs | +| Pulse Intelligence approved action attempts 30d | `1` | Count approved governed action attempts in the current 30-day telemetry window without sending action output, command text, or verification detail | +| Pulse Intelligence approved action successes 30d | `1` | Count approved governed actions that completed successfully in the current 30-day telemetry window without sending action output, command text, resource IDs, actors, reasons, or verification detail | #### Server-side handling and retention - Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis. -- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers or exact API-token counts. +- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers, exact API-token counts, prompts, chat messages, command text, action output, token values, or resource identifiers. +- Pulse may derive aggregate Pulse Intelligence adoption reports from those same rows, including whether an install reached Patrol issue activity, Patrol resolution, Assistant, direct external-agent, or MCP collaboration, Patrol mode starter use, paid Patrol mode cohorts, governed-action activity, approved or rejected action decisions, approved action success, recent retention, and observed free-to-paid movement within the source window. Those reports do not add prompts, findings, resource identifiers, tool names, tool inputs, tool outputs, command payloads, action outputs, account links, or exact commercial tiers. +- External-agent/MCP activity is stored only as a coarse adapter-origin flag plus capability-class counters: context, event stream, provisioning, operator state, findings, and action requests. - Telemetry rows older than **90 days** are purged automatically. - The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. @@ -93,7 +151,7 @@ Every field is listed below with the reason it exists — nothing else leaves yo - No IP addresses are stored in telemetry rows - No hostnames, node names, VM names, or any infrastructure identifiers - No Proxmox credentials, API tokens, or passwords -- No alert content, AI prompts, or chat messages +- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values - No personally identifiable information of any kind #### Install ID rotation diff --git a/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs b/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs index e31aa55d0b..dd2b86695a 100644 --- a/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs +++ b/frontend-modern/scripts/__tests__/settings-diagnostics-boundary-audit.test.mjs @@ -96,6 +96,33 @@ export function stripInternalAnalyticsDiagnosticsFields(payload) { expect(collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot })).toEqual([]); }); + it('reports legacy MCP vocabulary on native Assistant diagnostics', () => { + const { root, repoRoot } = makeFixture(({ root, repoRoot }) => ({ + [path.join(repoRoot, 'internal', 'api', 'diagnostics.go')]: ` +package api + +type AIChatDiagnostic struct { + MCPConnected bool +} +`, + [path.join(root, 'src', 'components', 'Settings', 'DiagnosticsResultsPanel.tsx')]: + "export const label = 'MCP Connection';\n", + [path.join(root, 'src', 'components', 'Settings', 'diagnosticsModel.ts')]: ` +export interface AIChatDiagnostic { + mcpConnected: boolean; +} +`, + })); + + const findings = collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot }); + + expect(findings.map((finding) => finding.rule)).toEqual([ + 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-api', + 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-ui', + 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-types', + ]); + }); + it('reports commercial analytics routes, stores, and settings in product runtime files', () => { const { root, repoRoot } = makeFixture(({ root, repoRoot }) => ({ [path.join(repoRoot, 'internal', 'api', 'router_routes_licensing.go')]: ` diff --git a/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs b/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs index 59825aab22..a6749c1814 100644 --- a/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs +++ b/frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs @@ -19,6 +19,12 @@ const INTERNAL_ANALYTICS_RULES = [ message: 'Do not expose maintainer/admin analytics from /api/diagnostics or customer product runtime.', }, + { + rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-api', + regex: /\b(?:MCPConnected|MCPToolCount|mcpConnected|mcpToolCount)\b/g, + message: + 'Do not expose native Pulse Assistant diagnostics through MCP transport fields.', + }, ], }, { @@ -74,6 +80,12 @@ const INTERNAL_ANALYTICS_RULES = [ message: 'Do not render maintainer/admin analytics in the user-facing Settings diagnostics panel.', }, + { + rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-ui', + regex: /\b(?:mcpConnected|mcpToolCount)\b|MCP Connection/g, + message: + 'Render Pulse Assistant diagnostics as native Assistant runtime availability, not MCP transport state.', + }, ], }, { @@ -87,6 +99,12 @@ const INTERNAL_ANALYTICS_RULES = [ message: 'Do not add commercial funnel or infrastructure onboarding fields to the customer diagnostics payload model.', }, + { + rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-types', + regex: /\b(?:mcpConnected|mcpToolCount)\b/g, + message: + 'Model Pulse Assistant diagnostics as native Assistant runtime availability, not MCP transport state.', + }, ], }, { diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index ee839d3d08..025a85fd2d 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -683,8 +683,7 @@ { "path": "src/components/patrol/ApprovalSection.tsx" }, { "path": "src/components/patrol/InvestigationMessages.tsx" }, { "path": "src/components/patrol/InvestigationSection.tsx" }, - { "path": "src/components/patrol/RunToolCallTrace.tsx" }, - { "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" } + { "path": "src/components/patrol/RunToolCallTrace.tsx" } ], "forbiddenPatterns": [ { @@ -796,12 +795,6 @@ "patterns": [ "h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" ] - }, - { - "path": "src/features/patrol/PatrolIntelligenceHeader.tsx", - "patterns": [ - "animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full" - ] } ], "proof": [ @@ -1576,8 +1569,7 @@ { "path": "src/components/shared/FilterToolbar.tsx" }, { "path": "src/features/alerts/AlertAppriseDestinationsSection.tsx" }, { "path": "src/features/alerts/AlertEscalationSection.tsx" }, - { "path": "src/features/alerts/AlertQuietHoursSection.tsx" }, - { "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" } + { "path": "src/features/alerts/AlertQuietHoursSection.tsx" } ], "proof": [ "src/components/shared/SharedPrimitives.guardrails.test.ts", @@ -1661,28 +1653,12 @@ "requiredConsumers": [ { "path": "src/components/Settings/AgentProfilesPanel.tsx" }, { "path": "src/components/Settings/DiagnosticsResultsPanel.tsx" }, - { "path": "src/components/patrol/RunHistoryEntry.tsx" }, - { "path": "src/components/patrol/PatrolStatusBar.tsx" }, - { "path": "src/features/patrol/PatrolIntelligenceSummary.tsx" } + { "path": "src/components/patrol/RunHistoryEntry.tsx" } ], "forbiddenPatterns": [ { "path": "src/components/patrol/RunHistoryEntry.tsx", "patterns": ["runStatus.badgeClass"] - }, - { - "path": "src/components/patrol/PatrolStatusBar.tsx", - "patterns": [ - "lastRunStatus.badgeClass", - "rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700" - ] - }, - { - "path": "src/features/patrol/PatrolIntelligenceSummary.tsx", - "patterns": [ - "runtimeShellPresentation().badgeClass", - "inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" - ] } ], "proof": [ @@ -1896,7 +1872,6 @@ { "path": "src/components/patrol/InvestigationSection.tsx" }, { "path": "src/components/patrol/RunHistoryEntry.tsx" }, { "path": "src/components/patrol/RunToolCallTrace.tsx" }, - { "path": "src/components/patrol/PatrolStatusBar.tsx" }, { "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx" }, { "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" } ], @@ -1953,10 +1928,6 @@ "getToolCallResultBadgeClass" ] }, - { - "path": "src/components/patrol/PatrolStatusBar.tsx", - "patterns": ["text-blue-600 dark:text-blue-400\">Findings snapshot unavailable"] - }, { "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx", "patterns": [ diff --git a/frontend-modern/src/api/__tests__/agentCapabilities.test.ts b/frontend-modern/src/api/__tests__/agentCapabilities.test.ts new file mode 100644 index 0000000000..ac4f4f6ec7 --- /dev/null +++ b/frontend-modern/src/api/__tests__/agentCapabilities.test.ts @@ -0,0 +1,849 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + AGENT_CAPABILITIES_PATH, + AGENT_OPERATIONS_LOOP_STATUS_PATH, + AGENT_SURFACE_ID_PULSE_MCP, + fetchAgentCapabilitiesManifest, + fetchAgentOperationsLoopStatus, + formatAgentMCPServersConfig, + formatAgentOpenCodeMCPConfig, + getAgentCapabilityErrorCodeSummaries, + getAgentMCPOperationsLoopReadiness, + getAgentManifestSurfaceToolContract, + getAgentManifestSurfaceToolContracts, + getAgentMCPClientExamples, + getAgentMCPConfigFamilyByShape, + getAgentSurfaceToolPosturePresentation, + getAgentSurfaceContractEntries, + getAgentWorkflowPrompts, + groupAgentCapabilitiesByManifestCategories, + normalizeAgentMCPAdapter, + normalizeAgentSurfaceToolContract, + type AgentCapability, + type AgentCapabilitiesManifest, + type AgentSurfaceToolContract, +} from '../agentCapabilities'; +import generatedAgentCapabilitiesSource from '@/api/generated/agentCapabilities.ts?raw'; +import { apiFetchJSON } from '@/utils/apiClient'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +const capability = (name: string, category: string): AgentCapability => ({ + name, + category, + description: `${name} description`, + method: 'GET', + path: `/api/${name}`, + scope: 'monitoring:read', + actionMode: 'read', + approvalPolicy: 'scope_only', +}); + +const surfaceContract = { + core: { + id: 'pulse_intelligence_core', + label: 'Pulse Intelligence Core', + description: + 'Canonical context, governed actions, safety gates, approval state, action audit, and verification.', + }, + proactiveEngine: { + id: 'pulse_patrol', + label: 'Pulse Patrol', + description: + 'Patrol is the first-party operations surface: it watches, investigates, acts within the chosen Patrol mode, verifies outcomes, and records what happened.', + }, + operatorSurfaces: [ + { + id: 'pulse_assistant', + label: 'Pulse Assistant', + description: + 'The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions.', + native: true, + externalAdapter: false, + affordances: { + tools: true, + interactiveQuestions: true, + }, + }, + { + id: 'pulse_mcp', + label: 'Pulse MCP', + description: 'The external-agent adapter.', + native: false, + externalAdapter: true, + affordances: { + tools: true, + resources: true, + prompts: true, + capabilityMetadata: true, + }, + }, + ], +}; + +const mcpAdapter = { + serverName: 'pulse', + command: 'pulse-mcp', + baseUrlFlag: '--base-url', + defaultBaseUrl: 'http://localhost:7655', + tokenEnv: 'PULSE_API_TOKEN', + configFamilies: [ + { + id: 'opencode', + label: 'OpenCode', + shape: 'opencode_mcp', + description: "Uses OpenCode's top-level mcp object.", + fileHints: ['opencode.json', 'opencode.jsonc'], + clientLabels: ['OpenCode'], + }, + { + id: 'claude-style', + label: 'Claude-style clients', + shape: 'mcp_servers', + description: 'Uses the common mcpServers object.', + fileHints: ['claude_desktop_config.json', '.mcp.json'], + clientLabels: ['Claude Desktop', 'Claude Code'], + }, + { + id: 'custom-mcp', + label: 'custom MCP clients', + shape: 'custom', + clientLabels: ['custom MCP clients'], + }, + ], +}; + +describe('agent capabilities API client', () => { + it('fetches the public manifest through the shared API client', async () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: ['monitoring:read'], + categories: [{ id: 'context', label: 'Context (read-only)' }], + workflowPrompts: [], + capabilities: [capability('get_resource_context', 'context')], + } satisfies AgentCapabilitiesManifest; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(manifest); + + await expect(fetchAgentCapabilitiesManifest()).resolves.toBe(manifest); + + expect(apiFetchJSON).toHaveBeenCalledWith(AGENT_CAPABILITIES_PATH, { + skipAuth: true, + headers: { Accept: 'application/json' }, + }); + }); + + it('fetches the authenticated operations-loop status through the shared API client', async () => { + const status = { + nextAction: 'open_assistant', + progressLabel: 'Open Assistant to explain the Patrol issue and safest next step.', + steps: [ + { id: 'patrol', label: 'Patrol', status: 'complete', count: 1 }, + { id: 'assistant', label: 'Assistant', status: 'current' }, + { id: 'governance', label: 'Governance', status: 'pending', count: 0 }, + { id: 'verification', label: 'Verification', status: 'pending', count: 0 }, + ], + patrolEvidenceCount: 1, + patrolIssueEvidenceCount: 1, + activeFindingCount: 1, + pendingApprovalCount: 0, + governedActionCount: 0, + approvedDecisionCount: 0, + rejectedDecisionCount: 0, + verifiedOutcomeCount: 0, + operationsLoopStarterCount: 0, + assistantOperationsLoopStarterCount: 0, + patrolOperationsLoopStarterCount: 0, + patrolControlOperationsLoopStarterCount: 0, + patrolControlCompletedOperationsLoopCount: 0, + patrolControlResolvedOperationsLoopCount: 0, + patrolControlValueState: 'not_started', + patrolAutonomyOperationsLoopStarterCount: 0, + patrolAutonomyCompletedOperationsLoopCount: 0, + patrolAutonomyResolvedOperationsLoopCount: 0, + patrolAutonomyValueState: 'not_started', + proActivationOperationsLoopStarterCount: 0, + proActivationCompletedOperationsLoopCount: 0, + proActivationResolvedOperationsLoopCount: 0, + proActivationValueProofState: 'not_started', + mcpOperationsLoopStarterCount: 0, + externalAgentReady: true, + windowStart: '2026-06-01T00:00:00Z', + generatedAt: '2026-06-20T00:00:00Z', + } satisfies Awaited>; + vi.mocked(apiFetchJSON).mockResolvedValueOnce(status); + + await expect(fetchAgentOperationsLoopStatus()).resolves.toBe(status); + + expect(apiFetchJSON).toHaveBeenCalledWith(AGENT_OPERATIONS_LOOP_STATUS_PATH, { + headers: { Accept: 'application/json' }, + }); + }); + + it('exports the generated surface tool contract type for Assistant and MCP consumers', () => { + const contract: AgentSurfaceToolContract = { + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + affordances: { + tools: true, + interactiveQuestions: true, + }, + }; + + expect(contract.surfaceId).toBe('pulse_assistant'); + expect(contract.registryToolNames).toEqual(['pulse_query']); + expect(contract.nativeToolNames).toEqual(['pulse_question']); + }); + + it('normalizes runtime surface tool contracts without local tool catalog drift', () => { + const normalized = normalizeAgentSurfaceToolContract({ + surfaceId: ' pulse_assistant ', + surfaceLabel: ' Pulse Assistant ', + toolSource: ' assistant_registry ', + toolNames: ['pulse_query', 'pulse_question', 'pulse_query', ' '], + registryToolNames: ['pulse_query', 'pulse_query'], + nativeToolNames: ['pulse_question'], + capabilityNames: [' '], + }); + + expect(normalized).toEqual({ + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + capabilityNames: undefined, + affordances: { + tools: true, + interactiveQuestions: true, + }, + }); + }); + + it('presents Assistant capability posture from the surface contract counts', () => { + const presentation = getAgentSurfaceToolPosturePresentation({ + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + }); + + expect(presentation).toMatchObject({ + label: '2 capabilities', + tone: 'ready', + toolCount: 2, + }); + expect(presentation?.title).toContain('Pulse Assistant capability availability'); + expect(presentation?.title).toContain('Source: Assistant registry'); + expect(presentation?.detail).toContain('1 registry capability'); + expect(presentation?.detail).toContain('1 native capability'); + expect(presentation?.detail).toContain('Interactive questions'); + }); + + it('uses manifest-owned external-adapter surface tool contracts before local fallback', () => { + const surfaceContractWithCLI = { + ...surfaceContract, + operatorSurfaces: [ + ...surfaceContract.operatorSurfaces, + { + id: 'pulse_cli_agent', + label: 'Pulse CLI Agent', + description: 'Another external-agent adapter.', + native: false, + externalAdapter: true, + affordances: { + tools: true, + capabilityMetadata: true, + }, + }, + ], + }; + const manifest = { + version: 'v1', + surfaceContract: surfaceContractWithCLI, + surfaceToolContracts: [ + { + surfaceId: 'pulse_mcp', + surfaceLabel: 'Pulse MCP', + toolSource: 'capability_manifest', + toolNames: ['get_fleet_context'], + capabilityNames: ['get_fleet_context'], + affordances: { + tools: true, + resources: true, + prompts: true, + capabilityMetadata: true, + }, + }, + { + surfaceId: 'pulse_cli_agent', + surfaceLabel: 'Pulse CLI Agent', + toolSource: 'capability_manifest', + toolNames: ['plan_action'], + capabilityNames: ['plan_action'], + }, + ], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [ + capability('get_fleet_context', 'context'), + capability('plan_action', 'actions'), + ], + } satisfies AgentCapabilitiesManifest; + + const mcp = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP); + expect(mcp?.toolNames).toEqual(['get_fleet_context']); + expect(mcp?.capabilityNames).toEqual(['get_fleet_context']); + + const contracts = getAgentManifestSurfaceToolContracts(manifest); + expect(contracts.map((contract) => contract.surfaceId)).toEqual([ + 'pulse_mcp', + 'pulse_cli_agent', + ]); + expect(contracts[1]?.toolNames).toEqual(['plan_action']); + }); + + it('does not infer external surface tools when stale payloads omit surface tool contracts', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [ + capability('get_fleet_context', 'context'), + { + ...capability('subscribe_events', 'events'), + description: 'Subscribe to Pulse Intelligence events.', + }, + capability('plan_action', 'actions'), + ], + } satisfies AgentCapabilitiesManifest; + + const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP); + + expect(contract).toBeUndefined(); + expect(getAgentSurfaceToolPosturePresentation(contract)).toBeNull(); + }); + + it('gates published external surface tool posture by the manifest surface affordance', () => { + const disabledToolSurfaceContract = { + ...surfaceContract, + operatorSurfaces: surfaceContract.operatorSurfaces.map((surface) => + surface.id === 'pulse_mcp' + ? { + ...surface, + affordances: { + resources: true, + prompts: true, + capabilityMetadata: true, + }, + } + : surface, + ), + }; + const manifest = { + version: 'v1', + surfaceContract: disabledToolSurfaceContract, + surfaceToolContracts: [ + { + surfaceId: 'pulse_mcp', + surfaceLabel: 'Pulse MCP', + toolSource: 'capability_manifest', + toolNames: ['get_fleet_context'], + capabilityNames: ['get_fleet_context'], + affordances: { + tools: true, + resources: true, + prompts: true, + capabilityMetadata: true, + }, + }, + ], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [capability('get_fleet_context', 'context')], + } satisfies AgentCapabilitiesManifest; + + const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP); + + expect(contract).toMatchObject({ + surfaceId: 'pulse_mcp', + surfaceLabel: 'Pulse MCP', + toolSource: 'capability_manifest', + toolNames: [], + capabilityNames: undefined, + affordances: { + resources: true, + prompts: true, + capabilityMetadata: true, + }, + }); + + const presentation = getAgentSurfaceToolPosturePresentation(contract); + expect(presentation).toMatchObject({ + label: '0 capabilities', + tone: 'empty', + toolCount: 0, + }); + expect(presentation?.detail).toContain('Resources'); + expect(presentation?.detail).not.toContain('published capability'); + }); + + it('does not project native Assistant runtime tools from the static manifest', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [capability('get_fleet_context', 'context')], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentManifestSurfaceToolContract(manifest, 'pulse_assistant')).toBeUndefined(); + expect(getAgentManifestSurfaceToolContract(manifest, 'missing_surface')).toBeUndefined(); + }); + + it('uses manifest-owned category order and presentation copy', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: ['monitoring:read', 'ai:execute'], + categories: [ + { + id: 'context', + label: 'Context (read-only)', + description: 'Discovery and read-only situated reads. Agents start here.', + }, + { + id: 'action', + label: 'Actions (governed plan/approval/execute)', + description: 'Plan, approve, and execute capability invocations against a resource.', + }, + ], + capabilities: [ + capability('plan_action', 'action'), + { + ...capability('get_patrol_control_status', 'context'), + description: + 'Return Patrol mode completed/resolved outcome status and compatibility aliases.', + }, + ], + workflowPrompts: [], + } satisfies AgentCapabilitiesManifest; + + const sections = groupAgentCapabilitiesByManifestCategories(manifest); + + expect(sections.map((section) => section.id)).toEqual(['context', 'action']); + expect(sections.map((section) => section.label)).toEqual([ + 'Context (read-only)', + 'Actions (governed plan/approval/execute)', + ]); + expect(sections[0]?.description).toBe( + 'Discovery and read-only situated reads. Agents start here.', + ); + expect(sections[0]?.entries.map((entry) => entry.name)).toEqual(['get_patrol_control_status']); + expect(sections[0]?.entries[0]?.description).toBe( + 'Return Patrol mode completed/resolved outcome status and compatibility aliases.', + ); + expect(sections[1]?.entries.map((entry) => entry.name)).toEqual(['plan_action']); + }); + + it('keeps older or extended manifests visible without a local category table', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [ + capability('future_tool', 'future-category'), + capability('uncategorized_tool', ' '), + ], + } satisfies AgentCapabilitiesManifest; + + const sections = groupAgentCapabilitiesByManifestCategories(manifest); + + expect(sections.map((section) => [section.id, section.label])).toEqual([ + ['future-category', 'future-category'], + ['uncategorized', 'Uncategorized'], + ]); + expect(sections.flatMap((section) => section.entries.map((entry) => entry.name))).toEqual([ + 'future_tool', + 'uncategorized_tool', + ]); + }); + + it('projects the manifest-owned Pulse Intelligence surface contract for settings UI', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [], + } satisfies AgentCapabilitiesManifest; + + const entries = getAgentSurfaceContractEntries(manifest); + + expect(entries.map((entry) => entry.label)).toEqual([ + 'Pulse Intelligence Core', + 'Pulse Patrol', + 'Pulse Assistant', + 'Pulse MCP', + ]); + expect(entries.map((entry) => entry.description)).toEqual([ + 'Canonical context, governed actions, safety gates, approval state, action audit, and verification.', + 'Patrol is the first-party operations surface: it watches, investigates, acts within the chosen Patrol mode, verifies outcomes, and records what happened.', + 'The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions.', + 'The external-agent adapter.', + ]); + expect(entries[2]?.badges).toEqual(['Native surface', 'Actions', 'Interactive questions']); + expect(entries[3]?.badges).toEqual([ + 'External adapter', + 'Actions', + 'Resources', + 'Prompts', + 'Capability metadata', + ]); + }); + + it('projects manifest-owned workflow prompts for Assistant and MCP consumers', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [ + { + name: ' pulse_triage_fleet ', + label: ' Triage fleet ', + presentationKind: ' fleet ', + description: ' Triage the fleet. ', + }, + { + name: 'pulse_investigate_resource', + description: ' Investigate a resource. ', + arguments: [ + { + name: ' resourceId ', + description: ' Canonical resource id. ', + required: true, + }, + { name: ' ', description: 'ignored' }, + ], + }, + { + name: ' ', + description: 'ignored', + }, + ], + capabilities: [], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentWorkflowPrompts(manifest)).toEqual([ + { + name: 'pulse_triage_fleet', + label: 'Triage fleet', + presentationKind: 'fleet', + description: 'Triage the fleet.', + arguments: [], + }, + { + name: 'pulse_investigate_resource', + description: 'Investigate a resource.', + arguments: [ + { + name: 'resourceId', + description: 'Canonical resource id.', + required: true, + }, + ], + }, + ]); + }); + + it('derives Pulse MCP Patrol-control readiness from prompts and surface tools', () => { + const patrolControlToolNames = [ + 'get_patrol_control_status', + 'get_fleet_context', + 'get_resource_context', + 'list_findings', + 'plan_action', + 'decide_action', + 'execute_action', + 'resolve_finding', + ]; + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [ + { + surfaceId: 'pulse_mcp', + surfaceLabel: 'Pulse MCP', + toolSource: 'capability_manifest', + toolNames: patrolControlToolNames, + capabilityNames: patrolControlToolNames, + affordances: { + tools: true, + resources: true, + prompts: true, + capabilityMetadata: true, + }, + }, + ], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [{ name: 'pulse_operations_loop', label: 'Ask Patrol to handle an issue' }], + capabilities: [], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentMCPOperationsLoopReadiness(manifest)).toEqual({ + available: true, + hasAdapter: true, + hasPrompt: true, + hasSurfaceTools: true, + missingCapabilities: [], + }); + + const baseMCPContract = manifest.surfaceToolContracts[0]!; + const incomplete = { + ...manifest, + surfaceToolContracts: [ + { + ...baseMCPContract, + toolNames: patrolControlToolNames.filter((name) => name !== 'execute_action'), + capabilityNames: patrolControlToolNames.filter((name) => name !== 'execute_action'), + }, + ], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentMCPOperationsLoopReadiness(incomplete)).toMatchObject({ + available: false, + hasAdapter: true, + hasPrompt: true, + hasSurfaceTools: false, + missingCapabilities: ['execute_action'], + }); + }); + + it('projects unique stable error codes with their declaring capabilities', () => { + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [ + { + ...capability('get_resource_context', 'context'), + errorCodes: ['resource_not_found', ''], + }, + { + ...capability('plan_action', 'actions'), + errorCodes: [' resource_not_found ', 'action_execution_unavailable'], + }, + { + ...capability('execute_action', 'actions'), + errorCodes: ['action_execution_unavailable'], + }, + ], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentCapabilityErrorCodeSummaries(manifest)).toEqual([ + { + code: 'resource_not_found', + capabilityNames: ['get_resource_context', 'plan_action'], + }, + { + code: 'action_execution_unavailable', + capabilityNames: ['plan_action', 'execute_action'], + }, + ]); + }); + + it('omits empty surface-contract entries from older manifests', () => { + const manifest = { + version: 'v1', + surfaceContract: { + core: { id: '', label: '', description: '' }, + proactiveEngine: { id: '', label: ' ', description: '' }, + operatorSurfaces: [ + { + id: 'pulse_mcp', + label: 'Pulse MCP', + description: ' ', + native: false, + externalAdapter: true, + }, + ], + }, + surfaceToolContracts: [], + mcpAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [], + } satisfies AgentCapabilitiesManifest; + + expect(getAgentSurfaceContractEntries(manifest)).toEqual([ + { + id: 'pulse_mcp', + label: 'Pulse MCP', + description: undefined, + badges: ['External adapter', 'Actions', 'Resources', 'Prompts', 'Capability metadata'], + }, + ]); + }); + + it('projects manifest-owned MCP adapter setup for settings UI snippets', () => { + const customAdapter = { + ...mcpAdapter, + serverName: 'pulse-prod', + command: 'pulse-mcp-prod', + baseUrlFlag: '--pulse-url', + defaultBaseUrl: 'https://pulse.example.com', + tokenEnv: 'PULSE_PROD_TOKEN', + configFamilies: [ + { + id: 'custom-opencode', + label: 'Custom OpenCode', + shape: 'opencode_mcp', + clientLabels: ['OpenCode'], + }, + { + id: 'custom-mcp-servers', + label: 'Custom MCP servers', + shape: 'mcp_servers', + clientLabels: ['Claude Desktop', 'Claude Code'], + }, + ], + }; + const manifest = { + version: 'v1', + surfaceContract, + surfaceToolContracts: [], + mcpAdapter: customAdapter, + requiredScopes: [], + categories: [], + workflowPrompts: [], + capabilities: [], + } satisfies AgentCapabilitiesManifest; + + expect(normalizeAgentMCPAdapter(manifest.mcpAdapter)).toMatchObject({ + serverName: 'pulse-prod', + command: 'pulse-mcp-prod', + baseUrlFlag: '--pulse-url', + tokenEnv: 'PULSE_PROD_TOKEN', + }); + expect(getAgentMCPClientExamples(manifest)).toEqual([ + 'OpenCode', + 'Claude Desktop', + 'Claude Code', + ]); + expect(getAgentMCPConfigFamilyByShape(manifest.mcpAdapter, 'mcp_servers')?.label).toBe( + 'Custom MCP servers', + ); + + expect(formatAgentOpenCodeMCPConfig(manifest.mcpAdapter, 'https://pulse.example.com')).toBe( + JSON.stringify( + { + $schema: 'https://opencode.ai/config.json', + mcp: { + 'pulse-prod': { + type: 'local', + command: ['pulse-mcp-prod', '--pulse-url', 'https://pulse.example.com'], + enabled: true, + environment: { + PULSE_PROD_TOKEN: '', + }, + }, + }, + }, + null, + 2, + ), + ); + expect(formatAgentMCPServersConfig(manifest.mcpAdapter, 'https://pulse.example.com')).toBe( + JSON.stringify( + { + mcpServers: { + 'pulse-prod': { + command: 'pulse-mcp-prod', + args: ['--pulse-url', 'https://pulse.example.com'], + env: { + PULSE_PROD_TOKEN: '', + }, + }, + }, + }, + null, + 2, + ), + ); + }); + + it('uses generated backend manifest types for the frontend contract', () => { + expect(generatedAgentCapabilitiesSource).toContain( + '// Source: internal/agentcapabilities manifest structs.', + ); + expect(generatedAgentCapabilitiesSource).toContain('export interface Manifest'); + expect(generatedAgentCapabilitiesSource).toContain('surfaceContract: SurfaceContract'); + expect(generatedAgentCapabilitiesSource).toContain('mcpAdapter: MCPAdapterContract'); + expect(generatedAgentCapabilitiesSource).toContain('workflowPrompts: PulseWorkflowPrompt[]'); + expect(generatedAgentCapabilitiesSource).toContain('export interface MCPAdapterContract'); + expect(generatedAgentCapabilitiesSource).toContain('export interface PulseWorkflowPrompt'); + expect(generatedAgentCapabilitiesSource).toContain('label?: string'); + expect(generatedAgentCapabilitiesSource).toContain('arguments?: PulseWorkflowPromptArgument[]'); + expect(generatedAgentCapabilitiesSource).toContain( + 'export interface SurfaceAffordanceContract', + ); + expect(generatedAgentCapabilitiesSource).toContain('affordances?: SurfaceAffordanceContract'); + expect(generatedAgentCapabilitiesSource).toContain('configFamilies: MCPAdapterConfigFamily[]'); + expect(generatedAgentCapabilitiesSource).toContain( + 'operatorSurfaces: OperatorSurfaceContract[]', + ); + expect(generatedAgentCapabilitiesSource).toContain('actionMode: AgentCapabilityActionMode'); + expect(generatedAgentCapabilitiesSource).toContain( + 'approvalPolicy: AgentCapabilityApprovalPolicy', + ); + expect(generatedAgentCapabilitiesSource).toContain('inputSchema?: Record'); + expect(generatedAgentCapabilitiesSource).toContain('export interface SurfaceToolContract'); + expect(generatedAgentCapabilitiesSource).toContain('surfaceId: string'); + expect(generatedAgentCapabilitiesSource).toContain('toolSource: string'); + expect(generatedAgentCapabilitiesSource).toContain('toolNames: string[]'); + expect(generatedAgentCapabilitiesSource).toContain('registryToolNames?: string[]'); + expect(generatedAgentCapabilitiesSource).toContain('capabilityNames?: string[]'); + expect(generatedAgentCapabilitiesSource).toContain('nativeToolNames?: string[]'); + }); +}); diff --git a/frontend-modern/src/api/__tests__/agentProfiles.test.ts b/frontend-modern/src/api/__tests__/agentProfiles.test.ts index 74bb349afc..88461a308a 100644 --- a/frontend-modern/src/api/__tests__/agentProfiles.test.ts +++ b/frontend-modern/src/api/__tests__/agentProfiles.test.ts @@ -406,16 +406,16 @@ describe('AgentProfilesAPI', () => { const mockResponse = { ok: false, status: 503 } as unknown as Response; vi.mocked(apiFetch).mockResolvedValueOnce(mockResponse); vi.mocked(assertAPIResponseOKOrThrowStatus).mockRejectedValueOnce( - new Error('Pulse Assistant service is not available. Please check Pulse Assistant settings.'), + new Error('Pulse Intelligence is not available. Please check Assistant & Patrol settings.'), ); await expect(AgentProfilesAPI.suggestProfile({ prompt: 'test' })).rejects.toThrow( - 'Pulse Assistant service is not available', + 'Pulse Intelligence is not available', ); expect(assertAPIResponseOKOrThrowStatus).toHaveBeenCalledWith( mockResponse, 503, - 'Pulse Assistant service is not available. Please check Pulse Assistant settings.', + 'Pulse Intelligence is not available. Please check Assistant & Patrol settings.', 'Failed to get suggestion: 503', ); }); @@ -431,7 +431,7 @@ describe('AgentProfilesAPI', () => { expect(assertAPIResponseOKOrThrowStatus).toHaveBeenCalledWith( mockResponse, 503, - 'Pulse Assistant service is not available. Please check Pulse Assistant settings.', + 'Pulse Intelligence is not available. Please check Assistant & Patrol settings.', 'Failed to get suggestion: 500', ); }); diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index c24ac734c8..4d033fc75d 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -15,7 +15,12 @@ vi.mock('@/utils/logger', () => ({ }, })); -import { AIChatAPI, createAIChatStreamPaintCheckpointPredicate } from '@/api/aiChat'; +import { + AIChatAPI, + PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME, + PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE, + createAIChatStreamPaintCheckpointPredicate, +} from '@/api/aiChat'; import { AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES, AI_CHAT_DEV_STREAM_FIXTURE_NAMES, @@ -104,6 +109,86 @@ describe('AIChatAPI', () => { expect(shouldYield({ type: 'thinking' })).toBe(false); }); + it('renders native Assistant workflow prompts through the shared AI route', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + description: 'Pulse fleet triage', + text: 'Use get_fleet_context...', + }); + + await expect( + AIChatAPI.renderWorkflowPrompt({ + name: 'pulse_triage_fleet', + arguments: {}, + }), + ).resolves.toEqual({ + description: 'Pulse fleet triage', + text: 'Use get_fleet_context...', + }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'pulse_triage_fleet', arguments: {} }), + }); + }); + + it('records first-party workflow prompt activity through the shared AI route', async () => { + apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect( + AIChatAPI.recordWorkflowPromptActivity({ + name: 'pulse_operations_loop', + surface: 'pulse_patrol', + }), + ).resolves.toBeUndefined(); + + expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/activity', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'pulse_operations_loop', surface: 'pulse_patrol' }), + }); + }); + + it('records Patrol control workflow prompt activity through the shared AI route', async () => { + apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect( + AIChatAPI.recordWorkflowPromptActivity({ + name: PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME, + surface: PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE, + }), + ).resolves.toBeUndefined(); + + expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/activity', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'pulse_operations_loop', + surface: 'patrol_control', + }), + }); + }); + + it('fetches the live native Assistant surface tool contract through the shared AI route', async () => { + const contract = { + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + affordances: { + tools: true, + interactiveQuestions: true, + }, + }; + apiFetchJSONMock.mockResolvedValueOnce(contract); + + await expect(AIChatAPI.getAssistantSurfaceTools()).resolves.toEqual(contract); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/assistant/surface-tools'); + }); + it('runs the local dev stream fixture without opening a provider request', async () => { const onEvent = vi.fn(); @@ -396,12 +481,7 @@ describe('AIChatAPI', () => { it('runs the tool-chain dev stream fixture without opening a provider request', async () => { const onEvent = vi.fn(); - await AIChatAPI.chat( - '/fixture tool-chain', - undefined, - 'openrouter:qwen/qwen3.7-plus', - onEvent, - ); + await AIChatAPI.chat('/fixture tool-chain', undefined, 'openrouter:qwen/qwen3.7-plus', onEvent); expect(apiFetchMock).not.toHaveBeenCalled(); expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([ @@ -464,20 +544,10 @@ describe('AIChatAPI', () => { expect(eventTypes()).toEqual(['session']); await vi.advanceTimersByTimeAsync(75); - expect(eventTypes()).toEqual([ - 'session', - 'workflow_state', - 'workflow_state', - 'tool_start', - ]); + expect(eventTypes()).toEqual(['session', 'workflow_state', 'workflow_state', 'tool_start']); await vi.advanceTimersByTimeAsync(1399); - expect(eventTypes()).toEqual([ - 'session', - 'workflow_state', - 'workflow_state', - 'tool_start', - ]); + expect(eventTypes()).toEqual(['session', 'workflow_state', 'workflow_state', 'tool_start']); await vi.advanceTimersByTimeAsync(1); expect(eventTypes()).toEqual([ @@ -1414,9 +1484,7 @@ describe('AIChatAPI', () => { it('rejects stalled chat stream reads instead of emitting synthetic completion', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const read = vi.fn( - () => new Promise>(() => undefined), - ); + const read = vi.fn(() => new Promise>(() => undefined)); const releaseLock = vi.fn(); const onEvent = vi.fn(); diff --git a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts index 7052210d8c..8a64f34f68 100644 --- a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts +++ b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts @@ -23,6 +23,7 @@ describe('AI chat stream event contract', () => { expect(event.data.id).toBe('sess-stream'); expect(aiChatEventsSource).toContain('export interface SessionData'); expect(aiChatEventsSource).toContain("type: 'session'"); + expect(aiChatEventsSource).toContain('context_limit_tokens?: number'); }); it('does not expose automatic provider fallback metadata on workflow state events', () => { diff --git a/frontend-modern/src/api/__tests__/patrolUtils.test.ts b/frontend-modern/src/api/__tests__/patrolUtils.test.ts index 9910e0ed57..20fc2fa77a 100644 --- a/frontend-modern/src/api/__tests__/patrolUtils.test.ts +++ b/frontend-modern/src/api/__tests__/patrolUtils.test.ts @@ -87,6 +87,10 @@ describe('patrol utilities', () => { expect(investigationOutcomeLabels.fix_failed).toBe('Fix Failed'); }); + it('has correct label for fix_rejected', () => { + expect(investigationOutcomeLabels.fix_rejected).toBe('Fix Rejected'); + }); + it('has correct label for needs_attention', () => { expect(investigationOutcomeLabels.needs_attention).toBe('Needs Attention'); }); @@ -125,6 +129,10 @@ describe('patrol utilities', () => { expect(investigationOutcomeColors.fix_failed).toContain('red'); }); + it('has amber colors for fix_rejected', () => { + expect(investigationOutcomeColors.fix_rejected).toContain('amber'); + }); + it('has amber colors for needs_attention', () => { expect(investigationOutcomeColors.needs_attention).toContain('amber'); }); diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index 37ac3b0326..cddc4ee175 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -1,11 +1,113 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { SettingsAPI, type SystemSettingsResponse } from '../settings'; +import { SettingsAPI, type SystemSettingsResponse, type TelemetryPingPreview } from '../settings'; import { apiFetchJSON } from '@/utils/apiClient'; vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn(), })); +const mockTelemetryPreviewPayload = { + install_id: 'preview-install-id', + version: '6.0.0', + version_channel: 'stable', + version_is_development: false, + version_is_published_release: true, + platform: 'docker', + os: 'linux', + arch: 'amd64', + event: 'heartbeat', + pve_nodes: 1, + pbs_instances: 0, + pmg_instances: 0, + vms: 2, + containers: 3, + agent_hosts: 1, + docker_hosts: 0, + docker_containers: 0, + kubernetes_clusters: 0, + kubernetes_nodes: 0, + kubernetes_pods: 0, + kubernetes_deployments: 0, + storage_pools: 1, + physical_disks: 2, + ceph_clusters: 0, + network_shares: 0, + truenas_systems: 0, + truenas_vms: 0, + truenas_apps: 0, + vmware_hosts: 0, + vmware_vms: 0, + vmware_datastores: 0, + availability_targets: 1, + ai_enabled: false, + patrol_enabled: false, + discovery_enabled: false, + notifications_enabled: false, + ai_actions_enabled: false, + active_alerts: 0, + relay_enabled: false, + sso_enabled: false, + multi_tenant: false, + paid_license: false, + has_api_tokens: true, + pulse_intelligence_loop_configured: false, + pulse_intelligence_loop_active_30d: false, + pulse_intelligence_complete_operations_loop_30d: false, + pulse_intelligence_approved_execution_loop_30d: false, + pulse_intelligence_resolved_operations_loop_30d: false, + pulse_intelligence_patrol_control_completed_operations_loop_30d: false, + pulse_intelligence_patrol_control_resolved_operations_loop_30d: false, + pulse_intelligence_patrol_control_paid_completed_operations_loop_30d: false, + pulse_intelligence_patrol_control_paid_resolved_operations_loop_30d: false, + pulse_intelligence_pro_activation_completed_operations_loop_30d: false, + pulse_intelligence_pro_activation_resolved_operations_loop_30d: false, + pulse_intelligence_pro_activation_paid_completed_operations_loop_30d: false, + pulse_intelligence_pro_activation_paid_resolved_operations_loop_30d: false, + pulse_intelligence_governed_action_active_30d: false, + pulse_intelligence_assistant_operations_loop_30d: false, + pulse_intelligence_assistant_approved_execution_loop_30d: false, + pulse_intelligence_assistant_approved_action_success_loop_30d: false, + pulse_intelligence_assistant_resolved_operations_loop_30d: false, + pulse_intelligence_external_agent_operations_loop_30d: false, + pulse_intelligence_external_agent_approved_execution_loop_30d: false, + pulse_intelligence_external_agent_approved_action_success_loop_30d: false, + pulse_intelligence_external_agent_resolved_operations_loop_30d: false, + pulse_intelligence_mcp_adapter_operations_loop_30d: false, + pulse_intelligence_mcp_adapter_approved_execution_loop_30d: false, + pulse_intelligence_mcp_adapter_approved_action_success_loop_30d: false, + pulse_intelligence_mcp_adapter_resolved_operations_loop_30d: false, + pulse_intelligence_operations_loop_starter_requests_30d: 0, + pulse_intelligence_assistant_operations_loop_starter_requests_30d: 0, + pulse_intelligence_patrol_operations_loop_starter_requests_30d: 0, + pulse_intelligence_patrol_control_operations_loop_starter_requests_30d: 0, + pulse_intelligence_pro_activation_operations_loop_starter_requests_30d: 0, + pulse_intelligence_mcp_operations_loop_starter_requests_30d: 0, + pulse_intelligence_assistant_ai_calls_30d: 0, + pulse_intelligence_assistant_context_ai_calls_30d: 0, + pulse_intelligence_assistant_tool_calls_30d: 0, + pulse_intelligence_patrol_ai_calls_30d: 0, + pulse_intelligence_patrol_runs_30d: 0, + pulse_intelligence_patrol_new_findings_30d: 0, + pulse_intelligence_patrol_investigations_30d: 0, + pulse_intelligence_patrol_resolved_findings_30d: 0, + pulse_intelligence_patrol_autofixes_30d: 0, + pulse_intelligence_external_agent_enabled: false, + pulse_intelligence_external_agent_used_30d: false, + pulse_intelligence_mcp_adapter_used_30d: false, + pulse_intelligence_external_agent_context_requests_30d: 0, + pulse_intelligence_external_agent_event_stream_requests_30d: 0, + pulse_intelligence_external_agent_provisioning_requests_30d: 0, + pulse_intelligence_external_agent_operator_state_requests_30d: 0, + pulse_intelligence_external_agent_finding_requests_30d: 0, + pulse_intelligence_external_agent_action_requests_30d: 0, + pulse_intelligence_action_plans_30d: 0, + pulse_intelligence_approval_requests_30d: 0, + pulse_intelligence_rejected_action_decisions_30d: 0, + pulse_intelligence_approved_action_decisions_30d: 0, + pulse_intelligence_approved_action_attempts_30d: 0, + pulse_intelligence_approved_action_successes_30d: 0, +} satisfies TelemetryPingPreview; + describe('SettingsAPI', () => { beforeEach(() => { vi.clearAllMocks(); @@ -46,51 +148,7 @@ describe('SettingsAPI', () => { it('fetches the telemetry preview payload', async () => { const mockPreview = { enabled: true, - payload: { - install_id: 'preview-install-id', - version: '6.0.0', - version_channel: 'stable', - version_is_development: false, - version_is_published_release: true, - platform: 'docker', - os: 'linux', - arch: 'amd64', - event: 'heartbeat', - pve_nodes: 1, - pbs_instances: 0, - pmg_instances: 0, - vms: 2, - containers: 3, - agent_hosts: 1, - docker_hosts: 0, - docker_containers: 0, - kubernetes_clusters: 0, - kubernetes_nodes: 0, - kubernetes_pods: 0, - kubernetes_deployments: 0, - storage_pools: 1, - physical_disks: 2, - ceph_clusters: 0, - network_shares: 0, - truenas_systems: 0, - truenas_vms: 0, - truenas_apps: 0, - vmware_hosts: 0, - vmware_vms: 0, - vmware_datastores: 0, - availability_targets: 1, - ai_enabled: false, - patrol_enabled: false, - discovery_enabled: false, - notifications_enabled: false, - ai_actions_enabled: false, - active_alerts: 0, - relay_enabled: false, - sso_enabled: false, - multi_tenant: false, - paid_license: false, - has_api_tokens: true, - }, + payload: mockTelemetryPreviewPayload, }; vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockPreview); diff --git a/frontend-modern/src/api/agentCapabilities.ts b/frontend-modern/src/api/agentCapabilities.ts new file mode 100644 index 0000000000..415b6b8080 --- /dev/null +++ b/frontend-modern/src/api/agentCapabilities.ts @@ -0,0 +1,738 @@ +import { apiFetchJSON } from '@/utils/apiClient'; +import type { + AgentCapabilityActionMode, + AgentCapabilityApprovalPolicy, + Capability, + CapabilityCategory, + Manifest, + MCPAdapterConfigFamily, + MCPAdapterContract, + OperatorSurfaceContract, + PulseWorkflowPrompt, + PulseWorkflowPromptArgument, + SurfaceAffordanceContract, + SurfaceContractComponent, + SurfaceToolContract, +} from './generated/agentCapabilities'; + +export const AGENT_CAPABILITIES_PATH = '/api/agent/capabilities'; +export const AGENT_PATROL_CONTROL_STATUS_PATH = '/api/agent/patrol-control/status'; +export const AGENT_OPERATIONS_LOOP_STATUS_PATH = AGENT_PATROL_CONTROL_STATUS_PATH; + +export type { AgentCapabilityActionMode, AgentCapabilityApprovalPolicy }; +export type AgentCapability = Capability; +export type AgentCapabilityCategory = CapabilityCategory; +export type AgentCapabilitiesManifest = Manifest; +export type AgentMCPAdapterConfigFamily = MCPAdapterConfigFamily; +export type AgentMCPAdapterContract = MCPAdapterContract; +export type AgentOperatorSurfaceContract = OperatorSurfaceContract; +export type AgentWorkflowPrompt = PulseWorkflowPrompt; +export type AgentWorkflowPromptArgument = PulseWorkflowPromptArgument; +export type AgentSurfaceAffordanceContract = SurfaceAffordanceContract; +export type AgentSurfaceContractComponent = SurfaceContractComponent; +export type AgentSurfaceToolContract = SurfaceToolContract; + +export interface AgentCapabilitySection { + id: string; + label: string; + description?: string; + entries: AgentCapability[]; +} + +export interface AgentSurfaceContractEntry { + id: string; + label: string; + description?: string; + badges: string[]; +} + +export interface AgentCapabilityErrorCodeSummary { + code: string; + capabilityNames: string[]; +} + +export interface AgentSurfaceToolPosturePresentation { + surfaceLabel: string; + label: string; + title: string; + detail?: string; + tone: 'ready' | 'empty'; + toolCount: number; +} + +export type AgentOperationsLoopNextAction = + | 'run_patrol' + | 'review_findings' + | 'open_assistant' + | 'review_approvals' + | 'open_mcp' + | 'complete'; + +export type AgentOperationsLoopStepStatus = 'complete' | 'current' | 'pending'; + +export type AgentOperationsLoopProActivationValueProofState = + | 'not_started' + | 'in_progress' + | 'governed_decision_recorded' + | 'verified_needs_mcp' + | 'verified'; + +export type AgentOperationsLoopPatrolControlValueState = + AgentOperationsLoopProActivationValueProofState; + +export type AgentOperationsLoopPatrolAutonomyValueState = + AgentOperationsLoopPatrolControlValueState; + +export interface AgentOperationsLoopStep { + id: 'patrol' | 'assistant' | 'governance' | 'verification'; + label: string; + status: AgentOperationsLoopStepStatus; + count?: number; +} + +export interface AgentOperationsLoopStatus { + nextAction: AgentOperationsLoopNextAction; + progressLabel: string; + steps: AgentOperationsLoopStep[]; + patrolEvidenceCount: number; + patrolIssueEvidenceCount: number; + activeFindingCount: number; + pendingApprovalCount: number; + governedActionCount: number; + approvedDecisionCount: number; + rejectedDecisionCount: number; + verifiedOutcomeCount: number; + operationsLoopStarterCount: number; + assistantOperationsLoopStarterCount: number; + patrolOperationsLoopStarterCount: number; + patrolControlOperationsLoopStarterCount?: number; + patrolControlCompletedOperationsLoopCount?: number; + patrolControlResolvedOperationsLoopCount?: number; + patrolControlValueState?: AgentOperationsLoopPatrolControlValueState; + patrolAutonomyOperationsLoopStarterCount?: number; + patrolAutonomyCompletedOperationsLoopCount?: number; + patrolAutonomyResolvedOperationsLoopCount?: number; + patrolAutonomyValueState?: AgentOperationsLoopPatrolAutonomyValueState; + proActivationOperationsLoopStarterCount?: number; + proActivationCompletedOperationsLoopCount?: number; + proActivationResolvedOperationsLoopCount?: number; + proActivationValueProofState?: AgentOperationsLoopProActivationValueProofState; + mcpOperationsLoopStarterCount: number; + externalAgentReady: boolean; + windowStart: string; + generatedAt: string; +} + +export const AGENT_MCP_TOKEN_PLACEHOLDER = ''; +export const AGENT_SURFACE_ID_PULSE_MCP = 'pulse_mcp'; +export const AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP = 'pulse_operations_loop'; + +const AGENT_OPERATIONS_LOOP_CAPABILITY_NAMES = [ + 'get_patrol_control_status', + 'get_fleet_context', + 'get_resource_context', + 'list_findings', + 'plan_action', + 'decide_action', + 'execute_action', + 'resolve_finding', +] as const; + +export interface AgentMCPOperationsLoopReadiness { + available: boolean; + hasAdapter: boolean; + hasPrompt: boolean; + hasSurfaceTools: boolean; + missingCapabilities: string[]; +} + +const DEFAULT_AGENT_MCP_CONFIG_FAMILIES: AgentMCPAdapterConfigFamily[] = [ + { + id: 'opencode', + label: 'OpenCode', + shape: 'opencode_mcp', + description: "Uses OpenCode's top-level mcp object.", + fileHints: ['opencode.json', 'opencode.jsonc', '~/.config/opencode/opencode.json'], + clientLabels: ['OpenCode'], + }, + { + id: 'claude-style', + label: 'Claude-style clients', + shape: 'mcp_servers', + description: 'Uses the common mcpServers object supported by Claude Desktop and Claude Code.', + fileHints: ['~/Library/Application Support/Claude/claude_desktop_config.json', '.mcp.json'], + clientLabels: ['Claude Desktop', 'Claude Code'], + }, + { + id: 'custom-mcp', + label: 'custom clients', + shape: 'custom', + description: + 'Keeps the connector command, base URL flag, and token environment variable while adapting the outer client config shape.', + clientLabels: ['custom clients'], + }, +]; + +const DEFAULT_AGENT_MCP_ADAPTER: AgentMCPAdapterContract = { + serverName: 'pulse', + command: 'pulse-mcp', + baseUrlFlag: '--base-url', + defaultBaseUrl: 'http://localhost:7655', + tokenEnv: 'PULSE_API_TOKEN', + configFamilies: DEFAULT_AGENT_MCP_CONFIG_FAMILIES, +}; + +const DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID: Record = { + pulse_assistant: { + tools: true, + interactiveQuestions: true, + }, + pulse_mcp: { + tools: true, + resources: true, + prompts: true, + capabilityMetadata: true, + }, +}; + +export async function fetchAgentCapabilitiesManifest(): Promise { + return apiFetchJSON(AGENT_CAPABILITIES_PATH, { + skipAuth: true, + headers: { Accept: 'application/json' }, + }); +} + +export async function fetchAgentOperationsLoopStatus(): Promise { + return apiFetchJSON(AGENT_OPERATIONS_LOOP_STATUS_PATH, { + headers: { Accept: 'application/json' }, + }); +} + +function trimOrDefault(value: string | undefined, fallback: string): string { + return value?.trim() || fallback; +} + +function uniqueTrimmed(values: string[] | undefined): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const raw of values ?? []) { + const value = raw.trim(); + if (!value || seen.has(value)) continue; + seen.add(value); + normalized.push(value); + } + return normalized; +} + +function optionalNonEmptyList(values: string[]): string[] | undefined { + return values.length > 0 ? values : undefined; +} + +function formatSurfaceToolSource(source: string): string { + switch (source) { + case 'assistant_registry': + return 'Assistant registry'; + case 'capability_manifest': + return 'Capability manifest'; + default: + return source + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + } +} + +function findAgentOperatorSurface( + manifest: AgentCapabilitiesManifest | undefined, + surfaceId: string, +): AgentOperatorSurfaceContract | undefined { + const normalizedSurfaceId = surfaceId.trim(); + if (!normalizedSurfaceId) return undefined; + return manifest?.surfaceContract?.operatorSurfaces?.find( + (surface) => surface.id?.trim() === normalizedSurfaceId, + ); +} + +function findAgentSurfaceToolContract( + manifest: AgentCapabilitiesManifest | undefined, + surfaceId: string, +): AgentSurfaceToolContract | undefined { + const normalizedSurfaceId = surfaceId.trim(); + if (!normalizedSurfaceId) return undefined; + return manifest?.surfaceToolContracts?.find( + (contract) => contract.surfaceId?.trim() === normalizedSurfaceId, + ); +} + +function cloneConfigFamilies( + families: AgentMCPAdapterConfigFamily[], +): AgentMCPAdapterConfigFamily[] { + return families.map((family) => ({ + ...family, + fileHints: family.fileHints ? [...family.fileHints] : undefined, + clientLabels: family.clientLabels ? [...family.clientLabels] : undefined, + })); +} + +function normalizedConfigFamilies( + families: AgentMCPAdapterConfigFamily[] | undefined, +): AgentMCPAdapterConfigFamily[] { + if (!families || families.length === 0) { + return cloneConfigFamilies(DEFAULT_AGENT_MCP_CONFIG_FAMILIES); + } + + const normalized: AgentMCPAdapterConfigFamily[] = []; + for (const family of families) { + const id = family.id?.trim() || family.label?.trim() || family.shape?.trim(); + const label = family.label?.trim() || id; + const shape = family.shape?.trim() || id; + if (!id || !label || !shape) continue; + normalized.push({ + ...family, + id, + label, + shape, + description: family.description?.trim() || undefined, + fileHints: family.fileHints?.map((hint) => hint.trim()).filter(Boolean), + clientLabels: family.clientLabels?.map((label) => label.trim()).filter(Boolean), + }); + } + + return normalized.length > 0 + ? normalized + : cloneConfigFamilies(DEFAULT_AGENT_MCP_CONFIG_FAMILIES); +} + +export function normalizeAgentMCPAdapter( + adapter: AgentMCPAdapterContract | undefined, +): AgentMCPAdapterContract { + return { + serverName: trimOrDefault(adapter?.serverName, DEFAULT_AGENT_MCP_ADAPTER.serverName), + command: trimOrDefault(adapter?.command, DEFAULT_AGENT_MCP_ADAPTER.command), + baseUrlFlag: trimOrDefault(adapter?.baseUrlFlag, DEFAULT_AGENT_MCP_ADAPTER.baseUrlFlag), + defaultBaseUrl: trimOrDefault( + adapter?.defaultBaseUrl, + DEFAULT_AGENT_MCP_ADAPTER.defaultBaseUrl, + ), + tokenEnv: trimOrDefault(adapter?.tokenEnv, DEFAULT_AGENT_MCP_ADAPTER.tokenEnv), + configFamilies: normalizedConfigFamilies(adapter?.configFamilies), + }; +} + +export function getAgentMCPAdapterConfigFamilies( + adapter: AgentMCPAdapterContract | undefined, +): AgentMCPAdapterConfigFamily[] { + return normalizeAgentMCPAdapter(adapter).configFamilies; +} + +export function getAgentMCPConfigFamilyByShape( + adapter: AgentMCPAdapterContract | undefined, + shape: string, +): AgentMCPAdapterConfigFamily | undefined { + const normalizedShape = shape.trim(); + if (!normalizedShape) return undefined; + return getAgentMCPAdapterConfigFamilies(adapter).find( + (family) => family.shape === normalizedShape, + ); +} + +export function getAgentMCPClientExamples( + manifest: AgentCapabilitiesManifest | undefined, +): string[] { + const labels: string[] = []; + for (const family of getAgentMCPAdapterConfigFamilies(manifest?.mcpAdapter)) { + const clientLabels = family.clientLabels?.filter(Boolean); + if (clientLabels && clientLabels.length > 0) { + labels.push(...clientLabels); + } else { + labels.push(family.label); + } + } + return labels; +} + +export function formatAgentOpenCodeMCPConfig( + adapter: AgentMCPAdapterContract | undefined, + baseUrl: string, + tokenPlaceholder = AGENT_MCP_TOKEN_PLACEHOLDER, +): string { + const normalized = normalizeAgentMCPAdapter(adapter); + const config = { + $schema: 'https://opencode.ai/config.json', + mcp: { + [normalized.serverName]: { + type: 'local', + command: [normalized.command, normalized.baseUrlFlag, baseUrl], + enabled: true, + environment: { + [normalized.tokenEnv]: tokenPlaceholder, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); +} + +export function formatAgentMCPServersConfig( + adapter: AgentMCPAdapterContract | undefined, + baseUrl: string, + tokenPlaceholder = AGENT_MCP_TOKEN_PLACEHOLDER, +): string { + const normalized = normalizeAgentMCPAdapter(adapter); + const config = { + mcpServers: { + [normalized.serverName]: { + command: normalized.command, + args: [normalized.baseUrlFlag, baseUrl], + env: { + [normalized.tokenEnv]: tokenPlaceholder, + }, + }, + }, + }; + return JSON.stringify(config, null, 2); +} + +function normalizedCapabilityCategoryID(category: string | undefined): string { + return category?.trim() || 'uncategorized'; +} + +function categoryFallbackLabel(category: string): string { + if (category === 'uncategorized') return 'Uncategorized'; + return category || 'Uncategorized'; +} + +function sanitizeAgentCapabilityDescription(description: string | undefined): string { + const trimmed = description?.trim(); + if (!trimmed) return ''; + return trimmed + .replace(/legacy Pro activation aliases/g, 'compatibility aliases') + .replace( + /Pro activation completed\/resolved proof/g, + 'Patrol mode completed/resolved outcome status', + ) + .replace( + /Patrol autonomy completed\/resolved outcome status/g, + 'Patrol mode completed/resolved outcome status', + ) + .replace( + /Patrol autonomy completed\/resolved outcome evidence/g, + 'Patrol mode outcome evidence', + ); +} + +function normalizeAgentCapabilityPresentation(capability: AgentCapability): AgentCapability { + return { + ...capability, + description: sanitizeAgentCapabilityDescription(capability.description), + }; +} + +export function groupAgentCapabilitiesByManifestCategories( + manifest: AgentCapabilitiesManifest | undefined, +): AgentCapabilitySection[] { + if (!manifest) return []; + + const byCategory = new Map(); + for (const cap of manifest.capabilities) { + const category = normalizedCapabilityCategoryID(cap.category); + const list = byCategory.get(category) ?? []; + list.push(normalizeAgentCapabilityPresentation(cap)); + byCategory.set(category, list); + } + + const sections: AgentCapabilitySection[] = []; + for (const category of manifest.categories ?? []) { + const id = normalizedCapabilityCategoryID(category.id); + const entries = byCategory.get(id); + if (!entries || entries.length === 0) continue; + const label = category.label?.trim() || categoryFallbackLabel(id); + const description = category.description?.trim(); + sections.push({ + id, + label, + description: description || undefined, + entries, + }); + byCategory.delete(id); + } + + for (const [unknownCategory, entries] of byCategory) { + sections.push({ + id: unknownCategory, + label: categoryFallbackLabel(unknownCategory), + entries, + }); + } + + return sections; +} + +function trimSurfaceContractComponent( + component: AgentSurfaceContractComponent | undefined, +): AgentSurfaceContractEntry | undefined { + const label = component?.label?.trim(); + if (!label) return undefined; + return { + id: component?.id?.trim() || label, + label, + description: component?.description?.trim() || undefined, + badges: [], + }; +} + +function surfaceBadges(surface: AgentOperatorSurfaceContract): string[] { + const badges: string[] = []; + if (surface.native) badges.push('Native surface'); + if (surface.externalAdapter) badges.push('External adapter'); + badges.push(...surfaceAffordanceLabels(normalizeSurfaceAffordances(surface))); + return badges; +} + +function surfaceAffordancesDeclared( + affordances: AgentSurfaceAffordanceContract | undefined, +): boolean { + return Boolean( + affordances?.tools || + affordances?.resources || + affordances?.prompts || + affordances?.capabilityMetadata || + affordances?.interactiveQuestions, + ); +} + +function normalizeSurfaceAffordances( + surface: AgentOperatorSurfaceContract, +): AgentSurfaceAffordanceContract { + if (surfaceAffordancesDeclared(surface.affordances)) { + return surface.affordances!; + } + const id = surface.id?.trim(); + return id ? (DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID[id] ?? {}) : {}; +} + +function surfaceAffordanceLabels(affordances: AgentSurfaceAffordanceContract): string[] { + const labels: string[] = []; + if (affordances.tools) labels.push('Actions'); + if (affordances.resources) labels.push('Resources'); + if (affordances.prompts) labels.push('Prompts'); + if (affordances.capabilityMetadata) labels.push('Capability metadata'); + if (affordances.interactiveQuestions) labels.push('Interactive questions'); + return labels; +} + +export function getAgentSurfaceContractEntries( + manifest: AgentCapabilitiesManifest | undefined, +): AgentSurfaceContractEntry[] { + if (!manifest) return []; + + const entries: AgentSurfaceContractEntry[] = []; + const core = trimSurfaceContractComponent(manifest.surfaceContract?.core); + if (core) entries.push(core); + + const proactiveEngine = trimSurfaceContractComponent(manifest.surfaceContract?.proactiveEngine); + if (proactiveEngine) entries.push(proactiveEngine); + + for (const surface of manifest.surfaceContract?.operatorSurfaces ?? []) { + const label = surface.label?.trim(); + if (!label) continue; + entries.push({ + id: surface.id?.trim() || label, + label, + description: surface.description?.trim() || undefined, + badges: surfaceBadges(surface), + }); + } + + return entries; +} + +export function getAgentWorkflowPrompts( + manifest: AgentCapabilitiesManifest | undefined, +): AgentWorkflowPrompt[] { + const prompts: AgentWorkflowPrompt[] = []; + for (const prompt of manifest?.workflowPrompts ?? []) { + const name = prompt.name?.trim(); + if (!name) continue; + + const args: AgentWorkflowPromptArgument[] = []; + for (const arg of prompt.arguments ?? []) { + const argName = arg.name?.trim(); + if (!argName) continue; + const normalizedArg: AgentWorkflowPromptArgument = { + name: argName, + required: Boolean(arg.required), + }; + const argDescription = arg.description?.trim(); + if (argDescription) normalizedArg.description = argDescription; + args.push(normalizedArg); + } + + const normalizedPrompt: AgentWorkflowPrompt = { + name, + arguments: args, + }; + const label = prompt.label?.trim(); + if (label) normalizedPrompt.label = label; + const presentationKind = prompt.presentationKind?.trim(); + if (presentationKind) normalizedPrompt.presentationKind = presentationKind; + const description = prompt.description?.trim(); + if (description) normalizedPrompt.description = description; + prompts.push(normalizedPrompt); + } + return prompts; +} + +export function getAgentMCPOperationsLoopReadiness( + manifest: AgentCapabilitiesManifest | undefined, +): AgentMCPOperationsLoopReadiness { + const adapter = normalizeAgentMCPAdapter(manifest?.mcpAdapter); + const hasAdapter = + Boolean(adapter.command) && + Boolean(adapter.serverName) && + Boolean(adapter.tokenEnv) && + adapter.configFamilies.length > 0; + const hasPrompt = getAgentWorkflowPrompts(manifest).some( + (prompt) => prompt.name === AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP, + ); + const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP); + const toolNames = new Set(contract?.toolNames ?? []); + const missingCapabilities = AGENT_OPERATIONS_LOOP_CAPABILITY_NAMES.filter( + (name) => !toolNames.has(name), + ); + const hasSurfaceTools = Boolean(contract) && missingCapabilities.length === 0; + + return { + available: hasAdapter && hasPrompt && hasSurfaceTools, + hasAdapter, + hasPrompt, + hasSurfaceTools, + missingCapabilities, + }; +} + +export function normalizeAgentSurfaceToolContract( + contract: AgentSurfaceToolContract | undefined, +): AgentSurfaceToolContract | undefined { + const surfaceId = contract?.surfaceId?.trim(); + if (!contract || !surfaceId) return undefined; + + const toolNames = uniqueTrimmed(contract.toolNames); + const registryToolNames = uniqueTrimmed(contract.registryToolNames); + const capabilityNames = uniqueTrimmed(contract.capabilityNames); + const nativeToolNames = uniqueTrimmed(contract.nativeToolNames); + const affordances = surfaceAffordancesDeclared(contract.affordances) + ? contract.affordances + : (DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID[surfaceId] ?? undefined); + const toolsEnabled = Boolean(affordances?.tools); + + return { + surfaceId, + surfaceLabel: contract.surfaceLabel?.trim() || undefined, + toolSource: contract.toolSource?.trim() || 'unknown', + toolNames: toolsEnabled ? toolNames : [], + registryToolNames: optionalNonEmptyList(toolsEnabled ? registryToolNames : []), + capabilityNames: optionalNonEmptyList(toolsEnabled ? capabilityNames : []), + nativeToolNames: optionalNonEmptyList(toolsEnabled ? nativeToolNames : []), + affordances, + }; +} + +export function getAgentManifestSurfaceToolContract( + manifest: AgentCapabilitiesManifest | undefined, + surfaceId: string, +): AgentSurfaceToolContract | undefined { + if (!manifest) return undefined; + + const normalizedSurfaceId = surfaceId.trim(); + if (!normalizedSurfaceId) return undefined; + + const surface = findAgentOperatorSurface(manifest, normalizedSurfaceId); + if (!surface?.externalAdapter) return undefined; + + const manifestOwnedContract = findAgentSurfaceToolContract(manifest, normalizedSurfaceId); + if (!manifestOwnedContract) return undefined; + + return normalizeAgentSurfaceToolContract({ + ...manifestOwnedContract, + surfaceLabel: surface.label?.trim() || normalizedSurfaceId, + affordances: normalizeSurfaceAffordances(surface), + }); +} + +export function getAgentManifestSurfaceToolContracts( + manifest: AgentCapabilitiesManifest | undefined, +): AgentSurfaceToolContract[] { + const contracts: AgentSurfaceToolContract[] = []; + for (const surface of manifest?.surfaceContract?.operatorSurfaces ?? []) { + if (!surface.externalAdapter) continue; + const contract = getAgentManifestSurfaceToolContract(manifest, surface.id); + if (contract) contracts.push(contract); + } + return contracts; +} + +export function getAgentSurfaceToolPosturePresentation( + contract: AgentSurfaceToolContract | undefined, +): AgentSurfaceToolPosturePresentation | null { + const normalized = normalizeAgentSurfaceToolContract(contract); + if (!normalized) return null; + + const toolCount = normalized.toolNames.length; + const registryCount = normalized.registryToolNames?.length ?? 0; + const capabilityCount = normalized.capabilityNames?.length ?? 0; + const nativeCount = normalized.nativeToolNames?.length ?? 0; + const source = formatSurfaceToolSource(normalized.toolSource); + const surfaceLabel = normalized.surfaceLabel || normalized.surfaceId; + const label = toolCount === 1 ? '1 capability' : `${toolCount} capabilities`; + const breakdown = [ + registryCount > 0 + ? `${registryCount} ${registryCount === 1 ? 'registry capability' : 'registry capabilities'}` + : '', + capabilityCount > 0 + ? `${capabilityCount} published ${capabilityCount === 1 ? 'capability' : 'capabilities'}` + : '', + nativeCount > 0 + ? `${nativeCount} ${nativeCount === 1 ? 'native capability' : 'native capabilities'}` + : '', + ].filter(Boolean); + const affordanceLabels = surfaceAffordanceLabels(normalized.affordances ?? {}); + const detail = [...breakdown, ...affordanceLabels].join(', '); + + return { + surfaceLabel, + label, + title: [`${surfaceLabel} capability availability`, `Source: ${source}`, detail || undefined] + .filter(Boolean) + .join('. '), + detail: detail || undefined, + tone: toolCount > 0 ? 'ready' : 'empty', + toolCount, + }; +} + +export function getAgentCapabilityErrorCodeSummaries( + manifest: AgentCapabilitiesManifest | undefined, +): AgentCapabilityErrorCodeSummary[] { + if (!manifest) return []; + + const summaries = new Map>(); + for (const capability of manifest.capabilities) { + const capabilityName = capability.name.trim(); + if (!capabilityName) continue; + + for (const rawCode of capability.errorCodes ?? []) { + const code = rawCode.trim(); + if (!code) continue; + const capabilityNames = summaries.get(code) ?? new Set(); + capabilityNames.add(capabilityName); + summaries.set(code, capabilityNames); + } + } + + return Array.from(summaries, ([code, capabilityNames]) => ({ + code, + capabilityNames: Array.from(capabilityNames), + })); +} diff --git a/frontend-modern/src/api/agentProfiles.ts b/frontend-modern/src/api/agentProfiles.ts index 5d301f79b8..f519363d10 100644 --- a/frontend-modern/src/api/agentProfiles.ts +++ b/frontend-modern/src/api/agentProfiles.ts @@ -34,14 +34,14 @@ export interface AgentProfileAssignment { } /** - * Request for AI-assisted profile suggestion. + * Request for Pulse Intelligence profile suggestion. */ export interface ProfileSuggestionRequest { prompt: string; } /** - * AI-generated profile suggestion. + * Pulse Intelligence profile suggestion. */ export interface ProfileSuggestion { name: string; @@ -365,7 +365,7 @@ export class AgentProfilesAPI { } /** - * Get AI-assisted profile suggestion. + * Get Pulse Intelligence profile suggestion. * Requires AI to be enabled and running. */ static async suggestProfile(request: ProfileSuggestionRequest): Promise { @@ -378,7 +378,7 @@ export class AgentProfilesAPI { await assertAPIResponseOKOrThrowStatus( response, 503, - 'Pulse Assistant service is not available. Please check Pulse Assistant settings.', + 'Pulse Intelligence is not available. Please check Assistant & Patrol settings.', `Failed to get suggestion: ${response.status}`, ); diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index aac27b6367..f0661b3c81 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -681,6 +681,7 @@ export type InvestigationOutcome = | 'fix_queued' | 'fix_executed' | 'fix_failed' + | 'fix_rejected' | 'needs_attention' | 'cannot_fix' | 'timed_out' diff --git a/frontend-modern/src/api/aiChat.ts b/frontend-modern/src/api/aiChat.ts index 2ffb6a6af6..0e1787a203 100644 --- a/frontend-modern/src/api/aiChat.ts +++ b/frontend-modern/src/api/aiChat.ts @@ -2,6 +2,7 @@ import { apiFetchJSON, apiFetch } from '@/utils/apiClient'; import { assertAPIResponseOK } from './responseUtils'; import { logger } from '@/utils/logger'; import type { AIChatStreamEvent } from './generated/aiChatEvents'; +import type { AgentSurfaceToolContract } from './agentCapabilities'; import { consumeJSONEventStream } from './streaming'; import { maybeRunAIChatDevStreamFixture } from './aiChatDevStreamFixture'; @@ -197,6 +198,34 @@ export interface AIStatus { engine: string; } +export interface AssistantWorkflowPromptRenderRequest { + name: string; + arguments?: Record; +} + +export const PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME = 'pulse_operations_loop'; +export const PULSE_PATROL_WORKFLOW_PROMPT_SURFACE = 'pulse_patrol'; +export const PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE = 'patrol_control'; +export const PULSE_PATROL_AUTONOMY_WORKFLOW_PROMPT_SURFACE = 'patrol_autonomy'; +export const PULSE_PRO_ACTIVATION_WORKFLOW_PROMPT_SURFACE = 'pulse_pro_activation'; + +export type AssistantWorkflowPromptActivitySurface = + | 'pulse_assistant' + | 'pulse_patrol' + | 'patrol_control' + | 'patrol_autonomy' + | 'pulse_pro_activation'; + +export interface AssistantWorkflowPromptActivityRequest { + name: string; + surface?: AssistantWorkflowPromptActivitySurface; +} + +export interface AssistantWorkflowPromptRenderResponse { + description: string; + text: string; +} + // AI Agent (build, code, etc.) with specific permissions and model export interface Agent { name: string; @@ -219,6 +248,35 @@ export class AIChatAPI { return apiFetchJSON(`${this.baseUrl}/status`) as Promise; } + // Get the live native Assistant surface tool contract for the current runtime mode. + static async getAssistantSurfaceTools(): Promise { + return apiFetchJSON( + `${this.baseUrl}/assistant/surface-tools`, + ) as Promise; + } + + // Render a shared Pulse Intelligence workflow prompt for the native Assistant surface. + static async renderWorkflowPrompt( + request: AssistantWorkflowPromptRenderRequest, + ): Promise { + return apiFetchJSON(`${this.baseUrl}/workflow-prompts/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) as Promise; + } + + // Record that a first-party Pulse surface started a shared workflow prompt. + static async recordWorkflowPromptActivity( + request: AssistantWorkflowPromptActivityRequest, + ): Promise { + await apiFetch(`${this.baseUrl}/workflow-prompts/activity`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + } + // List all chat sessions. Normalizes a null/non-array payload (e.g. a server // error body) to an empty array so callers can rely on array semantics and do // not crash on .length/.some()/.map() (#1149). @@ -310,9 +368,7 @@ export class AIChatAPI { } // Summarize a session (compress context when nearing limits) - static async summarizeSession( - sessionId: string, - ): Promise { + static async summarizeSession(sessionId: string): Promise { return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/summarize`, { method: 'POST', }) as Promise; diff --git a/frontend-modern/src/api/generated/agentCapabilities.ts b/frontend-modern/src/api/generated/agentCapabilities.ts new file mode 100644 index 0000000000..330dcfa550 --- /dev/null +++ b/frontend-modern/src/api/generated/agentCapabilities.ts @@ -0,0 +1,113 @@ +// This file is generated from scripts/generate-types.go; DO NOT EDIT. +// Source: internal/agentcapabilities manifest structs. + +/* eslint-disable */ + +export type AgentCapabilityActionMode = 'read' | 'mixed' | 'write'; +export type AgentCapabilityApprovalPolicy = 'scope_only' | 'action_plan'; + +export interface Capability { + name: string; + title?: string; + description: string; + category: string; + method: string; + path: string; + scope: string; + actionMode: AgentCapabilityActionMode; + approvalPolicy: AgentCapabilityApprovalPolicy; + responseShape?: string; + outputSchema?: Record; + errorCodes?: string[]; + requestBodyShape?: string; + inputSchema?: Record; +} + +export interface CapabilityCategory { + id: string; + label: string; + description?: string; +} + +export interface MCPAdapterConfigFamily { + id: string; + label: string; + shape: string; + description?: string; + fileHints?: string[]; + clientLabels?: string[]; +} + +export interface MCPAdapterContract { + serverName: string; + command: string; + baseUrlFlag: string; + defaultBaseUrl: string; + tokenEnv: string; + configFamilies: MCPAdapterConfigFamily[]; +} + +export interface Manifest { + version: string; + surfaceContract: SurfaceContract; + surfaceToolContracts: SurfaceToolContract[]; + mcpAdapter: MCPAdapterContract; + requiredScopes: string[]; + categories: CapabilityCategory[]; + workflowPrompts: PulseWorkflowPrompt[]; + capabilities: Capability[]; +} + +export interface OperatorSurfaceContract { + id: string; + label: string; + description: string; + native: boolean; + externalAdapter: boolean; + affordances?: SurfaceAffordanceContract; +} + +export interface PulseWorkflowPrompt { + name: string; + label?: string; + presentationKind?: string; + description?: string; + arguments?: PulseWorkflowPromptArgument[]; +} + +export interface PulseWorkflowPromptArgument { + name: string; + description?: string; + required?: boolean; +} + +export interface SurfaceAffordanceContract { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + capabilityMetadata?: boolean; + interactiveQuestions?: boolean; +} + +export interface SurfaceContract { + core: SurfaceContractComponent; + proactiveEngine: SurfaceContractComponent; + operatorSurfaces: OperatorSurfaceContract[]; +} + +export interface SurfaceContractComponent { + id: string; + label: string; + description: string; +} + +export interface SurfaceToolContract { + surfaceId: string; + surfaceLabel?: string; + toolSource: string; + toolNames: string[]; + registryToolNames?: string[]; + capabilityNames?: string[]; + nativeToolNames?: string[]; + affordances?: SurfaceAffordanceContract; +} diff --git a/frontend-modern/src/api/generated/aiChatEvents.ts b/frontend-modern/src/api/generated/aiChatEvents.ts index 469c63fd53..c049d63028 100644 --- a/frontend-modern/src/api/generated/aiChatEvents.ts +++ b/frontend-modern/src/api/generated/aiChatEvents.ts @@ -1,4 +1,4 @@ -// Code generated by scripts/generate-types.go; DO NOT EDIT. +// This file is generated from scripts/generate-types.go; DO NOT EDIT. // Source: internal/ai/chat event payload structs. /* eslint-disable */ @@ -58,6 +58,7 @@ export interface DoneData { model?: string; input_tokens?: number; output_tokens?: number; + context_limit_tokens?: number; } export interface ErrorData { diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index ef55f05627..4c6c6bcdce 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -47,7 +47,7 @@ export interface Finding { // quiet on re-detection until this timestamp passes, then the next // re-detection clears the dismissal and emits a "reminded" lifecycle event. remind_at?: string; - // Investigation fields (Patrol Autonomy) + // Investigation fields (Patrol mode) investigation_session_id?: string; investigation_status?: InvestigationStatus; investigation_outcome?: InvestigationOutcome; @@ -78,6 +78,7 @@ export type InvestigationOutcome = | 'fix_queued' | 'fix_executed' | 'fix_failed' + | 'fix_rejected' | 'needs_attention' | 'cannot_fix' | 'timed_out' @@ -403,18 +404,18 @@ export async function setFindingNote( } // ============================================================================= -// Patrol Autonomy APIs +// Patrol Mode APIs // ============================================================================= /** - * Get current patrol autonomy settings + * Get current Patrol mode settings */ export async function getPatrolAutonomySettings(): Promise { return apiFetchJSON('/api/ai/patrol/autonomy'); } /** - * Update patrol autonomy settings + * Update Patrol mode settings */ export async function updatePatrolAutonomySettings( settings: PatrolAutonomySettings, @@ -505,6 +506,7 @@ export const investigationOutcomeLabels: Record = fix_queued: 'Fix Queued', fix_executed: 'Fix Executed', fix_failed: 'Fix Failed', + fix_rejected: 'Fix Rejected', needs_attention: 'Needs Attention', cannot_fix: 'Cannot Remediate', timed_out: 'Timed Out — Will Retry', @@ -525,6 +527,8 @@ export const investigationOutcomeColors: Record = 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900 dark:text-green-300', fix_failed: 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300', + fix_rejected: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300', needs_attention: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300', cannot_fix: 'border-slate-200 bg-slate-50 text-slate-600', @@ -544,11 +548,7 @@ export const investigationOutcomeColors: Record = export type PatrolRunStatus = 'healthy' | 'issues_found' | 'critical' | 'error'; -export type ToolCallVerificationStatus = - | 'unknown' - | 'verified' - | 'unverified' - | 'failed'; +export type ToolCallVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed'; export interface ToolCallVerification { status: ToolCallVerificationStatus; diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index cf4132553c..ec84908435 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -51,6 +51,62 @@ export interface TelemetryPingPreview { multi_tenant: boolean; paid_license: boolean; has_api_tokens: boolean; + pulse_intelligence_loop_configured: boolean; + pulse_intelligence_loop_active_30d: boolean; + pulse_intelligence_complete_operations_loop_30d: boolean; + pulse_intelligence_approved_execution_loop_30d: boolean; + pulse_intelligence_resolved_operations_loop_30d: boolean; + pulse_intelligence_patrol_control_completed_operations_loop_30d: boolean; + pulse_intelligence_patrol_control_resolved_operations_loop_30d: boolean; + pulse_intelligence_patrol_control_paid_completed_operations_loop_30d: boolean; + pulse_intelligence_patrol_control_paid_resolved_operations_loop_30d: boolean; + pulse_intelligence_pro_activation_completed_operations_loop_30d: boolean; + pulse_intelligence_pro_activation_resolved_operations_loop_30d: boolean; + pulse_intelligence_pro_activation_paid_completed_operations_loop_30d: boolean; + pulse_intelligence_pro_activation_paid_resolved_operations_loop_30d: boolean; + pulse_intelligence_governed_action_active_30d: boolean; + pulse_intelligence_assistant_operations_loop_30d: boolean; + pulse_intelligence_assistant_approved_execution_loop_30d: boolean; + pulse_intelligence_assistant_approved_action_success_loop_30d: boolean; + pulse_intelligence_assistant_resolved_operations_loop_30d: boolean; + pulse_intelligence_external_agent_operations_loop_30d: boolean; + pulse_intelligence_external_agent_approved_execution_loop_30d: boolean; + pulse_intelligence_external_agent_approved_action_success_loop_30d: boolean; + pulse_intelligence_external_agent_resolved_operations_loop_30d: boolean; + pulse_intelligence_mcp_adapter_operations_loop_30d: boolean; + pulse_intelligence_mcp_adapter_approved_execution_loop_30d: boolean; + pulse_intelligence_mcp_adapter_approved_action_success_loop_30d: boolean; + pulse_intelligence_mcp_adapter_resolved_operations_loop_30d: boolean; + pulse_intelligence_operations_loop_starter_requests_30d: number; + pulse_intelligence_assistant_operations_loop_starter_requests_30d: number; + pulse_intelligence_patrol_operations_loop_starter_requests_30d: number; + pulse_intelligence_patrol_control_operations_loop_starter_requests_30d: number; + pulse_intelligence_pro_activation_operations_loop_starter_requests_30d: number; + pulse_intelligence_mcp_operations_loop_starter_requests_30d: number; + pulse_intelligence_assistant_ai_calls_30d: number; + pulse_intelligence_assistant_context_ai_calls_30d: number; + pulse_intelligence_assistant_tool_calls_30d: number; + pulse_intelligence_patrol_ai_calls_30d: number; + pulse_intelligence_patrol_runs_30d: number; + pulse_intelligence_patrol_new_findings_30d: number; + pulse_intelligence_patrol_investigations_30d: number; + pulse_intelligence_patrol_resolved_findings_30d: number; + pulse_intelligence_patrol_autofixes_30d: number; + pulse_intelligence_external_agent_enabled: boolean; + pulse_intelligence_external_agent_used_30d: boolean; + pulse_intelligence_mcp_adapter_used_30d: boolean; + pulse_intelligence_external_agent_context_requests_30d: number; + pulse_intelligence_external_agent_event_stream_requests_30d: number; + pulse_intelligence_external_agent_provisioning_requests_30d: number; + pulse_intelligence_external_agent_operator_state_requests_30d: number; + pulse_intelligence_external_agent_finding_requests_30d: number; + pulse_intelligence_external_agent_action_requests_30d: number; + pulse_intelligence_action_plans_30d: number; + pulse_intelligence_approval_requests_30d: number; + pulse_intelligence_rejected_action_decisions_30d: number; + pulse_intelligence_approved_action_decisions_30d: number; + pulse_intelligence_approved_action_attempts_30d: number; + pulse_intelligence_approved_action_successes_30d: number; } export interface TelemetryPreviewResponse { diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index 03d77e7c09..d67bf4973c 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -26,6 +26,7 @@ import { AI_COST_PANEL_TITLE, AI_COST_PATROL_USE_CASE_LABEL, AI_COST_PROVIDER_MODEL_PAIR_LABEL, + AI_COST_TARGET_TABLE_LABEL, AI_COST_RESET_HISTORY_LABEL, buildAICostExportFilename, getAICostBudgetNote, @@ -36,6 +37,7 @@ import { getAICostResetHistoryConfirmationMessage, getAICostResetHistoryErrorMessage, getAICostResetHistorySuccessMessage, + getAICostTargetPresentation, } from '@/utils/aiCostPresentation'; const usdFormatter = new Intl.NumberFormat(undefined, { @@ -548,7 +550,9 @@ export const AICostDashboard: Component = () => { - Top targets + + {AI_COST_TARGET_TABLE_LABEL} + Est. USD Calls Tokens @@ -556,27 +560,37 @@ export const AICostDashboard: Component = () => { - {(t) => ( - - - {t.target_type}:{t.target_id} - - - - - - {formatNumber(t.calls)} - - - {formatNumber(t.total_tokens)} - - - )} + {(t) => { + const target = getAICostTargetPresentation(t); + return ( + + + {target.label} + + {target.detail} + + + + + + + {formatNumber(t.calls)} + + + {formatNumber(t.total_tokens)} + + + ); + }}
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 f497d729c6..ca1343062b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -13,6 +13,7 @@ const { mockChat, mockAIAPI, mockAIChatAPI, + mockFetchAgentCapabilitiesManifest, mockNotificationStore, mockAiChatStore, mockByType, @@ -91,6 +92,18 @@ const { const mockAIChatAPI = { getStatus: vi.fn().mockResolvedValue({ running: true }), + getAssistantSurfaceTools: vi.fn().mockResolvedValue({ + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + affordances: { + tools: true, + interactiveQuestions: true, + }, + }), listSessions: vi.fn().mockResolvedValue([]), createSession: vi.fn().mockResolvedValue({ id: 'new', @@ -124,8 +137,25 @@ const { }), approveCommand: vi.fn().mockResolvedValue({ approved: true, message: 'ok' }), denyCommand: vi.fn().mockResolvedValue({ denied: true, message: 'ok' }), + renderWorkflowPrompt: vi.fn().mockResolvedValue({ + description: 'Pulse fleet triage', + text: 'Use get_fleet_context to triage the Pulse fleet.', + }), }; + const mockFetchAgentCapabilitiesManifest = vi.fn().mockResolvedValue({ + version: 'v1', + workflowPrompts: [], + capabilities: [], + categories: [], + requiredScopes: [], + surfaceContract: { + core: { id: 'pulse_intelligence_core', label: 'Pulse Intelligence Core', description: '' }, + proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' }, + operatorSurfaces: [], + }, + }); + const mockNotificationStore = { info: vi.fn(), success: vi.fn(), @@ -144,6 +174,7 @@ const { handoffResources: undefined, handoffActions: undefined, handoffMetadata: undefined, + preferredWorkflowPromptName: undefined, }); const mockAiChatStore = { @@ -189,6 +220,7 @@ const { runStatus?: string; runtimeFailure?: boolean; }; + preferredWorkflowPromptName?: string; briefing?: { sourceLabel: string; title: string; @@ -219,6 +251,7 @@ const { handoffResources: _handoffResources, handoffActions: _handoffActions, handoffMetadata: _handoffMetadata, + preferredWorkflowPromptName: _preferredWorkflowPromptName, ...rest } = mockAiChatStore.context; mockAiChatStore.context = rest; @@ -236,6 +269,7 @@ const { mockChat, mockAIAPI, mockAIChatAPI, + mockFetchAgentCapabilitiesManifest, mockNotificationStore, mockAiChatStore, mockByType, @@ -398,6 +432,36 @@ vi.mock('../MentionAutocomplete', () => ({ vi.mock('@/api/ai', () => ({ AIAPI: mockAIAPI })); vi.mock('@/api/aiChat', () => ({ AIChatAPI: mockAIChatAPI })); +vi.mock('@/api/agentCapabilities', () => ({ + fetchAgentCapabilitiesManifest: mockFetchAgentCapabilitiesManifest, + getAgentSurfaceToolPosturePresentation: ( + contract: + | { + surfaceLabel?: string; + toolSource?: string; + toolNames?: string[]; + registryToolNames?: string[]; + nativeToolNames?: string[]; + } + | undefined, + ) => { + if (!contract) return null; + const toolCount = contract.toolNames?.length ?? 0; + const label = toolCount === 1 ? '1 capability' : `${toolCount} capabilities`; + return { + label, + title: `${contract.surfaceLabel || 'Pulse Assistant'} capability availability. Source: ${ + contract.toolSource === 'assistant_registry' ? 'Assistant registry' : contract.toolSource + }. ${(contract.registryToolNames?.length ?? 0).toString()} registry capabilities. ${( + contract.nativeToolNames?.length ?? 0 + ).toString()} native capabilities.`, + tone: toolCount > 0 ? 'ready' : 'empty', + toolCount, + }; + }, + getAgentWorkflowPrompts: (manifest: { workflowPrompts?: unknown[] } | undefined) => + manifest?.workflowPrompts ?? [], +})); vi.mock('@/stores/notifications', () => ({ notificationStore: mockNotificationStore })); vi.mock('@/stores/aiChat', () => ({ aiChatStore: mockAiChatStore })); vi.mock('@/utils/logger', () => ({ @@ -511,7 +575,35 @@ beforeEach(() => { model: 'openai:gpt-4', }); mockAIChatAPI.getStatus.mockResolvedValue({ running: true }); + mockAIChatAPI.getAssistantSurfaceTools.mockResolvedValue({ + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question'], + registryToolNames: ['pulse_query'], + nativeToolNames: ['pulse_question'], + affordances: { + tools: true, + interactiveQuestions: true, + }, + }); mockAIChatAPI.listSessions.mockResolvedValue([]); + mockAIChatAPI.renderWorkflowPrompt.mockResolvedValue({ + description: 'Pulse fleet triage', + text: 'Use get_fleet_context to triage the Pulse fleet.', + }); + mockFetchAgentCapabilitiesManifest.mockResolvedValue({ + version: 'v1', + workflowPrompts: [], + capabilities: [], + categories: [], + requiredScopes: [], + surfaceContract: { + core: { id: 'pulse_intelligence_core', label: 'Pulse Intelligence Core', description: '' }, + proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' }, + operatorSurfaces: [], + }, + }); mockAIChatAPI.renameSession.mockResolvedValue({ id: 'renamed-session', title: 'Renamed session', @@ -813,6 +905,151 @@ describe('AIChat', () => { expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toBeInTheDocument(); }); + it('renders shared workflow starters and inserts rendered prompt text into the composer', async () => { + mockAiChatStore.context = { + targetType: 'vm', + targetId: 'vm:101', + findingId: undefined, + autonomousMode: undefined, + briefing: undefined, + }; + mockFetchAgentCapabilitiesManifest.mockResolvedValueOnce({ + version: 'v1', + workflowPrompts: [ + { + name: 'pulse_triage_fleet', + label: 'Review fleet posture', + presentationKind: 'fleet', + description: 'Triage the Pulse fleet.', + arguments: [], + }, + { + name: 'pulse_operations_loop', + label: 'Ask Patrol to handle an issue', + presentationKind: 'workflow', + description: + 'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.', + arguments: [], + }, + { + name: 'pulse_investigate_resource', + label: 'Inspect this resource', + presentationKind: 'resource', + description: 'Investigate one Pulse resource.', + arguments: [{ name: 'resourceId', required: true }], + }, + { + name: 'pulse_review_finding', + presentationKind: 'finding', + description: 'Review one Patrol finding.', + arguments: [{ name: 'finding_id', required: true }], + }, + ], + capabilities: [], + categories: [], + requiredScopes: [], + surfaceContract: { + core: { + id: 'pulse_intelligence_core', + label: 'Pulse Intelligence Core', + description: '', + }, + proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' }, + operatorSurfaces: [], + }, + }); + mockAIChatAPI.renderWorkflowPrompt.mockResolvedValueOnce({ + description: 'Pulse resource investigation', + text: 'Investigate Pulse resource "vm:101".', + }); + + renderChat(); + + expect( + await screen.findByTestId('assistant-workflow-starter-pulse_triage_fleet'), + ).toHaveTextContent('Review fleet posture'); + expect( + await screen.findByTestId('assistant-workflow-starter-pulse_operations_loop'), + ).toHaveTextContent('Ask Patrol to handle an issue'); + const resourceStarter = await screen.findByTestId( + 'assistant-workflow-starter-pulse_investigate_resource', + ); + expect(screen.queryByTestId('assistant-workflow-starter-pulse_review_finding')).toBeNull(); + + fireEvent.click(resourceStarter); + + await waitFor(() => { + expect(mockAIChatAPI.renderWorkflowPrompt).toHaveBeenCalledWith({ + name: 'pulse_investigate_resource', + arguments: { resourceId: 'vm:101' }, + }); + }); + expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toHaveValue( + 'Investigate Pulse resource "vm:101".', + ); + }); + + it('auto-renders a preferred operations-loop starter for scoped Patrol handoffs', async () => { + mockAiChatStore.context = { + targetType: 'vm', + targetId: 'vm:101', + findingId: 'finding-1', + autonomousMode: false, + preferredWorkflowPromptName: 'pulse_operations_loop', + }; + mockFetchAgentCapabilitiesManifest.mockResolvedValueOnce({ + version: 'v1', + workflowPrompts: [ + { + name: 'pulse_operations_loop', + label: 'Ask Patrol to handle an issue', + presentationKind: 'workflow', + description: + 'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.', + arguments: [], + }, + { + name: 'pulse_review_finding', + label: 'Review selected finding', + presentationKind: 'finding', + description: 'Review one Patrol finding.', + arguments: [{ name: 'finding_id', required: true }], + }, + ], + capabilities: [], + categories: [], + requiredScopes: [], + surfaceContract: { + core: { + id: 'pulse_intelligence_core', + label: 'Pulse Intelligence Core', + description: '', + }, + proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' }, + operatorSurfaces: [], + }, + }); + mockAIChatAPI.renderWorkflowPrompt.mockResolvedValueOnce({ + description: 'Patrol issue handling', + text: 'Handle this Patrol finding through explanation, governed action, verification, and history.', + }); + + renderChat(); + + await waitFor(() => { + expect(mockAIChatAPI.renderWorkflowPrompt).toHaveBeenCalledWith({ + name: 'pulse_operations_loop', + arguments: {}, + }); + }); + expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toHaveValue( + 'Handle this Patrol finding through explanation, governed action, verification, and history.', + ); + expect( + await screen.findByTestId('assistant-workflow-starter-pulse_review_finding'), + ).toHaveTextContent('Review selected finding'); + }); + it('registers and focuses the composer when opened', async () => { renderChat(); const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); @@ -833,6 +1070,30 @@ describe('AIChat', () => { expect(screen.getByTestId('model-selector')).toBeInTheDocument(); }); + it('renders native Assistant capability availability from the runtime surface contract', async () => { + mockAIChatAPI.getAssistantSurfaceTools.mockResolvedValueOnce({ + surfaceId: 'pulse_assistant', + surfaceLabel: 'Pulse Assistant', + toolSource: 'assistant_registry', + toolNames: ['pulse_query', 'pulse_question', 'pulse_storage'], + registryToolNames: ['pulse_query', 'pulse_storage'], + nativeToolNames: ['pulse_question'], + affordances: { + tools: true, + interactiveQuestions: true, + }, + }); + + renderChat(); + + const toolHealth = await screen.findByTestId('assistant-surface-tools-health'); + expect(mockAIChatAPI.getAssistantSurfaceTools).toHaveBeenCalled(); + expect(toolHealth).toHaveTextContent('3 capabilities'); + const title = toolHealth.getAttribute('title') || ''; + expect(title).toContain('Pulse Assistant capability availability'); + expect(title).toContain('Assistant registry'); + }); + it('opens the model selector from failed-turn recovery', async () => { renderChat(); @@ -1094,7 +1355,7 @@ describe('AIChat', () => { ); expect(screen.getByRole('link', { name: /Open settings/ })).toHaveAttribute( 'href', - '/settings/system-ai', + '/settings/pulse-intelligence/provider', ); }); @@ -1507,39 +1768,39 @@ describe('AIChat', () => { sourceLabel: 'Pulse Patrol', title: 'Patrol finding attached', subject: 'Provider connection issue on Patrol runtime', - actionLabel: 'Open Patrol provider settings', - actionHref: '/settings/system-ai', + actionLabel: 'Open Provider & Models', + actionHref: '/settings/pulse-intelligence/provider', }, }; renderChat(); - expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute( 'href', - '/settings/system-ai', + '/settings/pulse-intelligence/provider', ); }); - it('renders Patrol configuration handoff details without replacing the attached headline', () => { + it('renders Patrol mode handoff details without replacing the attached headline', () => { mockAiChatStore.context = { targetType: 'patrol-configuration', autonomousMode: false, briefing: { sourceLabel: 'Pulse Patrol', - title: 'Patrol configuration failure attached', + title: 'Patrol mode save failure attached', subject: 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.', statusLabel: 'HTTP 402 · model_unsupported_tools', detailLines: ['Provider: openrouter'], evidence: ['Command: sensitive or command detail withheld'], safetyNote: - 'Assistant can explain the configuration state; provider changes remain operator-controlled.', + 'Assistant can explain the Patrol mode state; provider changes remain operator-controlled.', }, }; renderChat(); const context = screen.getByLabelText('Assistant context'); - expect(context).toHaveTextContent('Patrol configuration failure attached'); + expect(context).toHaveTextContent('Patrol mode save failure attached'); expect(context).toHaveTextContent('patrol_autonomy_pro_required'); expect(context).toHaveTextContent('Provider: openrouter'); expect(context).toHaveTextContent('Command: sensitive or command detail withheld'); @@ -1748,7 +2009,7 @@ describe('AIChat', () => { const routeControls = screen.getByTestId('assistant-composer-route-controls'); const modelSelector = screen.getByTestId('model-selector'); const controlButton = screen.getByRole('button', { - name: 'Assistant control mode: Read-only', + name: 'Assistant chat action mode: Read-only', }); expect(closeButton).toHaveClass('order-2'); @@ -2004,7 +2265,7 @@ describe('AIChat', () => { fireEvent.input(textarea, { target: { value: '/connect' } }); fireEvent.keyDown(textarea, { key: 'Enter' }); - expect(mockNavigate).toHaveBeenCalledWith('/settings/system-ai'); + expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/provider'); expect(onClose).toHaveBeenCalledTimes(1); expect(mockChat.sendMessage).not.toHaveBeenCalled(); }); @@ -2018,7 +2279,7 @@ describe('AIChat', () => { selectorProps.onManageProviders?.(); - expect(mockNavigate).toHaveBeenCalledWith('/settings/system-ai'); + expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/provider'); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -3141,7 +3402,7 @@ describe('AIChat', () => { it('opens control menu on click and focuses the current mode', async () => { renderChat(); const controlButton = screen.getByRole('button', { - name: 'Assistant control mode: Read-only', + name: 'Assistant chat action mode: Read-only', }); expect(controlButton).toHaveAttribute('aria-expanded', 'false'); @@ -3149,20 +3410,20 @@ describe('AIChat', () => { expect(controlButton).toHaveAttribute('aria-expanded', 'true'); expect( - screen.getByRole('menu', { name: 'Assistant control mode options' }), + screen.getByRole('menu', { name: 'Assistant chat action options' }), ).toBeInTheDocument(); expect(screen.getByRole('menuitemradio', { name: /Read-only/ })).toHaveAttribute( 'aria-checked', 'true', ); - expect(screen.getByRole('menuitemradio', { name: /Approval/ })).toHaveAttribute( + expect(screen.getByRole('menuitemradio', { name: /Ask first/ })).toHaveAttribute( 'aria-checked', 'false', ); expect(screen.getByText('Default control mode')).toBeInTheDocument(); - expect(screen.getByText('No commands or control actions')).toBeInTheDocument(); - expect(screen.getByText('Ask before running commands')).toBeInTheDocument(); - expect(screen.getByText('Executes without approval')).toBeInTheDocument(); + expect(screen.getByText('Observes only')).toBeInTheDocument(); + expect(screen.getByText('Asks before chat-only actions')).toBeInTheDocument(); + expect(screen.getByText('Eligible chat-only actions')).toBeInTheDocument(); await waitFor(() => { expect(document.activeElement).toBe( screen.getByRole('menuitemradio', { name: /Read-only/ }), @@ -3173,14 +3434,14 @@ describe('AIChat', () => { it('opens the control menu from the keyboard', async () => { renderChat(); const controlButton = screen.getByRole('button', { - name: 'Assistant control mode: Read-only', + name: 'Assistant chat action mode: Read-only', }); fireEvent.keyDown(controlButton, { key: 'ArrowDown' }); expect(controlButton).toHaveAttribute('aria-expanded', 'true'); expect( - screen.getByRole('menu', { name: 'Assistant control mode options' }), + screen.getByRole('menu', { name: 'Assistant chat action options' }), ).toBeInTheDocument(); await waitFor(() => { expect(document.activeElement).toBe( @@ -3198,34 +3459,34 @@ describe('AIChat', () => { )); const controlButton = screen.getByRole('button', { - name: 'Assistant control mode: Read-only', + name: 'Assistant chat action mode: Read-only', }); fireEvent.click(controlButton); const readOnlyOption = screen.getByRole('menuitemradio', { name: /Read-only/ }); - const approvalOption = screen.getByRole('menuitemradio', { name: /Approval/ }); - const autonomousOption = screen.getByRole('menuitemradio', { name: /Autonomous/ }); + const askFirstOption = screen.getByRole('menuitemradio', { name: /Ask first/ }); + const chatActionsOption = screen.getByRole('menuitemradio', { name: /Chat actions/ }); await waitFor(() => { expect(document.activeElement).toBe(readOnlyOption); }); fireEvent.keyDown(readOnlyOption, { key: 'ArrowDown' }); - expect(document.activeElement).toBe(approvalOption); + expect(document.activeElement).toBe(askFirstOption); - fireEvent.keyDown(approvalOption, { key: 'End' }); - expect(document.activeElement).toBe(autonomousOption); + fireEvent.keyDown(askFirstOption, { key: 'End' }); + expect(document.activeElement).toBe(chatActionsOption); - fireEvent.keyDown(autonomousOption, { key: 'Home' }); + fireEvent.keyDown(chatActionsOption, { key: 'Home' }); expect(document.activeElement).toBe(readOnlyOption); fireEvent.keyDown(readOnlyOption, { key: 'ArrowUp' }); - expect(document.activeElement).toBe(autonomousOption); + expect(document.activeElement).toBe(chatActionsOption); - fireEvent.keyDown(autonomousOption, { key: 'Escape' }); + fireEvent.keyDown(chatActionsOption, { key: 'Escape' }); await waitFor(() => { expect( - screen.queryByRole('menu', { name: 'Assistant control mode options' }), + screen.queryByRole('menu', { name: 'Assistant chat action options' }), ).not.toBeInTheDocument(); expect(document.activeElement).toBe(controlButton); }); @@ -4249,7 +4510,7 @@ describe('AIChat', () => { }); }); - it('restores Patrol configuration failure handoff state as a runtime issue', async () => { + it('restores Patrol mode save failure handoff state as a runtime issue', async () => { mockAIChatAPI.listSessions.mockResolvedValue([ { id: 's-patrol-config', @@ -4293,9 +4554,9 @@ describe('AIChat', () => { }), briefing: expect.objectContaining({ sourceLabel: 'Pulse Patrol', - title: 'Patrol configuration failure', - subject: 'Patrol configuration', - actionLabel: 'Review Patrol configuration issue', + title: 'Patrol mode save failure', + subject: 'Patrol mode', + actionLabel: 'Review Patrol mode issue', statusLabel: 'runtime issue', }), }), @@ -4405,7 +4666,7 @@ describe('AIChat', () => { await waitFor(() => { expect(screen.getByText('Pulse Patrol')).toBeInTheDocument(); expect(screen.getByText('Context attached')).toBeInTheDocument(); - expect(screen.queryByText('Open Patrol provider settings')).not.toBeInTheDocument(); + expect(screen.queryByText('Open Provider & Models')).not.toBeInTheDocument(); }); fireEvent.click(screen.getByText('Context-only Patrol follow-up')); @@ -5274,14 +5535,14 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { const warning = screen.getByRole('status', { - name: 'Assistant autonomous control warning', + name: 'Assistant chat actions warning', }); expect(screen.getByTestId('assistant-activity-dock')).toContainElement(warning); - expect(warning).toHaveTextContent('Autonomous: commands execute without approval.'); + expect(warning).toHaveTextContent('Chat-only actions are allowed.'); }); }); - it('shows Switch to Approval button in autonomous warning row', async () => { + it('shows Switch to Ask first button in autonomous warning row', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'gpt-4', chat_model: '', @@ -5292,13 +5553,13 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { expect( - screen.getByRole('status', { name: 'Assistant autonomous control warning' }), + screen.getByRole('status', { name: 'Assistant chat actions warning' }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Switch Assistant control mode to Approval' }), + screen.getByRole('button', { name: 'Switch Assistant chat actions to Ask first' }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Dismiss autonomous control warning' }), + screen.getByRole('button', { name: 'Dismiss chat actions warning' }), ).toBeInTheDocument(); }); }); @@ -5320,13 +5581,13 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { - expect(screen.getByText('Approval')).toBeInTheDocument(); + expect(screen.getByText('Ask first')).toBeInTheDocument(); }); expect( screen.queryByText(/Approval required for this dashboard brief/), ).not.toBeInTheDocument(); expect( - screen.queryByRole('status', { name: 'Assistant autonomous control warning' }), + screen.queryByRole('status', { name: 'Assistant chat actions warning' }), ).not.toBeInTheDocument(); const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); @@ -5361,7 +5622,7 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { - expect(screen.getByText('Approval')).toBeInTheDocument(); + expect(screen.getByText('Ask first')).toBeInTheDocument(); }); expect(screen.getByText('Approval required before any action.')).toBeInTheDocument(); expect( @@ -5394,7 +5655,7 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { - expect(screen.getByText('Approval')).toBeInTheDocument(); + expect(screen.getByText('Ask first')).toBeInTheDocument(); }); expect(screen.getByText('Approval required before any action.')).toBeInTheDocument(); expect( @@ -5551,7 +5812,7 @@ describe('AIChat', () => { }); renderChat(); await waitFor(() => { - expect(screen.getByText('Workload Discovery is off.')).toBeInTheDocument(); + expect(screen.getByText('Discovery is off.')).toBeInTheDocument(); }); expect( screen.getByText( @@ -5565,7 +5826,7 @@ describe('AIChat', () => { await waitFor(() => { expect(mockAIAPI.getSettings).toHaveBeenCalled(); }); - expect(screen.queryByText('Workload Discovery is off.')).not.toBeInTheDocument(); + expect(screen.queryByText('Discovery is off.')).not.toBeInTheDocument(); }); }); @@ -6077,7 +6338,7 @@ describe('AIChat', () => { screen.getByLabelText('Assistant active turn status'), ); expect(activityDock).toContainElement( - screen.getByRole('status', { name: 'Assistant autonomous control warning' }), + screen.getByRole('status', { name: 'Assistant chat actions warning' }), ); }); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts index db6f64e983..bc90eabd05 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -842,7 +842,7 @@ describe('getAssistantActiveTurnStatus', () => { ), ).toEqual({ type: 'tool', - text: 'Preparing governed tools · command', + text: 'Preparing governed actions · command', }); }); @@ -861,7 +861,7 @@ describe('getAssistantActiveTurnStatus', () => { message: 'Preparing governed tools', tool: 'pulse_exec', }), - ).toBe('Preparing governed tools · command'); + ).toBe('Preparing governed actions · command'); }); it('lets a newer provider switch replace stale waiting progress', () => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/workflowStarters.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/workflowStarters.test.ts new file mode 100644 index 0000000000..f8e878cc6f --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/workflowStarters.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentWorkflowPrompt } from '@/api/agentCapabilities'; +import { getAssistantWorkflowStarters } from '../workflowStarters'; + +const prompts = [ + { + name: 'pulse_triage_fleet', + label: 'Review the whole fleet', + presentationKind: 'fleet', + description: 'Triage the Pulse fleet.', + arguments: [], + }, + { + name: 'pulse_operations_loop', + label: 'Ask Patrol to handle an issue', + presentationKind: 'workflow', + description: + 'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.', + arguments: [], + }, + { + name: 'pulse_investigate_resource', + label: 'Inspect selected resource', + presentationKind: 'resource', + description: 'Investigate one Pulse resource.', + arguments: [{ name: 'resourceId', required: true }], + }, + { + name: 'pulse_review_finding', + label: 'Review selected finding', + presentationKind: 'finding', + description: 'Review one Patrol finding.', + arguments: [{ name: 'finding_id', required: true }], + }, +] satisfies AgentWorkflowPrompt[]; + +describe('Assistant workflow starters', () => { + it('keeps global starters available without scoped context', () => { + expect(getAssistantWorkflowStarters(prompts, {})).toEqual([ + { + id: 'pulse_triage_fleet', + name: 'pulse_triage_fleet', + label: 'Review the whole fleet', + description: 'Triage the Pulse fleet.', + kind: 'fleet', + arguments: {}, + }, + { + id: 'pulse_operations_loop', + name: 'pulse_operations_loop', + label: 'Ask Patrol to handle an issue', + description: + 'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.', + kind: 'workflow', + arguments: {}, + }, + ]); + }); + + it('resolves resource workflow arguments from attached handoff resources first', () => { + const starters = getAssistantWorkflowStarters(prompts, { + targetType: 'vm', + targetId: 'vm:old', + handoffResources: [{ id: 'vm:101', name: 'web-101' }], + }); + + expect(starters.find((starter) => starter.name === 'pulse_investigate_resource')).toMatchObject( + { + label: 'Inspect selected resource', + kind: 'resource', + arguments: { resourceId: 'vm:101' }, + }, + ); + }); + + it('does not treat Patrol run ids as resource workflow arguments', () => { + const starters = getAssistantWorkflowStarters(prompts, { + targetType: 'patrol-run', + targetId: 'run-123', + findingId: 'finding-1', + }); + + expect(starters.map((starter) => starter.name)).toEqual([ + 'pulse_triage_fleet', + 'pulse_operations_loop', + 'pulse_review_finding', + ]); + }); + + it('resolves finding workflow arguments from direct and action handoff context', () => { + const direct = getAssistantWorkflowStarters(prompts, { + findingId: 'finding-direct', + }); + expect(direct.find((starter) => starter.name === 'pulse_review_finding')?.arguments).toEqual({ + finding_id: 'finding-direct', + }); + + const action = getAssistantWorkflowStarters(prompts, { + handoffActions: [{ findingId: 'finding-action' }], + }); + expect(action.find((starter) => starter.name === 'pulse_review_finding')?.arguments).toEqual({ + finding_id: 'finding-action', + }); + }); + + it('falls back to workflow presentation for undeclared kinds instead of inferring names', () => { + const [starter] = getAssistantWorkflowStarters( + [ + { + name: 'pulse_triage_fleet', + label: 'Legacy fleet prompt', + presentationKind: 'legacy', + arguments: [], + }, + ], + {}, + ); + + expect(starter).toMatchObject({ + name: 'pulse_triage_fleet', + kind: 'workflow', + }); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 5fafb49edd..d2000af04d 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -114,6 +114,7 @@ const sanitizeWorkflowStatusMessage = (message: string, tool?: string) => { } return sanitized + .replace(/\bgoverned tools\b/gi, 'governed actions') .replace(/\s+\./g, '.') .replace(/\s{2,}/g, ' ') .trim(); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 562ffb8080..7061b8dc88 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -2395,7 +2395,7 @@ export function useChat(options: UseChatOptions = {}) { updateQuestion(messageId, questionId, { isAnswering: true }); try { - // Send answer to Pulse AI via API + // Send answer to Pulse Assistant via API await AIChatAPI.answerQuestion(questionId, answers); // Remove the question card - it's been handled diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index b04606c66e..6dbc6a1405 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -23,6 +23,8 @@ import Redo2Icon from 'lucide-solid/icons/redo-2'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; import RotateCwIcon from 'lucide-solid/icons/rotate-cw'; import SettingsIcon from 'lucide-solid/icons/settings'; +import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; +import SparklesIcon from 'lucide-solid/icons/sparkles'; import Undo2Icon from 'lucide-solid/icons/undo-2'; import XIcon from 'lucide-solid/icons/x'; import BellIcon from 'lucide-solid/icons/bell'; @@ -34,6 +36,7 @@ import LoaderCircleIcon from 'lucide-solid/icons/loader-circle'; import Minimize2Icon from 'lucide-solid/icons/minimize-2'; import PlusIcon from 'lucide-solid/icons/plus'; import Trash2Icon from 'lucide-solid/icons/trash-2'; +import WrenchIcon from 'lucide-solid/icons/wrench'; import { AIAPI } from '@/api/ai'; import { AIChatAPI, @@ -41,6 +44,13 @@ import { type ChatSession, type ChatSessionHandoffSummary, } from '@/api/aiChat'; +import { + fetchAgentCapabilitiesManifest, + getAgentSurfaceToolPosturePresentation, + getAgentWorkflowPrompts, + type AgentSurfaceToolContract, + type AgentWorkflowPrompt, +} from '@/api/agentCapabilities'; import { ActionIconButton } from '@/components/shared/Button'; import { SearchField } from '@/components/shared/SearchField'; import { notificationStore } from '@/stores/notifications'; @@ -173,6 +183,7 @@ import { type AssistantSlashCommand, type AssistantSlashCommandAction, } from './assistantSlashCommands'; +import { getAssistantWorkflowStarters, type AssistantWorkflowStarter } from './workflowStarters'; import { assistantNotificationsEnabled, assistantNotificationsSupported, @@ -246,6 +257,22 @@ interface AssistantActiveRoutePresentation { title: string; } +type AssistantSurfaceToolsStatus = 'idle' | 'loading' | 'ready' | 'unavailable'; + +interface AssistantSurfaceToolsState { + status: AssistantSurfaceToolsStatus; + contract?: AgentSurfaceToolContract; + message?: string; +} + +interface AssistantSurfaceToolHealthPresentation { + label: string; + title: string; + className: string; + dotClassName: string; + iconClassName: string; +} + interface PromptHistoryEntry { prompt: string; mentions: MentionResource[]; @@ -520,7 +547,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde : isPatrolAssessment ? 'Patrol assessment handoff' : isPatrolConfigurationFailure - ? 'Patrol configuration failure' + ? 'Patrol mode save failure' : isPatrolFinding ? resourceLabel ? `Patrol finding on ${resourceLabel}` @@ -574,7 +601,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde : isPatrolAssessment ? 'Current Patrol assessment' : isPatrolConfigurationFailure - ? 'Patrol configuration' + ? 'Patrol mode' : findingId ? `Finding ${findingId}` : undefined, @@ -596,7 +623,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde actionLabel: summary.requires_approval ? 'Approval required' : isPatrolConfigurationFailure - ? 'Review Patrol configuration issue' + ? 'Review Patrol mode issue' : isPatrolAssessment ? 'Review Patrol assessment' : isPatrolRun && summary.runtime_failure @@ -679,6 +706,10 @@ export const AIChat: Component = (props) => { }); const [providerReadinessVisible, setProviderReadinessVisible] = createSignal(false); const [providerReadinessRetryNonce, setProviderReadinessRetryNonce] = createSignal(0); + const [assistantSurfaceTools, setAssistantSurfaceTools] = + createSignal({ + status: 'idle', + }); const [controlLevel, setControlLevel] = createSignal('read_only'); const [showControlMenu, setShowControlMenu] = createSignal(false); const [controlSaving, setControlSaving] = createSignal(false); @@ -687,6 +718,8 @@ export const AIChat: Component = (props) => { const [discoveryEnabled, setDiscoveryEnabled] = createSignal(null); // null = loading const [discoveryHintDismissed, setDiscoveryHintDismissed] = createSignal(false); const [autonomousBannerDismissed, setAutonomousBannerDismissed] = createSignal(false); + const [workflowPrompts, setWorkflowPrompts] = createSignal([]); + const [renderingWorkflowStarterId, setRenderingWorkflowStarterId] = createSignal(''); const wsStore = getGlobalWebSocketStore(); const allResources = createMemo(() => { const liveResources = wsStore.state.resources ?? []; @@ -2027,8 +2060,61 @@ export const AIChat: Component = (props) => { }; }); + const assistantSurfaceToolHealth = createMemo( + () => { + const state = assistantSurfaceTools(); + + if (state.status === 'loading') { + return { + label: 'Checking capabilities', + title: 'Checking Assistant capability availability', + className: + 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200', + dotClassName: 'bg-blue-500 animate-pulse', + iconClassName: 'text-blue-500 dark:text-blue-300', + }; + } + + if (state.status === 'unavailable') { + return { + label: 'Capabilities unavailable', + title: + state.message || + 'Pulse could not load the Assistant capability contract for this session.', + className: + 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100', + dotClassName: 'bg-amber-500', + iconClassName: 'text-amber-500 dark:text-amber-300', + }; + } + + if (state.status !== 'ready') return null; + const posture = getAgentSurfaceToolPosturePresentation(state.contract); + if (!posture) return null; + + const readyClassName = + posture.tone === 'ready' + ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200' + : 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100'; + const readyDotClassName = posture.tone === 'ready' ? 'bg-emerald-500' : 'bg-amber-500'; + const readyIconClassName = + posture.tone === 'ready' + ? 'text-emerald-500 dark:text-emerald-300' + : 'text-amber-500 dark:text-amber-300'; + + return { + label: posture.label, + title: posture.title, + className: readyClassName, + dotClassName: readyDotClassName, + iconClassName: readyIconClassName, + }; + }, + ); + let providerReadinessRequestId = 0; let lastProviderReadinessKey = ''; + let assistantSurfaceToolsRequestId = 0; const refreshSelectedProviderReadiness = async (provider: string, model: string) => { const requestId = ++providerReadinessRequestId; @@ -2146,6 +2232,41 @@ export const AIChat: Component = (props) => { } }; + const loadWorkflowPrompts = async () => { + try { + const manifest = await fetchAgentCapabilitiesManifest(); + setWorkflowPrompts(getAgentWorkflowPrompts(manifest)); + } catch (error) { + logger.debug('[AIChat] Workflow prompt manifest unavailable during initialization:', error); + setWorkflowPrompts([]); + } + }; + + const loadAssistantSurfaceTools = async () => { + const requestId = ++assistantSurfaceToolsRequestId; + setAssistantSurfaceTools({ status: 'loading' }); + + try { + const contract = await AIChatAPI.getAssistantSurfaceTools(); + if (requestId !== assistantSurfaceToolsRequestId) return; + setAssistantSurfaceTools({ status: 'ready', contract }); + } catch (error) { + if (requestId !== assistantSurfaceToolsRequestId) return; + logger.debug( + '[AIChat] Assistant surface tool contract unavailable during initialization:', + error, + ); + setAssistantSurfaceTools({ + status: 'unavailable', + message: 'Assistant capabilities could not be loaded.', + }); + } + }; + + const assistantWorkflowStarters = createMemo(() => + getAssistantWorkflowStarters(workflowPrompts(), aiChatStore.context), + ); + createEffect(() => { const settings = aiRuntimeSettings(); const chatOverride = (settings?.chat_model || '').trim(); @@ -2170,7 +2291,7 @@ export const AIChat: Component = (props) => { setControlLevel(resolved); if (resolved === 'autonomous') setAutonomousBannerDismissed(false); notificationStore.success( - `Control mode set to ${getAIChatControlLevelPresentation(resolved).label}`, + `Assistant chat action mode set to ${getAIChatControlLevelPresentation(resolved).label}`, 2000, ); } catch (error) { @@ -2351,12 +2472,23 @@ export const AIChat: Component = (props) => { setChatOverrideModel(''); setDiscoveryEnabled(false); setControlLevel('read_only'); + setWorkflowPrompts([]); + assistantSurfaceToolsRequestId += 1; + setAssistantSurfaceTools({ status: 'idle' }); return; } - const [sessionsResult, settingsResult, modelsResult] = await Promise.allSettled([ + const [ + sessionsResult, + settingsResult, + modelsResult, + workflowPromptsResult, + assistantSurfaceToolsResult, + ] = await Promise.allSettled([ refreshSessions(), loadAIRuntimeSettings(), loadAIRuntimeModels(), + loadWorkflowPrompts(), + loadAssistantSurfaceTools(), ]); if (sessionsResult.status === 'rejected') { logger.error('[AIChat] Failed to load sessions:', sessionsResult.reason); @@ -2370,6 +2502,18 @@ export const AIChat: Component = (props) => { modelsResult.reason, ); } + if (workflowPromptsResult.status === 'rejected') { + logger.debug( + '[AIChat] Workflow prompt manifest unavailable during initialization:', + workflowPromptsResult.reason, + ); + } + if (assistantSurfaceToolsResult.status === 'rejected') { + logger.debug( + '[AIChat] Assistant surface tool contract unavailable during initialization:', + assistantSurfaceToolsResult.reason, + ); + } } catch (error) { logger.error('[AIChat] Failed to initialize:', error); } @@ -3263,7 +3407,8 @@ export const AIChat: Component = (props) => { Boolean(ctx.handoffContext?.trim()) || Boolean(ctx.handoffResources?.length) || Boolean(ctx.handoffActions?.length) || - Boolean(ctx.handoffMetadata); + Boolean(ctx.handoffMetadata) || + Boolean(ctx.preferredWorkflowPromptName?.trim()); sendPromise .then((ok) => { if (!ok) { @@ -3294,6 +3439,78 @@ export const AIChat: Component = (props) => { focusComposer(); }; + const renderWorkflowStarterIntoComposer = async ( + starter: AssistantWorkflowStarter, + options: { onlyWhenComposerEmpty?: boolean } = {}, + ) => { + if (renderingWorkflowStarterId()) return; + if (options.onlyWhenComposerEmpty && input().trim()) return; + + setRenderingWorkflowStarterId(starter.id); + try { + const rendered = await AIChatAPI.renderWorkflowPrompt({ + name: starter.name, + arguments: starter.arguments, + }); + const starterText = rendered.text.trim(); + if (!starterText) { + notificationStore.warning('Workflow starter returned an empty prompt.', 3000); + return; + } + + setInput((current) => { + const draft = current.trim(); + if (options.onlyWhenComposerEmpty && draft) return current; + return draft ? `${draft}\n\n${starterText}` : starterText; + }); + setSlashCommandActive(false); + setSlashCommandQuery(''); + setMentionActive(false); + resetPromptHistoryNavigation(); + focusComposer(); + } catch (error) { + logger.error('[AIChat] Failed to render workflow starter:', error); + const message = + error instanceof Error ? error.message : 'Failed to prepare workflow starter.'; + notificationStore.error(message); + } finally { + setRenderingWorkflowStarterId(''); + } + }; + + const handleWorkflowStarterSelect = async (starter: AssistantWorkflowStarter) => { + await renderWorkflowStarterIntoComposer(starter); + }; + + let autoRenderedWorkflowStarterKey = ''; + createEffect(() => { + const context = aiChatStore.context; + const preferredWorkflowPromptName = (context.preferredWorkflowPromptName ?? '').trim(); + if (!preferredWorkflowPromptName) { + autoRenderedWorkflowStarterKey = ''; + return; + } + + const starter = assistantWorkflowStarters().find( + (candidate) => candidate.name === preferredWorkflowPromptName, + ); + if (!starter || renderingWorkflowStarterId()) return; + + const starterKey = [ + starter.name, + context.findingId ?? '', + context.targetType ?? '', + context.targetId ?? '', + JSON.stringify(starter.arguments), + ].join('|'); + if (autoRenderedWorkflowStarterKey === starterKey) return; + + autoRenderedWorkflowStarterKey = starterKey; + if (input().trim()) return; + + void renderWorkflowStarterIntoComposer(starter, { onlyWhenComposerEmpty: true }); + }); + const updateSlashCommandAutocomplete = (value: string, cursorPos: number) => { const textBeforeCursor = value.slice(0, cursorPos); const textAfterCursor = value.slice(cursorPos); @@ -3890,6 +4107,19 @@ export const AIChat: Component = (props) => { chat.updateQuestion(messageId, questionId, { removed: true }); }; + const workflowStarterIcon = (starter: AssistantWorkflowStarter) => { + switch (starter.kind) { + case 'resource': + return