Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions docs/plans/418-watcher-deltas-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 418 — Watcher Deltas + Chat Sessions

## Problem

The watcher re-investigates every incident on every PagerDuty refresh,
even when nothing has changed. This wastes AI tokens, floods the user
with duplicate assessments, and makes the investigation log noisy.
Additionally, investigations are scoped to the UI-selected incident
(`m.selectedIncident`) rather than the incident that actually triggered
the observation, creating identity mismatches.

## Approach

Incident-state diffing as the primary investigation gate, scoped
investigations, and the Chat interface abstraction.

### Key decisions

- **In-memory only.** No TurnStore, JSONL persistence, or file I/O.
Restarting srepd = fresh start. Keeps Diff pure and storage-agnostic.
- **Design choice (c) for context:** triggering incidents are
foregrounded in the observation context; sibling alerts appear as
background in a queue summary.
- **Delta gate is primary, cooldown is secondary.** If incident state
hasn't changed (`len(changes) == 0`), detectors don't run at all.
Cooldown-based dedup remains as a secondary rate limiter for cases
where an unrelated field changes.
- **First-sighting semantics:** a new incident with no prior snapshot
counts as changed (IncidentNew), so the first poll always triggers
investigation.

## Deliverables

| ID | Summary | Files | Commit |
|----|---------|-------|--------|
| D1a | Pure `Diff(prev, curr)` function, `Narrate`, `Snapshot` types | `pkg/delta/delta.go`, `pkg/delta/delta_test.go` | `df22984` |
| D1b | Wire delta into watcher, gate investigation on changes | `pkg/tui/watcher.go`, `pkg/tui/tui.go`, `pkg/tui/model.go`, `pkg/tui/watcher_integration_test.go` | `5883b99` |
| D2 | Scope investigations to triggering incident, not UI selection | `pkg/tui/watcher.go`, `pkg/tui/investigation.go`, `pkg/tui/model.go`, `pkg/tui/tui.go`, `pkg/tui/watcher_test.go`, `pkg/tui/investigation_test.go`, `pkg/tui/ask_wiring_test.go`, `pkg/tui/approvals_update_test.go` | `e0c41c2` |
| D3 | Feed delta changes into investigation seed context | `pkg/tui/watcher.go` (via `delta.Narrate`) | `5883b99` |
| D4 | Land Chat interface in pkg/ai (optional-interface pattern) | `pkg/ai/provider.go`, `pkg/ai/provider_test.go` | `4c56dc0` |
| D5 | Add `get_recent_events` tool as ClassRead | `pkg/ai/tools/handlers.go`, `pkg/ai/tools/handlers_test.go`, `pkg/tui/model.go` | `47046e9` |

## Delta change kinds

| Kind | When |
|------|------|
| IncidentNew | ID exists in current but not previous |
| IncidentResolved | ID exists in previous but not current |
| StatusChanged | Status field differs |
| UrgencyChanged | Urgency field differs |
| NoteAdded | Note count increased (skipped when previous count unknown) |
| AlertAdded | Alert count increased (skipped when previous count unknown) |
| IncidentUpdated | Title or service changed |

**Removed:** `Escalated` — the PagerDuty `Incident` struct on the list
response has no `escalation_level` field (that field exists only on
`IncidentAlert`). The level was hardcoded to 0, so `Escalated` could
never fire.

## Chat interface (D4)

Follows the optional-interface pattern established by `StreamingProvider`:

```go
type Chat interface {
Send(ctx context.Context, userMsg string) (string, error)
History() []Turn
}
```

Helper functions `SupportsChat(p Provider)` and `AsChat(p Provider)`
use type assertions — no provider is forced to implement Chat.

## Test coverage

- `pkg/delta`: 16 table-driven subtests covering all change kinds,
first sighting, reordering, empty inputs, escalation decrease
(no-change), and Narrate formatting
- `pkg/tui`: `TestDeltaGate_IdenticalRefreshesProduceOneInvestigation`,
`TestBuildObservationContext_ScopedToTriggeringIncident`,
`TestBuildObservationContext_MultipleTriggering`,
`TestBuildObservationContext_EmptyIncidentIDs`,
`TestBuildAskFromVerdict_UsesOriginatingIncident`
- `pkg/ai`: `TestSupportsChat`, `TestAsChat`
- `pkg/ai/tools`: `TestGetRecentEvents_{HappyPath,EmptyChanges,WithLimit,InvalidInput,IsClassRead}`

## Revert-check properties

1. **D1 delta gate:** identical refreshes produce zero changes →
`runDetectors` returns nil (test: `TestDeltaGate_IdenticalRefreshesProduceOneInvestigation`)
2. **D2 scoping:** observation context foregrounds triggering incidents,
not UI selection (test: `TestBuildObservationContext_ScopedToTriggeringIncident`)
3. **D5 get_recent_events:** tool is ClassRead and returns expected
JSON (test: `TestGetRecentEvents_IsClassRead`)

## Post-review fixes (PR #427)

