package loop import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // thinkingAdapter emits one full thinking segment followed by one text segment, // and records every request it is handed so a test can assert the loop's // reasoning knobs actually reached the model call. type thinkingAdapter struct { reqs []model.Request } func (a *thinkingAdapter) Name() string { return "thinking-mock" } func (a *thinkingAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { a.reqs = append(a.reqs, req) return model.Response{Content: "Hello"}, nil } func (a *thinkingAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { a.reqs = append(a.reqs, req) events := []model.StreamEvent{ {Kind: model.StreamKindThinkingStart}, {Kind: model.StreamKindThinkingDelta, Delta: "let me think"}, {Kind: model.StreamKindThinkingEnd}, {Kind: model.StreamKindTextStart}, {Kind: model.StreamKindTextDelta, Delta: "Hello"}, {Kind: model.StreamKindTextEnd}, } for _, ev := range events { if on != nil { on(ev) } } return model.Response{Content: "Hello"}, nil } // kindsOf returns the event kinds as a set for compact assertions. func kindsOf(events []model.StreamEvent) map[model.StreamKind]int { out := make(map[model.StreamKind]int, len(events)) for _, ev := range events { out[ev.Kind]++ } return out } // runThinkingTurn drives one streaming turn through a loop configured with the // given broadcast switch and returns the events OnStream saw. func runThinkingTurn(t *testing.T, show *bool) ([]model.StreamEvent, *thinkingAdapter) { t.Helper() ad := &thinkingAdapter{} onStream, streams := collectStream() l := New(Options{ Agent: newTestAgent(t, ad, nil), OnStream: onStream, MaxRounds: 8, ThinkingShowToUser: show, }) if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { t.Fatalf("Run: %v", err) } return streams(), ad } // TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames is the showToUser=false // contract: thinking frames never reach the client callback while the body text // segment is delivered untouched (display is gated, the answer is not). func TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames(t *testing.T) { t.Parallel() show := false events, _ := runThinkingTurn(t, &show) kinds := kindsOf(events) for _, k := range []model.StreamKind{model.StreamKindThinkingStart, model.StreamKindThinkingDelta, model.StreamKindThinkingEnd} { if n := kinds[k]; n != 0 { t.Fatalf("event kind %q emitted %d time(s) with showToUser=false", k, n) } } for _, k := range []model.StreamKind{model.StreamKindTextStart, model.StreamKindTextDelta, model.StreamKindTextEnd} { if kinds[k] != 1 { t.Fatalf("event kind %q emitted %d time(s), want 1 (body must be unaffected)", k, kinds[k]) } } if len(events) != 3 { t.Fatalf("events = %d, want only the 3 text frames", len(events)) } } // TestRun_ThinkingShowToUserDefaultBroadcastsThinking keeps the pre-existing // behavior when the option is unset (nil): thinking frames are pushed. func TestRun_ThinkingShowToUserDefaultBroadcastsThinking(t *testing.T) { t.Parallel() events, _ := runThinkingTurn(t, nil) kinds := kindsOf(events) if kinds[model.StreamKindThinkingDelta] != 1 || kinds[model.StreamKindThinkingStart] != 1 || kinds[model.StreamKindThinkingEnd] != 1 { t.Fatalf("thinking frames = %v, want start/delta/end each once with the option unset", kinds) } if len(events) != 6 { t.Fatalf("events = %d, want all 6 frames", len(events)) } } // TestRun_ThinkingShowToUserTrueBroadcastsThinking asserts an explicit true // behaves like the default. func TestRun_ThinkingShowToUserTrueBroadcastsThinking(t *testing.T) { t.Parallel() show := true events, _ := runThinkingTurn(t, &show) if kinds := kindsOf(events); kinds[model.StreamKindThinkingDelta] != 1 { t.Fatalf("thinking delta frames = %d, want 1 with showToUser=true", kinds[model.StreamKindThinkingDelta]) } } // TestRun_RequestCarriesThinkingKnobs asserts the loop injects its reasoning // options into every model request it builds, so a persona's thinking config is // actually applied rather than validated-and-ignored. func TestRun_RequestCarriesThinkingKnobs(t *testing.T) { t.Parallel() ad := &thinkingAdapter{} onStream, _ := collectStream() want := &model.Thinking{BudgetTokens: 4096} l := New(Options{ Agent: newTestAgent(t, ad, nil), OnStream: onStream, MaxRounds: 8, Thinking: want, ReasoningEffort: "low", }) if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { t.Fatalf("Run: %v", err) } if len(ad.reqs) == 0 { t.Fatal("no model request was recorded") } got := ad.reqs[0] if got.Thinking != want { t.Fatalf("request.Thinking = %+v, want %+v", got.Thinking, want) } if got.Thinking.BudgetTokens != 4096 { t.Fatalf("request.Thinking.BudgetTokens = %d, want 4096", got.Thinking.BudgetTokens) } if got.ReasoningEffort != "low" { t.Fatalf("request.ReasoningEffort = %q, want low", got.ReasoningEffort) } }