返回 DeepSeek-Reasonix
session_v5_migration_transcript_test.go
根目录 / desktop / session_v5_migration_transcript_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "log/slog"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "reasonix/desktop/internal/workspacestate"
15 "reasonix/internal/agent"
16 "reasonix/internal/provider"
17 )
18
19 func TestLegacyMigrationTranscriptFailurePreservesSourceAndContinues(t *testing.T) {
20 for _, mode := range []string{"legacy", "paired"} {
21 t.Run(mode, func(t *testing.T) {
22 isolateDesktopUserDirs(t)
23 oldVersion, oldEndpoint := version, crashEndpoint
24 version = "v9.9.9"
25 t.Cleanup(func() { version, crashEndpoint = oldVersion, oldEndpoint })
26 var uploaded []crashReport
27 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
28 var report crashReport
29 if err := json.NewDecoder(request.Body).Decode(&report); err != nil {
30 t.Error(err)
31 } else {
32 uploaded = append(uploaded, report)
33 }
34 w.WriteHeader(http.StatusAccepted)
35 }))
36 defer server.Close()
37 crashEndpoint = server.URL
38 root := t.TempDir()
39 logPath := filepath.Join(root, "service.log")
40 logFile, err := os.Create(logPath)
41 if err != nil {
42 t.Fatal(err)
43 }
44 t.Cleanup(func() { _ = logFile.Close() })
45 previousLogger := slog.Default()
46 slog.SetDefault(slog.New(slog.NewJSONHandler(logFile, nil)))
47 t.Cleanup(func() { slog.SetDefault(previousLogger) })
48 legacyDir := filepath.Join(root, "sessions")
49 badPath := filepath.Join(legacyDir, "a-conflicting.jsonl")
50 bad := agent.NewSession("system")
51 bad.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "retained question"})
52 bad.Add(provider.Message{ID: "result-one", Role: provider.RoleTool, ToolCallID: "reused-call", Content: "first result"})
53 bad.Add(provider.Message{ID: "result-two", Role: provider.RoleTool, ToolCallID: "reused-call", Content: "second result"})
54 if err := bad.Save(badPath); err != nil {
55 t.Fatal(err)
56 }
57 original, err := os.ReadFile(badPath)
58 if err != nil {
59 t.Fatal(err)
60 }
61 goodPath := filepath.Join(legacyDir, "b-healthy.jsonl")
62 good := agent.NewSession("system")
63 good.Add(provider.Message{ID: "healthy-user", Role: provider.RoleUser, Content: "healthy conversation"})
64 if err := good.Save(goodPath); err != nil {
65 t.Fatal(err)
66 }
67 source := desktopMigrationSource{root: legacyDir, scope: "global"}
68 if mode == "paired" {
69 source.pairedRoot = filepath.Join(root, "sessions-v4")
70 }
71 var targetID string
72 // A fresh app exercises the next startup, including persisted
73 // migration bookkeeping, without touching the original source.
74 for attempt := range 2 {
75 app := NewApp()
76 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
77 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
78 t.Cleanup(app.closeSessionServices)
79 err := app.migrateLegacyDirectory(t.Context(), source)
80 if err == nil || !strings.Contains(err.Error(), `duplicate transcript record "tool:reused-call"`) {
81 t.Fatalf("migration error = %v", err)
82 }
83 ledger, err := readDesktopMigrationLedger()
84 if err != nil {
85 t.Fatal(err)
86 }
87 failed := ledger.Records[desktopLegacyMigrationKey(badPath)]
88 if failed.Status != "failed" || failed.ErrorCode != "legacy_import" {
89 t.Fatalf("failed migration was not recorded: %+v", failed)
90 }
91 completed := ledger.Records[desktopLegacyMigrationKey(goodPath)]
92 if completed.Status != "completed" || completed.TargetSessionID == "" {
93 t.Fatalf("healthy migration did not continue: %+v", completed)
94 }
95 if targetID != "" && targetID != completed.TargetSessionID {
96 t.Fatal("restart duplicated the healthy session")
97 }
98 targetID = completed.TargetSessionID
99 state, err := app.desktopSessions.workspaceState.Load(t.Context())
100 if err != nil {
101 t.Fatal(err)
102 }
103 ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs
104 if len(ids) != 1 || ids[0] != targetID {
105 t.Fatalf("workspace sessions = %v", ids)
106 }
107 current, err := os.ReadFile(badPath)
108 if err != nil || !bytes.Equal(original, current) {
109 t.Fatalf("failed migration changed the source: %v", err)
110 }
111 logBody, err := os.ReadFile(logPath)
112 if err != nil {
113 t.Fatal(err)
114 }
115 for _, private := range []string{root, "retained question", "first result", "second result", "reused-call"} {
116 if bytes.Contains(logBody, []byte(private)) {
117 t.Fatalf("migration diagnostic exposed private value %q", private)
118 }
119 }
120 migrationLogs, runtimeLogs := 0, 0
121 var sessionKey string
122 for line := range bytes.SplitSeq(bytes.TrimSpace(logBody), []byte("\n")) {
123 var entry struct {
124 Message string `json:"msg"`
125 SourceKey string `json:"source_key"`
126 Diagnostic struct {
127 SessionKey string `json:"session_key"`
128 Baseline struct{ Code string } `json:"baseline"`
129 } `json:"diagnostic"`
130 }
131 if err := json.Unmarshal(line, &entry); err != nil {
132 t.Fatal(err)
133 }
134 switch entry.Message {
135 case "session transcript initialization failed":
136 runtimeLogs++
137 sessionKey = entry.Diagnostic.SessionKey
138 case "desktop session migration transcript initialization failed":
139 migrationLogs++
140 if entry.SourceKey != failed.SourceKey || sessionKey == "" || entry.Diagnostic.SessionKey != sessionKey || entry.Diagnostic.Baseline.Code != "duplicate_record_identity" {
141 t.Fatalf("migration log cannot be correlated with runtime and ledger: %+v", entry)
142 }
143 }
144 }
145 if migrationLogs != attempt+1 || runtimeLogs != attempt+1 {
146 t.Fatalf("missing diagnostics after restart: migration=%d runtime=%d", migrationLogs, runtimeLogs)
147 }
148 pending := pendingCrashQueuePaths()
149 if len(pending) != attempt+1 {
150 t.Fatalf("pending online diagnostics = %d, want %d", len(pending), attempt+1)
151 }
152 reportBody, err := os.ReadFile(pending[len(pending)-1])
153 if err != nil {
154 t.Fatal(err)
155 }
156 var report crashReport
157 if err := json.Unmarshal(reportBody, &report); err != nil {
158 t.Fatal(err)
159 }
160 if report.Kind != "exception" || report.Source != "desktop.session_migration" ||
161 report.Label != "transcript.initialization" || report.ErrorType != "TranscriptInitializationError" ||
162 !strings.Contains(report.FingerprintHint, "duplicate_record_identity") || report.InstallID != "" {
163 t.Fatalf("online diagnostic is incomplete: %+v", report)
164 }
165 for _, private := range []string{root, failed.SourceKey, sessionKey, "retained question", "first result", "second result", "reused-call"} {
166 if bytes.Contains(reportBody, []byte(private)) {
167 t.Fatalf("online diagnostic exposed local value %q", private)
168 }
169 }
170 app.closeSessionServices()
171 }
172 NewApp().flushPendingCrash()
173 if len(uploaded) != 2 || uploaded[0].Source != "desktop.session_migration" || uploaded[1].Source != "desktop.session_migration" || len(pendingCrashPaths()) != 0 {
174 t.Fatalf("diagnostic delivery did not preserve both startup failures: uploaded=%+v pending=%v", uploaded, pendingCrashPaths())
175 }
176 if uploaded[0].EventID == "" || uploaded[0].EventID == uploaded[1].EventID {
177 t.Fatalf("separate startup failures reused an event identity: uploaded=%+v", uploaded)
178 }
179 if uploaded[0].DedupKey == "" || uploaded[0].DedupKey != uploaded[1].DedupKey {
180 t.Fatalf("equivalent startup failures lost their correlation key: uploaded=%+v", uploaded)
181 }
182 })
183 }
184 }
185
185 lines GO