| Defect | Fix | Tests |
|--------|-----|-------|
| M1: delta gate suppression untested | Added `TestRunDetectors_DeltaGateBothDirections` exercising both directions | Revert check: `if false &&` → test fails |
| M2: lazy cache false-change burst | Changed `NoteCount`/`AlertCount` to `*int`; nil = unknown, skip comparison | `TestToSnapshots_UnloadedCacheSuppressesFalseChanges`, `TestToSnapshots_GenuineNoteAdditionAfterCacheLoad`, `TestDiff_NilNoteCountSkipsComparison`, `TestDiff_ZeroToNonZeroNoteCountDetected` |
| M3: `incidentList < 2` guard untested | Added `TestRunDetectors_SingleIncidentNoInvestigation` | Guard is redundant with detector thresholds (defense in depth) |
| N1: EscalationLevel hardcoded to 0 | Removed `Escalated` kind + `EscalationLevel` from Snapshot | PagerDuty Incident has no escalation_level on list response |
| N2: ClusterID set but never compared | Removed from Snapshot | Was never populated by toSnapshots |
| N3: watcherDedup.seen unbounded | Added eviction when map exceeds 100 entries | `TestWatcherDedup/evicts_expired_entries_when_threshold_exceeded` |
| N4: Title/Service never compared | Added comparison, new `IncidentUpdated` kind | `TestDiff_TitleChanged`, `TestDiff_ServiceChanged` |

**Dedup + delta coexistence:** the delta layer gates on whether incident
STATE changed; the dedup layer gates on whether the same OBSERVATION TEXT
was already investigated within the cooldown window. Both are needed.

## Constraints

- No new Go modules added
- No `//nolint` directives
- No `replace` directives or vendoring
- Pre-existing `cmd/` test failure (missing config keys) is not caused
by these changes
36 changes: 36 additions & 0 deletions pkg/ai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,42 @@ func ResolvedModel(p Provider) string {
return mr.Model()
}

// Chat is an optional interface a Provider may implement to support multi-turn
// conversations with accumulated history. Providers that do not implement Chat
// fall back to single-shot Query calls. The watcher uses this to maintain
// session continuity ("this is the same cluster I flagged 20 minutes ago").
type Chat interface {
Send(ctx context.Context, userMsg string) (string, error)
History() []Turn
}

// Turn represents a single message in a Chat history.
type Turn struct {
Role string // "user" or "assistant"
Content string
}

// SupportsChat reports whether p implements the Chat interface.
func SupportsChat(p Provider) bool {
if p == nil {
return false
}
_, ok := p.(Chat)
return ok
}

// AsChat returns p as a Chat if it implements the interface, or nil.
func AsChat(p Provider) Chat {
if p == nil {
return nil
}
c, ok := p.(Chat)
if !ok {
return nil
}
return c
}

// Config holds the configuration for an LLM API provider.
type Config struct {
Provider string `mapstructure:"provider"`
Expand Down
39 changes: 39 additions & 0 deletions pkg/ai/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,45 @@ func TestResolvedModel(t *testing.T) {
})
}

// chatProvider embeds a non-streaming provider and implements Chat.
type chatProvider struct{ nonStreamingProvider }

func (chatProvider) Send(_ context.Context, _ string) (string, error) {
return "reply", nil
}
func (chatProvider) History() []Turn { return nil }

func TestSupportsChat(t *testing.T) {
t.Run("provider without Chat is not chatty", func(t *testing.T) {
assert.False(t, SupportsChat(nonStreamingProvider{}))
})

t.Run("provider with Chat is chatty", func(t *testing.T) {
assert.True(t, SupportsChat(chatProvider{}))
})

t.Run("nil provider is not chatty", func(t *testing.T) {
assert.False(t, SupportsChat(nil))
})
}

func TestAsChat(t *testing.T) {
t.Run("returns Chat for implementing provider", func(t *testing.T) {
c := AsChat(chatProvider{})
assert.NotNil(t, c)
})

t.Run("returns nil for non-implementing provider", func(t *testing.T) {
c := AsChat(nonStreamingProvider{})
assert.Nil(t, c)
})

t.Run("returns nil for nil provider", func(t *testing.T) {
c := AsChat(nil)
assert.Nil(t, c)
})
}

func TestRealProviders_SupportStreaming(t *testing.T) {
// All shipped providers implement real streaming; they must advertise it so the
// TUI turns streaming on for them.
Expand Down
59 changes: 59 additions & 0 deletions pkg/ai/tools/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/PagerDuty/go-pagerduty"
"github.com/charmbracelet/log"
"github.com/clcollins/srepd/pkg/ai/policy"
"github.com/clcollins/srepd/pkg/delta"
"github.com/clcollins/srepd/pkg/ocm"
"github.com/clcollins/srepd/pkg/pd"
)
Expand Down Expand Up @@ -211,6 +212,64 @@ func newGetLimitedSupportTool(client ocm.OCMClient) Tool {
}
}

// RegisterDeltaTools registers the get_recent_events tool, which exposes
// recent incident-state changes to the AI. The getChanges function is called
// at handler time to read the current in-memory change log.
func RegisterDeltaTools(reg *Registry, getChanges func() []delta.Change) error {
return reg.Register(newGetRecentEventsTool(getChanges))
}

