返回 DeepSeek-Reasonix
runtime_test.go
根目录 / internal / boot / runtime_test.go
1 package boot
2
3 import (
4 "context"
5 "errors"
6 "slices"
7 "testing"
8
9 "reasonix/internal/control"
10 "reasonix/internal/provider"
11 )
12
13 // writeRuntimeFixture writes the minimal deterministic config the runtime
14 // tests share: a resolvable model, a fixed base system prompt, and the
15 // environment probe section disabled (it embeds machine-specific data).
16 func writeRuntimeFixture(t *testing.T, dir string) {
17 t.Helper()
18 writeFile(t, dir, "reasonix.toml", `
19 default_model = "test-model"
20
21 [agent]
22 system_prompt = "BASE SYSTEM PROMPT"
23
24 [environment]
25 enabled = false
26
27 [[providers]]
28 name = "test-model"
29 kind = "openai"
30 base_url = "https://example.invalid"
31 model = "x"
32 api_key_env = "REASONIX_TEST_KEY_UNSET"
33 `)
34 }
35
36 // buildRuntimeFixture builds one runtime against the fixture and registers
37 // its controller for cleanup.
38 func buildRuntimeFixture(t *testing.T) *BuildResult {
39 t.Helper()
40 res, err := BuildRuntime(context.Background(), Options{})
41 if err != nil {
42 t.Fatalf("BuildRuntime: %v", err)
43 }
44 if res.Controller == nil {
45 t.Fatal("BuildRuntime returned a nil controller")
46 }
47 t.Cleanup(res.Controller.Close)
48 return res
49 }
50
51 // TestBuildRuntimeSnapshotMatchesController pins the stage-3a contract: the
52 // kernel snapshot mirrors exactly what the build wired — same system prompt,
53 // same provider-visible tool contract — its cache fingerprint is stable
54 // across identical builds, and generations increase monotonically.
55 func TestBuildRuntimeSnapshotMatchesController(t *testing.T) {
56 isolateConfigHome(t)
57 dir := robustTempDir(t)
58 t.Chdir(dir)
59 writeRuntimeFixture(t, dir)
60
61 first := buildRuntimeFixture(t)
62 if first.Snapshot == nil {
63 t.Fatal("BuildRuntime returned a nil snapshot")
64 }
65 if first.Runtime == nil {
66 t.Fatal("BuildRuntime returned a nil runtime set")
67 }
68 if first.Runtime.Len() != 0 {
69 t.Fatalf("stage-3a runtime set holds %d closers, want 0 (sidecars arrive in stage 5)", first.Runtime.Len())
70 }
71 if first.Snapshot.Generation() == 0 {
72 t.Fatal("snapshot generation = 0, want the counter to start at 1")
73 }
74
75 // The snapshot's system prompt is exactly the controller's system
76 // message.
77 if got, want := first.Snapshot.SystemPrompt(), systemMessage(first.Controller.History()); got != want {
78 t.Fatalf("snapshot system prompt != controller system message\n got: %q\nwant: %q", got, want)
79 }
80
81 // The snapshot's tool schemas are exactly the controller's tool contract,
82 // entry for entry.
83 entries := first.Controller.ToolContractEntries()
84 schemas := first.Snapshot.ToolSchemas()
85 if len(entries) == 0 {
86 t.Fatal("BuildRuntime registered no tools")
87 }
88 if len(schemas) != len(entries) {
89 t.Fatalf("snapshot holds %d tool schemas, controller contract has %d", len(schemas), len(entries))
90 }
91 for i, e := range entries {
92 s := schemas[i]
93 if s.Name != e.Name || s.Description != e.Description || string(s.Parameters) != string(e.Schema) {
94 t.Fatalf("tool schema %d = (%q, %.40q, %.40q), want (%q, %.40q, %.40q)",
95 i, s.Name, s.Description, s.Parameters, e.Name, e.Description, e.Schema)
96 }
97 }
98
99 // A clean fixture records no diagnostics.
100 if diags := first.Snapshot.Diagnostics(); len(diags) != 0 {
101 t.Fatalf("snapshot diagnostics = %v, want none", diags)
102 }
103
104 // An identical second build reproduces the same CacheHash (the
105 // provider-cache fingerprint) at a higher generation.
106 second := buildRuntimeFixture(t)
107 if second.Snapshot == nil {
108 t.Fatal("second BuildRuntime returned a nil snapshot")
109 }
110 if got, want := second.Snapshot.CacheHash(), first.Snapshot.CacheHash(); got != want {
111 t.Fatalf("CacheHash drifted across identical builds: %s vs %s", got, want)
112 }
113 if got, before := second.Snapshot.Generation(), first.Snapshot.Generation(); got <= before {
114 t.Fatalf("generation did not increase across builds: %d then %d", before, got)
115 }
116 }
117
118 // TestRebuildMigratesSessionState drives the success path: an old controller
119 // with a conversation, session grants, and the session axes set rebuilds into
120 // a replacement that continues the same session file with everything carried
121 // — while the old controller stays fully usable.
122 func TestRebuildMigratesSessionState(t *testing.T) {
123 isolateConfigHome(t)
124 dir := robustTempDir(t)
125 t.Chdir(dir)
126 writeRuntimeFixture(t, dir)
127
128 old := buildRuntimeFixture(t)
129 oldCtrl := old.Controller
130
131 // Pin a session file and seed a conversation plus the session axes the
132 // rebuild must carry.
133 oldCtrl.EnsureSessionPath()
134 prevPath := oldCtrl.SessionPath()
135 if prevPath == "" {
136 t.Fatal("old controller pinned no session path")
137 }
138 oldCtrl.AdoptHistory([]provider.Message{
139 {Role: provider.RoleSystem, Content: systemMessage(oldCtrl.History())},
140 {Role: provider.RoleUser, Content: "hello"},
141 {Role: provider.RoleAssistant, Content: "hi there"},
142 }, prevPath)
143 oldCtrl.SetToolApprovalMode(control.ToolApprovalYolo)
144 oldCtrl.SetPlanMode(true)
145 oldCtrl.SetGoal("ship the kernel")
146 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
147 Grants: []string{"bash(go test ./...)"},
148 PlanModeReadOnlyCommands: []string{"git status"},
149 })
150 oldHistory := oldCtrl.History()
151
152 res, err := Rebuild(context.Background(), oldCtrl, Options{})
153 if err != nil {
154 t.Fatalf("Rebuild: %v", err)
155 }
156 if res.Snapshot == nil {
157 t.Fatal("Rebuild returned a nil snapshot")
158 }
159 if res.Snapshot.Generation() <= old.Snapshot.Generation() {
160 t.Fatalf("generation did not increase: old %d, new %d", old.Snapshot.Generation(), res.Snapshot.Generation())
161 }
162 defer res.Controller.Close()
163
164 // The conversation continues on the same session file with identical
165 // messages (the fixture rebuild produces the same system prompt, so the
166 // splice is invisible here).
167 if got := res.Controller.SessionPath(); got != prevPath {
168 t.Fatalf("session path = %q, want continued %q", got, prevPath)
169 }
170 newHistory := res.Controller.History()
171 if len(newHistory) != len(oldHistory) {
172 t.Fatalf("new history has %d messages, want %d", len(newHistory), len(oldHistory))
173 }
174 for i := range oldHistory {
175 if newHistory[i].Role != oldHistory[i].Role || newHistory[i].Content != oldHistory[i].Content {
176 t.Fatalf("history[%d] = (%s, %q), want (%s, %q)",
177 i, newHistory[i].Role, newHistory[i].Content, oldHistory[i].Role, oldHistory[i].Content)
178 }
179 }
180
181 // Session axes migrated.
182 if got := res.Controller.ToolApprovalMode(); got != control.ToolApprovalYolo {
183 t.Fatalf("tool approval mode = %q, want %q", got, control.ToolApprovalYolo)
184 }
185 if !res.Controller.PlanMode() {
186 t.Fatal("plan mode did not migrate")
187 }
188 if got := res.Controller.Goal(); got != "ship the kernel" {
189 t.Fatalf("goal = %q, want migrated %q", got, "ship the kernel")
190 }
191 auth := res.Controller.SessionAuthorizations()
192 if !slices.Contains(auth.Grants, "bash(go test ./...)") {
193 t.Fatalf("session grants = %v, want the migrated grant", auth.Grants)
194 }
195 if !slices.Contains(auth.PlanModeReadOnlyCommands, "git status") {
196 t.Fatalf("plan-mode trust = %v, want the migrated prefix", auth.PlanModeReadOnlyCommands)
197 }
198
199 // old keeps working: history intact, runtime set untouched, close clean.
200 if got := len(oldCtrl.History()); got != len(oldHistory) {
201 t.Fatalf("old controller history changed during rebuild: %d, want %d", got, len(oldHistory))
202 }
203 if old.Runtime.Closed() {
204 t.Fatal("Rebuild closed the old runtime set")
205 }
206 oldCtrl.Close()
207 }
208
209 // TestRebuildCarriesGoalWithoutSessionPath covers the in-memory fallback: an
210 // old controller that never pinned a session file has no Goal sidecar to
211 // restore, so the running Goal migrates from memory.
212 func TestRebuildCarriesGoalWithoutSessionPath(t *testing.T) {
213 isolateConfigHome(t)
214 dir := robustTempDir(t)
215 t.Chdir(dir)
216 writeRuntimeFixture(t, dir)
217
218 old := buildRuntimeFixture(t)
219 oldCtrl := old.Controller
220 if got := oldCtrl.SessionPath(); got != "" {
221 t.Fatalf("fresh controller session path = %q, want empty", got)
222 }
223 oldCtrl.SetGoal("ship the kernel")
224
225 res, err := Rebuild(context.Background(), oldCtrl, Options{})
226 if err != nil {
227 t.Fatalf("Rebuild: %v", err)
228 }
229 defer res.Controller.Close()
230 if got := res.Controller.Goal(); got != "ship the kernel" {
231 t.Fatalf("goal = %q, want seeded %q", got, "ship the kernel")
232 }
233 }
234
235 // TestRebuildFailureKeepsOldController drives the fail-atomic path: the
236 // replacement build fails (unknown model), the error propagates, and the old
237 // controller is untouched.
238 func TestRebuildFailureKeepsOldController(t *testing.T) {
239 isolateConfigHome(t)
240 dir := robustTempDir(t)
241 t.Chdir(dir)
242 writeRuntimeFixture(t, dir)
243
244 old := buildRuntimeFixture(t)
245 oldCtrl := old.Controller
246 oldCtrl.EnsureSessionPath()
247 prevPath := oldCtrl.SessionPath()
248 oldCtrl.AdoptHistory([]provider.Message{
249 {Role: provider.RoleSystem, Content: systemMessage(oldCtrl.History())},
250 {Role: provider.RoleUser, Content: "hello"},
251 }, prevPath)
252
253 res, err := Rebuild(context.Background(), oldCtrl, Options{Model: "definitely-unknown-model"})
254 if err == nil {
255 t.Fatal("Rebuild succeeded, want ErrUnknownModel")
256 }
257 if !errors.Is(err, ErrUnknownModel) {
258 t.Fatalf("Rebuild error = %v, want ErrUnknownModel", err)
259 }
260 if res != nil {
261 t.Fatalf("Rebuild returned a partial result on failure: %+v", res)
262 }
263
264 // old is fully usable: history and path intact, runtime set untouched,
265 // close clean.
266 if got := len(oldCtrl.History()); got != 2 {
267 t.Fatalf("old history = %d messages, want 2", got)
268 }
269 if got := oldCtrl.SessionPath(); got != prevPath {
270 t.Fatalf("old session path = %q, want %q", got, prevPath)
271 }
272 if old.Runtime.Closed() {
273 t.Fatal("Rebuild closed the old runtime set on failure")
274 }
275 oldCtrl.Close()
276 }
277
278 // TestSpliceFreshSystemPrompt pins the system-message splice used to refresh
279 // the profile contract on a continued conversation.
280 func TestSpliceFreshSystemPrompt(t *testing.T) {
281 fresh := []provider.Message{{Role: provider.RoleSystem, Content: "new prompt"}}
282 t.Run("replaces the carried system message", func(t *testing.T) {
283 carried := []provider.Message{
284 {Role: provider.RoleSystem, Content: "old prompt"},
285 {Role: provider.RoleUser, Content: "hi"},
286 }
287 got := spliceFreshSystemPrompt(carried, fresh)
288 if len(got) != 2 || got[0].Content != "new prompt" || got[1].Content != "hi" {
289 t.Fatalf("splice = %+v", got)
290 }
291 })
292 t.Run("prepends when the carried conversation has none", func(t *testing.T) {
293 carried := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
294 got := spliceFreshSystemPrompt(carried, fresh)
295 if len(got) != 2 || got[0].Role != provider.RoleSystem || got[1].Content != "hi" {
296 t.Fatalf("splice = %+v", got)
297 }
298 })
299 t.Run("no fresh system message leaves the conversation untouched", func(t *testing.T) {
300 carried := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
301 got := spliceFreshSystemPrompt(carried, []provider.Message{{Role: provider.RoleUser, Content: "yo"}})
302 if len(got) != 1 || got[0].Content != "hi" {
303 t.Fatalf("splice = %+v", got)
304 }
305 })
306 t.Run("does not alias the input slice", func(t *testing.T) {
307 carried := []provider.Message{{Role: provider.RoleSystem, Content: "old prompt"}}
308 got := spliceFreshSystemPrompt(carried, fresh)
309 got[0].Content = "mutated"
310 if carried[0].Content != "old prompt" {
311 t.Fatal("splice wrote through to the caller's slice")
312 }
313 })
314 }
315
315 lines GO