Files
Algis DumbrisandClaude Opus 4.6 8df22457ab feat: trust scores, claim semantics, state-change webhooks (011-trust-claims-triggers)
Trust scores: per (agent, action_type) pair, stored in agent_trust
table. Auto-adjusts when human reacts to AI agent messages (approve
+0.05, reject -0.1). Scores clamped [0.0, 1.0]. MCP get_trust action
+ REST API /api/trust/{agent}. Web UI shows trust progress bars on
agent detail pages.

Claim semantics: only one in_progress reaction per message enforced.
First agent to claim wins, duplicates rejected with clear error.

State-change webhooks: StateChangeNotifier interface fires
workflow.state_changed events through existing webhook infrastructure
when reactions change a message's derived workflow state.

Channel thresholds: publish_threshold and approve_threshold fields
on channels for configuring autonomy gates.

Migration 014_trust_claims.sql. 17 new test cases across trust
model + store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:41:54 +02:00

225 lines
5.4 KiB
Go

package trust
import (
"context"
"database/sql"
"fmt"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/storage"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
return db
}
func TestSQLiteStore_UpsertAndGet(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
ts, err := store.UpsertScore(ctx, "agent-a", ActionResearch, 0.5)
if err != nil {
t.Fatalf("UpsertScore: %v", err)
}
if ts.Score != 0.5 {
t.Errorf("Score = %f, want 0.5", ts.Score)
}
if ts.AgentName != "agent-a" {
t.Errorf("AgentName = %q, want %q", ts.AgentName, "agent-a")
}
if ts.ActionType != ActionResearch {
t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch)
}
if ts.AdjustmentsCount != 1 {
t.Errorf("AdjustmentsCount = %d, want 1", ts.AdjustmentsCount)
}
// Verify it's retrievable via GetScore
got, err := store.GetScore(ctx, "agent-a", ActionResearch)
if err != nil {
t.Fatalf("GetScore: %v", err)
}
if got.Score != 0.5 {
t.Errorf("GetScore Score = %f, want 0.5", got.Score)
}
if got.AgentName != "agent-a" {
t.Errorf("GetScore AgentName = %q, want %q", got.AgentName, "agent-a")
}
if got.ActionType != ActionResearch {
t.Errorf("GetScore ActionType = %q, want %q", got.ActionType, ActionResearch)
}
}
func TestSQLiteStore_UpsertIncrement(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
// First upsert: initial score
_, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.3)
if err != nil {
t.Fatalf("UpsertScore first: %v", err)
}
// Second upsert: should increment
ts, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.2)
if err != nil {
t.Fatalf("UpsertScore second: %v", err)
}
want := 0.5
if ts.Score != want {
t.Errorf("Score = %f, want %f", ts.Score, want)
}
if ts.AdjustmentsCount != 2 {
t.Errorf("AdjustmentsCount = %d, want 2", ts.AdjustmentsCount)
}
}
func TestSQLiteStore_ClampMax(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
// Insert a high score
_, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.9)
if err != nil {
t.Fatalf("UpsertScore first: %v", err)
}
// Push past 1.0
ts, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.5)
if err != nil {
t.Fatalf("UpsertScore second: %v", err)
}
if ts.Score != MaxScore {
t.Errorf("Score = %f, want %f (clamped to max)", ts.Score, MaxScore)
}
}
func TestSQLiteStore_ClampMin(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
// Insert a low score
_, err := store.UpsertScore(ctx, "agent-a", ActionOperate, 0.1)
if err != nil {
t.Fatalf("UpsertScore first: %v", err)
}
// Push past 0.0 with a large negative delta
ts, err := store.UpsertScore(ctx, "agent-a", ActionOperate, -0.5)
if err != nil {
t.Fatalf("UpsertScore second: %v", err)
}
if ts.Score != MinScore {
t.Errorf("Score = %f, want %f (clamped to min)", ts.Score, MinScore)
}
}
func TestSQLiteStore_GetAllScores(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
// Insert multiple action types for the same agent
actions := []struct {
actionType string
delta float64
}{
{ActionResearch, 0.3},
{ActionPublish, 0.5},
{ActionComment, 0.7},
}
for _, a := range actions {
if _, err := store.UpsertScore(ctx, "agent-a", a.actionType, a.delta); err != nil {
t.Fatalf("UpsertScore %s: %v", a.actionType, err)
}
}
scores, err := store.GetAllScores(ctx, "agent-a")
if err != nil {
t.Fatalf("GetAllScores: %v", err)
}
if len(scores) != 3 {
t.Fatalf("got %d scores, want 3", len(scores))
}
// Scores are ordered by action_type alphabetically
scoreMap := make(map[string]float64)
for _, s := range scores {
scoreMap[s.ActionType] = s.Score
}
for _, a := range actions {
got, ok := scoreMap[a.actionType]
if !ok {
t.Errorf("missing score for action %q", a.actionType)
continue
}
if got != a.delta {
t.Errorf("score for %q = %f, want %f", a.actionType, got, a.delta)
}
}
// Different agent should return empty
other, err := store.GetAllScores(ctx, "agent-nonexistent")
if err != nil {
t.Fatalf("GetAllScores (other): %v", err)
}
if len(other) != 0 {
t.Errorf("got %d scores for nonexistent agent, want 0", len(other))
}
}
func TestSQLiteStore_GetScoreNotFound(t *testing.T) {
db := newTestDB(t)
store := NewSQLiteStore(db)
ctx := context.Background()
// Get score for non-existent agent should return 0.0 (not an error)
ts, err := store.GetScore(ctx, "nonexistent-agent", ActionResearch)
if err != nil {
t.Fatalf("GetScore: %v", err)
}
if ts.Score != 0.0 {
t.Errorf("Score = %f, want 0.0 for non-existent agent", ts.Score)
}
if ts.AgentName != "nonexistent-agent" {
t.Errorf("AgentName = %q, want %q", ts.AgentName, "nonexistent-agent")
}
if ts.ActionType != ActionResearch {
t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch)
}
if ts.AdjustmentsCount != 0 {
t.Errorf("AdjustmentsCount = %d, want 0", ts.AdjustmentsCount)
}
}