返回 DeepSeek-Reasonix
sessiontool_test.go
根目录 / internal / tool / sessiontool / sessiontool_test.go
1 package sessiontool
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 // writeSessionJSONL writes provider.Messages as JSONL to a file, matching
15 // the format produced by agent.Session.Save.
16 func writeSessionJSONL(t *testing.T, path string, msgs []provider.Message) {
17 t.Helper()
18 ses := agent.NewSession("")
19 for _, m := range msgs {
20 ses.Add(m)
21 }
22 if err := ses.Save(path); err != nil {
23 t.Fatalf("save session: %v", err)
24 }
25 }
26
27 // runTool is a convenience wrapper for calling a tool's Execute with JSON args.
28 func runTool(t *testing.T, tl interface {
29 Execute(context.Context, json.RawMessage) (string, error)
30 Name() string
31 }, m map[string]any) string {
32 t.Helper()
33 b, err := json.Marshal(m)
34 if err != nil {
35 t.Fatalf("marshal args: %v", err)
36 }
37 out, err := tl.Execute(context.Background(), json.RawMessage(b))
38 if err != nil {
39 t.Fatalf("%s: %v", tl.Name(), err)
40 }
41 return out
42 }
43
44 // list_sessions tests
45
46 func TestListSessions_EmptyDir(t *testing.T) {
47 dir := t.TempDir()
48 tool := NewListSessionsTool(dir)
49 out, err := tool.Execute(context.Background(), json.RawMessage(`{}`))
50 if err != nil {
51 t.Fatalf("unexpected error: %v", err)
52 }
53 if !strings.Contains(out, "No sessions found") {
54 t.Errorf("expected 'No sessions found', got: %s", out)
55 }
56 }
57
58 func TestToolSchemasAreValidJSON(t *testing.T) {
59 dir := t.TempDir()
60 for _, tool := range []struct {
61 name string
62 schema json.RawMessage
63 }{
64 {name: "list_sessions", schema: NewListSessionsTool(dir).Schema()},
65 {name: "read_session", schema: NewReadSessionTool(dir).Schema()},
66 } {
67 if !json.Valid(tool.schema) {
68 t.Fatalf("%s schema is invalid JSON: %s", tool.name, tool.schema)
69 }
70 }
71 }
72
73 func TestListSessions_OnlyCleanupPending(t *testing.T) {
74 dir := t.TempDir()
75 sessionPath := filepath.Join(dir, "20260618-120000.000000000-test-model.jsonl")
76 writeSessionJSONL(t, sessionPath, []provider.Message{
77 {Role: provider.RoleUser, Content: "hello"},
78 })
79 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
80 t.Fatal(err)
81 }
82
83 tool := NewListSessionsTool(dir)
84 out, err := tool.Execute(context.Background(), json.RawMessage(`{}`))
85 if err != nil {
86 t.Fatalf("unexpected error: %v", err)
87 }
88 if !strings.Contains(out, "No sessions found") {
89 t.Errorf("cleanup-pending session should be excluded, got: %s", out)
90 }
91 }
92
93 func TestListSessions_SingleSession(t *testing.T) {
94 dir := t.TempDir()
95 sessionPath := filepath.Join(dir, "20260618-120000.000000000-test-model.jsonl")
96 writeSessionJSONL(t, sessionPath, []provider.Message{
97 {Role: provider.RoleUser, Content: "hello"},
98 {Role: provider.RoleAssistant, Content: "world"},
99 })
100
101 tool := NewListSessionsTool(dir)
102 out := runTool(t, tool, map[string]any{})
103
104 if !strings.Contains(out, "test-model") {
105 t.Errorf("expected model name in output, got: %s", out)
106 }
107 if !strings.Contains(out, "1 turn") && !strings.Contains(out, "| 1 |") {
108 t.Errorf("expected turn count in output, got: %s", out)
109 }
110 }
111
112 // read_session tests
113
114 func TestReadSession_ValidSession(t *testing.T) {
115 dir := t.TempDir()
116 sessionPath := filepath.Join(dir, "session.jsonl")
117 writeSessionJSONL(t, sessionPath, []provider.Message{
118 {Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "<pinned_context_revision>private pinned body</pinned_context_revision>"},
119 {Role: provider.RoleUser, Content: "user hello"},
120 {Role: provider.RoleAssistant, Content: "assistant response"},
121 })
122
123 tool := NewReadSessionTool(dir)
124 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
125
126 if !strings.Contains(out, "user hello") {
127 t.Errorf("expected user content, got: %s", out)
128 }
129 if !strings.Contains(out, "assistant response") {
130 t.Errorf("expected assistant content, got: %s", out)
131 }
132 if strings.Contains(out, "private pinned body") || strings.Contains(out, "turn 2") {
133 t.Errorf("pinned revision should be excluded from content and turn counts, got: %s", out)
134 }
135 }
136
137 func TestReadSession_ExcludesSystemPrompt(t *testing.T) {
138 dir := t.TempDir()
139 sessionPath := filepath.Join(dir, "session.jsonl")
140 writeSessionJSONL(t, sessionPath, []provider.Message{
141 {Role: provider.RoleSystem, Content: "SECRET_SYSTEM_PROMPT"},
142 {Role: provider.RoleUser, Content: "hello"},
143 {Role: provider.RoleAssistant, Content: "hi"},
144 })
145
146 tool := NewReadSessionTool(dir)
147 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
148
149 if strings.Contains(out, "SECRET_SYSTEM_PROMPT") {
150 t.Errorf("system prompt should be excluded, got: %s", out)
151 }
152 }
153
154 func TestReadSession_ExcludesReasoningContent(t *testing.T) {
155 dir := t.TempDir()
156 sessionPath := filepath.Join(dir, "session.jsonl")
157 writeSessionJSONL(t, sessionPath, []provider.Message{
158 {Role: provider.RoleUser, Content: "hello"},
159 {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "PASS_should_not_appear"},
160 })
161
162 tool := NewReadSessionTool(dir)
163 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
164
165 if strings.Contains(out, "PASS_should_not_appear") {
166 t.Errorf("reasoning content should be excluded, got: %s", out)
167 }
168 }
169
170 func TestReadSession_TruncatesLongContent(t *testing.T) {
171 dir := t.TempDir()
172 longContent := strings.Repeat("a", 5000)
173 sessionPath := filepath.Join(dir, "session.jsonl")
174 writeSessionJSONL(t, sessionPath, []provider.Message{
175 {Role: provider.RoleUser, Content: "hello"},
176 {Role: provider.RoleAssistant, Content: longContent},
177 })
178
179 tool := NewReadSessionTool(dir)
180 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
181
182 if len(out) > 2500 {
183 t.Errorf("output too long (%d chars) for truncated content", len(out))
184 }
185 if !strings.Contains(out, "...") {
186 t.Errorf("expected truncation marker '...' in output")
187 }
188 }
189
190 func TestReadSession_RespectsMaxTurns(t *testing.T) {
191 dir := t.TempDir()
192 sessionPath := filepath.Join(dir, "session.jsonl")
193 var msgs []provider.Message
194 for range 10 {
195 msgs = append(msgs,
196 provider.Message{Role: provider.RoleUser, Content: "turn"},
197 provider.Message{Role: provider.RoleAssistant, Content: "answer"},
198 )
199 }
200 writeSessionJSONL(t, sessionPath, msgs)
201
202 tool := NewReadSessionTool(dir)
203 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "max_turns": 2})
204
205 if !strings.Contains(out, "truncated") {
206 t.Errorf("expected truncation notice with max_turns=2, got: %s", out)
207 }
208 if strings.Contains(out, "User (turn 3)") {
209 t.Errorf("should not show turn 3 with max_turns=2, got: %s", out)
210 }
211 }
212
213 func TestReadSession_MaxTurnsZeroNoLimit(t *testing.T) {
214 dir := t.TempDir()
215 sessionPath := filepath.Join(dir, "session.jsonl")
216 var msgs []provider.Message
217 for range 60 {
218 msgs = append(msgs,
219 provider.Message{Role: provider.RoleUser, Content: "turn"},
220 provider.Message{Role: provider.RoleAssistant, Content: "answer"},
221 )
222 }
223 writeSessionJSONL(t, sessionPath, msgs)
224
225 tool := NewReadSessionTool(dir)
226 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "max_turns": 0})
227
228 if strings.Contains(out, "truncated") {
229 t.Errorf("max_turns=0 should show all turns, got truncation notice")
230 }
231 if !strings.Contains(out, "User (turn 60)") {
232 t.Errorf("expected turn 60 with max_turns=0, got: %s", out)
233 }
234 }
235
236 func TestReadSession_RejectsCleanupPending(t *testing.T) {
237 dir := t.TempDir()
238 sessionPath := filepath.Join(dir, "session.jsonl")
239 writeSessionJSONL(t, sessionPath, []provider.Message{
240 {Role: provider.RoleUser, Content: "data"},
241 })
242 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
243 t.Fatal(err)
244 }
245
246 tool := NewReadSessionTool(dir)
247 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"session.jsonl"}`))
248 if err == nil {
249 t.Fatal("expected error for cleanup-pending session, got nil")
250 }
251 if !strings.Contains(err.Error(), "pending cleanup") {
252 t.Errorf("expected 'pending cleanup' error, got: %v", err)
253 }
254 }
255
256 func TestReadSession_RejectsPathTraversal(t *testing.T) {
257 dir := t.TempDir()
258 tool := NewReadSessionTool(dir)
259 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"../../etc/passwd"}`))
260 if err == nil {
261 t.Fatal("expected error for path traversal, got nil")
262 }
263 if !strings.Contains(err.Error(), "outside the session directory") {
264 t.Errorf("expected 'outside the session directory' error, got: %v", err)
265 }
266 }
267
268 func TestReadSession_ToolResultsOmittedByDefault(t *testing.T) {
269 dir := t.TempDir()
270 sessionPath := filepath.Join(dir, "session.jsonl")
271 writeSessionJSONL(t, sessionPath, []provider.Message{
272 {Role: provider.RoleUser, Content: "list files"},
273 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
274 {ID: "call1", Name: "ls", Arguments: `{"path":"."}`},
275 }},
276 {Role: provider.RoleTool, Name: "ls", Content: "SECRET_FILE_CONTENT", ToolCallID: "call1"},
277 {Role: provider.RoleAssistant, Content: "here are the files"},
278 })
279
280 tool := NewReadSessionTool(dir)
281 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
282
283 if !strings.Contains(out, "Tool Calls") {
284 t.Errorf("expected Tool Calls section, got: %s", out)
285 }
286 if !strings.Contains(out, "Tool Result: ls") {
287 t.Errorf("expected Tool Result header, got: %s", out)
288 }
289 if strings.Contains(out, "SECRET_FILE_CONTENT") {
290 t.Errorf("tool result content should be omitted by default, got: %s", out)
291 }
292 }
293
294 func TestReadSession_ToolResultsWithOptIn(t *testing.T) {
295 dir := t.TempDir()
296 sessionPath := filepath.Join(dir, "session.jsonl")
297 writeSessionJSONL(t, sessionPath, []provider.Message{
298 {Role: provider.RoleUser, Content: "list files"},
299 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
300 {ID: "call1", Name: "ls", Arguments: `{"path":"."}`},
301 }},
302 {Role: provider.RoleTool, Name: "ls", Content: "file1.txt\nfile2.go", ToolCallID: "call1"},
303 {Role: provider.RoleAssistant, Content: "here are the files"},
304 })
305
306 tool := NewReadSessionTool(dir)
307 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "show_tool_results": true})
308
309 if !strings.Contains(out, "file1.txt") {
310 t.Errorf("expected tool result content with opt-in, got: %s", out)
311 }
312 }
313
314 // helper tests
315
316 func TestModelFromPath(t *testing.T) {
317 tests := []struct {
318 path string
319 want string
320 }{
321 {"20260618-231556.000000000-gpt-4.jsonl", "gpt-4"},
322 {"20260618-231556.000000000-claude-sonnet-4-20250514.jsonl", "claude-sonnet-4-20250514"},
323 {"plain.jsonl", "(unknown)"},
324 {"no-dash.jsonl", "dash"},
325 {"20260618-231556.jsonl", "231556"},
326 }
327 for _, tt := range tests {
328 got := modelFromPath(tt.path)
329 if got != tt.want {
330 t.Errorf("modelFromPath(%q) = %q, want %q", tt.path, got, tt.want)
331 }
332 }
333 }
334
335 func TestTruncateRunes(t *testing.T) {
336 tests := []struct {
337 s string
338 max int
339 want string
340 }{
341 {"hello", 10, "hello"},
342 {"hello world", 5, "hello..."},
343 {"", 10, ""},
344 {" spaced ", 10, "spaced"},
345 {"a👨‍👩‍👧‍👦bc", 2, "a👨‍👩‍👧‍👦..."},
346 }
347 for _, tt := range tests {
348 got := truncateRunes(tt.s, tt.max)
349 if got != tt.want {
350 t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.s, tt.max, got, tt.want)
351 }
352 }
353 }
354
355 // TestCleanupPendingContract verifies that our tools use the SAME marker
356 // contract as agent.MarkCleanupPending / agent.IsCleanupPending.
357 func TestCleanupPendingContract(t *testing.T) {
358 dir := t.TempDir()
359 sessionPath := filepath.Join(dir, "session.jsonl")
360 writeSessionJSONL(t, sessionPath, []provider.Message{
361 {Role: provider.RoleUser, Content: "data"},
362 })
363
364 // Mark cleanup-pending using the REAL agent function
365 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
366 t.Fatal(err)
367 }
368
369 // Verify both agent and our read_session detect it
370 if !agent.IsCleanupPending(sessionPath) {
371 t.Fatal("agent.IsCleanupPending should detect marker created by agent.MarkCleanupPending")
372 }
373
374 tool := NewReadSessionTool(dir)
375 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"session.jsonl"}`))
376 if err == nil {
377 t.Fatal("read_session should reject cleanup-pending session created by agent.MarkCleanupPending")
378 }
379 }
380
380 lines GO