返回 DeepSeek-Reasonix
loop_e2e_reasoning_recovery_test.go
根目录 / internal / agent / loop_e2e_reasoning_recovery_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent/testutil"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 )
18
19 // A provider without the DeepSeek tool-call reasoning policy must keep the
20 // ordinary two-call tool loop even when its tool-call turn has no reasoning.
21 func TestRunNonDeepSeekMissingToolCallReasoningDoesNotRetry(t *testing.T) {
22 mp := testutil.NewMock("openai",
23 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}},
24 testutil.Turn{Text: "all set"},
25 )
26 sink := &recordSink{}
27 a := New(mp, echoRegistry(), NewSession(""), Options{}, sink)
28
29 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
30 t.Fatalf("Run: %v", err)
31 }
32 if got := mp.CallCount(); got != 2 {
33 t.Fatalf("provider calls = %d, want tool turn + final turn without recovery retry", got)
34 }
35 if got := len(sink.kinds(event.ToolDispatch)); got != 1 {
36 t.Fatalf("tool dispatches = %d, want one", got)
37 }
38 sink.mu.Lock()
39 recovery := append([]event.ProtocolRecoveryAudit(nil), sink.recovery...)
40 sink.mu.Unlock()
41 if len(recovery) != 0 {
42 t.Fatalf("non-DeepSeek provider emitted protocol recovery audits: %+v", recovery)
43 }
44 }
45
46 // A one-off missing reasoning_content response is replaced before any tool
47 // executes. The retry reuses identical input, its usage is accounted for, and
48 // no provider-protocol warning or duplicate tool card reaches the user.
49 func TestRunSilentlyRecoversMissingToolCallReasoning(t *testing.T) {
50 mp := testutil.NewMock("deepseek-proxy",
51 testutil.Turn{
52 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
53 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, CacheMissTokens: 10, FinishReason: "tool_calls"},
54 },
55 testutil.Turn{
56 Reasoning: "retry reasoning",
57 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
58 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 3, TotalTokens: 13, CacheHitTokens: 10, ReasoningTokens: 2, FinishReason: "tool_calls"},
59 },
60 testutil.Turn{Text: "done"},
61 )
62 sink := &recordSink{}
63 a := New(strictToolCallReasoningProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
64
65 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
66 t.Fatalf("Run: %v", err)
67 }
68 var savedToolTurns int
69 var savedReasoning string
70 for _, m := range a.Session().Messages {
71 if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
72 savedToolTurns++
73 savedReasoning = m.ReasoningContent
74 }
75 }
76 if savedToolTurns != 1 || savedReasoning != "retry reasoning" {
77 t.Fatalf("saved tool turns = %d reasoning = %q, want one recovered turn: %+v", savedToolTurns, savedReasoning, a.Session().Messages)
78 }
79 if mp.CallCount() != 3 {
80 t.Fatalf("provider calls = %d, want malformed + retry + final", mp.CallCount())
81 }
82 requests := mp.Requests()
83 if len(requests) < 2 || !reflect.DeepEqual(requests[0], requests[1]) {
84 t.Fatalf("protocol retry changed provider-visible request:\nfirst=%+v\nretry=%+v", requests[0], requests[1])
85 }
86 for _, e := range sink.kinds(event.Notice) {
87 if strings.Contains(e.Text, "reasoning") || strings.Contains(e.Detail, "reasoning") {
88 t.Fatalf("provider protocol leaked into user notice: %+v", e)
89 }
90 }
91 if got := len(sink.kinds(event.ToolDispatch)); got != 1 {
92 t.Fatalf("tool dispatches = %d, want one adopted call", got)
93 }
94 usageEvents := sink.kinds(event.Usage)
95 if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 25 || usageEvents[0].Usage.CacheHitTokens != 10 || usageEvents[0].Usage.CacheMissTokens != 10 {
96 t.Fatalf("recovery usage was not merged truthfully: %+v", usageEvents)
97 }
98 if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted) != 1 || sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered) != 1 {
99 t.Fatalf("unexpected recovery audit: %+v", sink.recovery)
100 }
101 }
102
103 // An exact recovery replay may choose a normal final answer instead of
104 // repeating the original tool call. The replacement is authoritative because
105 // no tool has run yet: discard the speculative call, persist only the final
106 // response, and classify the outcome separately from recovered reasoning.
107 func TestMissingReasoningRecoveryAdoptsRetryWithoutToolCall(t *testing.T) {
108 mp := testutil.NewMock("deepseek-proxy",
109 testutil.Turn{
110 ToolCalls: []provider.ToolCall{{ID: "discarded", Name: "echo", Arguments: `{"text":"must not run"}`}},
111 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, FinishReason: "tool_calls"},
112 },
113 testutil.Turn{
114 Text: "completed without a tool",
115 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 3, TotalTokens: 13, FinishReason: "stop"},
116 },
117 )
118 sink := &recordSink{}
119 a := New(strictToolCallReasoningProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
120
121 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
122 t.Fatalf("Run: %v", err)
123 }
124 if mp.CallCount() != 2 {
125 t.Fatalf("provider calls = %d, want malformed + replacement", mp.CallCount())
126 }
127 var toolTurns, toolResults int
128 for _, message := range a.Session().Messages {
129 if message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 {
130 toolTurns++
131 }
132 if message.Role == provider.RoleTool && !message.LocalOnly {
133 toolResults++
134 }
135 }
136 if toolTurns != 0 || toolResults != 0 {
137 t.Fatalf("discarded tool response reached session: turns=%d results=%d session=%+v", toolTurns, toolResults, a.Session().Messages)
138 }
139 last := a.Session().Messages[len(a.Session().Messages)-1]
140 if last.Role != provider.RoleAssistant || last.Content != "completed without a tool" {
141 t.Fatalf("replacement response not adopted: %+v", last)
142 }
143 if got := len(sink.kinds(event.ToolDispatch)); got != 0 {
144 t.Fatalf("discarded tool dispatches = %d, want 0", got)
145 }
146 usageEvents := sink.kinds(event.Usage)
147 if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 25 {
148 t.Fatalf("replacement usage was not merged truthfully: %+v", usageEvents)
149 }
150 if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted) != 1 ||
151 sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryReplaced) != 1 ||
152 sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryRecovered) != 0 ||
153 sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback) != 0 {
154 t.Fatalf("unexpected recovery classification: %+v", sink.recovery)
155 }
156 }
157
158 func TestCompatibleMissingReasoningKeepsOriginalWithoutRecovery(t *testing.T) {
159 mp := testutil.NewMock("deepseek-proxy",
160 testutil.Turn{
161 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}},
162 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, FinishReason: "tool_calls"},
163 },
164 testutil.Turn{Text: "done"},
165 )
166 sink := &recordSink{}
167 a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
168
169 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
170 t.Fatalf("Run should keep the provider-compatible original response, got %v", err)
171 }
172 var toolResults int
173 for _, message := range a.Session().Messages {
174 if message.Role == provider.RoleTool && message.ToolCallID == "c1" {
175 toolResults++
176 }
177 }
178 if toolResults != 1 {
179 t.Fatalf("tool results = %d, want the original call executed once", toolResults)
180 }
181 usageEvents := sink.kinds(event.Usage)
182 if len(usageEvents) == 0 || usageEvents[0].Usage == nil || usageEvents[0].Usage.TotalTokens != 12 {
183 t.Fatalf("failed recovery usage was not accounted for: %+v", usageEvents)
184 }
185 if sink.recoveryCount(event.ProtocolRecoveryMissingReasoningFallback) != 0 {
186 t.Fatalf("unexpected long-lived fallback audit: %+v", sink.recovery)
187 }
188 }
189
190 func TestMissingReasoningRecoveryCancellationAccountsBothAttempts(t *testing.T) {
191 prov := &cancelMissingReasoningRetryProvider{retryUsageSent: make(chan struct{})}
192 sink := &recordSink{}
193 a := New(prov, echoRegistry(), NewSession(""), Options{}, sink)
194 ctx, cancel := context.WithCancel(context.Background())
195 done := make(chan error, 1)
196 go func() { done <- a.Run(ctx, "go") }()
197
198 select {
199 case <-prov.retryUsageSent:
200 cancel()
201 case <-time.After(time.Second):
202 cancel()
203 t.Fatal("timed out waiting for the recovery retry usage")
204 }
205 if err := <-done; !errors.Is(err, context.Canceled) {
206 t.Fatalf("Run error = %v, want context cancellation", err)
207 }
208 if got := prov.calls.Load(); got != 2 {
209 t.Fatalf("provider calls = %d, want malformed response plus recovery retry", got)
210 }
211 if got := len(sink.kinds(event.ToolDispatch)); got != 0 {
212 t.Fatalf("discarded tool dispatches = %d, want 0", got)
213 }
214 usages := sink.kinds(event.Usage)
215 if len(usages) != 1 || usages[0].Usage == nil || usages[0].Usage.TotalTokens != 23 || usages[0].Usage.FinishReason != "interrupted" {
216 t.Fatalf("recovery cancellation usage = %+v, want one merged interrupted total of 23", usages)
217 }
218 }
219
220 func TestCompatibleMissingReasoningDoesNotRearmOnSessionChange(t *testing.T) {
221 mp := testutil.NewMock("deepseek-proxy",
222 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}},
223 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}},
224 testutil.Turn{Text: "done"},
225 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}},
226 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2r", Name: "echo", Arguments: `{"text":"hi"}`}}},
227 testutil.Turn{Text: "done again"},
228 )
229 sink := &recordSink{}
230 a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
231
232 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
233 t.Fatalf("first Run: %v", err)
234 }
235 a.SetSession(NewSession(""))
236 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
237 t.Fatalf("second Run: %v", err)
238 }
239 if got := sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
240 t.Fatalf("recovery retries across two sessions = %d, want 0", got)
241 }
242 }
243
244 // A shared state dir turns the old warning cooldown into a cross-process retry
245 // circuit breaker. The first process retries once; a fresh process immediately
246 // uses the empty-key fallback without doubling the request.
247 func TestCompatibleMissingReasoningIgnoresLegacyIncidentState(t *testing.T) {
248 stateDir := t.TempDir()
249 mp := testutil.NewMock("deepseek-proxy",
250 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}},
251 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}},
252 testutil.Turn{Text: "done"},
253 )
254 sink1 := &recordSink{}
255 a1 := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink1)
256 if err := a1.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
257 t.Fatalf("first Run: %v", err)
258 }
259 if got := sink1.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
260 t.Fatalf("first process recovery retries = %d, want 0", got)
261 }
262
263 mp2 := testutil.NewMock("deepseek-proxy",
264 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}},
265 testutil.Turn{Text: "done again"},
266 )
267 sink2 := &recordSink{}
268 a2 := New(toolCallReasoningRequiredProvider{mp2}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink2)
269 if err := a2.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
270 t.Fatalf("second process Run: %v", err)
271 }
272 if got := sink2.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
273 t.Fatalf("fresh process recovery retries = %d, want 0", got)
274 }
275 if got := sink2.recoveryCount(event.ProtocolRecoveryMissingReasoningRetrySuppressed); got != 0 {
276 t.Fatalf("fresh process suppressed retries = %d, want 0", got)
277 }
278 }
279
280 func TestMissingReasoningRecoverySeparatesProviderConfigurations(t *testing.T) {
281 stateDir := t.TempDir()
282 retryCount := func(identity string) int {
283 mp := testutil.NewMock("deepseek-proxy",
284 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}},
285 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1r", Name: "echo", Arguments: `{"text":"hi"}`}}},
286 testutil.Turn{Text: "done"},
287 )
288 sink := &recordSink{}
289 a := New(configuredToolCallReasoningProvider{MockProvider: mp, identity: identity}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
290 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil && !isReplayFailureForTest(err) {
291 t.Fatalf("Run(%q): %v", identity, err)
292 }
293 return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
294 }
295 if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-pro"); got != 1 {
296 t.Fatalf("first configuration retries = %d, want 1", got)
297 }
298 if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-pro"); got != 0 {
299 t.Fatalf("same configuration retries = %d, want 0", got)
300 }
301 if got := retryCount("openai\x00endpoint-b\x00deepseek-v4-pro"); got != 1 {
302 t.Fatalf("changed endpoint retries = %d, want 1", got)
303 }
304 if got := retryCount("openai\x00endpoint-a\x00deepseek-v4-flash"); got != 1 {
305 t.Fatalf("changed model retries = %d, want 1", got)
306 }
307 }
308
309 func TestThreeHealthyToolCallReasoningTurnsRearmFutureRegression(t *testing.T) {
310 stateDir := t.TempDir()
311 run := func(turns ...testutil.Turn) int {
312 mp := testutil.NewMock("deepseek-proxy", turns...)
313 sink := &recordSink{}
314 a := New(strictToolCallReasoningProvider{mp}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
315 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil && !isReplayFailureForTest(err) {
316 t.Fatalf("Run: %v", err)
317 }
318 return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
319 }
320 missing := testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}}
321 healthy := testutil.Turn{Reasoning: "call echo", ToolCalls: []provider.ToolCall{{ID: "c2", Name: "echo", Arguments: `{"text":"hi"}`}}}
322 if got := run(missing, missing, testutil.Turn{Text: "done"}); got != 1 {
323 t.Fatalf("first incident retries = %d, want 1", got)
324 }
325 for healthyTurn := 1; healthyTurn <= missingReasoningHealthyResolveStreak; healthyTurn++ {
326 if got := run(healthy, testutil.Turn{Text: "done"}); got != 0 {
327 t.Fatalf("healthy turn %d retries = %d, want 0", healthyTurn, got)
328 }
329 }
330 if got := run(missing, missing, testutil.Turn{Text: "done"}); got != 1 {
331 t.Fatalf("post-recovery regression retries = %d, want 1", got)
332 }
333 }
334
335 func TestHealthyToolCallReasoningStreakWorksWithinOneAgentAndResetsOnMissing(t *testing.T) {
336 stateDir := t.TempDir()
337 prov := strictToolCallReasoningProvider{testutil.NewMock("deepseek-proxy")}
338 a := New(prov, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, event.Discard)
339 calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
340
341 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || !retry {
342 t.Fatalf("initial observation = missing:%v retry:%v, want true/true", missing, retry)
343 }
344 for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
345 a.observeMissingToolCallReasoning(calls, "healthy reasoning")
346 }
347 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || retry {
348 t.Fatalf("missing reset = missing:%v retry:%v, want true/false", missing, retry)
349 }
350 for healthy := 1; healthy <= missingReasoningHealthyResolveStreak; healthy++ {
351 a.observeMissingToolCallReasoning(calls, "healthy reasoning")
352 }
353 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || !retry {
354 t.Fatalf("post-recovery observation = missing:%v retry:%v, want true/true", missing, retry)
355 }
356 }
357
358 func TestMissingReasoningRecoveryIOFailureStillSuppressesLocally(t *testing.T) {
359 statePath := filepath.Join(t.TempDir(), "not-a-directory")
360 if err := os.WriteFile(statePath, []byte("occupied"), 0o600); err != nil {
361 t.Fatal(err)
362 }
363 prov := strictToolCallReasoningProvider{testutil.NewMock("deepseek-proxy")}
364 a := New(prov, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: statePath}, event.Discard)
365 calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
366
367 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || !retry {
368 t.Fatalf("initial observation = missing:%v retry:%v, want true/true", missing, retry)
369 }
370 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || retry {
371 t.Fatalf("repeated observation = missing:%v retry:%v, want true/false", missing, retry)
372 }
373 }
374
375 func TestHealthyToolCallReasoningRetriesTransientStateWriteFailure(t *testing.T) {
376 if runtime.GOOS == "windows" {
377 t.Skip("chmod permissions are not portable to Windows")
378 }
379 stateDir := t.TempDir()
380 prov := strictToolCallReasoningProvider{testutil.NewMock("deepseek-proxy")}
381 a := New(prov, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, event.Discard)
382 calls := []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}
383
384 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || !retry {
385 t.Fatalf("initial observation = missing:%v retry:%v, want true/true", missing, retry)
386 }
387 if err := os.Chmod(stateDir, 0o500); err != nil {
388 t.Fatal(err)
389 }
390 permissionsRestored := false
391 defer func() {
392 if !permissionsRestored {
393 _ = os.Chmod(stateDir, 0o700)
394 }
395 }()
396 if missing, retry := a.observeMissingToolCallReasoning(calls, "healthy reasoning"); missing || retry {
397 t.Fatalf("healthy observation = missing:%v retry:%v, want false/false", missing, retry)
398 }
399 if err := os.Chmod(stateDir, 0o700); err != nil {
400 t.Fatal(err)
401 }
402 permissionsRestored = true
403 for healthy := range missingReasoningHealthyResolveStreak - 1 {
404 if missing, retry := a.observeMissingToolCallReasoning(calls, "healthy reasoning"); missing || retry {
405 t.Fatalf("healthy recovery observation %d = missing:%v retry:%v, want false/false", healthy+1, missing, retry)
406 }
407 }
408
409 if missing, retry := a.observeMissingToolCallReasoning(calls, ""); !missing || !retry {
410 t.Fatalf("post-recovery observation = missing:%v retry:%v, want true/true", missing, retry)
411 }
412 }
413
414 func TestRunPreservesOriginalRequiredToolCallReasoningAcrossHook(t *testing.T) {
415 mp := testutil.NewMock("deepseek-proxy",
416 testutil.Turn{
417 Reasoning: "original reasoning",
418 ToolCalls: []provider.ToolCall{{
419 ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`,
420 }},
421 },
422 testutil.Turn{Text: "done"},
423 )
424 h := &stubHooks{hasPostLLM: true, postLLMOut: "translated display"}
425 a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
426
427 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
428 t.Fatalf("Run: %v", err)
429 }
430 reqs := mp.Requests()
431 if len(reqs) != 2 {
432 t.Fatalf("provider calls = %d, want 2", len(reqs))
433 }
434 var toolCallAssistant provider.Message
435 for _, m := range reqs[1].Messages {
436 if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
437 toolCallAssistant = m
438 break
439 }
440 }
441 if toolCallAssistant.ReasoningContent != "original reasoning" {
442 t.Fatalf("tool-call reasoning = %q, want original provider reasoning", toolCallAssistant.ReasoningContent)
443 }
444 if toolCallAssistant.ReasoningContent == "translated display" {
445 t.Fatal("translated display text leaked into provider-visible tool-call reasoning")
446 }
447 }
448
449 func TestRunStoresTransformedNonToolReasoningForToolCallOnlyProvider(t *testing.T) {
450 mp := testutil.NewMock("deepseek-proxy", testutil.Turn{
451 Reasoning: "original reasoning",
452 Text: "done",
453 })
454 h := &stubHooks{hasPostLLM: true, postLLMOut: "translated display"}
455 a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
456
457 if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
458 t.Fatalf("Run: %v", err)
459 }
460 if got := assistantReasoning(a.sess.conversation.Messages); got != "translated display" {
461 t.Fatalf("stored non-tool reasoning = %q, want transformed display text", got)
462 }
463 }
464
464 lines GO