package loop import ( "context" "errors" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // TestRunCallsOnTurnEndOnce 钉住 turn 边界:一个落定的 turn 恰好 // 上报一次 —— 携带该 turn 自己的用户文本、循环绑定的角色 // 以及答案 —— 钩子返回的标签记录在 Result.Mood 上, // 使持久化层能标记 assistant 消息。 func TestRunCallsOnTurnEndOnce(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在的呀♪"}}}} a := newTestAgent(t, ad, nil) var ends []TurnEnd l := New(Options{ Agent: a, PersonaID: "cyrene", OnTurnEnd: func(end TurnEnd) string { ends = append(ends, end) return "happy" }, }) res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "今天谢谢你")}) if err != nil { t.Fatalf("Run: %v", err) } if len(ends) != 1 { t.Fatalf("OnTurnEnd calls = %d, want 1", len(ends)) } got := ends[0] if got.SessionID != "web" || got.PersonaID != "cyrene" { t.Fatalf("TurnEnd = %+v, want session web / persona cyrene", got) } if got.UserText != "今天谢谢你" { t.Fatalf("TurnEnd.UserText = %q, want the user turn text", got.UserText) } if got.AssistantText != "在的呀♪" { t.Fatalf("TurnEnd.AssistantText = %q, want the final answer", got.AssistantText) } if got.StopReason != agent.StopReasonStop || got.Steps != 1 { t.Fatalf("TurnEnd stop/steps = %q/%d, want stop/1", got.StopReason, got.Steps) } if !got.Env.MemWritable { t.Fatalf("TurnEnd.Env = %+v, want the owner lane by default", got.Env) } if res.Mood != "happy" { t.Fatalf("Result.Mood = %q, want the hook's label", res.Mood) } } // TestRunPassesRestrictedEnvToTurnEnd 钉住会话的隔离 // 环境到达钩子,使群聊/陌生人 turn 能被保持只读。 func TestRunPassesRestrictedEnvToTurnEnd(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}} a := newTestAgent(t, ad, nil) var got TurnEnd l := New(Options{ Agent: a, OnTurnEnd: func(end TurnEnd) string { got = end return "" }, }) env := SessionEnv{ActorEnv: "group_member", MemWritable: false} ctx := WithSessionEnv(context.Background(), env) if _, err := l.Run(ctx, "sess-group", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil { t.Fatalf("Run: %v", err) } if !got.Env.Restricted() || got.Env != env { t.Fatalf("TurnEnd.Env = %+v, want the restricted env", got.Env) } } // TestRunTurnEndOnceForMultiStepTurn 钉住「每 turn 一次,而非每步 // 一次」:一个工具循环 turn(模型 → 工具调用 → 最终答案)只上报其边界 // 一次,且上报的用户文本仍是该 turn 的用户消息,而不是 // 回灌的工具结果。 func TestRunTurnEndOnceForMultiStepTurn(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, {resp: model.Response{Content: "Echoed!"}}, }} td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"}) a := newTestAgent(t, ad, td) var ends []TurnEnd l := New(Options{ Agent: a, PersonaID: "cyrene", OnTurnEnd: func(end TurnEnd) string { ends = append(ends, end) return "calm" }, }) res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")}) if err != nil { t.Fatalf("Run: %v", err) } if len(ends) != 1 { t.Fatalf("OnTurnEnd calls = %d, want 1 for a 2-step turn", len(ends)) } if ends[0].UserText != "echo this" { t.Fatalf("TurnEnd.UserText = %q, want the turn's user text", ends[0].UserText) } if ends[0].AssistantText != "Echoed!" || ends[0].Steps != 2 { t.Fatalf("TurnEnd = %+v, want the final answer at 2 steps", ends[0]) } if res.Mood != "calm" { t.Fatalf("Result.Mood = %q, want calm", res.Mood) } } // TestRunNoTurnEndOnError 钉住失败的 turn 不上报:什么都没 // 落定,因此人格状态不得推进。 func TestRunNoTurnEndOnError(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{err: errors.New("model down")}}} a := newTestAgent(t, ad, nil) calls := 0 l := New(Options{ Agent: a, OnTurnEnd: func(TurnEnd) string { calls++; return "happy" }, }) if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "我爱你")}); err == nil { t.Fatal("Run: want an error") } if calls != 0 { t.Fatalf("OnTurnEnd calls = %d, want 0 on a failed turn", calls) } } // TestRunWithoutTurnEndHook 钉住 nil 容忍:未接线的钩子让 // 情绪保持为空,Run 的行为与从前完全一致。 func TestRunWithoutTurnEndHook(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "hi"}}}} l := New(Options{Agent: newTestAgent(t, ad, nil)}) res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != "" { t.Fatalf("Result.Mood = %q, want empty without a hook", res.Mood) } }