返回 DeepSeek-Reasonix
session_test.go
根目录 / internal / agent / session_test.go
1 package agent
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "reasonix/internal/provider"
10 )
11
12 // --- NewSession ---
13
14 func TestNewSessionEmpty(t *testing.T) {
15 s := NewSession("")
16 if len(s.Messages) != 0 {
17 t.Errorf("empty session should have 0 messages, got %d", len(s.Messages))
18 }
19 }
20
21 func TestNewSessionWithSystem(t *testing.T) {
22 s := NewSession("You are a helpful assistant.")
23 if len(s.Messages) != 1 {
24 t.Fatalf("want 1 message, got %d", len(s.Messages))
25 }
26 if s.Messages[0].Role != provider.RoleSystem {
27 t.Errorf("role = %q, want system", s.Messages[0].Role)
28 }
29 if s.Messages[0].Content != "You are a helpful assistant." {
30 t.Errorf("content = %q", s.Messages[0].Content)
31 }
32 }
33
34 // --- Session.Add ---
35
36 func TestSessionAdd(t *testing.T) {
37 s := NewSession("")
38 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
39 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi there"})
40 if len(s.Messages) != 2 {
41 t.Fatalf("want 2 messages, got %d", len(s.Messages))
42 }
43 if s.Messages[0].Role != provider.RoleUser {
44 t.Errorf("first role = %q", s.Messages[0].Role)
45 }
46 if s.Messages[1].Role != provider.RoleAssistant {
47 t.Errorf("second role = %q", s.Messages[1].Role)
48 }
49 }
50
51 func TestSessionAddDecisionReceiptKeepsToolResultsAdjacent(t *testing.T) {
52 s := NewSession("")
53 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the check"})
54 s.Add(provider.Message{
55 Role: provider.RoleAssistant,
56 ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "bash", Arguments: `{}`}},
57 })
58 receipt := &provider.DecisionReceipt{ID: "approval-1", Kind: "tool", Tool: "bash", Outcome: "allow_once"}
59
60 s.AddDecisionReceipt(receipt)
61 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call-1", Name: "bash", Content: "ok"})
62
63 got := s.Snapshot()
64 if len(got) != 3 {
65 t.Fatalf("messages = %d, want the original three-message tool turn", len(got))
66 }
67 if len(got[1].DecisionReceipts) != 1 || got[1].DecisionReceipts[0] != receipt {
68 t.Fatalf("assistant receipts = %+v, want approval receipt", got[1].DecisionReceipts)
69 }
70 if got[2].Role != provider.RoleTool || got[2].ToolCallID != "call-1" {
71 t.Fatalf("tool result no longer follows assistant directly: %+v", got)
72 }
73 if !s.NeedsRewriteSave() {
74 t.Fatal("attaching receipt to an existing message must require a rewrite save")
75 }
76 }
77
78 // --- Session.HasContent ---
79
80 func TestHasContentEmpty(t *testing.T) {
81 s := NewSession("")
82 if s.HasContent() {
83 t.Error("empty session should not have content")
84 }
85 }
86
87 func TestHasContentSystemOnly(t *testing.T) {
88 s := NewSession("system prompt")
89 if s.HasContent() {
90 t.Error("system-only session should not have content")
91 }
92 }
93
94 func TestHasContentWithUser(t *testing.T) {
95 s := NewSession("system")
96 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
97 if !s.HasContent() {
98 t.Error("session with user message should have content")
99 }
100 }
101
102 func TestHasContentWithAssistant(t *testing.T) {
103 s := NewSession("")
104 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "response"})
105 if !s.HasContent() {
106 t.Error("session with assistant message should have content")
107 }
108 }
109
110 func TestHasContentWithTool(t *testing.T) {
111 s := NewSession("")
112 s.Add(provider.Message{Role: provider.RoleTool, Content: "result", ToolCallID: "tc1"})
113 if !s.HasContent() {
114 t.Error("session with tool message should have content")
115 }
116 }
117
118 // --- Session.HasSystemMessage ---
119
120 func TestHasSystemMessageWithSystem(t *testing.T) {
121 s := NewSession("system prompt")
122 if !s.HasSystemMessage() {
123 t.Error("session with system message should report HasSystemMessage true")
124 }
125 }
126
127 func TestHasSystemMessageWithoutSystem(t *testing.T) {
128 s := NewSession("")
129 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
130 if s.HasSystemMessage() {
131 t.Error("session without system message should report HasSystemMessage false")
132 }
133 }
134
135 func TestHasSystemMessageAfterReplaceWithoutSystem(t *testing.T) {
136 s := NewSession("system")
137 s.Add(provider.Message{Role: provider.RoleUser, Content: "kept"})
138 // Replace with messages that have no system message — simulates a
139 // compact/summarise path that failed to preserve the system prompt.
140 s.Replace([]provider.Message{
141 {Role: provider.RoleUser, Content: "replaced"},
142 })
143 if s.HasContent() {
144 // HasContent returns true because the user message exists.
145 if s.HasSystemMessage() {
146 t.Error("session replaced without system message should report HasSystemMessage false")
147 }
148 } else {
149 t.Error("session with user message should have content")
150 }
151 }
152
153 func TestHasSystemMessageCompactedKeepsSystem(t *testing.T) {
154 // This is the healthy path: compact preserves the system message at index 0.
155 s := NewSession("system")
156 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
157 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
158 s.Replace([]provider.Message{
159 {Role: provider.RoleSystem, Content: "system"},
160 {Role: provider.RoleUser, Content: "summary"},
161 })
162 if !s.HasSystemMessage() {
163 t.Error("compacted session should still have system message at index 0")
164 }
165 }
166
167 // --- Save / LoadSession round-trip ---
168
169 func TestSaveLoadSessionRoundTrip(t *testing.T) {
170 dir := t.TempDir()
171 path := filepath.Join(dir, "session.jsonl")
172
173 s := NewSession("system prompt")
174 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
175 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "world"})
176 if err := s.Save(path); err != nil {
177 t.Fatalf("save: %v", err)
178 }
179
180 loaded, err := LoadSession(path)
181 if err != nil {
182 t.Fatalf("load: %v", err)
183 }
184 if len(loaded.Messages) != 3 {
185 t.Fatalf("want 3 messages, got %d", len(loaded.Messages))
186 }
187 if loaded.Messages[0].Content != "system prompt" {
188 t.Errorf("system = %q", loaded.Messages[0].Content)
189 }
190 if loaded.Messages[1].Content != "hello" {
191 t.Errorf("user = %q", loaded.Messages[1].Content)
192 }
193 if loaded.Messages[2].Content != "world" {
194 t.Errorf("assistant = %q", loaded.Messages[2].Content)
195 }
196 }
197
198 func TestSaveEmptyPath(t *testing.T) {
199 s := NewSession("")
200 if err := s.Save(""); err == nil {
201 t.Fatal("expected error for empty path")
202 }
203 }
204
205 func TestSaveCreatesDir(t *testing.T) {
206 dir := t.TempDir()
207 path := filepath.Join(dir, "deep", "nested", "session.jsonl")
208 s := NewSession("")
209 s.Add(provider.Message{Role: provider.RoleUser, Content: "test"})
210 if err := s.Save(path); err != nil {
211 t.Fatalf("save: %v", err)
212 }
213 if _, err := os.Stat(path); err != nil {
214 t.Fatal("session file should exist")
215 }
216 }
217
218 func TestLoadSessionMissing(t *testing.T) {
219 _, err := LoadSession("/nonexistent/session.jsonl")
220 if err == nil {
221 t.Fatal("expected error for missing file")
222 }
223 if !os.IsNotExist(err) {
224 t.Errorf("error should be os.IsNotExist, got %v", err)
225 }
226 }
227
228 func TestLoadSessionMalformed(t *testing.T) {
229 dir := t.TempDir()
230 path := filepath.Join(dir, "bad.jsonl")
231 os.WriteFile(path, []byte("not valid json\n"), 0o644)
232 _, err := LoadSession(path)
233 if err == nil {
234 t.Fatal("expected error for malformed JSONL")
235 }
236 if !strings.Contains(err.Error(), "decode") {
237 t.Errorf("error should mention decode: %v", err)
238 }
239 }
240
241 // --- ListSessions ---
242
243 func TestListSessionsMissingDirReturnsNil(t *testing.T) {
244 sessions, err := ListSessions("/nonexistent/dir")
245 if err != nil {
246 t.Fatalf("expected nil error for missing dir, got %v", err)
247 }
248 if sessions != nil {
249 t.Errorf("expected nil sessions, got %v", sessions)
250 }
251 }
252
253 func TestListSessionsEmptyDir(t *testing.T) {
254 dir := t.TempDir()
255 sessions, err := ListSessions(dir)
256 if err != nil {
257 t.Fatalf("err: %v", err)
258 }
259 if len(sessions) != 0 {
260 t.Errorf("want 0 sessions, got %d", len(sessions))
261 }
262 }
263
264 func TestListSessionsSorted(t *testing.T) {
265 dir := t.TempDir()
266 // Create two sessions with different content.
267 s1 := NewSession("")
268 s1.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
269 s1.Save(filepath.Join(dir, "a.jsonl"))
270
271 s2 := NewSession("")
272 s2.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
273 s2.Save(filepath.Join(dir, "b.jsonl"))
274
275 sessions, err := ListSessions(dir)
276 if err != nil {
277 t.Fatalf("err: %v", err)
278 }
279 if len(sessions) != 2 {
280 t.Fatalf("want 2 sessions, got %d", len(sessions))
281 }
282 // Newest first.
283 if sessions[0].ModTime.Before(sessions[1].ModTime) {
284 t.Error("sessions should be sorted newest first")
285 }
286 }
287
288 func TestListSessionsSkipsEmpty(t *testing.T) {
289 dir := t.TempDir()
290 // A session with only a system prompt (no user interaction) should be skipped.
291 s := NewSession("system only")
292 s.Save(filepath.Join(dir, "empty.jsonl"))
293
294 sessions, err := ListSessions(dir)
295 if err != nil {
296 t.Fatalf("err: %v", err)
297 }
298 if len(sessions) != 0 {
299 t.Errorf("empty sessions should be skipped, got %d", len(sessions))
300 }
301 }
302
303 func TestListSessionsSkipsNonJSONL(t *testing.T) {
304 dir := t.TempDir()
305 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a session"), 0o644)
306 s := NewSession("")
307 s.Add(provider.Message{Role: provider.RoleUser, Content: "real"})
308 s.Save(filepath.Join(dir, "real.jsonl"))
309
310 sessions, err := ListSessions(dir)
311 if err != nil {
312 t.Fatalf("err: %v", err)
313 }
314 if len(sessions) != 1 {
315 t.Errorf("want 1 session, got %d", len(sessions))
316 }
317 }
318
319 // --- previewSession ---
320
321 func TestPreviewSession(t *testing.T) {
322 dir := t.TempDir()
323 path := filepath.Join(dir, "session.jsonl")
324 s := NewSession("system")
325 s.Add(provider.Message{Role: provider.RoleUser, Content: "Help me debug the auth module"})
326 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Sure, let me look..."})
327 s.Save(path)
328
329 preview, turns := previewSession(path)
330 if turns != 1 {
331 t.Errorf("turns = %d, want 1", turns)
332 }
333 if !strings.Contains(preview, "debug") {
334 t.Errorf("preview = %q", preview)
335 }
336 }
337
338 func TestPreviewSessionStripsTransientReasoningLanguageBlock(t *testing.T) {
339 dir := t.TempDir()
340 path := filepath.Join(dir, "session.jsonl")
341 s := NewSession("system")
342 s.Add(provider.Message{Role: provider.RoleUser, Content: "<reasoning-language>\nVisible reasoning/thinking text preference: use Simplified Chinese.\n</reasoning-language>\n\nHelp me debug the auth module"})
343 s.Save(path)
344
345 preview, turns := previewSession(path)
346 if turns != 1 {
347 t.Errorf("turns = %d, want 1", turns)
348 }
349 if preview != "Help me debug the auth module" {
350 t.Errorf("preview = %q, want user prompt", preview)
351 }
352 }
353
354 func TestPreviewSessionStripsTransientResponseLanguageBlock(t *testing.T) {
355 dir := t.TempDir()
356 path := filepath.Join(dir, "session.jsonl")
357 s := NewSession("system")
358 s.Add(provider.Message{Role: provider.RoleUser, Content: "<response-language>\nFinal answer language preference: use English.\n</response-language>\n\nHelp me debug the auth module"})
359 s.Save(path)
360
361 preview, turns := previewSession(path)
362 if turns != 1 {
363 t.Errorf("turns = %d, want 1", turns)
364 }
365 if preview != "Help me debug the auth module" {
366 t.Errorf("preview = %q, want user prompt", preview)
367 }
368 }
369
370 func TestPreviewSessionLongMessage(t *testing.T) {
371 dir := t.TempDir()
372 path := filepath.Join(dir, "session.jsonl")
373 s := NewSession("")
374 s.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("a", 200)})
375 s.Save(path)
376
377 preview, _ := previewSession(path)
378 if len([]rune(preview)) > 80 {
379 t.Errorf("preview should be capped at 80 runes, got %d", len([]rune(preview)))
380 }
381 if !strings.HasSuffix(preview, "…") {
382 t.Errorf("truncated preview should end with …, got %q", preview)
383 }
384 }
385
386 func TestPreviewSessionMalformed(t *testing.T) {
387 dir := t.TempDir()
388 path := filepath.Join(dir, "bad.jsonl")
389 os.WriteFile(path, []byte("not json\n"), 0o644)
390 preview, turns := previewSession(path)
391 if turns != 0 {
392 t.Errorf("turns = %d, want 0", turns)
393 }
394 if preview != "" {
395 t.Errorf("preview = %q, want empty", preview)
396 }
397 }
398
399 // --- NewSessionPath ---
400
401 func TestNewSessionPath(t *testing.T) {
402 dir := t.TempDir()
403 path := NewSessionPath(dir, "deepseek-chat")
404 if !strings.HasSuffix(path, ".jsonl") {
405 t.Errorf("should end with .jsonl: %s", path)
406 }
407 if !strings.Contains(path, "deepseek-chat") {
408 t.Errorf("should contain model name: %s", path)
409 }
410 if !strings.HasPrefix(path, dir) {
411 t.Errorf("should be under dir: %s", path)
412 }
413 }
414
415 func TestNewSessionPathSanitizesSlashes(t *testing.T) {
416 path := NewSessionPath("/dir", "provider/model")
417 base := filepath.Base(path)
418 if strings.Contains(base, "/") {
419 t.Errorf("filename should not contain /: %s", base)
420 }
421 if !strings.Contains(base, "provider-model") {
422 t.Errorf("slashes should be replaced: %s", base)
423 }
424 }
425
426 func TestNewSessionPathSanitizesWindowsReservedPunctuation(t *testing.T) {
427 dir := t.TempDir()
428 path := NewSessionPath(dir, `nemotron-3-nano:30b<>"|?*`)
429 base := filepath.Base(path)
430 if strings.ContainsAny(base, `:<>"|?*`) {
431 t.Fatalf("filename contains Windows-reserved punctuation: %s", base)
432 }
433 if !strings.Contains(base, "nemotron-3-nano-30b") {
434 t.Fatalf("colon should be replaced without hiding the model hint: %s", base)
435 }
436
437 s := NewSession("")
438 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
439 if err := s.Save(path); err != nil {
440 t.Fatalf("save session with sanitized model filename: %v", err)
441 }
442 if _, err := os.Stat(path); err != nil {
443 t.Fatalf("stat saved session: %v", err)
444 }
445 }
446
447 func TestNewSessionPathEmptyModel(t *testing.T) {
448 path := NewSessionPath("/dir", "")
449 if !strings.Contains(path, "session") {
450 t.Errorf("empty model should use 'session' fallback: %s", path)
451 }
452 }
453
454 // --- rewrite-save baseline ---
455
456 // TestNeedsRewriteSaveFollowsSaves pins the baseline's lifecycle on the
457 // session object itself: an in-memory rewrite demands a rewrite save, every
458 // full save re-anchors (including the plain force Save the depth-cap recovery
459 // path uses), and the baseline never moves backwards when a slower save
460 // reports an older capture.
461 func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
462 dir := t.TempDir()
463 path := filepath.Join(dir, "session.jsonl")
464 s := NewSession("sys")
465 s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
466 if s.NeedsRewriteSave() {
467 t.Fatal("fresh session should not need a rewrite save")
468 }
469 s.IncrementRewrite()
470 if !s.NeedsRewriteSave() {
471 t.Fatal("in-memory rewrite must demand a rewrite save")
472 }
473 if err := s.Save(path); err != nil {
474 t.Fatalf("Save: %v", err)
475 }
476 if s.NeedsRewriteSave() {
477 t.Fatal("force save must re-anchor the rewrite baseline")
478 }
479 s.IncrementRewrite()
480 if err := s.SaveRewrite(path); err != nil {
481 t.Fatalf("SaveRewrite: %v", err)
482 }
483 if s.NeedsRewriteSave() {
484 t.Fatal("SaveRewrite must re-anchor the rewrite baseline")
485 }
486
487 // A slower save that captured an older rewriteVersion must not roll the
488 // baseline back below what a faster save already persisted.
489 digest, err := digestSessionMessages(s.Snapshot())
490 if err != nil {
491 t.Fatalf("digest: %v", err)
492 }
493 s.markPersisted(path, digest, 1, 1, 0)
494 if s.NeedsRewriteSave() {
495 t.Fatal("stale capture rolled the rewrite baseline backwards")
496 }
497 }
498
499 func TestUpdateToolCallPreviewPersistsAfterMidTurnSnapshot(t *testing.T) {
500 path := filepath.Join(t.TempDir(), "session.jsonl")
501 s := NewSession("system")
502 s.Add(provider.Message{Role: provider.RoleUser, Content: "edit twice"})
503 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
504 {ID: "c1", Name: "edit_file", Arguments: `{}`},
505 {ID: "c2", Name: "edit_file", Arguments: `{}`},
506 }})
507 if err := s.SaveSnapshot(path); err != nil {
508 t.Fatalf("mid-turn snapshot: %v", err)
509 }
510
511 refreshed := provider.ToolCall{ID: "c2", Diff: "@@ -1 +1 @@\n-ready\n+done\n", Added: 1, Removed: 1}
512 if !s.UpdateToolCallPreview(refreshed) {
513 t.Fatal("matching tool call was not updated")
514 }
515 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c1", Name: "edit_file", Content: "ready"})
516 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c2", Name: "edit_file", Content: "done"})
517 if !s.NeedsRewriteSave() {
518 t.Fatal("mutating a snapshotted assistant message must require rewrite save")
519 }
520 if err := s.SaveRewrite(path); err != nil {
521 t.Fatalf("rewrite refreshed preview: %v", err)
522 }
523
524 loaded, err := LoadSession(path)
525 if err != nil {
526 t.Fatalf("reload: %v", err)
527 }
528 var got provider.ToolCall
529 for _, msg := range loaded.Messages {
530 for _, call := range msg.ToolCalls {
531 if call.ID == "c2" {
532 got = call
533 }
534 }
535 }
536 if got.Diff != refreshed.Diff || got.Added != 1 || got.Removed != 1 {
537 t.Fatalf("persisted preview = %+v, want %+v", got, refreshed)
538 }
539 }
540
541 func TestUpdateToolCallResolutionPersistsAfterMidTurnSnapshot(t *testing.T) {
542 path := filepath.Join(t.TempDir(), "session.jsonl")
543 s := NewSession("system")
544 s.Add(provider.Message{Role: provider.RoleUser, Content: "use MCP"})
545 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
546 ID: "c1", Name: "use_capability",
547 Arguments: `{"action":"call","capability_id":"mcp-tool:db/write"}`,
548 }}})
549 if err := s.SaveSnapshot(path); err != nil {
550 t.Fatalf("mid-turn snapshot: %v", err)
551 }
552
553 readOnly := false
554 resolved := provider.ToolCall{
555 ID: "c1", ResolvedName: "mcp__db__write",
556 CapabilityID: "mcp-tool:db/write", ResolvedReadOnly: &readOnly,
557 }
558 if !s.UpdateToolCallResolution(resolved) {
559 t.Fatal("matching tool call resolution was not updated")
560 }
561 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c1", Name: "use_capability", Content: "done"})
562 if !s.NeedsRewriteSave() {
563 t.Fatal("resolved metadata on a snapshotted assistant message must require rewrite save")
564 }
565 if err := s.SaveRewrite(path); err != nil {
566 t.Fatalf("rewrite resolved metadata: %v", err)
567 }
568
569 loaded, err := LoadSession(path)
570 if err != nil {
571 t.Fatalf("reload: %v", err)
572 }
573 got := loaded.Messages[2].ToolCalls[0]
574 if got.ResolvedReadOnly == nil || *got.ResolvedReadOnly ||
575 got.ResolvedName != resolved.ResolvedName || got.CapabilityID != resolved.CapabilityID {
576 t.Fatalf("persisted resolved metadata = %+v, want %+v", got, resolved)
577 }
578 }
579
580 // TestRewriteBaselineStaysWithClones: an unpersisted rewrite travels with the
581 // clone, and the source persisting later does not mark the clone's copy as
582 // saved — each session object owns its own baseline, so no swap can orphan or
583 // misattribute it.
584 func TestRewriteBaselineStaysWithClones(t *testing.T) {
585 dir := t.TempDir()
586 path := filepath.Join(dir, "session.jsonl")
587 s := NewSession("sys")
588 s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
589 s.IncrementRewrite()
590 clone := s.CloneWithMessages(s.Snapshot())
591 if !clone.NeedsRewriteSave() {
592 t.Fatal("clone must inherit the unpersisted rewrite")
593 }
594 if err := s.Save(path); err != nil {
595 t.Fatalf("Save: %v", err)
596 }
597 if s.NeedsRewriteSave() {
598 t.Fatal("source baseline not re-anchored by save")
599 }
600 if !clone.NeedsRewriteSave() {
601 t.Fatal("saving the source must not mark the clone's rewrite persisted")
602 }
603 }
604
604 lines GO