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 locks the turn boundary: a settled turn is reported // exactly once — with the turn's own user text, the persona the loop is bound to // and the answer — and the label the hook returns is recorded on Result.Mood so // the persistence layer can tag the assistant message. 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 locks that the conversation's isolation // environment reaches the hook, so a group/stranger turn can be kept read-only. 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 locks "once per turn, not per step": a // tool-loop turn (model → tool call → final answer) reports its boundary once, // and the reported user text stays the turn's user message rather than a // fed-back tool result. 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 locks that a failed turn is not reported: nothing // settled, so persona state must not advance. 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 locks the nil-tolerance: an unwired hook leaves the // mood empty and Run behaves exactly as before. 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) } }