返回 DeepSeek-Reasonix
transcript_recovery_test.go
根目录 / internal / session / transcript_recovery_test.go
1 package session
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/transcript"
14 )
15
16 // The shape reproduces the reported long tool-driven turn without any copied
17 // user text: 147 messages, 46 assistants with visible content, 72 tool results.
18 func syntheticTranscriptRecoveryMessages() []provider.Message {
19 messages := []provider.Message{
20 {ID: "user", Role: provider.RoleUser, Content: "create a synthetic illustration", Origin: provider.MessageOriginUser},
21 {ID: "introduction", Role: provider.RoleAssistant, Content: "Starting the illustration."},
22 }
23 for i := range 72 {
24 callID := fmt.Sprintf("call-%02d", i)
25 assistant := provider.Message{ID: fmt.Sprintf("assistant-%02d", i), Role: provider.RoleAssistant,
26 ToolCalls: []provider.ToolCall{{ID: callID, Name: "synthetic_tool", Arguments: `{}`}}}
27 if i < 44 {
28 assistant.Content = fmt.Sprintf("Iteration %02d is ready for inspection.", i)
29 assistant.ReasoningContent = fmt.Sprintf("Synthetic reasoning for iteration %02d.", i)
30 }
31 messages = append(messages, assistant, provider.Message{ID: fmt.Sprintf("tool-%02d", i), Role: provider.RoleTool, Name: "synthetic_tool", ToolCallID: callID, Content: fmt.Sprintf("synthetic result %02d", i)})
32 }
33 return append(messages, provider.Message{ID: "final-answer", Role: provider.RoleAssistant, Content: "The synthetic illustration is complete.", ReasoningContent: "All requested checks completed.", WorkDurationMs: 933524})
34 }
35
36 func TestTranscriptLongTurnRemainsReachableAfterBoundedEvictionAndRestart(t *testing.T) {
37 service, err := NewService("local", NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
38 if err != nil {
39 t.Fatal(err)
40 }
41 t.Cleanup(func() { _ = service.Shutdown(context.Background()) })
42 runtime, err := service.Create(t.Context(), CreateOptions{SessionID: "synthetic-long-turn"})
43 if err != nil {
44 t.Fatal(err)
45 }
46 messages := syntheticTranscriptRecoveryMessages()
47 if _, err := runtime.Session().Append(t.Context(), Batch{OperationID: "turn-start", TurnID: "turn", Events: []Event{{Kind: "turn/start"}}}); err != nil {
48 t.Fatal(err)
49 }
50 for i, message := range messages {
51 if len(message.ToolCalls) > 0 {
52 attempt, _ := json.Marshal(map[string]any{"id": message.ID, "messageId": message.ID, "action": "begin"})
53 call, _ := json.Marshal(map[string]any{"id": message.ToolCalls[0].ID, "name": "synthetic_tool"})
54 // Repeated records for one stable identity must not inflate counts.
55 if _, err := runtime.Session().Append(t.Context(), Batch{OperationID: fmt.Sprintf("sampling-%03d", i), TurnID: "turn", Events: []Event{
56 {Kind: "assistant/attempt", Payload: attempt}, {Kind: "assistant/attempt", Payload: attempt},
57 {Kind: "tool/call", Payload: call}, {Kind: "tool/call", Payload: call},
58 }}); err != nil {
59 t.Fatal(err)
60 }
61 }
62 payload, err := json.Marshal(map[string]any{"message": message})
63 if err != nil {
64 t.Fatal(err)
65 }
66 if _, err := runtime.Session().Append(t.Context(), Batch{OperationID: fmt.Sprintf("message-%03d", i), TurnID: "turn", Events: []Event{{Kind: "message/complete", Payload: payload}}}); err != nil {
67 t.Fatal(err)
68 }
69 }
70 if _, err := runtime.Session().Append(t.Context(), Batch{OperationID: "turn-end", TurnID: "turn", Events: []Event{{Kind: "turn/end", Payload: []byte(`{"status":"completed"}`)}}}); err != nil {
71 t.Fatal(err)
72 }
73 if _, err := runtime.Session().Flush(t.Context()); err != nil {
74 t.Fatal(err)
75 }
76 before, err := runtime.Transcript().Snapshot(transcript.PageRequest{Records: 32})
77 if err != nil || before.TotalRecords > 96 || len(before.Records) > 32 {
78 t.Fatalf("publisher exceeded resident budget: total=%d page=%d error=%v", before.TotalRecords, len(before.Records), err)
79 }
80 ref, query := runtime.Ref(), service.Query()
81 // Materialize the locator synchronously so this reachability regression
82 // does not depend on the unrelated asynchronous index polling budget.
83 indexCtx, cancelIndex := context.WithTimeout(t.Context(), 30*time.Second)
84 defer cancelIndex()
85 if _, _, err := query.prepareHistoryIndex(indexCtx, ref); err != nil {
86 t.Fatal(err)
87 }
88 seen := make(map[string]provider.Message)
89 newest := windowReady(t, query, ref, HistoryWindowRequest{Anchor: "newest", Limit: 32})
90 final := newest.Messages[len(newest.Messages)-1]
91 if !final.TurnFinal || final.MessageID != "final-answer" || final.TurnDurationMs != 933524 {
92 t.Fatalf("history lost the authoritative final identity or complete turn duration: %+v", final)
93 }
94 if final.SamplingCount == nil || *final.SamplingCount != 72 || final.ToolCount == nil || *final.ToolCount != 72 {
95 t.Fatalf("history lost distinct attempt/tool counts: %+v", final)
96 }
97 page := newest
98 for {
99 if page.Status != "ready" || len(page.Messages) > 32 || page.SnapshotSequence != newest.SnapshotSequence {
100 t.Fatalf("invalid bounded history page: %+v", page)
101 }
102 for _, stored := range page.Messages {
103 body := []byte(stored.Inline)
104 if stored.ContentRef != nil {
105 body, err = query.ReadContent(t.Context(), ref, *stored.ContentRef, 0, stored.ContentRef.Bytes)
106 if err != nil {
107 t.Fatal(err)
108 }
109 }
110 var message provider.Message
111 if err := json.Unmarshal(body, &message); err != nil {
112 t.Fatalf("decode %s: %v", stored.MessageID, err)
113 }
114 if _, duplicate := seen[message.ID]; duplicate {
115 t.Fatalf("history repeated message %q", message.ID)
116 }
117 seen[message.ID] = message
118 }
119 if !page.HasOlder {
120 break
121 }
122 if page.OlderCursor == "" {
123 t.Fatal("evicted older history has no continuation")
124 }
125 page = windowReady(t, query, ref, HistoryWindowRequest{Anchor: "cursor", Cursor: page.OlderCursor, Limit: 32})
126 }
127 contentAssistants, toolResults := 0, 0
128 for _, expected := range messages {
129 actual, found := seen[expected.ID]
130 if !found || actual.Content != expected.Content || actual.ReasoningContent != expected.ReasoningContent || actual.ToolCallID != expected.ToolCallID {
131 t.Fatalf("message %q is missing or its body changed after eviction", expected.ID)
132 }
133 if actual.Role == provider.RoleAssistant && (actual.Content != "" || actual.ReasoningContent != "") {
134 contentAssistants++
135 }
136 if actual.Role == provider.RoleTool {
137 toolResults++
138 }
139 }
140 if len(seen) != 147 || contentAssistants != 46 || toolResults != 72 {
141 t.Fatalf("recovered shape: messages=%d visibleAssistants=%d tools=%d", len(seen), contentAssistants, toolResults)
142 }
143 // The oldest window must lead back to the newest; reclaiming one edge is
144 // not deletion and must never strand the reader at an unloaded boundary.
145 newerSeen := make(map[string]bool)
146 for {
147 for _, message := range page.Messages {
148 newerSeen[message.MessageID] = true
149 }
150 if !page.HasNewer {
151 break
152 }
153 if page.NewerCursor == "" {
154 t.Fatal("older history has no forward continuation")
155 }
156 page = windowReady(t, query, ref, HistoryWindowRequest{Anchor: "cursor", Cursor: page.NewerCursor, Limit: 32})
157 }
158 if len(newerSeen) != len(messages) || !newerSeen["final-answer"] {
159 t.Fatal("bidirectional paging did not reach all messages and final answer")
160 }
161 for _, id := range []string{"introduction", "assistant-00", "final-answer"} {
162 location, err := query.LocateMessage(t.Context(), ref, id, newest.SnapshotSequence)
163 if err != nil || location.Status != "ready" || location.MessageID != id {
164 t.Fatalf("locate %q: status=%q error=%v", id, location.Status, err)
165 }
166 }
167 if err := service.Close(t.Context(), ref); err != nil {
168 t.Fatal(err)
169 }
170 binding, err := service.Open(t.Context(), ref)
171 if err != nil {
172 t.Fatal(err)
173 }
174 t.Cleanup(func() { _ = binding.Release(context.Background()) })
175 reopened := binding.Runtime()
176 // This path constructs only session/query objects: reopening and following
177 // must not need an Agent, provider, or a model request to reconstruct UI.
178 initial, err := reopened.FollowTranscript(t.Context(), transcript.FollowRequest{})
179 if err != nil || initial.Snapshot == nil {
180 t.Fatalf("follow reopened session: %v", err)
181 }
182 t.Cleanup(func() {
183 _, _ = reopened.FollowTranscript(context.Background(), transcript.FollowRequest{Subscription: initial.Subscription, Close: true})
184 })
185 cut := initial.Snapshot
186 if cut.Runtime.SamplingCount != 72 || cut.Runtime.ToolCount != 72 {
187 t.Fatalf("restart lost distinct attempt/tool counts: %+v", cut.Runtime)
188 }
189 if cut.TotalRecords > 96 || cut.Runtime.Status != event.TurnCompleted || cut.Runtime.FinalMessageID != "final-answer" || cut.Runtime.DurationMs != 933524 {
190 t.Errorf("reopened transcript lost terminal state or budget: records=%d runtime=%+v", cut.TotalRecords, cut.Runtime)
191 }
192 found := false
193 for _, row := range cut.Records {
194 found = found || row.Message.MessageID == "final-answer" && row.Message.Content == "The synthetic illustration is complete."
195 }
196 if !found || len(cut.ActiveAttempts) != 0 || len(cut.ActiveRecords) != 0 {
197 t.Fatal("reopened completed follow omitted the final answer or manufactured an active turn")
198 }
199 }
200
200 lines GO