返回 DeepSeek-Reasonix
tool_test.go
根目录 / internal / history / tool_test.go
1 package history
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/provider"
12 )
13
14 func TestHistoryToolSearchAndAroundAreUsable(t *testing.T) {
15 sessionDir := t.TempDir()
16 path := filepath.Join(sessionDir, "decision.jsonl")
17 writeSession(t, path, []provider.Message{
18 {Role: provider.RoleUser, Content: "Should history use vector embeddings?"},
19 {Role: provider.RoleAssistant, Content: "Decision: keep history retrieval lightweight with BM25 and no vector database."},
20 {Role: provider.RoleUser, Content: "Great, port that to Reasonix."},
21 })
22
23 tl := NewTool(Options{SessionDir: sessionDir})
24 if tl.Name() != "history" || !tl.ReadOnly() {
25 t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly())
26 }
27 if !json.Valid(tl.Schema()) {
28 t.Fatal("history schema is not valid JSON")
29 }
30
31 out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 vector database","limit":5}`))
32 if err != nil {
33 t.Fatalf("Execute search: %v", err)
34 }
35 for _, want := range []string{
36 "History search results",
37 "decision.jsonl",
38 "message_index=1",
39 "keep history retrieval lightweight",
40 `Use operation="around"`,
41 } {
42 if !strings.Contains(out, want) {
43 t.Fatalf("search output missing %q:\n%s", want, out)
44 }
45 }
46
47 args, _ := json.Marshal(map[string]any{
48 "operation": "around",
49 "session_path": path,
50 "message_index": 1,
51 "before": 1,
52 "after": 1,
53 })
54 out, err = tl.Execute(context.Background(), args)
55 if err != nil {
56 t.Fatalf("Execute around: %v", err)
57 }
58 for _, want := range []string{
59 "History around",
60 "[0 user]",
61 "[1 assistant]",
62 "[2 user]",
63 } {
64 if !strings.Contains(out, want) {
65 t.Fatalf("around output missing %q:\n%s", want, out)
66 }
67 }
68 }
69
70 func TestHistoryToolSkipsCleanupPending(t *testing.T) {
71 sessionDir := t.TempDir()
72 visiblePath := filepath.Join(sessionDir, "visible.jsonl")
73 pendingPath := filepath.Join(sessionDir, "pending.jsonl")
74 writeSession(t, visiblePath, []provider.Message{
75 {Role: provider.RoleUser, Content: "visible cleanup-safe retrieval note"},
76 })
77 writeSession(t, pendingPath, []provider.Message{
78 {Role: provider.RoleUser, Content: "pending cleanup-hidden retrieval note"},
79 })
80 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
81 t.Fatal(err)
82 }
83
84 tl := NewTool(Options{SessionDir: sessionDir})
85 out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"retrieval note","limit":5}`))
86 if err != nil {
87 t.Fatalf("Execute search: %v", err)
88 }
89 if !strings.Contains(out, "visible.jsonl") {
90 t.Fatalf("search output missing visible session:\n%s", out)
91 }
92 if strings.Contains(out, "pending.jsonl") || strings.Contains(out, "cleanup-hidden") {
93 t.Fatalf("search output leaked cleanup-pending session:\n%s", out)
94 }
95
96 args, _ := json.Marshal(map[string]any{
97 "operation": "around",
98 "session_path": pendingPath,
99 "message_index": 0,
100 })
101 if _, err := tl.Execute(context.Background(), args); err == nil {
102 t.Fatal("Execute around cleanup-pending error = nil, want rejection")
103 }
104 }
105
106 func TestHistoryToolSchemaIsCacheStable(t *testing.T) {
107 tl := NewTool(Options{SessionDir: t.TempDir()})
108 if got, want := tl.Description(), "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. Use search when past decisions, failed attempts, commands, or tool inputs may help the current task; use around with a returned session_path and message_index to inspect the nearby transcript. By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output."; got != want {
109 t.Fatalf("history description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got)
110 }
111 const wantSchema = `{
112 "type": "object",
113 "properties": {
114 "operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."},
115 "query": {"type": "string", "description": "Search query for operation=search."},
116 "scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."},
117 "kind": {"type": "array", "items": {"type": "string", "enum": ["user_text", "assistant_text", "tool_input", "tool_error", "tool_output"]}, "description": "History parts to search. Defaults to user_text, assistant_text, tool_input, and tool_error."},
118 "tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."},
119 "limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."},
120 "session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."},
121 "message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."},
122 "before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."},
123 "after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."}
124 },
125 "required": ["operation"]
126 }`
127 if got := string(tl.Schema()); got != wantSchema {
128 t.Fatalf("history schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got)
129 }
130 }
131
132 func TestHistoryToolValidatesInputs(t *testing.T) {
133 tl := NewTool(Options{SessionDir: t.TempDir()})
134 for _, tc := range []struct {
135 name string
136 args string
137 }{
138 {"missing operation", `{}`},
139 {"unknown operation", `{"operation":"scan"}`},
140 {"around missing index", `{"operation":"around","session_path":"/tmp/session.jsonl"}`},
141 {"bad json", `{"operation":`},
142 } {
143 t.Run(tc.name, func(t *testing.T) {
144 if _, err := tl.Execute(context.Background(), []byte(tc.args)); err == nil {
145 t.Fatalf("Execute(%s) error = nil, want validation error", tc.args)
146 }
147 })
148 }
149 }
150
150 lines GO