返回 DeepSeek-Reasonix
runtime_test.go
根目录 / internal / boot / runtime_test.go
1 package boot
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/provider"
16 "reasonix/internal/session"
17 )
18
19 func TestBuildRuntimeDisablesImplicitSkillInvocation(t *testing.T) {
20 isolateConfigHome(t)
21 dir := robustTempDir(t)
22 t.Chdir(dir)
23 writeRuntimeFixture(t, dir)
24 configPath := filepath.Join(dir, "reasonix.toml")
25 content, err := os.ReadFile(configPath)
26 if err != nil {
27 t.Fatalf("read fixture config: %v", err)
28 }
29 content = append(content, []byte("\n[skills]\ndisable_implicit_invocation = true\n")...)
30 if err := os.WriteFile(configPath, content, 0o644); err != nil {
31 t.Fatalf("write skills config: %v", err)
32 }
33 res := buildRuntimeFixture(t)
34 if res.Controller.ImplicitSkillInvocationEnabled() {
35 t.Fatal("controller should disable implicit skill invocation")
36 }
37 if res.Assembly == nil || res.Assembly.ImplicitSkillInvocation {
38 t.Fatal("reused assembly should record implicit skill invocation as disabled")
39 }
40 if strings.Contains(res.Snapshot.SystemPrompt(), "One-liner index") {
41 t.Fatal("skill index should not be provider-visible when implicit invocation is disabled")
42 }
43 for _, entry := range res.Controller.ToolContractEntries() {
44 switch entry.Name {
45 case "run_skill", "read_skill", "read_only_skill", "install_skill":
46 t.Fatalf("skill tool %q should not be exposed to the model", entry.Name)
47 }
48 }
49 if _, ok := res.Controller.RunSkill("/explore inspect"); !ok {
50 t.Fatal("explicit /skill invocation should remain available")
51 }
52 }
53
54 func TestRebuildFromForceFullRebuildRefreshesSkillPolicy(t *testing.T) {
55 isolateConfigHome(t)
56 dir := robustTempDir(t)
57 t.Chdir(dir)
58 writeRuntimeFixture(t, dir)
59 configPath := filepath.Join(dir, "reasonix.toml")
60 content, err := os.ReadFile(configPath)
61 if err != nil {
62 t.Fatalf("read fixture config: %v", err)
63 }
64 content = append(content, []byte("\n[skills]\ndisable_implicit_invocation = true\n")...)
65 if err := os.WriteFile(configPath, content, 0o644); err != nil {
66 t.Fatalf("write disabled skill policy: %v", err)
67 }
68 previous, err := BuildRuntime(context.Background(), Options{})
69 if err != nil {
70 t.Fatalf("initial BuildRuntime: %v", err)
71 }
72 if previous.Controller.ImplicitSkillInvocationEnabled() {
73 t.Fatal("initial controller should disable implicit skill invocation")
74 }
75 content = bytes.Replace(content, []byte("disable_implicit_invocation = true"), []byte("disable_implicit_invocation = false"), 1)
76 if err := os.WriteFile(configPath, content, 0o644); err != nil {
77 t.Fatalf("write enabled skill policy: %v", err)
78 }
79 res, err := RebuildFrom(context.Background(), previous, Options{
80 RuntimeReload: RuntimeReload{ForceFullRebuild: true},
81 })
82 if err != nil {
83 previous.Controller.Close()
84 t.Fatalf("forced RebuildFrom: %v", err)
85 }
86 t.Cleanup(func() {
87 res.Controller.Close()
88 })
89 if res.Controller == previous.Controller {
90 t.Fatal("forced rebuild reused the previous controller")
91 }
92 if !res.Controller.ImplicitSkillInvocationEnabled() {
93 t.Fatal("forced rebuild did not refresh the enabled skill policy")
94 }
95 previous.Controller.Close()
96 }
97
98 // writeRuntimeFixture writes the minimal deterministic config the runtime
99 // tests share: a resolvable model, a fixed base system prompt, and the
100 // environment probe section disabled (it embeds machine-specific data).
101 func writeRuntimeFixture(t *testing.T, dir string) {
102 t.Helper()
103 writeFile(t, dir, "reasonix.toml", `
104 default_model = "test-model"
105
106 [agent]
107 system_prompt = "BASE SYSTEM PROMPT"
108
109 [environment]
110 enabled = false
111
112 [[providers]]
113 name = "test-model"
114 kind = "openai"
115 base_url = "https://example.invalid"
116 model = "x"
117 api_key_env = "REASONIX_TEST_KEY_UNSET"
118 `)
119 approveWorkspace(t, dir)
120 }
121
122 // buildRuntimeFixture builds one runtime against the fixture and registers
123 // its controller for cleanup.
124 func buildRuntimeFixture(t *testing.T) *BuildResult {
125 t.Helper()
126 res, err := BuildRuntime(context.Background(), Options{})
127 if err != nil {
128 t.Fatalf("BuildRuntime: %v", err)
129 }
130 if res.Controller == nil {
131 t.Fatal("BuildRuntime returned a nil controller")
132 }
133 t.Cleanup(res.Controller.Close)
134 return res
135 }
136
137 // TestBuildRuntimeSnapshotMatchesController pins the stage-3a contract: the
138 // kernel snapshot mirrors exactly what the build wired — same system prompt,
139 // same provider-visible tool contract — its cache fingerprint is stable
140 // across identical builds, and generations increase monotonically.
141 func TestBuildRuntimeSnapshotMatchesController(t *testing.T) {
142 isolateConfigHome(t)
143 dir := robustTempDir(t)
144 t.Chdir(dir)
145 writeRuntimeFixture(t, dir)
146
147 first := buildRuntimeFixture(t)
148 if first.Snapshot == nil {
149 t.Fatal("BuildRuntime returned a nil snapshot")
150 }
151 if first.Runtime == nil {
152 t.Fatal("BuildRuntime returned a nil runtime set")
153 }
154 if first.Runtime.Len() != 0 {
155 t.Fatalf("stage-3a runtime set holds %d closers, want 0 (sidecars arrive in stage 5)", first.Runtime.Len())
156 }
157 if first.Snapshot.Generation() == 0 {
158 t.Fatal("snapshot generation = 0, want the counter to start at 1")
159 }
160
161 // The snapshot's system prompt is exactly the controller's system
162 // message.
163 if got, want := first.Snapshot.SystemPrompt(), systemMessage(first.Controller.History()); got != want {
164 t.Fatalf("snapshot system prompt != controller system message\n got: %q\nwant: %q", got, want)
165 }
166
167 // The snapshot's tool schemas are exactly the controller's tool contract,
168 // entry for entry.
169 entries := first.Controller.ToolContractEntries()
170 schemas := first.Snapshot.ToolSchemas()
171 if len(entries) == 0 {
172 t.Fatal("BuildRuntime registered no tools")
173 }
174 if len(schemas) != len(entries) {
175 t.Fatalf("snapshot holds %d tool schemas, controller contract has %d", len(schemas), len(entries))
176 }
177 for i, e := range entries {
178 s := schemas[i]
179 if s.Name != e.Name || s.Description != e.Description || string(s.Parameters) != string(e.Schema) {
180 t.Fatalf("tool schema %d = (%q, %.40q, %.40q), want (%q, %.40q, %.40q)",
181 i, s.Name, s.Description, s.Parameters, e.Name, e.Description, e.Schema)
182 }
183 }
184
185 // A clean fixture records no diagnostics.
186 if diags := first.Snapshot.Diagnostics(); len(diags) != 0 {
187 t.Fatalf("snapshot diagnostics = %v, want none", diags)
188 }
189
190 // An identical second build reproduces the same CacheHash (the
191 // provider-cache fingerprint) at a higher generation.
192 second := buildRuntimeFixture(t)
193 if second.Snapshot == nil {
194 t.Fatal("second BuildRuntime returned a nil snapshot")
195 }
196 if got, want := second.Snapshot.CacheHash(), first.Snapshot.CacheHash(); got != want {
197 t.Fatalf("CacheHash drifted across identical builds: %s vs %s", got, want)
198 }
199 if got, before := second.Snapshot.Generation(), first.Snapshot.Generation(); got <= before {
200 t.Fatalf("generation did not increase across builds: %d then %d", before, got)
201 }
202 }
203
204 // TestRebuildMigratesSessionState drives the success path: an old controller
205 // with a conversation, session grants, and the session axes set rebuilds into
206 // a replacement that continues the same session file with everything carried
207 // — while the old controller stays fully usable.
208 func TestRebuildMigratesSessionState(t *testing.T) {
209 isolateConfigHome(t)
210 dir := robustTempDir(t)
211 t.Chdir(dir)
212 writeRuntimeFixture(t, dir)
213
214 old, err := BuildRuntime(context.Background(), withTestSession(t, Options{}))
215 if err != nil {
216 t.Fatalf("BuildRuntime v3: %v", err)
217 }
218 t.Cleanup(old.Controller.Close)
219 oldCtrl := old.Controller
220
221 // Pin a v3 session and seed a conversation plus the session axes the
222 // rebuild must carry.
223 oldCtrl.EnsureSessionPath()
224 prevRef, ok := oldCtrl.SessionRef()
225 if !ok {
226 t.Fatal("old controller pinned no v3 session")
227 }
228 oldCtrl.AdoptHistory([]provider.Message{
229 {Role: provider.RoleSystem, Content: systemMessage(oldCtrl.History())},
230 {Role: provider.RoleUser, Content: "hello"},
231 {Role: provider.RoleAssistant, Content: "hi there"},
232 }, "")
233 oldCtrl.SetToolApprovalMode(control.ToolApprovalYolo)
234 oldCtrl.SetPlanMode(true)
235 oldCtrl.SetGoal("ship the kernel")
236 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
237 Grants: []string{"bash(go test ./...)"},
238 PlanModeReadOnlyCommands: []string{"git status"},
239 })
240 oldHistory := oldCtrl.History()
241
242 res, err := Rebuild(context.Background(), oldCtrl, Options{})
243 if err != nil {
244 t.Fatalf("Rebuild: %v", err)
245 }
246 if res.Snapshot == nil {
247 t.Fatal("Rebuild returned a nil snapshot")
248 }
249 if res.Snapshot.Generation() <= old.Snapshot.Generation() {
250 t.Fatalf("generation did not increase: old %d, new %d", old.Snapshot.Generation(), res.Snapshot.Generation())
251 }
252 defer res.Controller.Close()
253
254 // The conversation continues on the same immutable v3 identity with identical
255 // messages (the fixture rebuild produces the same system prompt, so the
256 // splice is invisible here).
257 gotRef, ok := res.Controller.SessionRef()
258 if !ok || gotRef != prevRef {
259 t.Fatalf("session ref = %+v, want continued %+v", gotRef, prevRef)
260 }
261 if got := res.Controller.SessionPath(); got != "" {
262 t.Fatalf("rebuilt v3 controller retained legacy path %q", got)
263 }
264 newHistory := res.Controller.History()
265 if len(newHistory) != len(oldHistory) {
266 t.Fatalf("new history has %d messages, want %d", len(newHistory), len(oldHistory))
267 }
268 for i := range oldHistory {
269 if newHistory[i].Role != oldHistory[i].Role || newHistory[i].Content != oldHistory[i].Content {
270 t.Fatalf("history[%d] = (%s, %q), want (%s, %q)",
271 i, newHistory[i].Role, newHistory[i].Content, oldHistory[i].Role, oldHistory[i].Content)
272 }
273 }
274
275 // Session axes migrated.
276 if got := res.Controller.ToolApprovalMode(); got != control.ToolApprovalYolo {
277 t.Fatalf("tool approval mode = %q, want %q", got, control.ToolApprovalYolo)
278 }
279 if !res.Controller.PlanMode() {
280 t.Fatal("plan mode did not migrate")
281 }
282 if got := res.Controller.Goal(); got != "ship the kernel" {
283 t.Fatalf("goal = %q, want migrated %q", got, "ship the kernel")
284 }
285 auth := res.Controller.SessionAuthorizations()
286 if !slices.Contains(auth.Grants, "bash(go test ./...)") {
287 t.Fatalf("session grants = %v, want the migrated grant", auth.Grants)
288 }
289 if !slices.Contains(auth.PlanModeReadOnlyCommands, "git status") {
290 t.Fatalf("plan-mode trust = %v, want the migrated prefix", auth.PlanModeReadOnlyCommands)
291 }
292
293 // old keeps working: history intact, runtime set untouched, close clean.
294 if got := len(oldCtrl.History()); got != len(oldHistory) {
295 t.Fatalf("old controller history changed during rebuild: %d, want %d", got, len(oldHistory))
296 }
297 if old.Runtime.Closed() {
298 t.Fatal("Rebuild closed the old runtime set")
299 }
300 oldCtrl.Close()
301 }
302
303 func TestRebuildKeepsLiveReadOnlyAfterStoredRemotePreset(t *testing.T) {
304 restoreSandbox := control.SetPresetSandboxForTest(true)
305 t.Cleanup(restoreSandbox)
306 isolateConfigHome(t)
307 dir := robustTempDir(t)
308 t.Chdir(dir)
309 writeRuntimeFixture(t, dir)
310 old, err := BuildRuntime(t.Context(), withTestSession(t, Options{}))
311 if err != nil {
312 t.Fatal(err)
313 }
314 t.Cleanup(old.Controller.Close)
315 ctrl := old.Controller
316 ctrl.EnsureSessionPath()
317 if _, _, err := ctrl.SetSessionPermissionPreset(t.Context(), control.ToolApprovalDangerFullAccess, ctrl.PermissionSnapshot().Revision); err != nil {
318 t.Fatal(err)
319 }
320 ctrl.SetToolApprovalMode(control.ToolApprovalReadOnly)
321 next, err := Rebuild(t.Context(), ctrl, Options{})
322 if err != nil {
323 t.Fatal(err)
324 }
325 t.Cleanup(next.Controller.Close)
326 if got := next.Controller.ToolApprovalMode(); got != control.ToolApprovalReadOnly {
327 t.Fatalf("rebuild widened live read-only mode to %q", got)
328 }
329 }
330
331 func TestRebuildKeepsLegacySessionNativeWithHostService(t *testing.T) {
332 isolateConfigHome(t)
333 dir := robustTempDir(t)
334 t.Chdir(dir)
335 writeRuntimeFixture(t, dir)
336
337 old, err := BuildRuntime(context.Background(), Options{})
338 if err != nil {
339 t.Fatalf("BuildRuntime legacy: %v", err)
340 }
341 t.Cleanup(old.Controller.Close)
342 old.Controller.EnsureSessionPath()
343 old.Controller.AdoptHistory([]provider.Message{
344 {Role: provider.RoleSystem, Content: systemMessage(old.Controller.History())},
345 {Role: provider.RoleUser, Content: "legacy history"},
346 }, old.Controller.SessionPath())
347 if err := old.Controller.Snapshot(); err != nil {
348 t.Fatalf("Snapshot legacy: %v", err)
349 }
350
351 workspace := filepath.Join(dir, "workspace")
352 storeRoot := filepath.Join(dir, "desktop-sessions-v5", "by-id")
353 service, err := session.NewService("local", session.NewFilesystemPersistence(storeRoot))
354 if err != nil {
355 t.Fatalf("NewService: %v", err)
356 }
357 t.Cleanup(func() { _ = service.Shutdown(context.Background()) })
358
359 rebuilt, err := Rebuild(context.Background(), old.Controller, Options{
360 SessionService: service,
361 SessionCreateOptions: session.CreateOptions{
362 CWD: workspace, Origin: session.SessionOriginLegacyImport,
363 },
364 })
365 if err != nil {
366 t.Fatalf("Rebuild: %v", err)
367 }
368 t.Cleanup(rebuilt.Controller.Close)
369 if _, ok := rebuilt.Controller.SessionRef(); ok || !rebuilt.Controller.NativeLegacySession() {
370 t.Fatal("rebuild converted historical storage")
371 }
372 if rebuilt.Controller.SessionPath() != old.Controller.SessionPath() || rebuilt.Controller.SessionService() != service {
373 t.Fatal("rebuild lost native path or current-store service")
374 }
375 if got := rebuilt.Controller.History(); len(got) != 2 || got[1].Content != "legacy history" {
376 t.Fatalf("rebuild lost history: %+v", got)
377 }
378 if entries, err := os.ReadDir(storeRoot); err != nil && !os.IsNotExist(err) || len(entries) != 0 {
379 t.Fatalf("rebuild created canonical history: %v %v", entries, err)
380 }
381 other, err := session.NewService("local", session.NewFilesystemPersistence(filepath.Join(dir, "other-root")))
382 if err != nil {
383 t.Fatal(err)
384 }
385 t.Cleanup(func() { _ = other.Shutdown(context.Background()) })
386 if err := control.ActivateControllerReplacement(old.Controller, rebuilt.Controller); err != nil {
387 t.Fatal(err)
388 }
389 next, err := Rebuild(context.Background(), rebuilt.Controller, Options{SessionService: other})
390 if err != nil {
391 t.Fatal(err)
392 }
393 t.Cleanup(next.Controller.Close)
394 if next.Controller.SessionService() != service || next.Controller.SessionCreationService() != service || next.Controller.SessionPath() != rebuilt.Controller.SessionPath() {
395 t.Fatal("second rebuild replaced the authoritative store or creation service")
396 }
397 }
398
399 func TestNativeRebuildPreservesSelectedDAGHead(t *testing.T) {
400 isolateConfigHome(t)
401 dir := robustTempDir(t)
402 t.Chdir(dir)
403 writeRuntimeFixture(t, dir)
404 old := buildRuntimeFixture(t)
405 t.Cleanup(old.Controller.Close)
406 path := filepath.Join(dir, "selected.jsonl")
407 s := agent.NewSession(systemMessage(old.Controller.History()))
408 s.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
409 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "main answer"})
410 if err := s.Save(path); err != nil {
411 t.Fatal(err)
412 }
413 if _, err := s.ForkHead(path, s.Snapshot()[1].ID, agent.HeadKindFork, "alternate"); err != nil {
414 t.Fatal(err)
415 }
416 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "alternate answer"})
417 if err := s.Save(path); err != nil {
418 t.Fatal(err)
419 }
420 selected, err := agent.LoadSessionHeadReadOnly(path, agent.SessionMainHead)
421 if err != nil {
422 t.Fatal(err)
423 }
424 if err := old.Controller.ResumeNativeSession(selected, path); err != nil {
425 t.Fatal(err)
426 }
427 rebuilt, err := Rebuild(t.Context(), old.Controller, Options{})
428 if err != nil {
429 t.Fatal(err)
430 }
431 t.Cleanup(rebuilt.Controller.Close)
432 head, ok := rebuilt.Controller.SessionHead()
433 if !ok || head.HeadID != agent.SessionMainHead {
434 t.Fatalf("rebuild switched head: %+v", head)
435 }
436 if err := rebuilt.Controller.Snapshot(); err != nil {
437 t.Fatal(err)
438 }
439 reopened, err := agent.LoadSessionHeadReadOnly(path, agent.SessionMainHead)
440 if err != nil {
441 t.Fatal(err)
442 }
443 if history := reopened.Snapshot(); history[len(history)-1].Content != "main answer" {
444 t.Fatalf("rebuild overwrote selected history: %+v", history)
445 }
446 other, err := agent.LoadSession(path)
447 if err != nil {
448 t.Fatal(err)
449 }
450 if history := other.Snapshot(); history[len(history)-1].Content != "alternate answer" {
451 t.Fatalf("rebuild changed default head: %+v", history)
452 }
453 }
454
455 // TestRebuildCarriesGoalWithoutSessionPath covers the in-memory fallback: an
456 // old controller that never pinned a session file has no Goal sidecar to
457 // restore, so the running Goal migrates from memory.
458 func TestRebuildCarriesGoalWithoutSessionPath(t *testing.T) {
459 isolateConfigHome(t)
460 dir := robustTempDir(t)
461 t.Chdir(dir)
462 writeRuntimeFixture(t, dir)
463
464 old := buildRuntimeFixture(t)
465 oldCtrl := old.Controller
466 if got := oldCtrl.SessionPath(); got != "" {
467 t.Fatalf("fresh controller session path = %q, want empty", got)
468 }
469 oldCtrl.SetGoal("ship the kernel")
470
471 res, err := Rebuild(context.Background(), oldCtrl, Options{})
472 if err != nil {
473 t.Fatalf("Rebuild: %v", err)
474 }
475 defer res.Controller.Close()
476 if got := res.Controller.Goal(); got != "ship the kernel" {
477 t.Fatalf("goal = %q, want seeded %q", got, "ship the kernel")
478 }
479 }
480
481 // TestRebuildFailureKeepsOldController drives the fail-atomic path: the
482 // replacement build fails (unknown model), the error propagates, and the old
483 // controller is untouched.
484 func TestRebuildFailureKeepsOldController(t *testing.T) {
485 isolateConfigHome(t)
486 dir := robustTempDir(t)
487 t.Chdir(dir)
488 writeRuntimeFixture(t, dir)
489
490 old := buildRuntimeFixture(t)
491 oldCtrl := old.Controller
492 oldCtrl.EnsureSessionPath()
493 prevPath := oldCtrl.SessionPath()
494 oldCtrl.AdoptHistory([]provider.Message{
495 {Role: provider.RoleSystem, Content: systemMessage(oldCtrl.History())},
496 {Role: provider.RoleUser, Content: "hello"},
497 }, prevPath)
498
499 res, err := Rebuild(context.Background(), oldCtrl, Options{Model: "definitely-unknown-model"})
500 if err == nil {
501 t.Fatal("Rebuild succeeded, want ErrUnknownModel")
502 }
503 if !errors.Is(err, ErrUnknownModel) {
504 t.Fatalf("Rebuild error = %v, want ErrUnknownModel", err)
505 }
506 if res != nil {
507 t.Fatalf("Rebuild returned a partial result on failure: %+v", res)
508 }
509
510 // old is fully usable: history and path intact, runtime set untouched,
511 // close clean.
512 if got := len(oldCtrl.History()); got != 2 {
513 t.Fatalf("old history = %d messages, want 2", got)
514 }
515 if got := oldCtrl.SessionPath(); got != prevPath {
516 t.Fatalf("old session path = %q, want %q", got, prevPath)
517 }
518 if old.Runtime.Closed() {
519 t.Fatal("Rebuild closed the old runtime set on failure")
520 }
521 oldCtrl.Close()
522 }
523
524 // TestSpliceFreshSystemPrompt pins the system-message splice used to refresh
525 // the profile contract on a continued conversation.
526 func TestSpliceFreshSystemPrompt(t *testing.T) {
527 fresh := []provider.Message{{Role: provider.RoleSystem, Content: "new prompt"}}
528 t.Run("replaces the carried system message", func(t *testing.T) {
529 carried := []provider.Message{
530 {Role: provider.RoleSystem, Content: "old prompt"},
531 {Role: provider.RoleUser, Content: "hi"},
532 }
533 got := spliceFreshSystemPrompt(carried, fresh)
534 if len(got) != 2 || got[0].Content != "new prompt" || got[1].Content != "hi" {
535 t.Fatalf("splice = %+v", got)
536 }
537 })
538 t.Run("prepends when the carried conversation has none", func(t *testing.T) {
539 carried := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
540 got := spliceFreshSystemPrompt(carried, fresh)
541 if len(got) != 2 || got[0].Role != provider.RoleSystem || got[1].Content != "hi" {
542 t.Fatalf("splice = %+v", got)
543 }
544 })
545 t.Run("no fresh system message leaves the conversation untouched", func(t *testing.T) {
546 carried := []provider.Message{{Role: provider.RoleUser, Content: "hi"}}
547 got := spliceFreshSystemPrompt(carried, []provider.Message{{Role: provider.RoleUser, Content: "yo"}})
548 if len(got) != 1 || got[0].Content != "hi" {
549 t.Fatalf("splice = %+v", got)
550 }
551 })
552 t.Run("does not alias the input slice", func(t *testing.T) {
553 carried := []provider.Message{{Role: provider.RoleSystem, Content: "old prompt"}}
554 got := spliceFreshSystemPrompt(carried, fresh)
555 got[0].Content = "mutated"
556 if carried[0].Content != "old prompt" {
557 t.Fatal("splice wrote through to the caller's slice")
558 }
559 })
560 }
561
561 lines GO