Files
2026-07-13 13:00:08 +08:00

111 lines
3.9 KiB
Go

package serve
import (
"context"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// primarySessionFiles filters a recovery-branch glob down to primary session
// transcripts, dropping the .events.jsonl / .guardian.jsonl sidecars that the
// *-recovery-*.jsonl pattern also matches.
func primarySessionFiles(paths []string) []string {
out := make([]string, 0, len(paths))
for _, path := range paths {
base := filepath.Base(path)
if strings.HasSuffix(base, ".jsonl") &&
!strings.HasSuffix(base, ".events.jsonl") &&
!strings.HasSuffix(base, ".guardian.jsonl") {
out = append(out, path)
}
}
return out
}
// TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict is the serve twin
// of the desktop rebuild fix: when the pre-switch Snapshot hits a conflict and
// retargets the old controller to a recovery branch, the rebuilt controller
// must continue on that recovery path. Capturing prevPath before Snapshot
// bound the just-recovered transcript back to the original file, so every
// later save re-conflicted and derived yet another recovery branch.
func TestSwitchModelContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
dir := t.TempDir()
originalPath := filepath.Join(dir, "switch-conflict.jsonl")
disk := agent.NewSession("sys prompt")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := disk.Save(originalPath); err != nil {
t.Fatalf("save disk session: %v", err)
}
stale := agent.NewSession("sys prompt")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
bc := NewBroadcaster()
old := control.New(control.Options{
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
SessionDir: dir,
SessionPath: originalPath,
Label: "old",
Sink: bc,
})
s := &Server{ctrl: old, bc: bc}
var built *control.Controller
s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
built = control.New(control.Options{
Executor: agent.New(nil, nil, agent.NewSession("sys prompt"), agent.Options{}, event.Discard),
SessionDir: dir,
Label: "new",
Sink: bc,
})
return built, nil
}
if err := s.switchModel(context.Background(), "next-model"); err != nil {
t.Fatalf("switchModel: %v", err)
}
recoveryPath := built.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("switched session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
}
if s.ctl() != built {
t.Fatal("switchModel did not publish the rebuilt controller")
}
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after switch = %v, want only %q", matches, recoveryPath)
}
// The rebuilt controller adopted the recovery file's baseline, so its next
// snapshot must not derive a second recovery branch.
if err := built.Snapshot(); err != nil {
t.Fatalf("Snapshot after switch: %v", err)
}
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches after snapshot: %v", err)
}
matches = primarySessionFiles(matches)
if len(matches) != 1 || matches[0] != recoveryPath {
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
}
}