返回 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, Content: "user hello"},
119 {Role: provider.RoleAssistant, Content: "assistant response"},
120 })
121
122 tool := NewReadSessionTool(dir)
123 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
124
125 if !strings.Contains(out, "user hello") {
126 t.Errorf("expected user content, got: %s", out)
127 }
128 if !strings.Contains(out, "assistant response") {
129 t.Errorf("expected assistant content, got: %s", out)
130 }
131 }
132
133 func TestReadSession_ExcludesSystemPrompt(t *testing.T) {
134 dir := t.TempDir()
135 sessionPath := filepath.Join(dir, "session.jsonl")
136 writeSessionJSONL(t, sessionPath, []provider.Message{
137 {Role: provider.RoleSystem, Content: "SECRET_SYSTEM_PROMPT"},
138 {Role: provider.RoleUser, Content: "hello"},
139 {Role: provider.RoleAssistant, Content: "hi"},
140 })
141
142 tool := NewReadSessionTool(dir)
143 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
144
145 if strings.Contains(out, "SECRET_SYSTEM_PROMPT") {
146 t.Errorf("system prompt should be excluded, got: %s", out)
147 }
148 }
149
150 func TestReadSession_ExcludesReasoningContent(t *testing.T) {
151 dir := t.TempDir()
152 sessionPath := filepath.Join(dir, "session.jsonl")
153 writeSessionJSONL(t, sessionPath, []provider.Message{
154 {Role: provider.RoleUser, Content: "hello"},
155 {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "PASS_should_not_appear"},
156 })
157
158 tool := NewReadSessionTool(dir)
159 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
160
161 if strings.Contains(out, "PASS_should_not_appear") {
162 t.Errorf("reasoning content should be excluded, got: %s", out)
163 }
164 }
165
166 func TestReadSession_TruncatesLongContent(t *testing.T) {
167 dir := t.TempDir()
168 longContent := strings.Repeat("a", 5000)
169 sessionPath := filepath.Join(dir, "session.jsonl")
170 writeSessionJSONL(t, sessionPath, []provider.Message{
171 {Role: provider.RoleUser, Content: "hello"},
172 {Role: provider.RoleAssistant, Content: longContent},
173 })
174
175 tool := NewReadSessionTool(dir)
176 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
177
178 if len(out) > 2500 {
179 t.Errorf("output too long (%d chars) for truncated content", len(out))
180 }
181 if !strings.Contains(out, "...") {
182 t.Errorf("expected truncation marker '...' in output")
183 }
184 }
185
186 func TestReadSession_RespectsMaxTurns(t *testing.T) {
187 dir := t.TempDir()
188 sessionPath := filepath.Join(dir, "session.jsonl")
189 var msgs []provider.Message
190 for i := 0; i < 10; i++ {
191 msgs = append(msgs,
192 provider.Message{Role: provider.RoleUser, Content: "turn"},
193 provider.Message{Role: provider.RoleAssistant, Content: "answer"},
194 )
195 }
196 writeSessionJSONL(t, sessionPath, msgs)
197
198 tool := NewReadSessionTool(dir)
199 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "max_turns": 2})
200
201 if !strings.Contains(out, "truncated") {
202 t.Errorf("expected truncation notice with max_turns=2, got: %s", out)
203 }
204 if strings.Contains(out, "User (turn 3)") {
205 t.Errorf("should not show turn 3 with max_turns=2, got: %s", out)
206 }
207 }
208
209 func TestReadSession_MaxTurnsZeroNoLimit(t *testing.T) {
210 dir := t.TempDir()
211 sessionPath := filepath.Join(dir, "session.jsonl")
212 var msgs []provider.Message
213 for i := 0; i < 60; i++ {
214 msgs = append(msgs,
215 provider.Message{Role: provider.RoleUser, Content: "turn"},
216 provider.Message{Role: provider.RoleAssistant, Content: "answer"},
217 )
218 }
219 writeSessionJSONL(t, sessionPath, msgs)
220
221 tool := NewReadSessionTool(dir)
222 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "max_turns": 0})
223
224 if strings.Contains(out, "truncated") {
225 t.Errorf("max_turns=0 should show all turns, got truncation notice")
226 }
227 if !strings.Contains(out, "User (turn 60)") {
228 t.Errorf("expected turn 60 with max_turns=0, got: %s", out)
229 }
230 }
231
232 func TestReadSession_RejectsCleanupPending(t *testing.T) {
233 dir := t.TempDir()
234 sessionPath := filepath.Join(dir, "session.jsonl")
235 writeSessionJSONL(t, sessionPath, []provider.Message{
236 {Role: provider.RoleUser, Content: "data"},
237 })
238 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
239 t.Fatal(err)
240 }
241
242 tool := NewReadSessionTool(dir)
243 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"session.jsonl"}`))
244 if err == nil {
245 t.Fatal("expected error for cleanup-pending session, got nil")
246 }
247 if !strings.Contains(err.Error(), "pending cleanup") {
248 t.Errorf("expected 'pending cleanup' error, got: %v", err)
249 }
250 }
251
252 func TestReadSession_RejectsPathTraversal(t *testing.T) {
253 dir := t.TempDir()
254 tool := NewReadSessionTool(dir)
255 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"../../etc/passwd"}`))
256 if err == nil {
257 t.Fatal("expected error for path traversal, got nil")
258 }
259 if !strings.Contains(err.Error(), "outside the session directory") {
260 t.Errorf("expected 'outside the session directory' error, got: %v", err)
261 }
262 }
263
264 func TestReadSession_ToolResultsOmittedByDefault(t *testing.T) {
265 dir := t.TempDir()
266 sessionPath := filepath.Join(dir, "session.jsonl")
267 writeSessionJSONL(t, sessionPath, []provider.Message{
268 {Role: provider.RoleUser, Content: "list files"},
269 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
270 {ID: "call1", Name: "ls", Arguments: `{"path":"."}`},
271 }},
272 {Role: provider.RoleTool, Name: "ls", Content: "SECRET_FILE_CONTENT", ToolCallID: "call1"},
273 {Role: provider.RoleAssistant, Content: "here are the files"},
274 })
275
276 tool := NewReadSessionTool(dir)
277 out := runTool(t, tool, map[string]any{"session": "session.jsonl"})
278
279 if !strings.Contains(out, "Tool Calls") {
280 t.Errorf("expected Tool Calls section, got: %s", out)
281 }
282 if !strings.Contains(out, "Tool Result: ls") {
283 t.Errorf("expected Tool Result header, got: %s", out)
284 }
285 if strings.Contains(out, "SECRET_FILE_CONTENT") {
286 t.Errorf("tool result content should be omitted by default, got: %s", out)
287 }
288 }
289
290 func TestReadSession_ToolResultsWithOptIn(t *testing.T) {
291 dir := t.TempDir()
292 sessionPath := filepath.Join(dir, "session.jsonl")
293 writeSessionJSONL(t, sessionPath, []provider.Message{
294 {Role: provider.RoleUser, Content: "list files"},
295 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
296 {ID: "call1", Name: "ls", Arguments: `{"path":"."}`},
297 }},
298 {Role: provider.RoleTool, Name: "ls", Content: "file1.txt\nfile2.go", ToolCallID: "call1"},
299 {Role: provider.RoleAssistant, Content: "here are the files"},
300 })
301
302 tool := NewReadSessionTool(dir)
303 out := runTool(t, tool, map[string]any{"session": "session.jsonl", "show_tool_results": true})
304
305 if !strings.Contains(out, "file1.txt") {
306 t.Errorf("expected tool result content with opt-in, got: %s", out)
307 }
308 }
309
310 // ---- helper tests -----------------------------------------------------------
311
312 func TestModelFromPath(t *testing.T) {
313 tests := []struct {
314 path string
315 want string
316 }{
317 {"20260618-231556.000000000-gpt-4.jsonl", "gpt-4"},
318 {"20260618-231556.000000000-claude-sonnet-4-20250514.jsonl", "claude-sonnet-4-20250514"},
319 {"plain.jsonl", "(unknown)"},
320 {"no-dash.jsonl", "dash"},
321 {"20260618-231556.jsonl", "231556"},
322 }
323 for _, tt := range tests {
324 got := modelFromPath(tt.path)
325 if got != tt.want {
326 t.Errorf("modelFromPath(%q) = %q, want %q", tt.path, got, tt.want)
327 }
328 }
329 }
330
331 func TestTruncateRunes(t *testing.T) {
332 tests := []struct {
333 s string
334 max int
335 want string
336 }{
337 {"hello", 10, "hello"},
338 {"hello world", 5, "hello..."},
339 {"", 10, ""},
340 {" spaced ", 10, "spaced"},
341 {"a👨‍👩‍👧‍👦bc", 2, "a👨‍👩‍👧‍👦..."},
342 }
343 for _, tt := range tests {
344 got := truncateRunes(tt.s, tt.max)
345 if got != tt.want {
346 t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.s, tt.max, got, tt.want)
347 }
348 }
349 }
350
351 // TestCleanupPendingContract verifies that our tools use the SAME marker
352 // contract as agent.MarkCleanupPending / agent.IsCleanupPending.
353 func TestCleanupPendingContract(t *testing.T) {
354 dir := t.TempDir()
355 sessionPath := filepath.Join(dir, "session.jsonl")
356 writeSessionJSONL(t, sessionPath, []provider.Message{
357 {Role: provider.RoleUser, Content: "data"},
358 })
359
360 // Mark cleanup-pending using the REAL agent function
361 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
362 t.Fatal(err)
363 }
364
365 // Verify both agent and our read_session detect it
366 if !agent.IsCleanupPending(sessionPath) {
367 t.Fatal("agent.IsCleanupPending should detect marker created by agent.MarkCleanupPending")
368 }
369
370 tool := NewReadSessionTool(dir)
371 _, err := tool.Execute(context.Background(), json.RawMessage(`{"session":"session.jsonl"}`))
372 if err == nil {
373 t.Fatal("read_session should reject cleanup-pending session created by agent.MarkCleanupPending")
374 }
375 }
376
376 lines GO