返回 DeepSeek-Reasonix
listsessions_sidecar_test.go
根目录 / internal / agent / listsessions_sidecar_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/provider"
11 )
12
13 func writeSessionFile(t *testing.T, path string, msgs []provider.Message) {
14 t.Helper()
15 f, err := os.Create(path)
16 if err != nil {
17 t.Fatalf("create %s: %v", path, err)
18 }
19 defer f.Close()
20 enc := json.NewEncoder(f)
21 for _, m := range msgs {
22 if err := enc.Encode(m); err != nil {
23 t.Fatalf("encode: %v", err)
24 }
25 }
26 }
27
28 // SessionPreviewFromMessages must match a from-disk decode byte-for-byte, since
29 // Session.Save persists exactly the messages it is handed.
30 func TestSessionPreviewFromMessagesMatchesDecode(t *testing.T) {
31 dir := t.TempDir()
32 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
33 msgs := []provider.Message{
34 {Role: provider.RoleSystem, Content: "you are helpful"},
35 {Role: provider.RoleUser, Content: "first question about the bug"},
36 {Role: provider.RoleAssistant, Content: "here is an answer", ReasoningContent: "thinking"},
37 {Role: provider.RoleUser, Content: "follow up"},
38 {Role: provider.RoleAssistant, Content: "more"},
39 }
40 writeSessionFile(t, path, msgs)
41
42 filePreview, fileTurns := previewSession(path)
43 memPreview, memTurns := SessionPreviewFromMessages(msgs)
44 if fileTurns != memTurns || filePreview != memPreview {
45 t.Fatalf("mismatch: file=(%q,%d) mem=(%q,%d)", filePreview, fileTurns, memPreview, memTurns)
46 }
47 if memTurns != 2 {
48 t.Fatalf("expected 2 user turns, got %d", memTurns)
49 }
50 }
51
52 // When the sidecar records Turns/Preview, ListSessions must trust them and not
53 // re-derive from the .jsonl. We prove that by planting counts that disagree with
54 // the file: if ListSessions returns the planted values, it used the sidecar.
55 func TestListSessionsUsesSidecarWithoutDecoding(t *testing.T) {
56 dir := t.TempDir()
57 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
58 writeSessionFile(t, path, []provider.Message{
59 {Role: provider.RoleUser, Content: "real content in the file"},
60 {Role: provider.RoleAssistant, Content: "a"},
61 })
62 // Sidecar deliberately disagrees with the file (3 turns, custom preview).
63 if err := UpdateSessionMeta(path, "", "cached preview line", 3, true); err != nil {
64 t.Fatalf("UpdateSessionMeta: %v", err)
65 }
66
67 infos, err := ListSessions(dir)
68 if err != nil {
69 t.Fatalf("ListSessions: %v", err)
70 }
71 if len(infos) != 1 {
72 t.Fatalf("expected 1 session, got %d", len(infos))
73 }
74 if infos[0].Turns != 3 || infos[0].Preview != "cached preview line" {
75 t.Fatalf("expected sidecar values (3, %q), got (%d, %q)", "cached preview line", infos[0].Turns, infos[0].Preview)
76 }
77 }
78
79 // A legacy session whose sidecar has no recorded turn count must be decoded once
80 // and then backfilled, so the second listing reads the sidecar.
81 func TestListSessionsBackfillsLegacySession(t *testing.T) {
82 dir := t.TempDir()
83 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
84 writeSessionFile(t, path, []provider.Message{
85 {Role: provider.RoleUser, Content: "legacy question"},
86 {Role: provider.RoleAssistant, Content: "answer"},
87 {Role: provider.RoleUser, Content: "again"},
88 {Role: provider.RoleAssistant, Content: "ok"},
89 })
90 // Sidecar exists but predates the counts (no Turns/Preview), with a fixed
91 // UpdatedAt we expect backfill to preserve.
92 updated := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
93 if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{ID: BranchID(path), CreatedAt: updated, UpdatedAt: updated, Scope: "global"}); err != nil {
94 t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
95 }
96
97 infos, err := ListSessions(dir)
98 if err != nil {
99 t.Fatalf("ListSessions: %v", err)
100 }
101 if len(infos) != 1 || infos[0].Turns != 2 {
102 t.Fatalf("expected 1 session with 2 turns, got %+v", infos)
103 }
104
105 // Backfill must have written the counts into the sidecar without bumping
106 // activity time (ordering must stay stable).
107 meta, ok, err := LoadBranchMeta(path)
108 if err != nil || !ok {
109 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
110 }
111 if meta.Turns != 2 || meta.Preview != "legacy question" {
112 t.Fatalf("backfill missing: turns=%d preview=%q", meta.Turns, meta.Preview)
113 }
114 if !meta.UpdatedAt.Equal(updated) {
115 t.Fatalf("backfill bumped UpdatedAt: got %v want %v", meta.UpdatedAt, updated)
116 }
117 }
118
119 // A counts-authoritative sidecar (SchemaVersion stamped) that records Turns == 0
120 // must be trusted as empty and skipped WITHOUT decoding the .jsonl. We prove the
121 // version gate by planting a file that actually has content but a meta claiming
122 // it is empty: if the session is decoded it would be listed; trusting the meta
123 // skips it.
124 func TestListSessionsTrustsRecordedEmptyWithoutDecoding(t *testing.T) {
125 dir := t.TempDir()
126 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
127 writeSessionFile(t, path, []provider.Message{
128 {Role: provider.RoleUser, Content: "real content the meta will lie about"},
129 {Role: provider.RoleAssistant, Content: "a"},
130 })
131 if err := SaveBranchMeta(path, BranchMeta{ID: BranchID(path), Turns: 0, SchemaVersion: BranchMetaCountsVersion}); err != nil {
132 t.Fatalf("SaveBranchMeta: %v", err)
133 }
134
135 infos, err := ListSessions(dir)
136 if err != nil {
137 t.Fatalf("ListSessions: %v", err)
138 }
139 if len(infos) != 0 {
140 t.Fatalf("a counts-authoritative empty session should be skipped without decoding; got %d", len(infos))
141 }
142 }
143
144 // A genuinely-empty legacy session (no counts-aware meta) must be decoded once
145 // and then recorded as authoritative-empty, so later listings trust it instead
146 // of re-decoding the .jsonl on every refresh (the Turns == 0 overload bug).
147 func TestListSessionsRecordsEmptyLegacySessionOnce(t *testing.T) {
148 dir := t.TempDir()
149 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
150 writeSessionFile(t, path, []provider.Message{
151 {Role: provider.RoleSystem, Content: "system prompt"},
152 {Role: provider.RoleAssistant, Content: "a greeting with no user turn"},
153 })
154
155 infos, err := ListSessions(dir)
156 if err != nil {
157 t.Fatalf("ListSessions: %v", err)
158 }
159 if len(infos) != 0 {
160 t.Fatalf("empty session must not be listed; got %d", len(infos))
161 }
162
163 meta, ok, err := LoadBranchMeta(path)
164 if err != nil || !ok {
165 t.Fatalf("empty session should have been stamped: ok=%v err=%v", ok, err)
166 }
167 if meta.SchemaVersion != BranchMetaCountsVersion || meta.Turns != 0 {
168 t.Fatalf("empty session not recorded as authoritative-empty: version=%d turns=%d", meta.SchemaVersion, meta.Turns)
169 }
170 }
171
171 lines GO