返回 DeepSeek-Reasonix
postllmcall_flow_test.go
根目录 / internal / agent / postllmcall_flow_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 reasoningTurn() [][]provider.Chunk {
14 return [][]provider.Chunk{{
15 {Type: provider.ChunkReasoning, Text: "think A "},
16 {Type: provider.ChunkReasoning, Text: "think B"},
17 {Type: provider.ChunkText, Text: "the answer"},
18 {Type: provider.ChunkDone},
19 }}
20 }
21
22 func recordReasoning(events *[]string) event.Sink {
23 return event.FuncSink(func(e event.Event) {
24 if e.Kind == event.Reasoning {
25 *events = append(*events, e.Text)
26 }
27 })
28 }
29
30 func assistantReasoning(msgs []provider.Message) string {
31 for _, m := range msgs {
32 if m.Role == provider.RoleAssistant {
33 return m.ReasoningContent
34 }
35 }
36 return ""
37 }
38
39 type reasoningRoundTripScriptedProvider struct {
40 *scriptedProvider
41 }
42
43 func (reasoningRoundTripScriptedProvider) RequiresReasoningRoundTrip() bool { return true }
44
45 type assistantReasoningReplayScriptedProvider struct {
46 *scriptedProvider
47 }
48
49 func (assistantReasoningReplayScriptedProvider) RequiresAssistantReasoningReplay(m provider.Message) bool {
50 return m.ReasoningContent != ""
51 }
52
53 // TestPostLLMCallAbsentStreamsReasoningLive is the regression guard: with no
54 // PostLLMCall hook, reasoning must still stream chunk-by-chunk (one Reasoning
55 // event per delta) so the live "thinking…" display keeps working.
56 func TestPostLLMCallAbsentStreamsReasoningLive(t *testing.T) {
57 prov := &scriptedProvider{name: "p", turns: reasoningTurn()}
58 var reasoningEvents []string
59 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, recordReasoning(&reasoningEvents))
60
61 if err := a.Run(context.Background(), "go"); err != nil {
62 t.Fatalf("Run: %v", err)
63 }
64
65 if len(reasoningEvents) != 2 {
66 t.Fatalf("want 2 live reasoning events (one per chunk), got %d: %v", len(reasoningEvents), reasoningEvents)
67 }
68 if joined := strings.Join(reasoningEvents, ""); joined != "think A think B" {
69 t.Fatalf("streamed reasoning = %q, want the full chain", joined)
70 }
71 if got := assistantReasoning(a.sess.conversation.Messages); got != "think A think B" {
72 t.Fatalf("stored reasoning = %q, want the untransformed chain", got)
73 }
74 }
75
76 // TestPostLLMCallTransformsReasoningOnce proves a configured hook suppresses the
77 // live stream, sees the full reasoning, and its output replaces both the single
78 // emitted Reasoning event and the stored reasoning_content.
79 func TestPostLLMCallTransformsReasoningOnce(t *testing.T) {
80 prov := &scriptedProvider{name: "p", turns: reasoningTurn()}
81 var reasoningEvents []string
82 h := &stubHooks{hasPostLLM: true, postLLMOut: "TRANSLATED"}
83 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, recordReasoning(&reasoningEvents))
84
85 if err := a.Run(context.Background(), "go"); err != nil {
86 t.Fatalf("Run: %v", err)
87 }
88
89 if len(reasoningEvents) != 1 || reasoningEvents[0] != "TRANSLATED" {
90 t.Fatalf("want one transformed reasoning event, got %v", reasoningEvents)
91 }
92 if len(h.postLLMSeen) != 1 || h.postLLMSeen[0] != "think A think B" {
93 t.Fatalf("hook saw %v, want the full original reasoning once", h.postLLMSeen)
94 }
95 if len(h.postLLMTurns) != 1 || h.postLLMTurns[0] != 1 {
96 t.Fatalf("hook turns = %v, want [1]", h.postLLMTurns)
97 }
98 if got := assistantReasoning(a.sess.conversation.Messages); got != "TRANSLATED" {
99 t.Fatalf("stored reasoning = %q, want the hook's replacement", got)
100 }
101 }
102
103 func TestPostLLMCallKeepsOriginalForReasoningRoundTripProvider(t *testing.T) {
104 prov := reasoningRoundTripScriptedProvider{&scriptedProvider{name: "p", turns: reasoningTurn()}}
105 h := &stubHooks{hasPostLLM: true, postLLMOut: "TRANSLATED"}
106 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
107
108 if err := a.Run(context.Background(), "go"); err != nil {
109 t.Fatalf("Run: %v", err)
110 }
111 if got := assistantReasoning(a.sess.conversation.Messages); got != "think A think B" {
112 t.Fatalf("stored reasoning = %q, want raw provider reasoning for replay", got)
113 }
114 }
115
116 func TestPostLLMCallKeepsOriginalForPlainReasoningReplayProvider(t *testing.T) {
117 prov := assistantReasoningReplayScriptedProvider{&scriptedProvider{name: "p", turns: reasoningTurn()}}
118 h := &stubHooks{hasPostLLM: true, postLLMOut: "TRANSLATED"}
119 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
120
121 if err := a.Run(context.Background(), "go"); err != nil {
122 t.Fatalf("Run: %v", err)
123 }
124 if got := assistantReasoning(a.sess.conversation.Messages); got != "think A think B" {
125 t.Fatalf("stored reasoning = %q, want original provider reasoning", got)
126 }
127 }
128
129 // TestPostLLMCallKeepsOriginalForProviderReasoningMetadata proves that a
130 // provider-issued reasoning item ID/status pins the original reasoning text.
131 // Replaying hook-transformed text beside that metadata can make the provider
132 // reject the next request because the ID no longer identifies the same item.
133 func TestPostLLMCallKeepsOriginalForProviderReasoningMetadata(t *testing.T) {
134 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{
135 {Type: provider.ChunkReasoning, Text: "think A "},
136 {Type: provider.ChunkReasoning, Text: "think B"},
137 {Type: provider.ChunkReasoning, ReasoningID: "rs_123", ReasoningStatus: "completed"},
138 {Type: provider.ChunkText, Text: "answer"},
139 {Type: provider.ChunkDone},
140 }}}
141 var reasoningEvents []string
142 h := &stubHooks{hasPostLLM: true, postLLMOut: "TRANSLATED"}
143 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, recordReasoning(&reasoningEvents))
144
145 if err := a.Run(context.Background(), "go"); err != nil {
146 t.Fatalf("Run: %v", err)
147 }
148 if len(reasoningEvents) != 1 || reasoningEvents[0] != "TRANSLATED" {
149 t.Fatalf("want transformed reasoning shown live, got %v", reasoningEvents)
150 }
151 for _, m := range a.sess.conversation.Messages {
152 if m.Role != provider.RoleAssistant {
153 continue
154 }
155 if m.ReasoningContent != "think A think B" {
156 t.Fatalf("stored reasoning = %q, want original provider text", m.ReasoningContent)
157 }
158 if m.ReasoningID != "rs_123" || m.ReasoningStatus != "completed" {
159 t.Fatalf("stored reasoning metadata = (%q, %q), want (rs_123, completed)", m.ReasoningID, m.ReasoningStatus)
160 }
161 return
162 }
163 t.Fatal("assistant message was not stored")
164 }
165
166 // TestPostLLMCallConfiguredButNoReasoning makes sure a hook with an empty
167 // reasoning chain neither calls the hook nor emits a stray Reasoning event.
168 func TestPostLLMCallConfiguredButNoReasoning(t *testing.T) {
169 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{
170 {Type: provider.ChunkText, Text: "answer, no thinking"},
171 {Type: provider.ChunkDone},
172 }}}
173 var reasoningEvents []string
174 h := &stubHooks{hasPostLLM: true, postLLMOut: "should not be used"}
175 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, recordReasoning(&reasoningEvents))
176
177 if err := a.Run(context.Background(), "go"); err != nil {
178 t.Fatalf("Run: %v", err)
179 }
180
181 if len(reasoningEvents) != 0 {
182 t.Fatalf("no reasoning should emit no Reasoning events, got %v", reasoningEvents)
183 }
184 if len(h.postLLMSeen) != 0 {
185 t.Fatalf("hook should not fire on empty reasoning, saw %v", h.postLLMSeen)
186 }
187 }
188
189 // TestPostLLMCallKeepsSignedReasoningOriginal proves that when the reasoning is
190 // pinned by a provider signature (Anthropic extended thinking), a transform hook
191 // changes only the live display — the stored reasoning_content stays the original
192 // so the signed thinking block can be replayed verbatim on the next tool-call
193 // turn. Storing the transformed text under the original signature is a 400.
194 func TestPostLLMCallKeepsSignedReasoningOriginal(t *testing.T) {
195 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{
196 {Type: provider.ChunkReasoning, Text: "think A "},
197 {Type: provider.ChunkReasoning, Text: "think B", Signature: "sig-xyz"},
198 {Type: provider.ChunkText, Text: "answer"},
199 {Type: provider.ChunkDone},
200 }}}
201 var reasoningEvents []string
202 h := &stubHooks{hasPostLLM: true, postLLMOut: "TRANSLATED"}
203 a := New(prov, tool.NewRegistry(), NewSession(""), Options{Hooks: h}, recordReasoning(&reasoningEvents))
204
205 if err := a.Run(context.Background(), "go"); err != nil {
206 t.Fatalf("Run: %v", err)
207 }
208 if len(reasoningEvents) != 1 || reasoningEvents[0] != "TRANSLATED" {
209 t.Fatalf("want the transformed reasoning shown live, got %v", reasoningEvents)
210 }
211 if got := assistantReasoning(a.sess.conversation.Messages); got != "think A think B" {
212 t.Fatalf("stored reasoning = %q, want the original (signature pins it)", got)
213 }
214 for _, m := range a.sess.conversation.Messages {
215 if m.Role == provider.RoleAssistant && m.ReasoningSignature != "sig-xyz" {
216 t.Fatalf("stored signature = %q, want sig-xyz alongside its original text", m.ReasoningSignature)
217 }
218 }
219 }
220
220 lines GO