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