| 1 | package serve |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | type switchAskProvider struct { |
| 19 | turn int |
| 20 | } |
| 21 | |
| 22 | func (*switchAskProvider) Name() string { return "switch-ask" } |
| 23 | |
| 24 | func (p *switchAskProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) { |
| 25 | ch := make(chan provider.Chunk, 2) |
| 26 | if p.turn == 0 { |
| 27 | ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ |
| 28 | ID: "ask-after-switch", |
| 29 | Name: "ask", |
| 30 | Arguments: `{"questions":[{"header":"Direction","question":"Which path?","options":[{"label":"A"},{"label":"B"}]}]}`, |
| 31 | }} |
| 32 | } else { |
| 33 | ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"} |
| 34 | } |
| 35 | p.turn++ |
| 36 | ch <- provider.Chunk{Type: provider.ChunkDone} |
| 37 | close(ch) |
| 38 | return ch, nil |
| 39 | } |
| 40 | |
| 41 | func TestSwitchModelKeepsAskInteractive(t *testing.T) { |
| 42 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 43 | dir := t.TempDir() |
| 44 | |
| 45 | bc := NewBroadcaster() |
| 46 | old := control.New(control.Options{ |
| 47 | Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard), |
| 48 | SessionDir: dir, |
| 49 | Label: "old", |
| 50 | Sink: bc, |
| 51 | }) |
| 52 | old.EnableInteractiveApproval() |
| 53 | |
| 54 | askCh := make(chan event.Ask, 1) |
| 55 | s := &Server{ctrl: old, bc: bc} |
| 56 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 57 | reg := tool.NewRegistry() |
| 58 | reg.Add(agent.NewAskTool()) |
| 59 | exec := agent.New(&switchAskProvider{}, reg, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 60 | return control.New(control.Options{ |
| 61 | Executor: exec, |
| 62 | SessionDir: dir, |
| 63 | Label: "new", |
| 64 | Sink: event.FuncSink(func(e event.Event) { |
| 65 | if e.Kind == event.AskRequest { |
| 66 | askCh <- e.Ask |
| 67 | } |
| 68 | }), |
| 69 | }), nil |
| 70 | } |
| 71 | |
| 72 | if err := s.switchModel(context.Background(), "next-model"); err != nil { |
| 73 | t.Fatalf("switchModel: %v", err) |
| 74 | } |
| 75 | |
| 76 | newCtrl := s.ctl().(*control.Controller) |
| 77 | runDone := make(chan error, 1) |
| 78 | go func() { runDone <- newCtrl.Executor().Run(context.Background(), "ask the user") }() |
| 79 | |
| 80 | select { |
| 81 | case ask := <-askCh: |
| 82 | newCtrl.AnswerQuestion(ask.ID, []event.AskAnswer{{QuestionID: "q1", Selected: []string{"A"}}}) |
| 83 | case err := <-runDone: |
| 84 | t.Fatalf("ask tool returned without an ask_request after model switch: %v", err) |
| 85 | case <-time.After(2 * time.Second): |
| 86 | t.Fatal("ask tool did not emit ask_request after model switch") |
| 87 | } |
| 88 | |
| 89 | select { |
| 90 | case err := <-runDone: |
| 91 | if err != nil { |
| 92 | t.Fatalf("run after answering ask_request: %v", err) |
| 93 | } |
| 94 | case <-time.After(2 * time.Second): |
| 95 | t.Fatal("run stayed blocked after answering ask_request") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // primarySessionFiles filters a recovery-branch glob down to primary session |
| 100 | // transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the |
| 101 | // *-recovery-*.jsonl pattern also matches. |
| 102 | func primarySessionFiles(paths []string) []string { |
| 103 | out := make([]string, 0, len(paths)) |
| 104 | for _, path := range paths { |
| 105 | base := filepath.Base(path) |
| 106 | if strings.HasSuffix(base, ".jsonl") && |
| 107 | !strings.HasSuffix(base, ".events.jsonl") && |
| 108 | !strings.HasSuffix(base, ".guardian.jsonl") { |
| 109 | out = append(out, path) |
| 110 | } |
| 111 | } |
| 112 | return out |
| 113 | } |
| 114 | |
| 115 | // TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict is the serve twin |
| 116 | // of the desktop rebuild fix: when the pre-switch Snapshot hits a conflict and |
| 117 | // retargets the old controller to a recovery branch, the rebuilt controller |
| 118 | // must continue on that recovery path. Capturing prevPath before Snapshot |
| 119 | // bound the just-recovered transcript back to the original file, so every |
| 120 | // later save re-conflicted and derived yet another recovery branch. |
| 121 | func TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) { |
| 122 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 123 | dir := t.TempDir() |
| 124 | originalPath := filepath.Join(dir, "switch-conflict.jsonl") |
| 125 | |
| 126 | disk := agent.NewSession("sys prompt") |
| 127 | disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) |
| 128 | disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) |
| 129 | disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"}) |
| 130 | if err := disk.Save(originalPath); err != nil { |
| 131 | t.Fatalf("save disk session: %v", err) |
| 132 | } |
| 133 | |
| 134 | stale := agent.NewSession("sys prompt") |
| 135 | stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) |
| 136 | stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) |
| 137 | stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"}) |
| 138 | |
| 139 | bc := NewBroadcaster() |
| 140 | old := control.New(control.Options{ |
| 141 | Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard), |
| 142 | SessionDir: dir, |
| 143 | SessionPath: originalPath, |
| 144 | Label: "old", |
| 145 | Sink: bc, |
| 146 | }) |
| 147 | s := &Server{ctrl: old, bc: bc} |
| 148 | leases := control.NewSessionLeaseKeeper() |
| 149 | t.Cleanup(leases.Release) |
| 150 | if err := leases.Rebind(originalPath); err != nil { |
| 151 | t.Fatalf("seed original lease: %v", err) |
| 152 | } |
| 153 | s.SetSessionLeases(leases) |
| 154 | |
| 155 | var built *control.Controller |
| 156 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 157 | built = control.New(control.Options{ |
| 158 | Executor: agent.New(nil, nil, agent.NewSession("sys prompt"), agent.Options{}, event.Discard), |
| 159 | SessionDir: dir, |
| 160 | Label: "new", |
| 161 | Sink: bc, |
| 162 | }) |
| 163 | return built, nil |
| 164 | } |
| 165 | |
| 166 | if err := s.switchModel(context.Background(), "next-model"); err != nil { |
| 167 | t.Fatalf("switchModel: %v", err) |
| 168 | } |
| 169 | |
| 170 | recoveryPath := built.SessionPath() |
| 171 | if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") { |
| 172 | t.Fatalf("switched session path = %q, want recovery path distinct from %q", recoveryPath, originalPath) |
| 173 | } |
| 174 | if s.ctl() != built { |
| 175 | t.Fatal("switchModel did not publish the rebuilt controller") |
| 176 | } |
| 177 | if got, want := leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want { |
| 178 | t.Fatalf("lease after pre-switch recovery = %q, want %q", got, want) |
| 179 | } |
| 180 | |
| 181 | matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) |
| 182 | if err != nil { |
| 183 | t.Fatalf("glob recovery branches: %v", err) |
| 184 | } |
| 185 | matches = primarySessionFiles(matches) |
| 186 | if len(matches) != 1 || matches[0] != recoveryPath { |
| 187 | t.Fatalf("recovery branches after switch = %v, want only %q", matches, recoveryPath) |
| 188 | } |
| 189 | |
| 190 | // The rebuilt controller adopted the recovery file's baseline, so its next |
| 191 | // snapshot must not derive a second recovery branch. |
| 192 | if err := built.Snapshot(); err != nil { |
| 193 | t.Fatalf("Snapshot after switch: %v", err) |
| 194 | } |
| 195 | matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) |
| 196 | if err != nil { |
| 197 | t.Fatalf("glob recovery branches after snapshot: %v", err) |
| 198 | } |
| 199 | matches = primarySessionFiles(matches) |
| 200 | if len(matches) != 1 || matches[0] != recoveryPath { |
| 201 | t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath) |
| 202 | } |
| 203 | |
| 204 | // A later ordinary autosave on the rebuilt controller must use the same |
| 205 | // ownership callback. Force another divergence after the switch and verify |
| 206 | // the keeper follows the second recovery before the controller commits it. |
| 207 | diskAfterSwitch, err := agent.LoadSession(recoveryPath) |
| 208 | if err != nil { |
| 209 | t.Fatalf("load recovery transcript for external change: %v", err) |
| 210 | } |
| 211 | diskAfterSwitch.Add(provider.Message{Role: provider.RoleUser, Content: "disk third"}) |
| 212 | if err := diskAfterSwitch.Save(recoveryPath); err != nil { |
| 213 | t.Fatalf("save external recovery transcript change: %v", err) |
| 214 | } |
| 215 | built.Executor().Session().Add(provider.Message{Role: provider.RoleUser, Content: "local third"}) |
| 216 | if err := built.Snapshot(); err != nil { |
| 217 | t.Fatalf("Snapshot rebuilt controller after divergence: %v", err) |
| 218 | } |
| 219 | secondRecoveryPath := built.SessionPath() |
| 220 | if secondRecoveryPath == recoveryPath || !strings.Contains(filepath.Base(secondRecoveryPath), "-recovery-") { |
| 221 | t.Fatalf("rebuilt controller path = %q, want recovery path distinct from %q", secondRecoveryPath, recoveryPath) |
| 222 | } |
| 223 | if got, want := leases.HeldPath(), agent.CanonicalSessionPath(secondRecoveryPath); got != want { |
| 224 | t.Fatalf("lease after rebuilt-controller recovery = %q, want %q", got, want) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestSwitchModelRefreshesLeadingSystemPrompt pins the fix for the bug where |
| 229 | // switchModel rebuilt the controller with the target model/profile's own |
| 230 | // system prompt, only for AdoptHistory to immediately overwrite it with the |
| 231 | // carried history's leading message — the outgoing controller's system |
| 232 | // prompt. The user-visible symptom was that the model kept following the |
| 233 | // previous system prompt after every /model switch. |
| 234 | func TestSwitchModelRefreshesLeadingSystemPrompt(t *testing.T) { |
| 235 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 236 | dir := t.TempDir() |
| 237 | |
| 238 | oldSession := agent.NewSession("old system prompt") |
| 239 | oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) |
| 240 | oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"}) |
| 241 | |
| 242 | bc := NewBroadcaster() |
| 243 | old := control.New(control.Options{ |
| 244 | Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard), |
| 245 | SessionDir: dir, |
| 246 | Label: "old", |
| 247 | Sink: bc, |
| 248 | }) |
| 249 | s := &Server{ctrl: old, bc: bc} |
| 250 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 251 | return control.New(control.Options{ |
| 252 | Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard), |
| 253 | SessionDir: dir, |
| 254 | Label: "new", |
| 255 | Sink: bc, |
| 256 | }), nil |
| 257 | } |
| 258 | |
| 259 | if err := s.switchModel(context.Background(), "next-model"); err != nil { |
| 260 | t.Fatalf("switchModel: %v", err) |
| 261 | } |
| 262 | |
| 263 | history := s.ctl().History() |
| 264 | if len(history) != 3 || history[0].Role != provider.RoleSystem { |
| 265 | t.Fatalf("history = %+v, want a leading system message", history) |
| 266 | } |
| 267 | if got, want := history[0].Content, "new system prompt"; got != want { |
| 268 | t.Fatalf("leading system message = %q, want %q (stale outgoing prompt carried forward)", got, want) |
| 269 | } |
| 270 | if history[1].Content != "hello" || history[2].Content != "hi" { |
| 271 | t.Fatalf("history after switch = %+v, want carried user/assistant turns preserved", history) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // TestSwitchModelRestoresSessionAuthorizations pins the fix for switchModel |
| 276 | // dropping same-session "Allow for this session" tool grants and Plan-mode |
| 277 | // read-only command trust on every /model switch, forcing the user to |
| 278 | // re-approve something already granted this session. |
| 279 | func TestSwitchModelRestoresSessionAuthorizations(t *testing.T) { |
| 280 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 281 | dir := t.TempDir() |
| 282 | |
| 283 | bc := NewBroadcaster() |
| 284 | old := control.New(control.Options{ |
| 285 | Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard), |
| 286 | SessionDir: dir, |
| 287 | Label: "old", |
| 288 | Sink: bc, |
| 289 | }) |
| 290 | old.RestoreSessionAuthorizations(control.SessionAuthorizations{ |
| 291 | Grants: []string{"bash|go test ./..."}, |
| 292 | PlanModeReadOnlyCommands: []string{"go test ./..."}, |
| 293 | }) |
| 294 | |
| 295 | s := &Server{ctrl: old, bc: bc} |
| 296 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 297 | return control.New(control.Options{ |
| 298 | Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard), |
| 299 | SessionDir: dir, |
| 300 | Label: "new", |
| 301 | Sink: bc, |
| 302 | }), nil |
| 303 | } |
| 304 | |
| 305 | if err := s.switchModel(context.Background(), "next-model"); err != nil { |
| 306 | t.Fatalf("switchModel: %v", err) |
| 307 | } |
| 308 | |
| 309 | newCtrl, ok := s.ctl().(*control.Controller) |
| 310 | if !ok { |
| 311 | t.Fatalf("s.ctl() = %T, want *control.Controller", s.ctl()) |
| 312 | } |
| 313 | got := newCtrl.SessionAuthorizations() |
| 314 | if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." { |
| 315 | t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants) |
| 316 | } |
| 317 | if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." { |
| 318 | t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | // TestSwitchModelPersistsRefreshedSystemPromptToDisk pins the disk half of the |
| 323 | // system-prompt splice: switchModel refreshes the leading system message in |
| 324 | // the new controller's memory, and nothing snapshots an idle session again, so |
| 325 | // the switch itself must persist the adopted history or a restart + /resume |
| 326 | // revives the outgoing controller's contract from disk. |
| 327 | func TestSwitchModelPersistsRefreshedSystemPromptToDisk(t *testing.T) { |
| 328 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 329 | dir := t.TempDir() |
| 330 | path := filepath.Join(dir, "switch-persist.jsonl") |
| 331 | |
| 332 | oldSession := agent.NewSession("old system prompt") |
| 333 | oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) |
| 334 | oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"}) |
| 335 | if err := oldSession.Save(path); err != nil { |
| 336 | t.Fatalf("save base session: %v", err) |
| 337 | } |
| 338 | |
| 339 | bc := NewBroadcaster() |
| 340 | old := control.New(control.Options{ |
| 341 | Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard), |
| 342 | SessionDir: dir, |
| 343 | SessionPath: path, |
| 344 | Label: "old", |
| 345 | Sink: bc, |
| 346 | }) |
| 347 | s := &Server{ctrl: old, bc: bc} |
| 348 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 349 | return control.New(control.Options{ |
| 350 | Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard), |
| 351 | SessionDir: dir, |
| 352 | Label: "new", |
| 353 | Sink: bc, |
| 354 | }), nil |
| 355 | } |
| 356 | |
| 357 | if err := s.switchModel(context.Background(), "next-model"); err != nil { |
| 358 | t.Fatalf("switchModel: %v", err) |
| 359 | } |
| 360 | |
| 361 | loaded, err := agent.LoadSession(s.ctl().SessionPath()) |
| 362 | if err != nil { |
| 363 | t.Fatalf("load transcript after switch: %v", err) |
| 364 | } |
| 365 | msgs := loaded.Snapshot() |
| 366 | if len(msgs) != 3 || msgs[0].Role != provider.RoleSystem { |
| 367 | t.Fatalf("on-disk history after switch = %+v, want 3 messages with a leading system message", msgs) |
| 368 | } |
| 369 | if got, want := msgs[0].Content, "new system prompt"; got != want { |
| 370 | t.Fatalf("on-disk leading system message = %q, want %q (a restart would revive the outgoing contract)", got, want) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | func TestSwitchModelSnapshotFailureKeepsOldController(t *testing.T) { |
| 375 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 376 | invalidSessionDir := filepath.Join(t.TempDir(), "session-dir-is-a-file") |
| 377 | if err := os.WriteFile(invalidSessionDir, []byte("not a directory"), 0o644); err != nil { |
| 378 | t.Fatalf("write invalid session dir: %v", err) |
| 379 | } |
| 380 | |
| 381 | oldSession := agent.NewSession("old system prompt") |
| 382 | oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) |
| 383 | bc := NewBroadcaster() |
| 384 | old := control.New(control.Options{ |
| 385 | Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard), |
| 386 | Label: "old", |
| 387 | Sink: bc, |
| 388 | }) |
| 389 | t.Cleanup(old.Close) |
| 390 | s := &Server{ctrl: old, bc: bc} |
| 391 | s.buildController = func(_ context.Context, _ string) (*control.Controller, error) { |
| 392 | return control.New(control.Options{ |
| 393 | Executor: agent.New(nil, nil, agent.NewSession("new system prompt"), agent.Options{}, event.Discard), |
| 394 | SessionDir: invalidSessionDir, |
| 395 | Label: "new", |
| 396 | Sink: bc, |
| 397 | }), nil |
| 398 | } |
| 399 | |
| 400 | err := s.switchModel(context.Background(), "next-model") |
| 401 | if err == nil || !strings.Contains(err.Error(), "snapshot adopted history") { |
| 402 | t.Fatalf("switchModel error = %v, want snapshot adopted history failure", err) |
| 403 | } |
| 404 | if got := s.ctl(); got != old { |
| 405 | t.Fatalf("active controller changed after persistence failure: got %T %p, want outgoing %p", got, got, old) |
| 406 | } |
| 407 | } |
| 408 |