diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index dff1128e0..f5040a4a5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -86,12 +86,19 @@ runtime cost control, and shared AI transport surfaces. platform-native reads or writes must extend the shared Assistant tool contracts, and read-only or augmentation-only platforms must stay explicit there instead of drifting into provider-local tools. -8. Keep self-hosted Patrol quickstart messaging aligned with backend runtime +8. Keep Pulse Assistant action governance canonical in the shared tool + registry. Tool prompts and approval surfaces must derive read, mixed, write, + and approval-policy claims from `internal/ai/tools/registry.go` and + `internal/ai/tools/executor.go` instead of maintaining hand-written + prompt-only tool lists, and frontend approval cards must surface backend + approval risk/description without hiding a pending approval when skip or + deny fails. +9. Keep self-hosted Patrol quickstart messaging aligned with backend runtime truth: the governed quickstart contract is Patrol-only first-run acceleration on activated or trial-backed installs with server-authoritative run inventory, not a general hosted chat entitlement or a replacement for BYOK once Patrol leaves the quickstart path. -9. Keep discovery-analysis prompt bounds and response budgets aligned across +10. Keep discovery-analysis prompt bounds and response budgets aligned across `internal/ai/service.go` and the shared service-discovery prompt builders: the runtime must reserve enough output tokens for structured discovery JSON, and discovery prompts must cap fact/path/port fan-out explicitly instead of diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 38308bf57..75774b576 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -287,6 +287,11 @@ regression protection. second resource scan, alert fetch, storage read, or recovery read just to clarify first-viewport copy. 32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for launcher, drawer, session-menu, and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell. Blocking shared dialogs must also suppress closed assistant affordances through the shared dialog runtime instead of leaving the mounted shell clickable behind another overlay. + Approval presentation inside that mounted assistant shell must stay + state-local to the existing drawer/session state and backend approval + endpoints. Deny/skip failure handling may preserve the pending approval + card, but it must not add polling, resource hydration, or mounted-shell work + to recover UI state. That same mounted-shell hot path must protect usable width on constrained viewports. When the shared assistant drawer opens inside `frontend-modern/src/components/AI/Chat/index.tsx`, it may not shrink the diff --git a/frontend-modern/src/components/AI/Chat/ApprovalCard.tsx b/frontend-modern/src/components/AI/Chat/ApprovalCard.tsx index 2b8487f08..6f3c6cc17 100644 --- a/frontend-modern/src/components/AI/Chat/ApprovalCard.tsx +++ b/frontend-modern/src/components/AI/Chat/ApprovalCard.tsx @@ -7,6 +7,11 @@ interface ApprovalCardProps { onSkip: () => void; } +const riskLabel = (risk?: string) => { + const normalized = risk?.trim(); + return normalized ? normalized.toUpperCase() : 'REVIEW'; +}; + export const ApprovalCard: Component = (props) => { return (
@@ -23,6 +28,9 @@ export const ApprovalCard: Component = (props) => {
Approval Required + + {riskLabel(props.approval.risk)} + Agent @@ -37,6 +45,31 @@ export const ApprovalCard: Component = (props) => { {/* Command */}
+ +

+ {props.approval.description} +

+
+ +
+
+
Tool
+
{props.approval.toolName}
+
+
+
Target
+
+ {props.approval.targetHost || 'Pulse runtime'} +
+
+
+
Execution
+
+ {props.approval.runOnHost ? 'Agent routed' : 'Pulse API'} +
+
+
+
{props.approval.command} 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 fe1cfbe92..001f801bb 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -438,7 +438,7 @@ describe('AIChat', () => { it('opens control menu on click', () => { renderChat(); fireEvent.click(screen.getByTitle('Control mode')); - expect(screen.getByText('Control mode for this chat')).toBeInTheDocument(); + 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 (Pro)')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ApprovalCard.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ApprovalCard.test.tsx index 9c897d9bd..06ca38e1c 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ApprovalCard.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ApprovalCard.test.tsx @@ -196,4 +196,26 @@ describe('ApprovalCard', () => { expect(screen.getByText(longCommand)).toBeInTheDocument(); }); + + it('renders governed action context when provided', () => { + render(() => ( + + )); + + expect(screen.getByText('HIGH')).toBeInTheDocument(); + expect( + screen.getByText('Restart the web service after applying the new config.'), + ).toBeInTheDocument(); + expect(screen.getByText('pulse_control')).toBeInTheDocument(); + expect(screen.getByText('web1')).toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 5b1b3aec5..b4a584a7a 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -551,6 +551,8 @@ describe('useChat', () => { tool_name: 'run_command', run_on_host: true, target_host: 'web1', + risk: 'high', + description: 'Restart web service', approval_id: 'appr-5', }, }); @@ -563,6 +565,8 @@ describe('useChat', () => { toolName: 'run_command', runOnHost: true, targetHost: 'web1', + risk: 'high', + description: 'Restart web service', isExecuting: false, approvalId: 'appr-5', }); @@ -1128,7 +1132,7 @@ describe('useChat', () => { dispose(); }); - it('re-initiates stream when idle after answering (reconnection path)', async () => { + it('does not start a blank follow-up stream when idle after answering', async () => { let fireEvent!: TestStreamDispatch; let chatCallCount = 0; mockChat.mockImplementation( @@ -1156,12 +1160,7 @@ describe('useChat', () => { await chat.answerQuestion(msgId, 'q-50', [{ id: 'q1', value: 'yes' }]); - // Should have called chat again with empty prompt for reconnection - expect(chatCallCount).toBeGreaterThan(chatCallsBefore); - // The reconnection call should use empty prompt and same session - const reconnectCall = mockChat.mock.calls[mockChat.mock.calls.length - 1]; - expect(reconnectCall[0]).toBe(''); - expect(reconnectCall[1]).toBe('s'); + expect(chatCallCount).toBe(chatCallsBefore); dispose(); }); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 9321a6778..5b82970ab 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -7,6 +7,7 @@ import type { ChatMessage, ToolExecution, StreamDisplayEvent, + PendingApproval, PendingQuestion, PendingTool, } from '../types'; @@ -230,7 +231,9 @@ export function useChat(options: UseChatOptions = {}) { // Find the matching pending tool (prefer tool ID, then fall back to name). const resolvedPendingIndex = data.id ? pendingTools.findIndex((t) => t.id === data.id) - : pendingTools.findIndex((t) => normalizeChatToolName(t.name) === normalizedEndName); + : pendingTools.findIndex( + (t) => normalizeChatToolName(t.name) === normalizedEndName, + ); const updatedPending = resolvedPendingIndex >= 0 ? [ @@ -320,10 +323,12 @@ export function useChat(options: UseChatOptions = {}) { tool_name: string; run_on_host: boolean; target_host?: string; + risk?: string; + description?: string; approval_id?: string; }; - const approval = { + const approval: PendingApproval = { command: data.command, toolId: data.tool_id, toolName: data.tool_name, @@ -332,6 +337,12 @@ export function useChat(options: UseChatOptions = {}) { isExecuting: false, approvalId: data.approval_id, }; + if (typeof data.risk === 'string') { + approval.risk = data.risk; + } + if (typeof data.description === 'string') { + approval.description = data.description; + } // Add to streamEvents for chronological display const updated = addStreamEvent(msg, { type: 'approval', approval }); @@ -679,44 +690,6 @@ export function useChat(options: UseChatOptions = {}) { // Remove the question card - it's been handled updateQuestion(messageId, questionId, { removed: true }); - // After answering, check if the stream is still active. - // If it closed (e.g. on question), we force a re-connection to receive continuation events. - if (!isLoading()) { - logger.debug('[useChat] Stream closed, re-initiating to catch continuation', { - questionId, - messageId, - }); - - const currentSessionId = sessionId(); - if (currentSessionId) { - setIsLoading(true); - abortControllerRef = new AbortController(); - - // Set the message back to streaming state to show the AI is working - setMessages((prev) => - prev.map((m) => (m.id === messageId ? { ...m, isStreaming: true } : m)), - ); - - AIChatAPI.chat( - '', // Empty prompt - just resume listening for completion - currentSessionId, - model() || undefined, - (event) => { - processEvent(messageId, event); - }, - abortControllerRef.signal, - ) - .catch((err) => { - if (err instanceof Error && err.name === 'AbortError') return; - logger.error('[useChat] Re-connection failed:', err); - }) - .finally(() => { - setIsLoading(false); - abortControllerRef = null; - }); - } - } - logger.debug('[useChat] Question answered, waiting for AI to continue', { questionId, }); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index aae2db29e..a5d87389f 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -818,8 +818,7 @@ export const AIChat: Component = (props) => { chat.updateApproval(messageId, toolId, { removed: true }); } catch (error) { logger.error('[AIChat] Skip/deny failed:', error); - // Still remove from UI even if API fails - chat.updateApproval(messageId, toolId, { removed: true }); + notificationStore.error('Failed to skip approval'); } }; @@ -929,7 +928,7 @@ export const AIChat: Component = (props) => {
- Control mode for this chat + Default control mode