返回 DeepSeek-Reasonix
sessionstate_test.go
根目录 / internal / agent / sessionstate_test.go
1 package agent
2
3 import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "testing"
8 "time"
9 )
10
11 // sessionReset names the fields a new conversation starts from. taskRuntime
12 // gets this for free — one assignment zeroes anything unlisted — but a struct
13 // holding atomics and mutexes cannot be assigned, so reset must name each field
14 // and this list is what keeps it honest.
15 var sessionReset = map[string]bool{
16 "mu": true,
17 "conversation": true,
18 "output": true,
19 "cacheHit": true,
20 "cacheMiss": true,
21 "missingReasoning": true,
22 // A strong-projection repair belongs to the corrupted conversation; a new
23 // conversation starts without it.
24 "reasoningReplayStrongProjection": true,
25 "reasoningReplayStrongProjectionAnchor": true,
26 "compactionMu": true,
27 "compactionState": true,
28 "cacheState": true,
29 "checkpointState": true,
30 "pendingModelContextCommit": true,
31 "compaction": true,
32 "todoMu": true,
33 "todoState": true,
34 "todoWritten": true,
35 }
36
37 // sessionCarryOver names the fields reset deliberately leaves alone, each with
38 // an owner that rebinds it. Being on this list is a claim that someone else
39 // sets the field for the new conversation — not that it does not matter.
40 var sessionCarryOver = map[string]bool{
41 "compactionRunMu": true, // a singleflight latch, not conversation state
42 "path": true, // preflight rebinds on the next transcript bind
43 // lastPrefixShape survives the swap today; the next request compares its
44 // prefix against the replaced conversation's shape. Left as found here.
45 "lastPrefixShape": true,
46 "haveLastPrefixShape": true,
47 }
48
49 func sessionRuntimeFields(t *testing.T) map[string]bool {
50 t.Helper()
51 fset := token.NewFileSet()
52 file, err := parser.ParseFile(fset, "sessionstate.go", nil, 0)
53 if err != nil {
54 t.Fatalf("parse sessionstate.go: %v", err)
55 }
56 fields := map[string]bool{}
57 ast.Inspect(file, func(n ast.Node) bool {
58 spec, ok := n.(*ast.TypeSpec)
59 if !ok || spec.Name.Name != "sessionRuntime" {
60 return true
61 }
62 st, ok := spec.Type.(*ast.StructType)
63 if !ok {
64 return false
65 }
66 for _, field := range st.Fields.List {
67 if len(field.Names) == 0 {
68 // An embedded type contributes its own name.
69 if ident, ok := field.Type.(*ast.Ident); ok {
70 fields[ident.Name] = true
71 }
72 continue
73 }
74 for _, name := range field.Names {
75 fields[name.Name] = true
76 }
77 }
78 return false
79 })
80 if len(fields) == 0 {
81 t.Fatal("sessionRuntime has no fields; the guard would pass vacuously")
82 }
83 return fields
84 }
85
86 func TestSessionRuntimeLifetimeListsCoverTheStruct(t *testing.T) {
87 fields := sessionRuntimeFields(t)
88 for _, list := range []map[string]bool{sessionReset, sessionCarryOver} {
89 for name := range list {
90 if !fields[name] {
91 t.Errorf("the lifetime lists name %q, which sessionRuntime no longer has", name)
92 }
93 }
94 }
95 for name := range fields {
96 switch {
97 case sessionReset[name] && sessionCarryOver[name]:
98 t.Errorf("sessionRuntime.%s is listed as both reset and carried", name)
99 case !sessionReset[name] && !sessionCarryOver[name]:
100 t.Errorf("sessionRuntime.%s is on neither list; decide whether a new conversation starts from it", name)
101 }
102 }
103 }
104
105 // The list above is a claim about reset's body, so it is read back from the
106 // source: a field dropped from reset stops being covered, and the mismatch
107 // fails here instead of surfacing as state leaking between conversations.
108 func TestSessionRuntimeResetAssignsEveryResetField(t *testing.T) {
109 fset := token.NewFileSet()
110 file, err := parser.ParseFile(fset, "sessionstate.go", nil, 0)
111 if err != nil {
112 t.Fatalf("parse sessionstate.go: %v", err)
113 }
114 touched := map[string]bool{}
115 ast.Inspect(file, func(n ast.Node) bool {
116 fn, ok := n.(*ast.FuncDecl)
117 if !ok || fn.Name.Name != "reset" {
118 return true
119 }
120 ast.Inspect(fn, func(inner ast.Node) bool {
121 sel, ok := inner.(*ast.SelectorExpr)
122 if !ok {
123 return true
124 }
125 if recv, ok := sel.X.(*ast.Ident); ok && recv.Name == "r" {
126 touched[sel.Sel.Name] = true
127 }
128 return true
129 })
130 return false
131 })
132 for name := range sessionReset {
133 if !touched[name] {
134 t.Errorf("reset never touches sessionRuntime.%s, but the list says a new conversation starts from it", name)
135 }
136 }
137 }
138
139 func TestSetSessionRestartsTheConversationState(t *testing.T) {
140 a := &Agent{}
141 a.sess.cacheHit.Store(11)
142 a.sess.cacheMiss.Store(7)
143 a.sess.missingReasoning = missingReasoningWatch{active: true, stateRecorded: true, healthyStreak: 2}
144 a.sess.reasoningReplayStrongProjection = 7
145 a.sess.reasoningReplayStrongProjectionAnchor = "anchor"
146 a.sess.compaction.stuck = true
147 a.sess.compaction.stuckInputHash = "old-input"
148 a.sess.compaction.consecutive = 3
149 a.sess.compaction.failedTurn.Store(8)
150 a.sess.compaction.lastTurn.Store(9)
151 a.sess.compactionState = CompactionState{}
152 a.sess.checkpointState = "pending"
153 a.sess.pendingModelContextCommit = &SessionModelContextCommit{OperationID: "old-operation"}
154 a.unwrittenResolve.at = time.Unix(1, 0)
155
156 next := NewSession("")
157 a.SetSession(next)
158
159 if a.sess.session() != next {
160 t.Error("SetSession did not bind the new conversation")
161 }
162 if a.sess.cacheHit.Load() != 0 || a.sess.cacheMiss.Load() != 0 {
163 t.Errorf("cache tallies = %d/%d, want a fresh aggregate", a.sess.cacheHit.Load(), a.sess.cacheMiss.Load())
164 }
165 if a.sess.missingReasoning != (missingReasoningWatch{}) {
166 t.Errorf("missingReasoning = %+v, want the incident to end with its conversation", a.sess.missingReasoning)
167 }
168 if a.sess.reasoningReplayStrongProjection != 0 {
169 t.Errorf("reasoningReplayStrongProjection = %d, want it restarted", a.sess.reasoningReplayStrongProjection)
170 }
171 if a.sess.reasoningReplayStrongProjectionAnchor != "" {
172 t.Errorf("reasoningReplayStrongProjectionAnchor = %q, want it restarted", a.sess.reasoningReplayStrongProjectionAnchor)
173 }
174 if a.sess.compaction.stuck || a.sess.compaction.stuckInputHash != "" || a.sess.compaction.consecutive != 0 ||
175 a.sess.compaction.failedTurn.Load() != 0 || a.sess.compaction.lastTurn.Load() != 0 {
176 t.Errorf("compaction progress = stuck:%t hash:%q consecutive:%d failedTurn:%d lastTurn:%d, want it restarted",
177 a.sess.compaction.stuck, a.sess.compaction.stuckInputHash, a.sess.compaction.consecutive,
178 a.sess.compaction.failedTurn.Load(), a.sess.compaction.lastTurn.Load())
179 }
180 if a.sess.cacheState != CacheStateUnknown {
181 t.Errorf("cacheState = %q, want %q", a.sess.cacheState, CacheStateUnknown)
182 }
183 if a.sess.checkpointState != "none" || a.sess.pendingModelContextCommit != nil {
184 t.Errorf("pending model context leaked across session reset: state=%q pending=%+v", a.sess.checkpointState, a.sess.pendingModelContextCommit)
185 }
186 if a.unwrittenResolve.at.IsZero() {
187 t.Error("unwrittenResolve was cleared; the retry it owes belongs to the provider configuration, not the conversation")
188 }
189 }
190
190 lines GO