返回 DeepSeek-Reasonix
session_title_test.go
根目录 / internal / control / session_title_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 )
12
13 type sessionTitleProviderStub struct {
14 out string
15 reasoning string
16 finishReason string
17 err error
18 requests []provider.Request
19 }
20
21 type sessionTitleResolverStub struct {
22 descriptors []provider.Descriptor
23 provider provider.Provider
24 resolve func(provider.Selection) (provider.Provider, error)
25 selections []provider.Selection
26 }
27
28 func (r *sessionTitleResolverStub) Catalog() []provider.Descriptor {
29 return append([]provider.Descriptor(nil), r.descriptors...)
30 }
31
32 func (r *sessionTitleResolverStub) Resolve(selection provider.Selection) (provider.Provider, error) {
33 r.selections = append(r.selections, selection)
34 if r.resolve != nil {
35 return r.resolve(selection)
36 }
37 return r.provider, nil
38 }
39
40 func (p *sessionTitleProviderStub) ReasoningCapability() provider.ReasoningCapability {
41 return provider.ReasoningOptions("", "low", "high")
42 }
43
44 func (p *sessionTitleProviderStub) Name() string { return "session-title-stub" }
45
46 func (p *sessionTitleProviderStub) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
47 p.requests = append(p.requests, req)
48 if p.err != nil {
49 return nil, p.err
50 }
51 ch := make(chan provider.Chunk, 4)
52 if p.reasoning != "" {
53 ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: p.reasoning}
54 }
55 if p.out != "" {
56 ch <- provider.Chunk{Type: provider.ChunkText, Text: p.out}
57 }
58 if p.finishReason != "" {
59 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: p.finishReason}}
60 }
61 ch <- provider.Chunk{Type: provider.ChunkDone}
62 close(ch)
63 return ch, nil
64 }
65
66 func sessionTitleTestController(t *testing.T, prov provider.Provider, sink event.Sink) *Controller {
67 return newOwnedTestController(t, Options{
68 ModelRef: "test/title-model",
69 Sink: sink,
70 ProviderResolver: &provider.StaticResolver{
71 Descriptors: []provider.Descriptor{{Ref: "test/title-model"}},
72 Providers: map[string]provider.Provider{"test/title-model": prov},
73 },
74 })
75 }
76
77 func TestGenerateSessionTitleUsesBoundedNoToolRequest(t *testing.T) {
78 prov := &sessionTitleProviderStub{out: " “Fix login redirect loop” "}
79 ctrl := sessionTitleTestController(t, prov, event.Discard)
80 title, err := ctrl.GenerateSessionTitle(context.Background(), "User: login redirects forever")
81 if err != nil {
82 t.Fatalf("GenerateSessionTitle: %v", err)
83 }
84 if title != "Fix login redirect loop" {
85 t.Fatalf("title = %q", title)
86 }
87 if len(prov.requests) != 1 {
88 t.Fatalf("requests = %d", len(prov.requests))
89 }
90 req := prov.requests[0]
91 if len(req.Tools) != 0 || req.MaxTokens != 512 || req.EffortOverride != "low" || len(req.Messages) != 2 {
92 t.Fatalf("request = %+v", req)
93 }
94 if req.Messages[0].Content != sessionTitleSystemPrompt {
95 t.Fatal("unexpected system prompt")
96 }
97 }
98
99 func TestGenerateSessionTitleDisablesAdvertisedReasoning(t *testing.T) {
100 thinking := &sessionTitleProviderStub{reasoning: "The transcript is about", finishReason: "length"}
101 disabled := &sessionTitleProviderStub{out: "Short title", finishReason: "stop"}
102 resolver := &sessionTitleResolverStub{
103 descriptors: []provider.Descriptor{{
104 Ref: "test/title-model",
105 Efforts: []string{"disabled", "high", "max"},
106 }},
107 resolve: func(selection provider.Selection) (provider.Provider, error) {
108 if selection.Effort != nil && *selection.Effort == "disabled" {
109 return disabled, nil
110 }
111 return thinking, nil
112 },
113 }
114 ctrl := newOwnedTestController(t, Options{
115 ModelRef: "test/title-model",
116 Sink: event.Discard,
117 ProviderResolver: resolver,
118 })
119 if _, err := ctrl.GenerateSessionTitle(context.Background(), "User: diagnose an empty generated title"); err != nil {
120 t.Fatalf("GenerateSessionTitle: %v", err)
121 }
122 if len(resolver.selections) != 1 {
123 t.Fatalf("provider resolutions = %d, want 1", len(resolver.selections))
124 }
125 effort := resolver.selections[0].Effort
126 if effort == nil || *effort != "disabled" {
127 t.Fatalf("title effort = %v, want disabled", effort)
128 }
129 }
130
131 func TestGenerateSessionTitleForModelUsesTargetModelWithoutMutatingControllerSelection(t *testing.T) {
132 active := &sessionTitleProviderStub{out: "active title"}
133 target := &sessionTitleProviderStub{out: "target title"}
134 resolver := &sessionTitleResolverStub{
135 descriptors: []provider.Descriptor{{Ref: "active/model"}, {Ref: "target/model"}},
136 resolve: func(selection provider.Selection) (provider.Provider, error) {
137 if selection.Ref == "target/model" {
138 return target, nil
139 }
140 return active, nil
141 },
142 }
143 ctrl := newOwnedTestController(t, Options{
144 ModelRef: "active/model",
145 Sink: event.Discard,
146 ProviderResolver: resolver,
147 })
148 title, err := ctrl.GenerateSessionTitleForModel(t.Context(), "target/model", "cold target transcript")
149 if err != nil || title != "target title" {
150 t.Fatalf("GenerateSessionTitleForModel = %q, %v", title, err)
151 }
152 if len(resolver.selections) != 1 || resolver.selections[0].Ref != "target/model" {
153 t.Fatalf("provider selections = %+v", resolver.selections)
154 }
155 if len(active.requests) != 0 || len(target.requests) != 1 {
156 t.Fatalf("active requests = %d, target requests = %d", len(active.requests), len(target.requests))
157 }
158 if ctrl.ModelRef() != "active/model" {
159 t.Fatalf("controller model changed to %q", ctrl.ModelRef())
160 }
161 }
162
163 func TestGenerateSessionTitleBoundsTranscriptAndOutput(t *testing.T) {
164 prov := &sessionTitleProviderStub{out: strings.Repeat("long title ", 20)}
165 ctrl := sessionTitleTestController(t, prov, event.Discard)
166 title, err := ctrl.GenerateSessionTitle(context.Background(), strings.Repeat("界", sessionTitleMaxTranscriptRunes+100))
167 if err != nil {
168 t.Fatalf("GenerateSessionTitle: %v", err)
169 }
170 if got := len([]rune(prov.requests[0].Messages[1].Content)); got != sessionTitleMaxTranscriptRunes {
171 t.Fatalf("transcript runes = %d", got)
172 }
173 if got := len([]rune(title)); got > sessionTitleMaxRunes+1 {
174 t.Fatalf("title runes = %d: %q", got, title)
175 }
176 }
177
178 func TestGenerateSessionTitleRejectsMissingInputsAndProviderFailures(t *testing.T) {
179 if _, err := sessionTitleTestController(t, &sessionTitleProviderStub{}, event.Discard).GenerateSessionTitle(context.Background(), " "); err == nil {
180 t.Fatal("empty transcript should fail")
181 }
182 if _, err := newOwnedTestController(t, Options{ModelRef: "test/title-model"}).GenerateSessionTitle(context.Background(), "hello"); err == nil {
183 t.Fatal("missing resolver should fail")
184 }
185 ctrl := sessionTitleTestController(t, &sessionTitleProviderStub{err: errors.New("boom")}, event.Discard)
186 if _, err := ctrl.GenerateSessionTitle(context.Background(), "hello"); err == nil || !strings.Contains(err.Error(), "boom") {
187 t.Fatalf("provider error = %v", err)
188 }
189 }
190
191 func TestCleanSessionTitle(t *testing.T) {
192 tests := map[string]string{
193 `"Hello"`: "Hello",
194 " spaced out ": "spaced out",
195 "Trailing punctuation!": "Trailing punctuation!",
196 "‘中文会话标题’": "中文会话标题",
197 }
198 for input, want := range tests {
199 if got := cleanSessionTitle(input); got != want {
200 t.Errorf("cleanSessionTitle(%q) = %q, want %q", input, got, want)
201 }
202 }
203 }
204
204 lines GO