func newGetRecentEventsTool(getChanges func() []delta.Change) Tool {
return Tool{
Name: "get_recent_events",
Description: "Get recent incident-state change events (new, resolved, status/urgency changes, new alerts/notes)",
Class: policy.ClassRead,
Schema: []byte(`{"type":"object","properties":{"limit":{"type":"integer","description":"Maximum number of recent events to return (default 50, max 200)"}}}`),
Handler: func(_ context.Context, input json.RawMessage) (string, error) {
var params struct {
Limit int `json:"limit"`
}
if len(input) > 0 {
if err := json.Unmarshal(input, &params); err != nil {
return formatError("invalid input", err), nil
}
}
if params.Limit <= 0 {
params.Limit = 50
}
if params.Limit > 200 {
params.Limit = 200
}

changes := getChanges()
if len(changes) == 0 {
return "[]", nil
}

start := 0
if len(changes) > params.Limit {
start = len(changes) - params.Limit
}
recent := changes[start:]

type eventJSON struct {
Kind string `json:"kind"`
IncidentID string `json:"incident_id"`
Summary string `json:"summary"`
}
events := make([]eventJSON, 0, len(recent))
for _, c := range recent {
events = append(events, eventJSON{
Kind: c.Kind.String(),
IncidentID: c.IncidentID,
Summary: c.Summary,
})
}
return marshalResult(events)
},
}
}

func marshalResult(v any) (string, error) {
data, err := json.Marshal(v)
if err != nil {
Expand Down
69 changes: 69 additions & 0 deletions pkg/ai/tools/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package tools_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/clcollins/srepd/pkg/ai/policy"
"github.com/clcollins/srepd/pkg/ai/tools"
"github.com/clcollins/srepd/pkg/delta"
"github.com/clcollins/srepd/pkg/ocm"
"github.com/clcollins/srepd/pkg/pd"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -249,6 +252,72 @@ func TestHandler_FormatErrorIncludesClass(t *testing.T) {
assert.Contains(t, result, "(", "error string should include a parenthesized error class")
}

func TestGetRecentEvents_HappyPath(t *testing.T) {
changes := []delta.Change{
{Kind: delta.IncidentNew, IncidentID: "P1", Summary: "New incident: Alert A"},
{Kind: delta.StatusChanged, IncidentID: "P2", Summary: "Status changed: triggered → acknowledged"},
}
reg := tools.NewRegistry()
require.NoError(t, tools.RegisterDeltaTools(reg, func() []delta.Change { return changes }))

tool := findTool(t, reg, "get_recent_events")
result, err := tool.Handler(context.Background(), json.RawMessage(`{}`))
require.NoError(t, err)
assert.Contains(t, result, "P1")
assert.Contains(t, result, "P2")
assert.Contains(t, result, "new")
assert.Contains(t, result, "status_changed")
}

func TestGetRecentEvents_EmptyChanges(t *testing.T) {
reg := tools.NewRegistry()
require.NoError(t, tools.RegisterDeltaTools(reg, func() []delta.Change { return nil }))

tool := findTool(t, reg, "get_recent_events")
result, err := tool.Handler(context.Background(), json.RawMessage(`{}`))
require.NoError(t, err)
assert.Equal(t, "[]", result)
}

func TestGetRecentEvents_WithLimit(t *testing.T) {
var changes []delta.Change
for i := 0; i < 10; i++ {
changes = append(changes, delta.Change{
Kind: delta.IncidentNew,
IncidentID: fmt.Sprintf("P%d", i),
Summary: fmt.Sprintf("Event %d", i),
})
}
reg := tools.NewRegistry()
require.NoError(t, tools.RegisterDeltaTools(reg, func() []delta.Change { return changes }))

tool := findTool(t, reg, "get_recent_events")
result, err := tool.Handler(context.Background(), json.RawMessage(`{"limit":3}`))
require.NoError(t, err)

var parsed []map[string]string
require.NoError(t, json.Unmarshal([]byte(result), &parsed))
assert.Len(t, parsed, 3, "limit must cap returned events")
assert.Equal(t, "P7", parsed[0]["incident_id"], "must return most recent events")
}

func TestGetRecentEvents_InvalidInput(t *testing.T) {
reg := tools.NewRegistry()
require.NoError(t, tools.RegisterDeltaTools(reg, func() []delta.Change { return nil }))

tool := findTool(t, reg, "get_recent_events")
result, err := tool.Handler(context.Background(), json.RawMessage(`{invalid}`))
require.NoError(t, err)
assert.Contains(t, result, "invalid input")
}

func TestGetRecentEvents_IsClassRead(t *testing.T) {
reg := tools.NewRegistry()
require.NoError(t, tools.RegisterDeltaTools(reg, func() []delta.Change { return nil }))
tool := findTool(t, reg, "get_recent_events")
assert.Equal(t, policy.ClassRead, tool.Class)
}

// findTool finds a tool by name in the registry.
func findTool(t *testing.T, reg *tools.Registry, name string) tools.Tool {
t.Helper()
Expand Down
Loading