Files
noema/tests/harness/harness.go
T
Andy Dixon 9b2521bffd feat(epistemics): record assertions between observations and beliefs
A statement made to a mind is now three things kept apart: the observation
that it arrived, an append-only assertion (attributed actor, stance,
certainty, modality, negation, reported or quoted speech, hypothetical
status, interpretation confidence and notes), and, only for factual
assertions, one piece of evidence for a proposition belief attributed to
whoever actually stands behind it. Reported speech is attributed to the
person reported and weighed as second-hand; hypotheticals, questions and
imperatives create no belief; expressed doubt counts against; a possibly
sarcastic remark is recorded with low interpretation confidence rather
than guessed at. Interpretation is deterministic code; a model may enrich
the claim list but never sets stance or confidence.

Association no longer treats conversational turns as world events:
hypotheticals, questions, requests and consecutive turns by one speaker
cannot seed causal hypotheses.

Adds internal/epistemics, migration 0018, the assertions API, assertions
on belief pages and in belief detail, a shared end-to-end test harness
(tests/harness), end-to-end tests for eight speech patterns, and updates
the milestone test to the new provenance.
2026-08-05 19:22:48 +01:00

208 lines
9.3 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/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/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/predictions"
"github.com/andydixon/noema/internal/reflection"
"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/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"
// Start builds and serves a daemon for one test.
func Start(t *testing.T) *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)
}
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}
deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds}
deps.Assertions = epistemics.NewStore(pool)
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() })
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}).
AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).AddProcess(cognition.Act{})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph, Assertions: deps.Assertions}.Register(registry)
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}}, group)
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
}