Files

248 lines
11 KiB
Go
Raw Permalink Normal View History

package elderagent
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type personaProvider struct {
updates int
config, agent map[string]any
fail, verifyFail bool
onUpdate func()
}
func (p *personaProvider) Ensure(_ context.Context, name string, c, a map[string]any) (Bot, error) {
p.config = c
p.agent = a
return Bot{ID: "bot-" + name, Name: name}, nil
}
func (p *personaProvider) Update(_ context.Context, _ string, c, a map[string]any) error {
p.updates++
p.config = c
p.agent = a
if p.onUpdate != nil {
p.onUpdate()
}
if p.fail {
return errors.New("SECRET_PROVIDER_ERROR")
}
return nil
}
func (p *personaProvider) VerifyConfiguration(context.Context, string, map[string]any, map[string]any) error {
if p.verifyFail {
return errors.New("SECRET_VERIFY_ERROR")
}
return nil
}
func (p *personaProvider) role() string {
v, _ := configurationField(p.config, []string{"S2SConfig", "ProviderParams", "dialog", "system_role"})
s, _ := v.(string)
return s
}
type personaFixture struct {
s *Service
p *personaProvider
pool *pgxpool.Pool
id, public, device string
}
func personaTestFixture(t *testing.T) personaFixture {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
f := personaFixture{pool: pool, id: uuid.NewString(), public: uuid.NewString(), device: uuid.NewString(), p: &personaProvider{}}
f.s = &Service{Pool: pool, Provider: f.p, CallbackURL: "https://example.com/callback", CallbackSecret: []byte(strings.Repeat("k", 32))}
f.exec(t, `INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status,profile_data,conversation_preferences) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','旧称呼','active','{"background":"退休教师","interests":["京剧"]}','{"conversation_goal":"轻松交流","pace":"slow","preferred_topics":["家乡"],"avoided_topics":["收入"]}')`, f.id, f.public)
f.exec(t, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.id)
f.exec(t, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,1,'{"voice_chat":true}','[]')`, f.id)
raw := `{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test-app","token":"test-token"},"tts":{"speaker":"voice"},"dialog":{"system_role":"基础人设\n你正在陪伴的长者称呼是旧称呼。","extra":{"model":"O"}}}},"MemoryConfig":{"Enable":false}}`
agent := `{"WelcomeMessage":"原开场","ServerMessageURLForRTS":"https://example.com/base","ServerMessageSignatureForRTS":"base-signature"}`
f.exec(t, `INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status,config,agent_config,persona_base_role) VALUES($1::uuid,$2,$2,'READY',$3::jsonb,$4::jsonb,'基础人设')`, f.id, "test-"+f.id, raw, agent)
f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',encode(sha256($1::text::bytea),'hex'),'test','test','registered',$1,'1','1')`, f.device)
return f
}
func (f personaFixture) exec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(context.Background(), q, args...); err != nil {
t.Fatal(err)
}
}
func (f personaFixture) sync(t *testing.T) Info {
t.Helper()
i, err := f.s.Sync(context.Background(), f.public)
if err != nil {
t.Fatal(err)
}
return i
}
func TestPersonaSyncBackfillLatestAndClear(t *testing.T) {
f := personaTestFixture(t)
i := f.sync(t)
if i.PersonaSyncStatus != "SYNCED" || !i.PersonalizationIncluded || !strings.Contains(f.p.role(), "京剧") {
t.Fatal(i)
}
f.exec(t, `UPDATE subject_profile SET preferred_name='中间称呼',profile_data='{"interests":["养花"]}' WHERE id=$1::uuid`, f.id)
f.exec(t, `UPDATE subject_profile SET preferred_name='最新称呼',profile_data='{"interests":["编织"],"avatar_data_url":"SECRET_AVATAR"}',conversation_preferences='{"conversation_goal":"聊聊生活","preferred_topics":["手工"],"avoided_topics":["家庭矛盾"]}' WHERE id=$1::uuid`, f.id)
i = f.sync(t)
for _, v := range []string{"最新称呼", "编织", "聊聊生活", "手工", "家庭矛盾"} {
if !strings.Contains(f.p.role(), v) {
t.Fatal("missing field", v)
}
}
for _, v := range []string{"中间称呼", "旧称呼", "京剧", "养花", "SECRET_AVATAR"} {
if strings.Contains(f.p.role(), v) {
t.Fatal("stale or private field", v)
}
}
if i.PersonaSyncedRevision != i.PersonaRevision || f.p.updates != 2 {
t.Fatal("did not coalesce changes", i, f.p.updates)
}
rev := i.PersonaRevision
f.exec(t, `UPDATE subject_profile SET profile_data=profile_data||'{"avatar_data_url":"NEW_AVATAR"}'::jsonb,profile_version=profile_version+1 WHERE id=$1::uuid`, f.id)
if i = f.sync(t); i.PersonaRevision != rev || f.p.updates != 2 {
t.Fatal("avatar caused provider update")
}
f.exec(t, `UPDATE subject_profile SET profile_data='{}',conversation_preferences='{}' WHERE id=$1::uuid`, f.id)
f.sync(t)
if strings.Contains(f.p.role(), "编织") || strings.Contains(f.p.role(), "家庭矛盾") || strings.Contains(f.p.role(), "聊聊生活") {
t.Fatal("cleared field retained")
}
f.exec(t, `UPDATE subject_voice_agent SET config=jsonb_set(config,'{S2SConfig,ProviderParams,dialog,system_role}','"新版基础角色"') WHERE subject_id=$1::uuid`, f.id)
f.sync(t)
if !strings.HasPrefix(f.p.role(), "新版基础角色") {
t.Fatal("ignored base persona change")
}
}
func TestPersonaSyncRetryReadbackAndConsentRace(t *testing.T) {
f := personaTestFixture(t)
f.p.fail = true
i, err := f.s.Sync(context.Background(), f.public)
if err == nil || i.Status != "READY" || i.PersonaSyncStatus != "FAILED" || strings.Contains(err.Error(), "SECRET") {
t.Fatal(i, err)
}
f.p.fail = false
f.p.verifyFail = true
if _, err = f.s.Sync(context.Background(), f.public); err == nil {
t.Fatal("acknowledged unverified update")
}
f.p.verifyFail = false
f.exec(t, `UPDATE subject_profile SET preferred_name='重试最新称呼' WHERE id=$1::uuid`, f.id)
if err = f.s.SyncPending(context.Background()); err != nil {
t.Fatal(err)
}
i, err = f.s.Get(context.Background(), f.public)
if err != nil || i.PersonaSyncStatus != "SYNCED" || !strings.Contains(f.p.role(), "重试最新称呼") {
t.Fatal(i, err)
}
f.exec(t, `UPDATE subject_profile SET preferred_name='并发授权测试' WHERE id=$1::uuid`, f.id)
f.p.onUpdate = func() {
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.id)
f.p.onUpdate = nil
}
i = f.sync(t)
if i.PersonaSyncStatus == "SYNCED" || i.PersonaRevision == i.PersonaSyncedRevision {
t.Fatal("old request acknowledged new consent", i)
}
i = f.sync(t)
if i.PersonalizationIncluded || strings.Contains(f.p.role(), "退休教师") || strings.Contains(f.p.role(), "京剧") || !strings.Contains(f.p.role(), "收入") {
t.Fatal("withdrawal failed to clear personal facts or retain boundary", i)
}
}
func TestPersonaSyncDefersActiveCallAndCleansSessionContext(t *testing.T) {
f := personaTestFixture(t)
f.sync(t)
ctx := context.Background()
sid := uuid.NewString()
bot := "test-" + f.id
if err := f.s.PrepareWithWelcome(ctx, f.id, bot, sid, "本通临时记忆", "本通开场"); err != nil {
t.Fatal(err)
}
f.exec(t, `INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,manifest_status,budget_snapshot,call_state,remote_state) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$1::uuid,$1,0,0,'ISSUED','{"protocol":"DIRECT-CHAT-1"}','TALKING','UNKNOWN')`, sid, f.device, f.id)
f.exec(t, `UPDATE subject_profile SET preferred_name='通话后称呼' WHERE id=$1::uuid`, f.id)
updates := f.p.updates
if i := f.sync(t); i.PersonaSyncStatus != "DEFERRED" || f.p.updates != updates {
t.Fatal("overwrote active call", i)
}
f.exec(t, `UPDATE call_session SET call_state='ENDED',manifest_status='DISCARDED',local_audio_off=true,ended_at=now() WHERE id=$1::uuid`, sid)
if i := f.sync(t); i.PersonaSyncStatus != "DEFERRED" || f.p.updates != updates {
t.Fatal("overwrote SDK still running", i)
}
f.exec(t, `UPDATE call_session SET sdk_stopped=true WHERE id=$1::uuid`, sid)
i := f.sync(t)
if i.PersonaSyncStatus != "SYNCED" || strings.Contains(f.p.role(), "本通临时记忆") || strings.Contains(f.p.role(), "本通开场") || !strings.Contains(f.p.role(), "通话后称呼") || f.p.agent["WelcomeMessage"] != "原开场" {
t.Fatal("session context persisted", i)
}
var saved []byte
if err := f.pool.QueryRow(ctx, `SELECT config FROM subject_voice_agent WHERE subject_id=$1::uuid`, f.id).Scan(&saved); err != nil {
t.Fatal(err)
}
if strings.Contains(string(saved), "本通临时记忆") {
t.Fatal("modified stable source config")
}
// Opening preparation itself uses the latest profile, even before the worker.
f.exec(t, `UPDATE subject_profile SET profile_data='{"interests":["养花"]}' WHERE id=$1::uuid`, f.id)
if err := f.s.PrepareWithWelcome(ctx, f.id, bot, uuid.NewString(), "本通第二份记忆", "第二通开场"); err != nil {
t.Fatal(err)
}
if !strings.Contains(f.p.role(), "养花") || strings.Contains(f.p.role(), "京剧") || strings.Contains(f.p.role(), "本通临时记忆") {
t.Fatal("opening used stale persona")
}
}
func TestPersonaSyncConsentExpiryWithoutProfileEdit(t *testing.T) {
f := personaTestFixture(t)
f.exec(t, `UPDATE consent_record SET expires_at=now()+interval '300 milliseconds' WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.id)
f.sync(t)
time.Sleep(350 * time.Millisecond)
if err := f.s.SyncPending(context.Background()); err != nil {
t.Fatal(err)
}
if strings.Contains(f.p.role(), "京剧") {
t.Fatal("expired personal facts remained on provider")
}
}
func TestPersonaProfileLockAndRollback(t *testing.T) {
f := personaTestFixture(t)
ctx := context.Background()
f.sync(t)
tx, err := f.pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `UPDATE subject_profile SET preferred_name='未提交称呼' WHERE id=$1::uuid`, f.id); err != nil {
t.Fatal(err)
}
n := f.p.updates
i, err := f.s.Sync(ctx, f.public)
if err != nil || f.p.updates != n {
t.Fatal("published uncommitted edit", i, err)
}
tx.Rollback(ctx)
i = f.sync(t)
if strings.Contains(f.p.role(), "未提交称呼") || i.PersonaRevision != i.PersonaSyncedRevision {
t.Fatal("rollback left queued revision", i)
}
}