返回 DeepSeek-Reasonix
empty_final_test.go
根目录 / internal / agent / empty_final_test.go
1 package agent
2
3 import (
4 "context"
5 "strings"
6 "testing"
7
8 "reasonix/internal/event"
9 "reasonix/internal/provider"
10 "reasonix/internal/tool"
11 )
12
13 func TestRunRetriesReasoningOnlyFinalAnswer(t *testing.T) {
14 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
15 {
16 {Type: provider.ChunkReasoning, Text: "I should answer the user."},
17 {Type: provider.ChunkDone},
18 },
19 {
20 {Type: provider.ChunkText, Text: "visible reply"},
21 {Type: provider.ChunkDone},
22 },
23 }}
24 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
25
26 if err := a.Run(context.Background(), "answer me"); err != nil {
27 t.Fatalf("Run: %v", err)
28 }
29 if prov.call != 2 {
30 t.Fatalf("provider calls = %d, want retry after reasoning-only answer", prov.call)
31 }
32 if got := lastAssistantContent(a.session); got != "visible reply" {
33 t.Fatalf("last assistant content = %q, want visible reply", got)
34 }
35 if !sessionHasUserMessageContaining(a.session, "visible answer") {
36 t.Fatal("missing synthetic visible-answer retry message")
37 }
38 }
39
40 func TestRunPrefixesReasoningLanguageOnSyntheticRetry(t *testing.T) {
41 prov := &mockProvider{name: "p", streams: [][]provider.Chunk{
42 {
43 {Type: provider.ChunkReasoning, Text: "I should answer the user."},
44 {Type: provider.ChunkDone},
45 },
46 {
47 {Type: provider.ChunkText, Text: "visible reply"},
48 {Type: provider.ChunkDone},
49 },
50 }}
51 a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningLanguage: "zh"}, event.Discard)
52
53 if err := a.Run(context.Background(), "answer me"); err != nil {
54 t.Fatalf("Run: %v", err)
55 }
56 if len(prov.requests) != 2 {
57 t.Fatalf("provider requests = %d, want 2", len(prov.requests))
58 }
59 for i, req := range prov.requests {
60 got := lastUser(req)
61 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") {
62 t.Fatalf("request %d last user = %q, want reasoning-language prefix", i, got)
63 }
64 }
65 if !strings.Contains(lastUser(prov.requests[1]), "visible answer") {
66 t.Fatalf("retry request last user = %q, want visible-answer retry", lastUser(prov.requests[1]))
67 }
68 }
69
70 func TestRunStopsAfterRepeatedEmptyFinalAnswers(t *testing.T) {
71 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
72 {{Type: provider.ChunkReasoning, Text: "thinking 1"}, {Type: provider.ChunkDone}},
73 {{Type: provider.ChunkReasoning, Text: "thinking 2"}, {Type: provider.ChunkDone}},
74 {{Type: provider.ChunkReasoning, Text: "thinking 3"}, {Type: provider.ChunkDone}},
75 }}
76 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
77
78 err := a.Run(context.Background(), "answer me")
79 if err == nil {
80 t.Fatal("expected repeated empty final answers to stop the run")
81 }
82 if !strings.Contains(err.Error(), "visible final answer") {
83 t.Fatalf("error = %v, want visible final answer", err)
84 }
85 if prov.call != 3 {
86 t.Fatalf("provider calls = %d, want three empty-answer attempts", prov.call)
87 }
88 }
89
90 func lastAssistantContent(s *Session) string {
91 var out string
92 for _, m := range s.Messages {
93 if m.Role == provider.RoleAssistant {
94 out = m.Content
95 }
96 }
97 return out
98 }
99
100 // deepseekThinkingProvider marks a scripted provider as DeepSeek thinking mode
101 // (provider.ToolCallReasoningPolicy) — the scope within which a reasoning-only
102 // finish_reason="stop" turn is accepted as a final answer.
103 type deepseekThinkingProvider struct{ *scriptedProvider }
104
105 func (deepseekThinkingProvider) RequiresToolCallReasoning() bool { return true }
106
107 func TestRunAcceptsReasoningOnlyFinalWhenModelStopped(t *testing.T) {
108 // DeepSeek thinking mode streams a long reasoning_content and then
109 // finishes with finish_reason="stop" but an empty content block. The
110 // model has explicitly signalled completion and its reasoning was
111 // streamed to the user, so the host must accept the turn instead of
112 // retrying and forcing another expensive thinking round.
113 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
114 {
115 {Type: provider.ChunkReasoning, Text: "The user asked a simple question; I have reasoned through it and the answer is ready."},
116 {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}},
117 {Type: provider.ChunkDone},
118 },
119 }}
120 a := New(deepseekThinkingProvider{prov}, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
121
122 if err := a.Run(context.Background(), "answer me"); err != nil {
123 t.Fatalf("Run: %v", err)
124 }
125 if prov.call != 1 {
126 t.Fatalf("provider calls = %d, want 1 (model signalled stop; no retry)", prov.call)
127 }
128 if sessionHasUserMessageContaining(a.session, "visible answer") {
129 t.Fatal("must not inject a synthetic visible-answer retry when the model signalled stop")
130 }
131 if got := lastAssistantContent(a.session); got != "" {
132 t.Fatalf("last assistant content = %q, want empty (answer lived in reasoning)", got)
133 }
134 }
135
136 func TestRunRetriesReasoningOnlyStopWithoutDeepSeekPolicy(t *testing.T) {
137 // Same chunk sequence as the accept test, but the provider does not
138 // declare DeepSeek thinking mode (ToolCallReasoningPolicy). The accept
139 // path must stay scoped to DeepSeek: local <think>-tag models keep the
140 // retry safety net that often recovers a visible answer on the second
141 // attempt, and a gateway that mislabels truncation as "stop" must not
142 // have a degenerate turn committed as the final answer.
143 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
144 {
145 {Type: provider.ChunkReasoning, Text: "thinking only, nothing visible"},
146 {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}},
147 {Type: provider.ChunkDone},
148 },
149 {
150 {Type: provider.ChunkText, Text: "visible reply"},
151 {Type: provider.ChunkDone},
152 },
153 }}
154 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
155
156 if err := a.Run(context.Background(), "answer me"); err != nil {
157 t.Fatalf("Run: %v", err)
158 }
159 if prov.call != 2 {
160 t.Fatalf("provider calls = %d, want 2 (non-DeepSeek providers must keep the retry)", prov.call)
161 }
162 if !sessionHasUserMessageContaining(a.session, "visible answer") {
163 t.Fatal("missing synthetic visible-answer retry message for non-DeepSeek provider")
164 }
165 if got := lastAssistantContent(a.session); got != "visible reply" {
166 t.Fatalf("last assistant content = %q, want visible reply", got)
167 }
168 }
169
170 func BenchmarkHasVisibleFinalAnswer(b *testing.B) {
171 cases := []struct {
172 name string
173 text string
174 }{
175 {"normal", "visible reply"},
176 {"leading-space", strings.Repeat(" ", 256) + "visible reply"},
177 {"all-space", strings.Repeat(" \n\t", 256)},
178 }
179 for _, tc := range cases {
180 b.Run(tc.name, func(b *testing.B) {
181 b.ReportAllocs()
182 var got bool
183 for i := 0; i < b.N; i++ {
184 got = hasVisibleFinalAnswer(tc.text)
185 }
186 _ = got
187 })
188 }
189 }
190
190 lines GO