diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 390c0b0fe..88560b066 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -279,7 +279,11 @@ runtime cost control, and shared AI transport surfaces. and action counts, a primary resource label, last-known approval/action status, risk level, and timestamp, but it must not expose model-only handoff text, action preflight/result bodies, remediation descriptions, raw - commands, or approval command payloads. + commands, or approval command payloads. Its `requires_approval` field is a + current operator-decision flag only: pending approval states may set it, but + approved, denied, rejected, executing, completed, failed, expired, or + otherwise historical action references must remain action context without + being relabeled as requiring approval. When the Assistant drawer restores any session from that `handoff_summary`, it must restore the scoped request-local approval boundary as well as the safe visible briefing: the next chat turn must carry diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 829760651..03d310041 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -186,7 +186,10 @@ work extends shared components instead of creating new local variants. `handoffContext`, `handoffResources`, and `handoffActions` while preserving the safe visible briefing and scoped approval-required posture, so later turns rely on backend session hydration instead of resending stale browser - context. + context. The drawer must treat `handoff_summary.requires_approval` as a + current pending-decision flag, not a historical action marker, so completed + or rejected handoff actions render as action context rather than pending + approval. 9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary. 10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary. That shared boundary must preserve `availability` as the agentless 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 37d42e8a3..07190201c 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -700,6 +700,67 @@ describe('AIChat', () => { expect(restoredContext.handoffActions).toBeUndefined(); }); + it('shows completed Patrol actions as action context instead of pending approval', async () => { + mockAIChatAPI.listSessions.mockResolvedValue([ + { + id: 's-patrol-complete', + title: 'Completed remediation follow-up', + created_at: '', + updated_at: '', + message_count: 6, + handoff_summary: { + kind: 'patrol_finding', + finding_id: 'finding-complete', + has_model_context: true, + resource_count: 1, + primary_resource: { + id: 'host:web-server', + name: 'web-server', + type: 'host', + node: 'pve-1', + }, + action_count: 1, + requires_approval: false, + last_known_approval_status: 'approved', + last_known_action_state: 'completed', + last_known_action_risk: 'medium', + }, + }, + ]); + + renderChat(); + await waitFor(() => { + expect(mockAIChatAPI.listSessions).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByTitle('Pulse Assistant sessions')); + + await waitFor(() => { + expect(screen.getByText('Pulse Patrol')).toBeInTheDocument(); + expect(screen.getByText('Action context')).toBeInTheDocument(); + expect(screen.getByText(/approval approved/)).toBeInTheDocument(); + expect(screen.queryByText('Approval required')).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Completed remediation follow-up')); + + await waitFor(() => { + expect(mockAiChatStore.setContext).toHaveBeenCalledWith( + expect.objectContaining({ + findingId: 'finding-complete', + autonomousMode: false, + context: expect.objectContaining({ + requiresApproval: false, + lastKnownActionState: 'completed', + }), + briefing: expect.objectContaining({ + actionLabel: 'Governed action context', + commandSummary: expect.stringContaining('action completed'), + }), + }), + ); + }); + }); + it('keeps restored Patrol handoffs approval-bound without queued actions', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'gpt-4', diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index e9a4e9a94..2bbe1c971 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -268,8 +268,8 @@ func TestService_ListSessionsRefreshesHandoffActionSummary(t *testing.T) { if summary.LastKnownActionState != string(unifiedresources.ActionStateCompleted) { t.Fatalf("action state = %q, want completed", summary.LastKnownActionState) } - if !summary.RequiresApproval || summary.ActionCount != 1 { - t.Fatalf("action summary = %#v, want approval-required action context", summary) + if summary.RequiresApproval || summary.ActionCount != 1 { + t.Fatalf("action summary = %#v, want completed action context without approval requirement", summary) } actions, err := store.GetModelHandoffActions(session.ID) diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index f81d86411..fee9e80b1 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -151,6 +151,25 @@ const ( sessionHandoffKindScopedContext = "scoped_context" ) +func handoffActionCurrentlyRequiresApproval(action HandoffAction) bool { + approvalStatus := strings.ToLower(strings.TrimSpace(action.ApprovalStatus)) + actionState := strings.ToLower(strings.TrimSpace(action.ActionState)) + + if approvalStatus == "pending" && !action.ApprovalConsumed { + return true + } + switch actionState { + case "pending_approval", "awaiting_approval": + return approvalStatus == "" || approvalStatus == "pending" + case "approved", "rejected", "executing", "completed", "failed", "planned": + return false + } + if approvalStatus != "" || strings.TrimSpace(action.ApprovalID) != "" { + return false + } + return action.ActionRequiresApproval +} + func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHandoffSummary { if modelContextEmpty(modelContext) { return nil @@ -189,7 +208,7 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando summary.PrimaryResource = &primaryResource } for _, action := range actions { - if !summary.RequiresApproval && (action.ActionRequiresApproval || strings.TrimSpace(action.ApprovalID) != "") { + if !summary.RequiresApproval && handoffActionCurrentlyRequiresApproval(action) { summary.RequiresApproval = true } if summary.LastKnownApprovalStatus == "" { diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index cbc8a8177..7676f9b7e 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -419,6 +419,76 @@ func TestSessionStore_ListIncludesSafeHandoffSummary(t *testing.T) { } } +func TestModelContextHandoffSummaryRequiresApprovalOnlyForCurrentPendingApproval(t *testing.T) { + tests := []struct { + name string + action HandoffAction + requiresApproval bool + }{ + { + name: "pending approval", + action: HandoffAction{ + ApprovalID: "approval-pending", + ApprovalStatus: "pending", + ActionState: "pending_approval", + ActionRequiresApproval: true, + }, + requiresApproval: true, + }, + { + name: "legacy awaiting approval state", + action: HandoffAction{ + ApprovalID: "approval-awaiting", + ActionState: "awaiting_approval", + ActionRequiresApproval: true, + }, + requiresApproval: true, + }, + { + name: "approved completed action", + action: HandoffAction{ + ApprovalID: "approval-approved", + ApprovalStatus: "approved", + ActionState: "completed", + ActionRequiresApproval: true, + }, + requiresApproval: false, + }, + { + name: "denied historical action", + action: HandoffAction{ + ApprovalID: "approval-denied", + ApprovalStatus: "denied", + ActionState: "rejected", + ActionRequiresApproval: true, + }, + requiresApproval: false, + }, + { + name: "approval reference without current pending state", + action: HandoffAction{ + ApprovalID: "approval-unknown", + ActionRequiresApproval: true, + }, + requiresApproval: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := modelContextHandoffSummary(&sessionModelContext{ + HandoffActions: []HandoffAction{tt.action}, + }) + if summary == nil { + t.Fatalf("summary is nil") + } + if summary.RequiresApproval != tt.requiresApproval { + t.Fatalf("requires approval = %v, want %v; summary=%#v", summary.RequiresApproval, tt.requiresApproval, summary) + } + }) + } +} + func TestSessionStore_ClearModelHandoffContext(t *testing.T) { store, err := NewSessionStore(t.TempDir()) if err != nil {