package main import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // testConfigWithTimezone builds the minimal config the location lookup reads. func testConfigWithTimezone(tz string) *config.Config { return &config.Config{Timezone: tz} } // newTestHistory opens an isolated in-memory chat-history store for the wiring // tests, mirroring what wire.go hands the composition root. func newTestHistory(t *testing.T) *history.Store { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", testLog()) if err != nil { t.Fatalf("open memory db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) st, err := history.NewStore(ctx, conn, testLog()) if err != nil { t.Fatalf("new history store: %v", err) } return st } // newTestPersonas registers one explicit role plus the default character, so the // adapter's two answers (a role's display name and the default role id) are real. func newTestPersonas(t *testing.T) *personareg.Registry { t.Helper() reg := personareg.New() for _, p := range []*loader.Persona{ { ID: "cyrene", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10}, }, { ID: "yuki", Name: "雪", Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5}, }, } { if err := reg.RegisterPersona(p); err != nil { t.Fatalf("RegisterPersona(%s): %v", p.ID, err) } } if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil { t.Fatalf("RegisterCharacter(cyrene): %v", err) } if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "雪", PersonaID: "yuki"}); err != nil { t.Fatalf("RegisterCharacter(yuki): %v", err) } return reg } // TestWireSessionReadToolRegistersOwnerOnly locks the composition-root wiring: the // cross-session read tool must land in the capability registry AND carry the // default reachability markers, so a group/stranger session can neither see nor // call it. That is the auditable gate design/22 §8.1 约束 4 relies on — the tool // itself writes no audit record. func TestWireSessionReadToolRegistersOwnerOnly(t *testing.T) { reg := registry.New() d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } a := &app{log: testLog(), dispatcher: d, history: newTestHistory(t), personas: newTestPersonas(t)} a.wireSessionReadTool() if _, gerr := reg.Get(sessionread.ToolName); gerr != nil { t.Fatalf("capability %q is not registered: %v", sessionread.ToolName, gerr) } advertised := false for _, s := range d.ToolSchemas() { if s.Name == sessionread.ToolName { advertised = true } } if !advertised { t.Fatalf("tool schemas %v, want %q advertised", d.ToolSchemas(), sessionread.ToolName) } if !restrict.SetContext(reg, restrict.ActorOwner).Allow(sessionread.ToolName) { t.Fatal("owner was denied the session read tool") } if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(sessionread.ToolName) { t.Fatal("group member may invoke the session read tool; want owner-only") } if restrict.SetContext(reg, restrict.ActorStranger).Allow(sessionread.ToolName) { t.Fatal("stranger may invoke the session read tool; want owner-only") } // The registered capability must be the tool itself, not a placeholder. cap, _ := reg.Get(sessionread.ToolName) if _, ok := cap.Value.(*sessionread.Tool); !ok { t.Fatalf("capability value = %T, want *sessionread.Tool", cap.Value) } } // TestWireSessionReadToolDegradedBoot asserts a boot without a history store or a // dispatcher logs and skips the registration instead of panicking — the tool must // never be registered half-wired and then fail on the model's first call. func TestWireSessionReadToolDegradedBoot(t *testing.T) { t.Parallel() reg := registry.New() d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } for name, a := range map[string]*app{ "no history": {log: testLog(), dispatcher: d}, "no dispatcher": {log: testLog(), history: newTestHistory(t)}, } { t.Run(name, func(t *testing.T) { a.wireSessionReadTool() if _, gerr := reg.Get(sessionread.ToolName); gerr == nil { t.Fatalf("the tool was registered in a degraded boot (%s)", name) } }) } } // TestSessionReadPersonasAdapter locks the adapter the tool's attribution depends // on: a known role answers its display name, an unknown one is reported unknown // (so the raw id is shown instead of a wrong name), and the default is the persona // of the default character. func TestSessionReadPersonasAdapter(t *testing.T) { t.Parallel() adapter := sessionReadPersonas{reg: newTestPersonas(t)} if got, ok := adapter.PersonaName("yuki"); !ok || got != "雪" { t.Fatalf("PersonaName(yuki) = %q, %v; want 雪, true", got, ok) } if _, ok := adapter.PersonaName("nobody"); ok { t.Fatal("PersonaName(nobody) reported a name; want unknown") } if got, ok := adapter.PersonaName(""); ok || got != "" { t.Fatalf("PersonaName(\"\") = %q, %v; want \"\", false", got, ok) } if got := adapter.DefaultPersonaID(); got != "cyrene" { t.Fatalf("DefaultPersonaID() = %q, want cyrene", got) } // A nil registry must degrade quietly, not panic: the wiring tolerates a // persona-less boot. empty := sessionReadPersonas{} if _, ok := empty.PersonaName("yuki"); ok || empty.DefaultPersonaID() != "" { t.Fatal("a nil registry answered a role lookup") } } // TestSessionReadLocation asserts the configured zone is honored and an // unresolvable one degrades to nil (the tool's process-local default) instead of // failing the wiring. func TestSessionReadLocation(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: testConfigWithTimezone("Asia/Shanghai")} if loc := a.sessionReadLocation(testLog()); loc == nil || loc.String() != "Asia/Shanghai" { t.Fatalf("sessionReadLocation() = %v, want Asia/Shanghai", loc) } bad := &app{log: testLog(), cfg: testConfigWithTimezone("Not/AZone")} if loc := bad.sessionReadLocation(testLog()); loc != nil { t.Fatalf("sessionReadLocation() = %v, want nil for an unresolvable zone", loc) } none := &app{log: testLog()} if loc := none.sessionReadLocation(testLog()); loc != nil { t.Fatalf("sessionReadLocation() = %v, want nil without config", loc) } }