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