返回 DeepSeek-Reasonix
search_test.go
根目录 / internal / history / search_test.go
1 package history
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/provider"
13 )
14
15 func TestSearchRanksSavedSessionHistory(t *testing.T) {
16 sessionDir := t.TempDir()
17 archiveDir := t.TempDir()
18
19 writeSession(t, filepath.Join(sessionDir, "first.jsonl"), []provider.Message{
20 {Role: provider.RoleUser, Content: "We need a cache-first implementation."},
21 {Role: provider.RoleAssistant, Content: "Decision: keep the prefix stable and avoid CGO SQLite for Reasonix history retrieval."},
22 })
23 writeSession(t, filepath.Join(sessionDir, "second.jsonl"), []provider.Message{
24 {Role: provider.RoleUser, Content: "Talk about dashboard colors."},
25 {Role: provider.RoleAssistant, Content: "No database decision here."},
26 })
27
28 searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir})
29 hits, err := searcher.Search(context.Background(), SearchRequest{Query: "SQLite CGO cache", Limit: 5})
30 if err != nil {
31 t.Fatalf("Search() error = %v", err)
32 }
33 if len(hits) == 0 {
34 t.Fatal("Search() returned no hits")
35 }
36 if got := filepath.Base(hits[0].SessionPath); got != "first.jsonl" {
37 t.Fatalf("top hit path = %q, want first.jsonl", got)
38 }
39 if hits[0].Kind != KindAssistantText {
40 t.Fatalf("top hit kind = %q, want %q", hits[0].Kind, KindAssistantText)
41 }
42 }
43
44 func TestSearchGlobalIncludesArchives(t *testing.T) {
45 sessionDir := t.TempDir()
46 archiveDir := t.TempDir()
47 writeSession(t, filepath.Join(archiveDir, "archive.jsonl"), []provider.Message{
48 {Role: provider.RoleUser, Content: "Old decision: Obelisk retrieval query runtime stays code-driven."},
49 })
50
51 searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir})
52 projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "project"})
53 if err != nil {
54 t.Fatalf("project Search() error = %v", err)
55 }
56 if len(projectHits) != 0 {
57 t.Fatalf("project Search() hits = %d, want 0", len(projectHits))
58 }
59 globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "global"})
60 if err != nil {
61 t.Fatalf("global Search() error = %v", err)
62 }
63 if len(globalHits) != 1 {
64 t.Fatalf("global Search() hits = %d, want 1", len(globalHits))
65 }
66 if globalHits[0].Source != "archive" {
67 t.Fatalf("global hit source = %q, want archive", globalHits[0].Source)
68 }
69 }
70
71 func TestSearchGlobalIncludesGlobalSessionDir(t *testing.T) {
72 projectDir := t.TempDir()
73 globalDir := t.TempDir()
74 writeSession(t, filepath.Join(projectDir, "project.jsonl"), []provider.Message{
75 {Role: provider.RoleUser, Content: "Project-only decision about local UI spacing."},
76 })
77 globalPath := filepath.Join(globalDir, "global.jsonl")
78 writeSession(t, globalPath, []provider.Message{
79 {Role: provider.RoleUser, Content: "Global-only decision about synthesis cache reuse."},
80 })
81
82 searcher := NewSearcher(Options{SessionDir: projectDir, GlobalSessionDir: globalDir})
83 projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "project"})
84 if err != nil {
85 t.Fatalf("project Search() error = %v", err)
86 }
87 if len(projectHits) != 0 {
88 t.Fatalf("project Search() hits = %d, want 0", len(projectHits))
89 }
90 globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "global"})
91 if err != nil {
92 t.Fatalf("global Search() error = %v", err)
93 }
94 if len(globalHits) != 1 {
95 t.Fatalf("global Search() hits = %d, want 1", len(globalHits))
96 }
97 if globalHits[0].Source != "global" || globalHits[0].SessionPath != globalPath {
98 t.Fatalf("global hit = %+v, want source=global path=%s", globalHits[0], globalPath)
99 }
100 if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: globalPath, MessageIndex: 0}); err != nil {
101 t.Fatalf("Around() for global session path failed: %v", err)
102 }
103 }
104
105 func TestSearchIndexesToolInputsAndErrors(t *testing.T) {
106 sessionDir := t.TempDir()
107 writeSession(t, filepath.Join(sessionDir, "tools.jsonl"), []provider.Message{
108 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"go test ./internal/history"}`}}},
109 {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"},
110 })
111
112 searcher := NewSearcher(Options{SessionDir: sessionDir})
113 hits, err := searcher.Search(context.Background(), SearchRequest{Query: "go test fail", ToolName: "bash", Limit: 5})
114 if err != nil {
115 t.Fatalf("Search() error = %v", err)
116 }
117 if len(hits) < 2 {
118 t.Fatalf("Search() hits = %d, want at least 2", len(hits))
119 }
120 kinds := map[Kind]bool{}
121 for _, hit := range hits {
122 kinds[hit.Kind] = true
123 }
124 if !kinds[KindToolInput] || !kinds[KindToolError] {
125 t.Fatalf("hits kinds = %#v, want tool input and tool error", kinds)
126 }
127 }
128
129 func TestSearchDropsCommonWordNoise(t *testing.T) {
130 sessionDir := t.TempDir()
131 writeSession(t, filepath.Join(sessionDir, "rare.jsonl"), []provider.Message{
132 {Role: provider.RoleUser, Content: "rareterm common common common"},
133 })
134 for i := 0; i < 12; i++ {
135 writeSession(t, filepath.Join(sessionDir, "common-"+string(rune('a'+i))+".jsonl"), []provider.Message{
136 {Role: provider.RoleUser, Content: "common"},
137 })
138 }
139
140 searcher := NewSearcher(Options{SessionDir: sessionDir})
141 hits, err := searcher.Search(context.Background(), SearchRequest{Query: "rareterm common", Limit: 20})
142 if err != nil {
143 t.Fatalf("Search() error = %v", err)
144 }
145 if len(hits) != 1 {
146 t.Fatalf("Search() hits = %d, want only rare hit: %+v", len(hits), hits)
147 }
148 if got := filepath.Base(hits[0].SessionPath); got != "rare.jsonl" {
149 t.Fatalf("hit path = %q, want rare.jsonl", got)
150 }
151 }
152
153 func TestSearchSkipsCleanupPending(t *testing.T) {
154 sessionDir := t.TempDir()
155 visiblePath := filepath.Join(sessionDir, "visible.jsonl")
156 pendingPath := filepath.Join(sessionDir, "pending.jsonl")
157 writeSession(t, visiblePath, []provider.Message{
158 {Role: provider.RoleUser, Content: "ordinary alpha visible decision"},
159 })
160 writeSession(t, pendingPath, []provider.Message{
161 {Role: provider.RoleUser, Content: "hidden alpha cleanup pending secret"},
162 })
163 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
164 t.Fatal(err)
165 }
166
167 searcher := NewSearcher(Options{SessionDir: sessionDir})
168 hits, err := searcher.Search(context.Background(), SearchRequest{Query: "alpha", Limit: 5})
169 if err != nil {
170 t.Fatalf("Search() error = %v", err)
171 }
172 if len(hits) != 1 || hits[0].SessionPath != visiblePath {
173 t.Fatalf("Search() hits = %+v, want only visible path %s", hits, visiblePath)
174 }
175 hits, err = searcher.Search(context.Background(), SearchRequest{Query: "cleanup pending secret", Limit: 5})
176 if err != nil {
177 t.Fatalf("Search() pending-only query error = %v", err)
178 }
179 if len(hits) != 0 {
180 t.Fatalf("Search() pending-only hits = %+v, want none", hits)
181 }
182 }
183
184 func TestAroundRejectsCleanupPending(t *testing.T) {
185 sessionDir := t.TempDir()
186 path := filepath.Join(sessionDir, "pending.jsonl")
187 writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "pending secret"}})
188 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
189 t.Fatal(err)
190 }
191
192 searcher := NewSearcher(Options{SessionDir: sessionDir})
193 if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil {
194 t.Fatal("Around() cleanup-pending error = nil, want rejection")
195 }
196 }
197
198 func TestHistorySkipsSubagentsOwnedByCleanupPendingParent(t *testing.T) {
199 sessionDir := t.TempDir()
200 parentPath := filepath.Join(sessionDir, "parent.jsonl")
201 writeSession(t, parentPath, []provider.Message{{Role: provider.RoleUser, Content: "parent prompt"}})
202 visibleParentPath := filepath.Join(sessionDir, "visible-parent.jsonl")
203 writeSession(t, visibleParentPath, []provider.Message{{Role: provider.RoleUser, Content: "visible parent prompt"}})
204
205 pendingSubagentPath := writeSubagentSession(t, sessionDir, "sa_pending", agent.BranchID(parentPath), "subagent hidden orchid result")
206 visibleSubagentPath := writeSubagentSession(t, sessionDir, "sa_visible", agent.BranchID(visibleParentPath), "subagent visible orchid result")
207 if err := agent.MarkCleanupPending(parentPath, "delete"); err != nil {
208 t.Fatal(err)
209 }
210
211 searcher := NewSearcher(Options{SessionDir: sessionDir})
212 hits, err := searcher.Search(context.Background(), SearchRequest{Query: "orchid result", Limit: 5})
213 if err != nil {
214 t.Fatalf("Search() error = %v", err)
215 }
216 if len(hits) != 1 || hits[0].SessionPath != visibleSubagentPath {
217 t.Fatalf("Search() hits = %+v, want only visible subagent %s", hits, visibleSubagentPath)
218 }
219 if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: pendingSubagentPath, MessageIndex: 0}); err == nil {
220 t.Fatal("Around() cleanup-pending parent subagent error = nil, want rejection")
221 }
222 if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: visibleSubagentPath, MessageIndex: 0}); err != nil {
223 t.Fatalf("Around() visible subagent error = %v", err)
224 }
225 }
226
227 func TestAroundRequiresPathUnderHistoryRoots(t *testing.T) {
228 sessionDir := t.TempDir()
229 outside := t.TempDir()
230 path := filepath.Join(outside, "outside.jsonl")
231 writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "secret"}})
232
233 searcher := NewSearcher(Options{SessionDir: sessionDir})
234 if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil {
235 t.Fatal("Around() error = nil, want path confinement error")
236 }
237 }
238
239 func TestAroundRendersNearbyMessages(t *testing.T) {
240 sessionDir := t.TempDir()
241 path := filepath.Join(sessionDir, "nearby.jsonl")
242 writeSession(t, path, []provider.Message{
243 {Role: provider.RoleUser, Content: "first"},
244 {Role: provider.RoleAssistant, Content: "second"},
245 {Role: provider.RoleUser, Content: "third"},
246 })
247
248 searcher := NewSearcher(Options{SessionDir: sessionDir})
249 msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 1, Before: 1, After: 1})
250 if err != nil {
251 t.Fatalf("Around() error = %v", err)
252 }
253 if len(msgs) != 3 {
254 t.Fatalf("Around() returned %d messages, want 3", len(msgs))
255 }
256 joined := msgs[0].Text + "\n" + msgs[1].Text + "\n" + msgs[2].Text
257 for _, want := range []string{"[0 user]", "[1 assistant]", "[2 user]"} {
258 if !strings.Contains(joined, want) {
259 t.Fatalf("Around() output missing %q:\n%s", want, joined)
260 }
261 }
262 }
263
264 func TestAroundClampsLargeAfterWithoutOverflow(t *testing.T) {
265 sessionDir := t.TempDir()
266 path := filepath.Join(sessionDir, "nearby.jsonl")
267 writeSession(t, path, []provider.Message{
268 {Role: provider.RoleUser, Content: "first"},
269 {Role: provider.RoleAssistant, Content: "second"},
270 })
271
272 searcher := NewSearcher(Options{SessionDir: sessionDir})
273 msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0, Before: 0, After: maxAround * 10})
274 if err != nil {
275 t.Fatalf("Around() error = %v", err)
276 }
277 if len(msgs) != 2 {
278 t.Fatalf("Around() returned %d messages, want 2", len(msgs))
279 }
280 if msgs[0].Index != 0 || msgs[1].Index != 1 {
281 t.Fatalf("Around() message indexes = [%d, %d], want [0, 1]", msgs[0].Index, msgs[1].Index)
282 }
283 }
284
285 func writeSession(t *testing.T, path string, msgs []provider.Message) {
286 t.Helper()
287 sess := agent.NewSession("")
288 for _, msg := range msgs {
289 sess.Add(msg)
290 }
291 if err := sess.Save(path); err != nil {
292 t.Fatalf("Save(%s) error = %v", path, err)
293 }
294 }
295
296 func writeSubagentSession(t *testing.T, sessionDir, ref, parentSession, content string) string {
297 t.Helper()
298 dir := filepath.Join(sessionDir, "subagents")
299 if err := os.MkdirAll(dir, 0o755); err != nil {
300 t.Fatal(err)
301 }
302 path := filepath.Join(dir, ref+".jsonl")
303 writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: content}})
304 meta := agent.SubagentMeta{Ref: ref, ParentSession: parentSession}
305 b, err := json.Marshal(meta)
306 if err != nil {
307 t.Fatal(err)
308 }
309 if err := os.WriteFile(filepath.Join(dir, ref+".meta.json"), b, 0o644); err != nil {
310 t.Fatal(err)
311 }
312 return path
313 }
314
314 lines GO