A1 情绪接线: - loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步 - persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700) - 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发 - cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd - 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟 - Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照) - 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环 拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批 回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
|
|
// test can assert the mood the loop reported is what gets persisted.
|
|
type moodRunner struct {
|
|
mood string
|
|
}
|
|
|
|
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
|
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
|
|
}
|
|
|
|
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
|
|
// path: the mood the loop's turn-boundary hook reported lands on the persisted
|
|
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
|
|
// `message_persisted` and history replay carry real data instead of an always
|
|
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
|
|
// mood to the assistant reply).
|
|
func TestOrchestratorPersistsTurnMood(t *testing.T) {
|
|
t.Parallel()
|
|
hist := &fakeHistory{}
|
|
o := NewOrchestrator(OrchestratorOptions{
|
|
Run: moodRunner{mood: "happy"}.Run,
|
|
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
History: hist,
|
|
})
|
|
|
|
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
|
|
if !sr.Accepted || res == nil {
|
|
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
|
|
}
|
|
recs := hist.records()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
|
}
|
|
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
|
|
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
|
|
}
|
|
wire := toHistoryMessage(recs[1])
|
|
if wire.Mood != "happy" {
|
|
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
|
|
}
|
|
if recs[0].Mood != "" {
|
|
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
|
|
}
|
|
}
|
|
|
|
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
|
|
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
|
|
// empty rather than being fabricated.
|
|
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
|
|
t.Parallel()
|
|
hist := &fakeHistory{}
|
|
o := NewOrchestrator(OrchestratorOptions{
|
|
Run: moodRunner{mood: ""}.Run,
|
|
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
History: hist,
|
|
})
|
|
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
|
|
t.Fatal("sync result is nil")
|
|
}
|
|
recs := hist.records()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("appends = %d, want 2", len(recs))
|
|
}
|
|
if recs[1].Mood != "" {
|
|
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
|
|
}
|
|
}
|
|
|
|
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
|
|
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
|
|
// affection ride along as extra detail.
|
|
func TestServerBroadcastMood(t *testing.T) {
|
|
t.Parallel()
|
|
env := newEnv(t, nil, false)
|
|
c := newFakeConn(env.srv.hub, 32)
|
|
env.srv.hub.register(c)
|
|
|
|
env.srv.BroadcastMood("cyrene", "happy", 60)
|
|
ev := readFakeFrame(t, c)
|
|
if ev.Type != "mood" {
|
|
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
|
|
}
|
|
data, ok := ev.Data.(map[string]any)
|
|
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
|
|
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
|
|
}
|
|
if got, ok := data["affection"].(float64); !ok || got != 60 {
|
|
t.Fatalf("frame affection = %v, want 60", data["affection"])
|
|
}
|
|
}
|