| 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 |