248 lines
11 KiB
Go
248 lines
11 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|