diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e84b10441..9de068412 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -364,7 +364,9 @@ profile and assignment columns, but embedded table framing must route through stores the originating finding ID to refresh the current unified finding and investigation record on follow-up turns, that stored reference remains an AI/runtime context selector and still cannot become agent enrollment, - lifecycle, or command-websocket authority. Structured Assistant handoff action + lifecycle, or command-websocket authority. Clearing that stored handoff when + the finding no longer resolves is also AI/runtime invalidation, not an agent + lifecycle transition. Structured Assistant handoff action references from the same Patrol finding remain AI/runtime review metadata only; lifecycle code must not treat approval IDs, fix IDs, risk, or target-resource labels from that handoff as agent command grants, enrollment diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 2be3095bd..f8cde71dc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -189,7 +189,11 @@ runtime cost control, and shared AI transport surfaces. persist as session model-context metadata so follow-up turns can refresh the current unified finding and investigation record before model execution; those references remain model-only context selectors, not saved user text or - lifecycle authority. Assistant runtime may also hydrate canonical + lifecycle authority. If the referenced finding no longer resolves through + the current unified finding store, Assistant must invalidate the stored + model-only handoff and unpinned handoff-seeded resource scope instead of + falling back to stale investigation context. Assistant runtime may also + hydrate canonical resource-policy context for those handoff resources, using the same unified-resource resolution and policy presentation helpers that govern mention prefetch and provider-bound redaction; that context remains diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e8fa9ac14..c332da73e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -760,7 +760,10 @@ the canonical monitored-system blocked payload. finding ID as model-context metadata so later turns in the same Assistant session can refresh the current unified finding and investigation record before model execution; that reference is not lifecycle authority and must not - be persisted as user text. Chat execution may also hydrate canonical + be persisted as user text. If that reference no longer resolves through the + current unified finding store, the API/chat boundary must clear the stored + handoff context and unpinned handoff-seeded resource scope rather than + replaying stale investigation state. Chat execution may also hydrate canonical resource-policy context for those resources through unified-resource resolution and shared policy presentation helpers, but the resulting handling guidance is read-only, model-only context and must not become saved user text, diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8a24ade4a..69622bf43 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -113,9 +113,11 @@ Patrol-specific presentation helpers. resource-policy guidance, canonical resource-relationship context, and recent canonical resource-timeline changes for explanation; Assistant resolves those timeline lookups through the current canonical unified-resource model before - falling back to handoff IDs. Patrol must keep the visible finding and drawer - briefing tied to the shared investigation payload rather than forking a - Patrol-local lifecycle, policy, topology, or timeline summary. + falling back to handoff IDs. If the referenced finding is no longer current, + Assistant must drop the stored handoff instead of continuing from stale + Patrol context. Patrol must keep the visible finding and drawer briefing tied + to the shared investigation payload rather than forking a Patrol-local + lifecycle, policy, topology, or timeline summary. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 15bb2cc4a..47696c13d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -420,8 +420,11 @@ bypass the API fail-closed execution gate. unified finding and investigation-record context on follow-up turns, that stored reference is still an adjacent AI/runtime context selector and must not become backup freshness, restore support, recovery execution authority, - or storage-local lifecycle state. Structured action or approval references - carried by that handoff are also adjacent AI/runtime review metadata only; + or storage-local lifecycle state. Clearing that handoff when the current + finding no longer resolves is adjacent AI/runtime invalidation, not a + recovery freshness or restore-support decision. Structured action or + approval references carried by that handoff are also adjacent AI/runtime + review metadata only; storage and recovery surfaces must not reinterpret approval IDs, fix IDs, risk, or target labels as restore support, backup freshness, recovery execution authority, or a storage-local approval bypass. Any refreshed diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 06f933862..45f98c678 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -1610,6 +1610,24 @@ func (s *Service) GetModelHandoffFindingID(ctx context.Context, sessionID string return sessions.GetModelHandoffFindingID(sessionID) } +// ClearModelHandoffContext invalidates product-originated model-only handoff +// state after its source record can no longer be resolved. Unpinned resolved +// resources are cleared with it so stale Patrol handoffs cannot remain action +// scope on follow-up turns. +func (s *Service) ClearModelHandoffContext(ctx context.Context, sessionID string) error { + s.mu.RLock() + sessions := s.sessions + s.mu.RUnlock() + + if sessions == nil { + return fmt.Errorf("service not started") + } + + err := sessions.ClearModelHandoffContext(sessionID) + sessions.ClearSessionState(sessionID, true) + return err +} + // AbortSession aborts an ongoing session func (s *Service) AbortSession(ctx context.Context, sessionID string) error { s.mu.RLock() diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 6cda82d89..363d122b7 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -800,6 +800,66 @@ func TestRefreshHandoffActionApprovalStatusRejectsCrossOrgApproval(t *testing.T) } } +func TestService_ClearModelHandoffContextInvalidatesUnpinnedActionScope(t *testing.T) { + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + session, err := store.Create() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + if err := store.SetModelHandoffFindingID(session.ID, "finding-123"); err != nil { + t.Fatalf("SetModelHandoffFindingID failed: %v", err) + } + if err := store.SetModelHandoffContext(session.ID, "[Finding Context]\nID: finding-123"); err != nil { + t.Fatalf("SetModelHandoffContext failed: %v", err) + } + if err := store.SetModelHandoffResources(session.ID, []HandoffResource{{ + ID: "vm-100", + Name: "web-server", + Type: "vm", + Node: "pve-1", + }}); err != nil { + t.Fatalf("SetModelHandoffResources failed: %v", err) + } + + resolvedCtx := store.GetResolvedContext(session.ID) + resolvedCtx.AddResolvedResource(tools.ResourceRegistration{ + Kind: "vm", + ProviderUID: "100", + HostName: "pve-1", + Name: "web-server", + }) + resolvedCtx.AddResolvedResource(tools.ResourceRegistration{ + Kind: "vm", + ProviderUID: "101", + HostName: "pve-1", + Name: "pinned-vm", + }) + resolvedCtx.PinResource("vm:pve-1:101") + + svc := &Service{ + sessions: store, + started: true, + } + if err := svc.ClearModelHandoffContext(context.Background(), session.ID); err != nil { + t.Fatalf("ClearModelHandoffContext failed: %v", err) + } + + if got, err := store.GetModelHandoffFindingID(session.ID); err != nil { + t.Fatalf("GetModelHandoffFindingID failed: %v", err) + } else if got != "" { + t.Fatalf("handoff finding ID after clear = %q, want empty", got) + } + if _, ok := resolvedCtx.GetResourceByID("vm:pve-1:100"); ok { + t.Fatalf("stale handoff resource remained in resolved context") + } + if _, ok := resolvedCtx.GetResourceByID("vm:pve-1:101"); !ok { + t.Fatalf("pinned user resource should survive handoff invalidation") + } +} + func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing.T) { tmpDir := t.TempDir() store, err := NewSessionStore(tmpDir) diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index 136db877c..63040efc3 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -572,6 +572,15 @@ func (s *SessionStore) clearModelHandoffContextLocked(id string) error { return s.writeSession(*data) } +// ClearModelHandoffContext removes product-originated model-only handoff +// metadata while leaving the user-authored message history intact. +func (s *SessionStore) ClearModelHandoffContext(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.clearModelHandoffContextLocked(id) +} + // UpdateLastMessage updates the last message in a session (for streaming updates) func (s *SessionStore) UpdateLastMessage(id string, msg Message) error { s.mu.Lock() diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index d70313d42..47a8c52c2 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -318,6 +318,74 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) { } } +func TestSessionStore_ClearModelHandoffContext(t *testing.T) { + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + session, err := store.Create() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + if err := store.SetModelHandoffFindingID(session.ID, "finding-123"); err != nil { + t.Fatalf("SetModelHandoffFindingID failed: %v", err) + } + if err := store.SetModelHandoffContext(session.ID, "[Finding Context]\nID: finding-123"); err != nil { + t.Fatalf("SetModelHandoffContext failed: %v", err) + } + if err := store.SetModelHandoffResources(session.ID, []HandoffResource{{ + ID: "vm-100", + Name: "web-server", + Type: "vm", + }}); err != nil { + t.Fatalf("SetModelHandoffResources failed: %v", err) + } + if err := store.SetModelHandoffActions(session.ID, []HandoffAction{{ + FindingID: "finding-123", + ApprovalID: "approval-123", + FixID: "fix-123", + }}); err != nil { + t.Fatalf("SetModelHandoffActions failed: %v", err) + } + if err := store.AddMessage(session.ID, Message{Role: "user", Content: "What changed?"}); err != nil { + t.Fatalf("AddMessage failed: %v", err) + } + + if err := store.ClearModelHandoffContext(session.ID); err != nil { + t.Fatalf("ClearModelHandoffContext failed: %v", err) + } + + if got, err := store.GetModelHandoffFindingID(session.ID); err != nil { + t.Fatalf("GetModelHandoffFindingID failed: %v", err) + } else if got != "" { + t.Fatalf("handoff finding ID after clear = %q, want empty", got) + } + if got, err := store.GetModelHandoffContext(session.ID); err != nil { + t.Fatalf("GetModelHandoffContext failed: %v", err) + } else if got != "" { + t.Fatalf("handoff context after clear = %q, want empty", got) + } + if got, err := store.GetModelHandoffResources(session.ID); err != nil { + t.Fatalf("GetModelHandoffResources failed: %v", err) + } else if len(got) != 0 { + t.Fatalf("handoff resources after clear = %#v, want empty", got) + } + if got, err := store.GetModelHandoffActions(session.ID); err != nil { + t.Fatalf("GetModelHandoffActions failed: %v", err) + } else if len(got) != 0 { + t.Fatalf("handoff actions after clear = %#v, want empty", got) + } + + messages, err := store.GetMessages(session.ID) + if err != nil { + t.Fatalf("GetMessages failed: %v", err) + } + if len(messages) != 1 || messages[0].Content != "What changed?" { + t.Fatalf("messages after handoff clear = %#v, want user history retained", messages) + } +} + func TestSessionStore_ResolvedContextLifecycle(t *testing.T) { store, err := NewSessionStore(t.TempDir()) if err != nil { diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 808e345c0..55f27e088 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -44,6 +44,7 @@ type AIService interface { DeleteSession(ctx context.Context, sessionID string) error GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) + ClearModelHandoffContext(ctx context.Context, sessionID string) error AbortSession(ctx context.Context, sessionID string) error SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) GetSessionDiff(ctx context.Context, sessionID string) (map[string]interface{}, error) @@ -1185,14 +1186,24 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) { } } if findingID != "" { + findingResolved := false store := h.GetUnifiedStoreForOrg(GetOrgID(ctx)) if store != nil { if f := store.Get(findingID); f != nil { + findingResolved = true handoffContext = buildUnifiedFindingChatContext(f) handoffResources = buildUnifiedFindingHandoffResources(f) handoffActions = buildUnifiedFindingHandoffActions(f) } } + if !findingResolved { + if sessionID := strings.TrimSpace(req.SessionID); sessionID != "" { + if err := svc.ClearModelHandoffContext(ctx, sessionID); err != nil { + log.Debug().Err(err).Str("session_id", sessionID).Str("finding_id", findingID).Msg("Unable to clear stale Assistant finding handoff context") + } + } + findingID = "" + } } // Stream from AI chat service diff --git a/internal/api/ai_handler_recovery_wiring_test.go b/internal/api/ai_handler_recovery_wiring_test.go index a04c0e8bb..32b4e3f5f 100644 --- a/internal/api/ai_handler_recovery_wiring_test.go +++ b/internal/api/ai_handler_recovery_wiring_test.go @@ -46,6 +46,9 @@ func (s *capturingAIService) GetMessages(ctx context.Context, sessionID string) func (s *capturingAIService) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) { return "", nil } +func (s *capturingAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error { + return nil +} func (s *capturingAIService) AbortSession(ctx context.Context, sessionID string) error { return nil } func (s *capturingAIService) SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error) { return nil, nil diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index 22215120d..c5d607058 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -93,6 +93,16 @@ func (m *MockAIService) GetModelHandoffFindingID(ctx context.Context, sessionID return "", nil } +func (m *MockAIService) ClearModelHandoffContext(ctx context.Context, sessionID string) error { + for _, call := range m.ExpectedCalls { + if call.Method == "ClearModelHandoffContext" { + args := m.Called(ctx, sessionID) + return args.Error(0) + } + } + return nil +} + func (m *MockAIService) AbortSession(ctx context.Context, sessionID string) error { args := m.Called(ctx, sessionID) return args.Error(0) @@ -738,6 +748,43 @@ func TestHandleChat_RefreshesStoredFindingContextForFollowUp(t *testing.T) { mockSvc.AssertExpectations(t) } +func TestHandleChat_ClearsStoredFindingContextWhenFollowUpFindingMissing(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + h.SetUnifiedStore(unified.NewUnifiedStore(unified.DefaultAlertToFindingConfig())) + + mockSvc.On("IsRunning").Return(true) + mockSvc. + On("GetModelHandoffFindingID", mock.Anything, "session-123"). + Return("finding-missing", nil) + mockSvc. + On("ClearModelHandoffContext", mock.Anything, "session-123"). + Return(nil) + mockSvc. + On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + reqArg := args.Get(1).(chat.ExecuteRequest) + assert.Equal(t, "session-123", reqArg.SessionID) + assert.Equal(t, "", reqArg.FindingID) + assert.Equal(t, "What changed?", reqArg.Prompt) + assert.Equal(t, "", reqArg.HandoffContext) + assert.Empty(t, reqArg.HandoffResources) + assert.Empty(t, reqArg.HandoffActions) + }) + + body := `{"prompt":"What changed?","session_id":"session-123"}` + req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.HandleChat(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockSvc.AssertExpectations(t) +} + func TestHandleChat_DropsLegacyMentionTypes(t *testing.T) { cfg := &config.Config{} h := newTestAIHandler(cfg, nil, nil) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index fcf4d9a25..37c161a48 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -141,6 +141,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { handlerText := string(handlerSource) for _, required := range []string{ `svc.GetModelHandoffFindingID(ctx, req.SessionID)`, + `svc.ClearModelHandoffContext(ctx, sessionID)`, "handoffContext = buildUnifiedFindingChatContext(f)", "handoffResources = buildUnifiedFindingHandoffResources(f)", "handoffActions = buildUnifiedFindingHandoffActions(f)", @@ -162,6 +163,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "handoffContext := strings.TrimSpace(req.HandoffContext)", "handoffFindingID := strings.TrimSpace(req.FindingID)", "sessions.SetModelHandoffFindingID(session.ID, handoffFindingID)", + "ClearModelHandoffContext", "handoffResources := normalizeHandoffResources(req.HandoffResources)", "sessions.SetModelHandoffContext(session.ID, handoffContext)", "GetModelHandoffFindingID", @@ -196,6 +198,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "ModelContext *sessionModelContext", "SetModelHandoffFindingID", "GetModelHandoffFindingID", + "ClearModelHandoffContext", "HandoffFindingID string", "SetModelHandoffContext", "GetModelHandoffContext",