Files
Andy Dixon 7d67745ff8 test(agency): the Part L incident scenario end to end, and failure modes
The full incident scenario now runs against the assembled daemon: an
operator goal becomes a want to reduce uncertainty, an investigation
intention and a diagnosis plan; telemetry is read; logs need authority,
so a scoped request with risk, mitigation and fallback is raised and the
intention is blocked until an operator grants it; the logs are read with
personal data masked and summarised back into cognition; the operator
supplies the judgement that a restart should help; a standing commitment
turns the restart into a request for approval; after approval the typed
capability runs and health is verified rather than assumed; the fault
persists, so a prediction error weakens the supporting belief, the plan
is replanned into an escalation that reuses the live log grant and does
not repeat the restart; once the database is fixed a second approved
restart verifies, the procedure is learnt, and "Why did you restart
atlas-worker?" is answered from the recorded chain and scores as
supported.

Changes the scenario exposed:
- New operator goals are raised by the goal monitor, so a cycle runs and
  wants are derived from them.
- Intentions link live beliefs about their action's target, so a failed
  expectation is evidence against the belief it rested on and the plan
  lists it as an assumption.
- Reconsideration reacts to a plan's own intention only; a subordinate
  "obtain approval" intention shares the plan and previously caused the
  parent's plan to be closed.
- The harness accepts a clock for the executor's verification windows.

Further failure-mode tests: a retracted assumption replans a waiting plan
away from the restart and withdraws its approval request; conflicting
commitments are raised once in a cycle while both stay represented.
2026-09-27 23:34:43 +01:00

249 lines
11 KiB
Go

