Invalidate stale Assistant handoff context

This commit is contained in:
rcourtman 2026-05-06 19:36:05 +01:00
parent 50fee63c9e
commit 2832bd321e
13 changed files with 241 additions and 8 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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",