返回 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 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/provider"
12 )
13
14 func writeSessionFile(t *testing.T, path string, msgs []provider.Message) {
15 t.Helper()
16 f, err := os.Create(path)
17 if err != nil {
18 t.Fatalf("create %s: %v", path, err)
19 }
20 defer f.Close()
21 enc := json.NewEncoder(f)
22 for _, m := range msgs {
23 if err := enc.Encode(m); err != nil {
24 t.Fatalf("encode: %v", err)
25 }
26 }
27 }
28
29 // SessionPreviewFromMessages must match a from-disk decode byte-for-byte, since
30 // Session.Save persists exactly the messages it is handed.
31 func TestSessionPreviewFromMessagesMatchesDecode(t *testing.T) {
32 dir := t.TempDir()
33 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
34 msgs := []provider.Message{
35 {Role: provider.RoleSystem, Content: "you are helpful"},
36 {Role: provider.RoleUser, Content: "first question about the bug"},
37 {Role: provider.RoleAssistant, Content: "here is an answer", ReasoningContent: "thinking"},
38 {Role: provider.RoleUser, Content: "follow up"},
39 {Role: provider.RoleAssistant, Content: "more"},
40 }
41 writeSessionFile(t, path, msgs)
42
43 filePreview, fileTurns := previewSession(path)
44 memPreview, memTurns := SessionPreviewFromMessages(msgs)
45 if fileTurns != memTurns || filePreview != memPreview {
46 t.Fatalf("mismatch: file=(%q,%d) mem=(%q,%d)", filePreview, fileTurns, memPreview, memTurns)
47 }
48 if memTurns != 2 {
49 t.Fatalf("expected 2 user turns, got %d", memTurns)
50 }
51 }
52
53 // When the sidecar records Turns/Preview, ListSessions must trust them and not
54 // re-derive from the .jsonl. We prove that by planting counts that disagree with
55 // the file: if ListSessions returns the planted values, it used the sidecar.
56 func TestListSessionsUsesSidecarWithoutDecoding(t *testing.T) {
57 dir := t.TempDir()
58 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
59 msgs := []provider.Message{
60 {Role: provider.RoleUser, Content: "real content in the file"},
61 {Role: provider.RoleAssistant, Content: "a"},
62 }
63 writeSessionFile(t, path, msgs)
64 digest, err := digestSessionMessages(msgs)
65 if err != nil {
66 t.Fatal(err)
67 }
68 if err := SaveBranchMeta(path, BranchMeta{ID: BranchID(path), Revision: 1, ContentDigest: digestString(digest)}); err != nil {
69 t.Fatalf("SaveBranchMeta: %v", err)
70 }
71 // Sidecar deliberately disagrees with the file (3 turns, custom preview).
72 if err := UpdateSessionMeta(path, "", "cached preview line", 3, true); err != nil {
73 t.Fatalf("UpdateSessionMeta: %v", err)
74 }
75
76 infos, err := ListSessions(dir)
77 if err != nil {
78 t.Fatalf("ListSessions: %v", err)
79 }
80 if len(infos) != 1 {
81 t.Fatalf("expected 1 session, got %d", len(infos))
82 }
83 if infos[0].Turns != 3 || infos[0].Preview != "cached preview line" {
84 t.Fatalf("expected sidecar values (3, %q), got (%d, %q)", "cached preview line", infos[0].Turns, infos[0].Preview)
85 }
86 }
87
88 func TestListSessionsLeavesLegacyCountsUnknownWithoutDecodingOrWriting(t *testing.T) {
89 dir := t.TempDir()
90 path := filepath.Join(dir, "legacy.jsonl")
91 if err := os.WriteFile(path, []byte("not valid jsonl\n"), 0o600); err != nil {
92 t.Fatal(err)
93 }
94 updated := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
95 if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{
96 ID: BranchID(path), CreatedAt: updated, UpdatedAt: updated, Scope: "global",
97 }); err != nil {
98 t.Fatal(err)
99 }
100
101 infos, err := ListSessions(dir)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if len(infos) != 1 || infos[0].CountsKnown || infos[0].Turns != 0 || !strings.Contains(infos[0].Preview, "being indexed") {
106 t.Fatalf("legacy listing = %#v", infos)
107 }
108 meta, ok, err := LoadBranchMeta(path)
109 if err != nil || !ok {
110 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
111 }
112 if meta.SchemaVersion != 0 || meta.Turns != 0 || !meta.UpdatedAt.Equal(updated) {
113 t.Fatalf("listing mutated legacy sidecar: %+v", meta)
114 }
115 }
116
117 // A legacy session whose sidecar has no recorded turn count remains visible
118 // without a synchronous transcript decode. The catalog repair worker owns the
119 // eventual backfill.
120 func TestListSessionsLeavesLegacySessionForBackgroundRepair(t *testing.T) {
121 dir := t.TempDir()
122 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
123 writeSessionFile(t, path, []provider.Message{
124 {Role: provider.RoleUser, Content: "legacy question"},
125 {Role: provider.RoleAssistant, Content: "answer"},
126 {Role: provider.RoleUser, Content: "again"},
127 {Role: provider.RoleAssistant, Content: "ok"},
128 })
129 // Sidecar exists but predates the counts (no Turns/Preview), with a fixed
130 // UpdatedAt we expect backfill to preserve.
131 updated := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
132 if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{ID: BranchID(path), CreatedAt: updated, UpdatedAt: updated, Scope: "global"}); err != nil {
133 t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
134 }
135
136 infos, err := ListSessions(dir)
137 if err != nil {
138 t.Fatalf("ListSessions: %v", err)
139 }
140 if len(infos) != 1 || infos[0].Turns != 0 || infos[0].CountsKnown {
141 t.Fatalf("expected one unknown-count session, got %+v", infos)
142 }
143
144 // Listing must not mutate the sidecar or bump activity time.
145 meta, ok, err := LoadBranchMeta(path)
146 if err != nil || !ok {
147 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
148 }
149 if meta.Turns != 0 || meta.Preview != "" || meta.SchemaVersion != 0 {
150 t.Fatalf("listing mutated counts: %+v", meta)
151 }
152 if !meta.UpdatedAt.Equal(updated) {
153 t.Fatalf("backfill bumped UpdatedAt: got %v want %v", meta.UpdatedAt, updated)
154 }
155 }
156
157 // A counts-authoritative sidecar (SchemaVersion stamped) that records Turns == 0
158 // must be trusted as empty and skipped WITHOUT decoding the .jsonl. We prove the
159 // version gate by planting a file that actually has content but a meta claiming
160 // it is empty: if the session is decoded it would be listed; trusting the meta
161 // skips it.
162 func TestListSessionsTrustsRecordedEmptyWithoutDecoding(t *testing.T) {
163 dir := t.TempDir()
164 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
165 msgs := []provider.Message{
166 {Role: provider.RoleUser, Content: "real content the meta will lie about"},
167 {Role: provider.RoleAssistant, Content: "a"},
168 }
169 writeSessionFile(t, path, msgs)
170 digest, err := digestSessionMessages(msgs)
171 if err != nil {
172 t.Fatal(err)
173 }
174 meta := BranchMeta{
175 ID: BranchID(path),
176 Turns: 0,
177 SchemaVersion: BranchMetaCountsVersion,
178 Revision: 1,
179 ContentDigest: digestString(digest),
180 }
181 stampSessionListingProjection(&meta)
182 if err := SaveBranchMeta(path, meta); err != nil {
183 t.Fatalf("SaveBranchMeta: %v", err)
184 }
185
186 infos, err := ListSessions(dir)
187 if err != nil {
188 t.Fatalf("ListSessions: %v", err)
189 }
190 if len(infos) != 0 {
191 t.Fatalf("a counts-authoritative empty session should be skipped without decoding; got %d", len(infos))
192 }
193 }
194
195 // A legacy artifact that might be empty is shown as unknown until the catalog
196 // repair worker validates it; listing cannot know without decoding.
197 func TestListSessionsShowsPotentiallyEmptyLegacySessionAsUnknown(t *testing.T) {
198 dir := t.TempDir()
199 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
200 writeSessionFile(t, path, []provider.Message{
201 {Role: provider.RoleSystem, Content: "system prompt"},
202 {Role: provider.RoleAssistant, Content: "a greeting with no user turn"},
203 })
204
205 infos, err := ListSessions(dir)
206 if err != nil {
207 t.Fatalf("ListSessions: %v", err)
208 }
209 if len(infos) != 1 || infos[0].CountsKnown {
210 t.Fatalf("legacy session should be visible as unknown; got %+v", infos)
211 }
212
213 if _, ok, err := LoadBranchMeta(path); err != nil || ok {
214 t.Fatalf("listing unexpectedly created metadata: ok=%v err=%v", ok, err)
215 }
216 }
217
218 func TestListSessionsDefersPreviouslyCachedZeroRepair(t *testing.T) {
219 dir := t.TempDir()
220 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
221 writeSessionFile(t, path, []provider.Message{
222 {Role: provider.RoleUser, Content: "recovered question"},
223 {Role: provider.RoleAssistant, Content: "recovered answer"},
224 })
225 if err := SaveBranchMeta(path, BranchMeta{
226 ID: BranchID(path),
227 Turns: 0,
228 SchemaVersion: branchMetaCountsInitialVersion,
229 }); err != nil {
230 t.Fatalf("SaveBranchMeta: %v", err)
231 }
232
233 infos, err := ListSessions(dir)
234 if err != nil {
235 t.Fatalf("ListSessions: %v", err)
236 }
237 if len(infos) != 1 || infos[0].Turns != 0 || infos[0].CountsKnown || !strings.Contains(infos[0].Preview, "being indexed") {
238 t.Fatalf("legacy zero was not exposed as unknown: %+v", infos)
239 }
240 meta, ok, err := LoadBranchMeta(path)
241 if err != nil || !ok {
242 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
243 }
244 if meta.SchemaVersion != branchMetaCountsInitialVersion || meta.Turns != 0 || meta.Preview != "" {
245 t.Fatalf("listing unexpectedly repaired metadata: %+v", meta)
246 }
247 }
248
249 func TestListSessionsDefersOldRecordedEmptyValidation(t *testing.T) {
250 dir := t.TempDir()
251 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
252 writeSessionFile(t, path, []provider.Message{
253 {Role: provider.RoleSystem, Content: "system prompt"},
254 {Role: provider.RoleAssistant, Content: "a greeting with no user turn"},
255 })
256 if err := SaveBranchMeta(path, BranchMeta{
257 ID: BranchID(path),
258 Turns: 0,
259 SchemaVersion: branchMetaCountsInitialVersion,
260 }); err != nil {
261 t.Fatalf("SaveBranchMeta: %v", err)
262 }
263
264 infos, err := ListSessions(dir)
265 if err != nil {
266 t.Fatalf("ListSessions (migration): %v", err)
267 }
268 if len(infos) != 1 || infos[0].CountsKnown {
269 t.Fatalf("old zero should remain visible as unknown; got %+v", infos)
270 }
271 meta, ok, err := LoadBranchMeta(path)
272 if err != nil || !ok {
273 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
274 }
275 if meta.SchemaVersion != branchMetaCountsInitialVersion || meta.Turns != 0 {
276 t.Fatalf("listing changed old zero metadata: %+v", meta)
277 }
278
279 // Current-version zero counts are authoritative. Making the artifact invalid
280 // after migration must not cause the listing path to decode it again.
281 if err := os.WriteFile(path, []byte("not valid json\n"), 0o600); err != nil {
282 t.Fatalf("replace session with corrupt content: %v", err)
283 }
284 infos, err = ListSessions(dir)
285 if err != nil {
286 t.Fatalf("ListSessions (steady state): %v", err)
287 }
288 if len(infos) != 1 || infos[0].CountsKnown {
289 t.Fatalf("unknown session should stay visible without re-probe: %+v", infos)
290 }
291 }
292
293 func TestListSessionsKeepsUnreadableNonEmptySessionsVisible(t *testing.T) {
294 for _, tc := range []struct {
295 name string
296 cachedZero bool
297 }{
298 {name: "legacy"},
299 {name: "previously cached as empty", cachedZero: true},
300 } {
301 t.Run(tc.name, func(t *testing.T) {
302 dir := t.TempDir()
303 path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl")
304 if err := os.WriteFile(path, []byte("not valid json\n"), 0o600); err != nil {
305 t.Fatalf("write corrupt session: %v", err)
306 }
307 if tc.cachedZero {
308 if err := SaveBranchMeta(path, BranchMeta{
309 ID: BranchID(path),
310 Turns: 0,
311 SchemaVersion: branchMetaCountsInitialVersion,
312 }); err != nil {
313 t.Fatalf("SaveBranchMeta: %v", err)
314 }
315 }
316
317 infos, err := ListSessions(dir)
318 if err != nil {
319 t.Fatalf("ListSessions: %v", err)
320 }
321 if len(infos) != 1 {
322 t.Fatalf("unreadable non-empty session must remain visible; got %d entries", len(infos))
323 }
324 if infos[0].Turns != 0 || infos[0].CountsKnown || !strings.Contains(infos[0].Preview, "being indexed") {
325 t.Fatalf("unexpected corrupt-session listing: %+v", infos[0])
326 }
327
328 meta, ok, err := LoadBranchMeta(path)
329 if err != nil {
330 t.Fatalf("LoadBranchMeta: %v", err)
331 }
332 if ok && meta.SchemaVersion >= BranchMetaCountsVersion {
333 t.Fatalf("unreadable session was incorrectly stamped authoritative: %+v", meta)
334 }
335 })
336 }
337 }
338
338 lines GO