返回 DeepSeek-Reasonix
server_search_e2e_test.go
根目录 / internal / agent / server_search_e2e_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/agent/testutil"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 type strictAssistantReasoningProvider struct{ *testutil.MockProvider }
18
19 func (p strictAssistantReasoningProvider) RequiresToolCallReasoning() bool { return true }
20 func (p strictAssistantReasoningProvider) WarnOnMissingToolCallReasoning() bool { return true }
21 func (p strictAssistantReasoningProvider) RequiresAssistantReasoningReplay(m provider.Message) bool {
22 return len(m.ToolCalls) > 0 || len(m.ServerSearch) > 0
23 }
24
25 type strictRoundTripReasoningProvider struct{ *testutil.MockProvider }
26
27 func (p strictRoundTripReasoningProvider) RequiresReasoningRoundTrip() bool { return true }
28
29 func TestRunPersistsServerSearchAndEmitsCardEvents(t *testing.T) {
30 search := provider.ServerSearchCall{
31 ID: "s1",
32 Query: "latest",
33 Results: []provider.ServerSearchHit{{Title: "Change Log", URL: "https://api-docs.deepseek.com/updates/"}},
34 Raw: json.RawMessage(`[{"title":"Change Log","encrypted_content":"xxx"}]`),
35 }
36 sink := &recordSink{}
37 prov := testutil.NewMock("deepseek", testutil.Turn{Chunks: []provider.Chunk{
38 {Type: provider.ChunkServerSearch, ServerSearch: &provider.ServerSearchCall{ID: search.ID}},
39 {Type: provider.ChunkServerSearch, ServerSearch: &provider.ServerSearchCall{ID: search.ID, Query: search.Query}},
40 {Type: provider.ChunkServerSearch, ServerSearch: &search},
41 {Type: provider.ChunkText, Text: "answer only"},
42 {Type: provider.ChunkDone},
43 }})
44 session := NewSession("system")
45 agent := New(prov, tool.NewRegistry(), session, Options{}, sink)
46 if err := agent.Run(context.Background(), "search"); err != nil {
47 t.Fatalf("Run: %v", err)
48 }
49
50 assistant := session.Snapshot()[len(session.Snapshot())-1]
51 if assistant.Content != "answer only" || len(assistant.ServerSearch) != 1 || assistant.ServerSearch[0].ID != "s1" || assistant.ServerSearch[0].Query != "latest" {
52 t.Fatalf("persisted assistant = %#v", assistant)
53 }
54 if string(assistant.ServerSearch[0].Raw) != string(search.Raw) {
55 t.Fatalf("persisted raw = %s", assistant.ServerSearch[0].Raw)
56 }
57
58 dispatches := sink.kinds(event.ToolDispatch)
59 results := sink.kinds(event.ToolResult)
60 if len(dispatches) == 0 || dispatches[0].Tool.Name != "web_search" || dispatches[0].Tool.ID != "s1" {
61 t.Fatalf("dispatches = %#v", dispatches)
62 }
63 if len(results) != 1 || results[0].Tool.Name != "web_search" || !strings.Contains(results[0].Tool.Output, "Change Log") {
64 t.Fatalf("results = %#v", results)
65 }
66
67 path := filepath.Join(t.TempDir(), "server-search.jsonl")
68 if err := session.Save(path); err != nil {
69 t.Fatalf("Save: %v", err)
70 }
71 loaded, err := LoadSession(path)
72 if err != nil {
73 t.Fatalf("LoadSession: %v", err)
74 }
75 loadedAssistant := loaded.Messages[len(loaded.Messages)-1]
76 if len(loadedAssistant.ServerSearch) != 1 || loadedAssistant.ServerSearch[0].Query != "latest" {
77 t.Fatalf("reloaded ServerSearch = %#v", loadedAssistant.ServerSearch)
78 }
79 }
80
81 func serverSearchChunks(search provider.ServerSearchCall, reasoning, text string) []provider.Chunk {
82 var chunks []provider.Chunk
83 if reasoning != "" {
84 chunks = append(chunks, provider.Chunk{Type: provider.ChunkReasoning, Text: reasoning})
85 }
86 chunks = append(chunks,
87 provider.Chunk{Type: provider.ChunkServerSearch, ServerSearch: &provider.ServerSearchCall{ID: search.ID, Query: search.Query}},
88 provider.Chunk{Type: provider.ChunkServerSearch, ServerSearch: &search},
89 )
90 if text != "" {
91 chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: text})
92 }
93 return append(chunks, provider.Chunk{Type: provider.ChunkDone})
94 }
95
96 func TestServerSearchPreservesRawReasoningAcrossPostLLMHook(t *testing.T) {
97 search := provider.ServerSearchCall{ID: "s1", Query: "latest", Raw: json.RawMessage(`[]`)}
98 mp := testutil.NewMock("deepseek", testutil.Turn{Chunks: serverSearchChunks(search, "provider original", "answer")})
99 hooks := &stubHooks{hasPostLLM: true, postLLMOut: "translated display"}
100 session := NewSession("system")
101 a := New(strictAssistantReasoningProvider{mp}, tool.NewRegistry(), session, Options{Hooks: hooks}, event.Discard)
102 if err := a.Run(withNoClosedLoop(context.Background()), "search"); err != nil {
103 t.Fatal(err)
104 }
105 assistant := session.Snapshot()[len(session.Snapshot())-1]
106 if assistant.ReasoningContent != "provider original" {
107 t.Fatalf("stored reasoning = %q", assistant.ReasoningContent)
108 }
109 }
110
111 func TestMissingServerSearchReasoningRetriesThenSalvagesHistory(t *testing.T) {
112 search := provider.ServerSearchCall{ID: "s1", Query: "latest", Raw: json.RawMessage(`[]`)}
113 mp := testutil.NewMock("deepseek",
114 testutil.Turn{Chunks: serverSearchChunks(search, "", "answer")},
115 testutil.Turn{Chunks: serverSearchChunks(search, "", "answer")},
116 testutil.Turn{Text: "continued"},
117 )
118 sink := &recordSink{}
119 session := NewSession("system")
120 a := New(strictAssistantReasoningProvider{mp}, tool.NewRegistry(), session, Options{}, sink)
121 if err := a.Run(withNoClosedLoop(context.Background()), "search"); err != nil {
122 t.Fatal(err)
123 }
124 assistant := session.Snapshot()[len(session.Snapshot())-1]
125 if assistant.Content != "answer" || assistant.ReasoningContent != "" || len(assistant.ServerSearch) != 1 {
126 t.Fatalf("salvaged assistant = %+v", assistant)
127 }
128 if got := sink.recoveryCount(event.ProtocolRecoveryServerSearchSalvaged); got != 1 {
129 t.Fatalf("salvage audits = %d", got)
130 }
131 if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err != nil {
132 t.Fatal(err)
133 }
134 req := mp.Requests()[2]
135 for _, m := range req.Messages {
136 if len(m.ServerSearch) > 0 {
137 t.Fatalf("unreplayable search leaked to next request: %+v", req.Messages)
138 }
139 }
140 lastUser := req.Messages[len(req.Messages)-1]
141 if !strings.Contains(lastUser.Content, "<interrupted-turn-recovery>") {
142 t.Fatalf("missing one-shot recovery handoff: %s", lastUser.Content)
143 }
144 }
145
146 func TestMissingClientToolReasoningFailsBeforeExecution(t *testing.T) {
147 call := provider.ToolCall{ID: "c1", Name: "echo", Arguments: `{"text":"must not run"}`}
148 mp := testutil.NewMock("deepseek",
149 testutil.Turn{ToolCalls: []provider.ToolCall{call}},
150 testutil.Turn{ToolCalls: []provider.ToolCall{call}},
151 )
152 session := NewSession("system")
153 a := New(strictAssistantReasoningProvider{mp}, echoRegistry(), session, Options{}, event.Discard)
154 err := a.Run(withNoClosedLoop(context.Background()), "go")
155 var replayErr *ReasoningReplayError
156 if !errors.As(err, &replayErr) || replayErr.Kind != ReasoningReplayMissing {
157 t.Fatalf("Run error = %v", err)
158 }
159 for _, m := range provider.ModelMessages(session.Snapshot()) {
160 if len(m.ToolCalls) > 0 || m.Role == provider.RoleTool {
161 t.Fatalf("unreplayable client tool committed: %+v", session.Snapshot())
162 }
163 }
164 }
165
166 func TestReasoningOverflowIsSafeForReplayContracts(t *testing.T) {
167 long := strings.Repeat("思考", 32)
168 call := provider.ToolCall{ID: "c1", Name: "echo", Arguments: `{"text":"no"}`}
169 t.Run("client tool fails", func(t *testing.T) {
170 mp := testutil.NewMock("deepseek", testutil.Turn{Reasoning: long, ToolCalls: []provider.ToolCall{call}})
171 a := New(strictAssistantReasoningProvider{mp}, echoRegistry(), NewSession("system"), Options{ReasoningByteLimit: 16}, event.Discard)
172 var replayErr *ReasoningReplayError
173 if err := a.Run(withNoClosedLoop(context.Background()), "go"); !errors.As(err, &replayErr) || replayErr.Kind != ReasoningReplayOverflow {
174 t.Fatalf("Run error = %v", err)
175 }
176 })
177 t.Run("server search keeps answer", func(t *testing.T) {
178 search := provider.ServerSearchCall{ID: "s1", Query: "latest", Raw: json.RawMessage(`[]`)}
179 mp := testutil.NewMock("deepseek", testutil.Turn{Chunks: serverSearchChunks(search, long, "answer")})
180 session := NewSession("system")
181 a := New(strictAssistantReasoningProvider{mp}, tool.NewRegistry(), session, Options{ReasoningByteLimit: 16}, event.Discard)
182 if err := a.Run(withNoClosedLoop(context.Background()), "search"); err != nil {
183 t.Fatal(err)
184 }
185 assistant := session.Snapshot()[len(session.Snapshot())-1]
186 if assistant.Content != "answer" || assistant.ReasoningContent != "" || len(assistant.ServerSearch) != 1 {
187 t.Fatalf("assistant = %+v", assistant)
188 }
189 })
190 t.Run("all-turn round trip fails", func(t *testing.T) {
191 mp := testutil.NewMock("roundtrip", testutil.Turn{Reasoning: long, Text: "answer"})
192 a := New(strictRoundTripReasoningProvider{mp}, tool.NewRegistry(), NewSession("system"), Options{ReasoningByteLimit: 16}, event.Discard)
193 var replayErr *ReasoningReplayError
194 if err := a.Run(withNoClosedLoop(context.Background()), "go"); !errors.As(err, &replayErr) || replayErr.Kind != ReasoningReplayOverflow {
195 t.Fatalf("Run error = %v", err)
196 }
197 })
198 }
199
200 func TestLegacyMissingReasoningToolHistoryIsProjectedOutOnce(t *testing.T) {
201 call := provider.ToolCall{ID: "old", Name: "write_file", Arguments: `{"path":"done.txt","content":"done"}`}
202 session := NewSession("system")
203 session.Add(provider.Message{Role: provider.RoleUser, Content: "write it"})
204 session.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{call}})
205 session.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "old", Name: "write_file", Content: "ok"})
206 path := filepath.Join(t.TempDir(), "v1.25.1-poisoned.jsonl")
207 if err := session.Save(path); err != nil {
208 t.Fatal(err)
209 }
210 loaded, err := LoadSession(path)
211 if err != nil {
212 t.Fatal(err)
213 }
214 mp := testutil.NewMock("deepseek", testutil.Turn{Text: "continued"}, testutil.Turn{Text: "continued again"})
215 a := New(strictAssistantReasoningProvider{mp}, tool.NewRegistry(), loaded, Options{}, event.Discard)
216 if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err != nil {
217 t.Fatal(err)
218 }
219 first := mp.Requests()[0]
220 for _, m := range first.Messages {
221 if len(m.ToolCalls) > 0 || (m.Role == provider.RoleTool && !m.LocalOnly) {
222 t.Fatalf("legacy poisoned pair leaked: %+v", first.Messages)
223 }
224 }
225 if !strings.Contains(first.Messages[len(first.Messages)-1].Content, "<interrupted-turn-recovery>") {
226 t.Fatalf("first request missing recovery: %+v", first.Messages)
227 }
228 if err := a.Run(withNoClosedLoop(context.Background()), "next"); err != nil {
229 t.Fatal(err)
230 }
231 second := mp.Requests()[1]
232 if strings.Contains(second.Messages[len(second.Messages)-1].Content, "<interrupted-turn-recovery>") {
233 t.Fatalf("recovery repeated: %+v", second.Messages)
234 }
235 canonical := loaded.Snapshot()
236 if len(canonical) < 3 || len(canonical[2].ToolCalls) != 1 || canonical[3].Role != provider.RoleTool {
237 t.Fatalf("canonical legacy history was destroyed: %+v", canonical)
238 }
239 }
240
241 func TestReasoningHistoryRepairKeepsHealthyAndEmptyFallbackBytes(t *testing.T) {
242 healthy := []provider.Message{{
243 Role: provider.RoleAssistant, ReasoningContent: "original",
244 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{}`}},
245 }}
246 strict := strictAssistantReasoningProvider{testutil.NewMock("strict")}
247 if got, changed := provider.ProjectReplaySafeMessages(strict, healthy); changed || &got[0] != &healthy[0] {
248 t.Fatal("healthy replay history must keep its exact backing slice")
249 }
250
251 emptyFallback := []provider.Message{{
252 Role: provider.RoleAssistant,
253 ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{}`}},
254 }}
255 openAIStyle := toolCallReasoningRequiredProvider{testutil.NewMock("openai")}
256 if got, changed := provider.ProjectReplaySafeMessages(openAIStyle, emptyFallback); changed || &got[0] != &emptyFallback[0] {
257 t.Fatal("empty-key fallback history must remain byte-identical")
258 }
259 }
260
260 lines GO