// Package harness assembles the daemon the way cmd/noemad does, with a
// deterministic cognitive registry and no model, for end-to-end tests
// (security, epistemics, milestones).
package harness
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/agency"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/capabilities"
"github.com/andydixon/noema/internal/capabilities/host"
"github.com/andydixon/noema/internal/cognition"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/dreaming"
"github.com/andydixon/noema/internal/epistemics"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/integrations"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/motivation"
"github.com/andydixon/noema/internal/narrative"
"github.com/andydixon/noema/internal/notifications"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/planning"
"github.com/andydixon/noema/internal/predictions"
"github.com/andydixon/noema/internal/privacy"
"github.com/andydixon/noema/internal/reflection"
"github.com/andydixon/noema/internal/research"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/ui"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
type Harness struct {
T *testing.T
Deps app.Deps
Srv *httptest.Server
Admin string // bearer token, administrator
Obs string // bearer token, observer
}
const AdminPass, ObserverPass = "administrator-pass-1", "observer-pass-12345"
// Options adjust the assembled daemon for a test.
type Options struct {
// HostRunner backs the host and service capabilities. Nil leaves them
// registered but failing, so no test touches a real systemd unit.
HostRunner host.Runner
// Capabilities are extra test capabilities registered before the
// registry is frozen.
Capabilities []capabilities.Capability
// Now replaces the plan executor's clock (verification windows).
Now func() time.Time
}
// Start builds and serves a daemon for one test.
func Start(t *testing.T) *Harness {
t.Helper()
return StartWith(t, Options{})
}
// StartWith builds a daemon with options.
func StartWith(t *testing.T, o Options) *Harness {
t.Helper()
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
log := slog.New(slog.DiscardHandler)
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
sec, _ := secrets.New("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
llmStore := router.NewStore(pool)
authSvc := auth.NewService(pool, time.Hour, 2*time.Hour)
authSvc.SetSecrets(sec)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: authSvc, Audit: audit.New(pool, log), Metrics: observability.NewMetrics(),
Minds: minds.NewStore(pool), Events: evstore.NewStore(pool, evstore.NewBus(64), nil), Memory: memory.NewStore(ctx, pool, nil), Beliefs: beliefs.NewStore(pool),
Conversations: conversation.NewStore(pool), Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Secrets: sec, LLMStore: llmStore, LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
deps.Resolver = &predictions.Resolver{Store: deps.Predictions, Events: deps.Events, Beliefs: deps.Beliefs, Log: log}
deps.Graph = graph.NewStore(pool)
capReg := capabilities.NewRegistry()
for _, c := range capabilities.Builtins() {
capReg.Add(c)
}
for _, c := range host.Capabilities(host.Options{Runner: o.HostRunner}) {
capReg.Add(c)
}
for _, c := range o.Capabilities {
capReg.Add(c)
}
capReg.Freeze()
deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log}
deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}}
deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log, Volition: volition.NewStore(pool)}
deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds}
deps.Assertions = epistemics.NewStore(pool)
deps.Privacy = &privacy.Service{DB: pool, Events: deps.Events, Audit: deps.Audit, Snapshots: deps.Snapshots, Log: log}
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
deps.Reflection = &reflection.Service{DB: pool, Events: deps.Events, Beliefs: deps.Beliefs, Goals: deps.Goals, Memory: deps.Memory, Predictions: deps.Predictions, Log: log}
if _, err := deps.Prompts.SeedDefaults(ctx); err != nil {
t.Fatal(err)
}
group := scheduler.NewGroup(ctx, log)
t.Cleanup(func() { group.Stop() })
deps.Volition = volition.NewStore(pool)
deps.Plans = planning.NewStore(pool)
deps.Executor = &planning.Executor{Plans: deps.Plans, Volition: deps.Volition, Caps: deps.Capabilities, Predictions: deps.Predictions, Resolver: deps.Resolver, Events: deps.Events, Minds: deps.Minds, Memory: deps.Memory, Log: log,
Planner: &planning.Planner{Registry: capReg, Effect: planning.PolicyEffect(deps.Capabilities), Model: planning.ModelPlanner(deps.LLM, deps.Prompts)}, Now: o.Now}
deps.Research = research.NewStore(pool)
deps.Agency = &agency.Service{DB: pool, Caps: deps.Capabilities, Plans: deps.Plans, Volition: deps.Volition, Goals: deps.Goals, Beliefs: deps.Beliefs, Predictions: deps.Predictions, Events: deps.Events}
registry := cognition.NewRegistry(cognition.Stores{Agency: deps.Agency, Research: deps.Research, Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph, Assertions: deps.Assertions})
deps.Brain = brain.NewManager(brain.Config{DB: pool, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(2), Log: log, Metrics: deps.Metrics,
Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts, Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions, Capabilities: deps.Capabilities,
Volition: deps.Volition, Plans: deps.Plans, Executor: deps.Executor, Minds: deps.Minds}, AfterCommit: deps.Executor.WakeOn()}, group)
group.Go("planning.executor", func(ctx context.Context) error { return deps.Executor.Run(ctx, time.Second) })
if planEvents, err := deps.Events.Bus().Subscribe(group.Context(), ids.Nil, 512); err == nil {
group.Go("planning.react", func(ctx context.Context) error { return deps.Executor.Listen(ctx, planEvents) })
}
if err := deps.Brain.Start(ctx); err != nil {
t.Fatal(err)
}
adminUser, err := deps.Auth.CreateUser(ctx, "admin", AdminPass, auth.RoleAdministrator)
if err != nil {
t.Fatal(err)
}
obsUser, _ := deps.Auth.CreateUser(ctx, "watcher", ObserverPass, auth.RoleObserver)
adminTok, _ := deps.Auth.CreateAPIToken(ctx, adminUser.ID, "t", nil)
obsTok, _ := deps.Auth.CreateAPIToken(ctx, obsUser.ID, "t", nil)
var srv *api.Server
uiHandler, err := ui.New(deps, ui.Actions{
Status: func(r *http.Request) any { return srv.Status(r.Context()) },
Workspace: func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) },
Memory: func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) },
Belief: func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) { return srv.BeliefDetail(r, mind, id) },
Post: func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error) {
return srv.PostMessage(r, mind, conv, text, who)
},
Study: func() *api.Server { return srv },
})
if err != nil {
t.Fatal(err)
}
srv = api.New(deps, uiHandler)
h := &Harness{T: t, Deps: deps, Srv: httptest.NewServer(srv.Handler()), Admin: adminTok, Obs: obsTok}
t.Cleanup(h.Srv.Close)
return h
}
// Do performs an API request with a bearer token.
func (h *Harness) Do(token, method, path string, body any) (int, []byte) {
h.T.Helper()
var rd io.Reader = strings.NewReader("")
if body != nil {
b, _ := json.Marshal(body)
rd = strings.NewReader(string(b))
}
req, _ := http.NewRequest(method, h.Srv.URL+path, rd)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
h.T.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res.StatusCode, out
}
// Mind creates an active mind through the API.
func (h *Harness) Mind(slug string) minds.Mind {
h.T.Helper()
code, _ := h.Do(h.Admin, "POST", "/api/v1/minds", map[string]any{"slug": slug, "display_name": slug})
if code != 201 {
h.T.Fatalf("create mind %s: %d", slug, code)
}
m, err := h.Deps.Minds.Resolve(context.Background(), slug)
if err != nil {
h.T.Fatal(err)
}
return m
}
// browser logs in through the UI and returns a client with the session cookie
// and the CSRF token.
// Browser logs in through the UI and returns a cookie client and CSRF token.
func (h *Harness) Browser(user, pass string) (*http.Client, string) {
h.T.Helper()
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := c.PostForm(h.Srv.URL+"/login", url.Values{"username": {user}, "password": {pass}})
if err != nil || res.StatusCode != 303 {
h.T.Fatalf("login: %v %d", err, res.StatusCode)
}
res.Body.Close()
res, _ = c.Get(h.Srv.URL + "/account")
body, _ := io.ReadAll(res.Body)
res.Body.Close()
i := strings.Index(string(body), `name="_csrf" value="`)
if i < 0 {
h.T.Fatal("no csrf token on page")
}
rest := string(body)[i+len(`name="_csrf" value="`):]
return c, rest[:strings.Index(rest, `"`)]
}
// Settle gives the mind time to process and forces two cycles.
func (h *Harness) Settle(mind ids.ID) {
time.Sleep(900 * time.Millisecond)
h.Deps.Brain.Command(context.Background(), mind, "cycle", nil) //nolint:errcheck
time.Sleep(400 * time.Millisecond)
h.Deps.Brain.Command(context.Background(), mind, "cycle", nil) //nolint:errcheck
}
// EventsOf lists a mind's events of the given types.
func (h *Harness) EventsOf(mind ids.ID, types ...events.Type) []events.Event {
evs, _ := h.Deps.Events.Query(context.Background(), evstore.Filter{MindID: mind, Types: types, Limit: 500})
return evs
}