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.
208 lines
9.3 KiB
Go
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
|
|
}
|