mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Invalidate stale Assistant handoff context
This commit is contained in:
parent
50fee63c9e
commit
2832bd321e
13 changed files with 241 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue