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) } }