| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/agent/testutil" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/session" |
| 16 | "reasonix/internal/tool" |
| 17 | ) |
| 18 | |
| 19 | // newForkTargetsHarness builds an exclusive v3 controller over a filesystem |
| 20 | // service, the harness the other v3 session tests use. |
| 21 | func newForkTargetsHarness(t *testing.T, sessionID string, script ...testutil.Turn) (*session.Service, *session.FilesystemPersistence, *Controller) { |
| 22 | t.Helper() |
| 23 | root := t.TempDir() |
| 24 | persistence := session.NewFilesystemPersistence(filepath.Join(root, "sessions-v4")) |
| 25 | service, err := session.NewService("desktop", persistence) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | t.Cleanup(func() { _ = service.CloseAll(context.Background()) }) |
| 30 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: sessionID}) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | exec := agent.New(testutil.NewMock(sessionID, script...), tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard) |
| 35 | c := newOwnedTestController(t, Options{ |
| 36 | Runner: exec, Executor: exec, Sink: event.Discard, |
| 37 | SessionDir: filepath.Join(root, "legacy"), |
| 38 | SessionService: service, SessionRuntime: runtime, ExclusiveSession: true, |
| 39 | }) |
| 40 | return service, persistence, c |
| 41 | } |
| 42 | |
| 43 | // sessionDirNames lists the session identities a filesystem service holds. The |
| 44 | // query cache is a read artifact, not a session, so it is excluded. |
| 45 | func sessionDirNames(t *testing.T, root string) []string { |
| 46 | t.Helper() |
| 47 | entries, err := os.ReadDir(root) |
| 48 | if err != nil { |
| 49 | if os.IsNotExist(err) { |
| 50 | return nil |
| 51 | } |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | names := make([]string, 0, len(entries)) |
| 55 | for _, entry := range entries { |
| 56 | if strings.HasPrefix(entry.Name(), ".") { |
| 57 | continue |
| 58 | } |
| 59 | names = append(names, entry.Name()) |
| 60 | } |
| 61 | return names |
| 62 | } |
| 63 | |
| 64 | func TestForkTargetsReportsCompletedTurnAvailable(t *testing.T) { |
| 65 | _, _, c := newForkTargetsHarness(t, "targets-parent", testutil.Turn{Text: "answer one"}, testutil.Turn{Text: "answer two"}) |
| 66 | if err := c.RunTurn(t.Context(), "one"); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := c.RunTurn(t.Context(), "two"); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | |
| 73 | targets, err := c.ForkTargets() |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if !targets.Verifiable { |
| 78 | t.Fatal("a session with committed turns must be verifiable") |
| 79 | } |
| 80 | if len(targets.Targets) != 2 { |
| 81 | t.Fatalf("targets = %+v, want both completed turns", targets.Targets) |
| 82 | } |
| 83 | for index, target := range targets.Targets { |
| 84 | if !target.Available || target.Reason != "" { |
| 85 | t.Fatalf("target %d = %+v, want an available turn", index, target) |
| 86 | } |
| 87 | if target.TurnID == "" || target.MessageID == "" { |
| 88 | t.Fatalf("target %d lost its identity: %+v", index, target) |
| 89 | } |
| 90 | if target.TurnNumber != index+1 || target.Status != event.TurnCompleted { |
| 91 | t.Fatalf("target %d = %+v, want completed turn %d", index, target, index+1) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestCreateForkSessionCreatesChildWithoutSwitchingController(t *testing.T) { |
| 97 | service, _, c := newForkTargetsHarness(t, "fork-session-parent", testutil.Turn{Text: "answer one"}, testutil.Turn{Text: "answer two"}) |
| 98 | if err := c.RunTurn(t.Context(), "one"); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | if err := c.RunTurn(t.Context(), "two"); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | targets, err := c.ForkTargets() |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | parentRef, ok := c.SessionRef() |
| 109 | if !ok { |
| 110 | t.Fatal("controller has no v3 session") |
| 111 | } |
| 112 | parentPath := c.SessionPath() |
| 113 | |
| 114 | childID, err := c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: targets.Targets[0].TurnID, |
| 115 | BoundarySequence: targets.Targets[0].BoundarySequence, OperationID: "op-create-child"}, "first turn") |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if childID == "" || childID == parentRef.SessionID { |
| 120 | t.Fatalf("child id = %q, parent = %q", childID, parentRef.SessionID) |
| 121 | } |
| 122 | if ref, ok := c.SessionRef(); !ok || ref != parentRef { |
| 123 | t.Fatalf("controller session = %+v, want %+v", ref, parentRef) |
| 124 | } |
| 125 | if got := c.SessionPath(); got != parentPath { |
| 126 | t.Fatalf("controller session path = %q, want %q", got, parentPath) |
| 127 | } |
| 128 | |
| 129 | childRef := session.SessionRef{HostID: service.HostID(), SessionID: childID} |
| 130 | if _, opened := service.Runtime(childRef); opened { |
| 131 | t.Fatal("CreateForkSession published a child runtime") |
| 132 | } |
| 133 | childTargets, err := service.ForkTargetSetFor(t.Context(), childRef) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if !childTargets.Verifiable || len(childTargets.Targets) != 1 || |
| 138 | !childTargets.Targets[0].Available || childTargets.Targets[0].TurnID != targets.Targets[0].TurnID { |
| 139 | t.Fatalf("child targets = %+v, want the one inherited turn", childTargets) |
| 140 | } |
| 141 | childSnapshot, err := service.Query().Snapshot(t.Context(), childRef) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | if got := childSnapshot.Projection.Title; got != "first turn" { |
| 146 | t.Fatalf("child title = %q, want %q", got, "first turn") |
| 147 | } |
| 148 | if got := childSnapshot.Projection.Messages; len(got) != 3 || got[1].Content != "one" || got[2].Content != "answer one" { |
| 149 | t.Fatalf("child history = %#v, want the parent prefix through turn one", got) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestCreateForkSessionRejectsAnchorAfterControllerRebind(t *testing.T) { |
| 154 | service, persistence, c := newForkTargetsHarness(t, "anchor-parent", testutil.Turn{Text: "answer"}) |
| 155 | if err := c.RunTurn(t.Context(), "one"); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | targets, err := c.ForkTargets() |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | target := targets.Targets[0] |
| 163 | // Seed a second session from the same boundary so it inherits the same turn |
| 164 | // and message identities. Only the source SessionRef distinguishes it. |
| 165 | second, err := service.CreateFork(t.Context(), session.ForkRequest{Source: targets.Source, TurnID: target.TurnID, |
| 166 | BoundarySequence: target.BoundarySequence, OperationID: "seed-second-session"}) |
| 167 | if err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | if _, err := c.OpenSession(t.Context(), second.Child); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | before := sessionDirNames(t, persistence.Root) |
| 174 | _, err = c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: target.TurnID, |
| 175 | BoundarySequence: target.BoundarySequence, OperationID: "stale-source-create"}, "") |
| 176 | var unavailable *session.ForkUnavailableError |
| 177 | if !errors.As(err, &unavailable) || unavailable.Reason != session.ForkStaleSource { |
| 178 | t.Fatalf("rebound create error = %v, want stale_source", err) |
| 179 | } |
| 180 | if after := sessionDirNames(t, persistence.Root); !slices.Equal(before, after) { |
| 181 | t.Fatalf("stale source created a child: %v then %v", before, after) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | func TestCreateForkSessionUnknownTurnCreatesNoChild(t *testing.T) { |
| 186 | _, persistence, c := newForkTargetsHarness(t, "unknown-turn-parent", testutil.Turn{Text: "answer"}) |
| 187 | if err := c.RunTurn(t.Context(), "one"); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | parentRef, ok := c.SessionRef() |
| 191 | if !ok { |
| 192 | t.Fatal("controller has no v3 session") |
| 193 | } |
| 194 | before := sessionDirNames(t, persistence.Root) |
| 195 | |
| 196 | childID, err := c.CreateForkSession(session.ForkRequest{Source: parentRef, TurnID: "no-such-turn", |
| 197 | BoundarySequence: 1, OperationID: "op-unknown-turn"}, "") |
| 198 | if childID != "" || err == nil { |
| 199 | t.Fatalf("CreateForkSession(unknown turn) = %q, %v", childID, err) |
| 200 | } |
| 201 | var unavailable *session.ForkUnavailableError |
| 202 | if !errors.As(err, &unavailable) || unavailable.Reason != session.ForkHistoryUnverifiable { |
| 203 | t.Fatalf("error = %v, want an unverifiable-history refusal", err) |
| 204 | } |
| 205 | if after := sessionDirNames(t, persistence.Root); !slices.Equal(before, after) { |
| 206 | t.Fatalf("refused fork changed session identities: %v then %v", before, after) |
| 207 | } |
| 208 | if ref, ok := c.SessionRef(); !ok || ref != parentRef { |
| 209 | t.Fatalf("controller session = %+v, want %+v", ref, parentRef) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestCreateForkSessionWorksWhileTurnRunning(t *testing.T) { |
| 214 | _, _, c := newForkTargetsHarness(t, "running-parent", testutil.Turn{Text: "answer"}) |
| 215 | if err := c.RunTurn(t.Context(), "one"); err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | parentRef, ok := c.SessionRef() |
| 219 | if !ok { |
| 220 | t.Fatal("controller has no v3 session") |
| 221 | } |
| 222 | parentPath := c.SessionPath() |
| 223 | |
| 224 | // The harness expresses a live foreground turn the way the rotation tests |
| 225 | // do; no body runs, so the completed turn below stays committed. |
| 226 | c.mu.Lock() |
| 227 | c.turns.phase = session.RuntimeRunning |
| 228 | c.mu.Unlock() |
| 229 | defer func() { |
| 230 | c.mu.Lock() |
| 231 | c.turns.phase = session.RuntimeIdle |
| 232 | c.mu.Unlock() |
| 233 | }() |
| 234 | |
| 235 | // The gate this test is about: the switching entry point refuses while the |
| 236 | // turn runs, so the call below is not passing for a trivial reason. |
| 237 | if _, err := c.ForkSession(1, ""); err == nil || !strings.Contains(err.Error(), "cannot fork while a turn is running") { |
| 238 | t.Fatalf("ForkSession while a turn runs = %v, want a running-turn refusal", err) |
| 239 | } |
| 240 | targets, err := c.ForkTargets() |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if len(targets.Targets) != 1 || !targets.Targets[0].Available { |
| 245 | t.Fatalf("targets while a turn runs = %+v", targets) |
| 246 | } |
| 247 | |
| 248 | childID, err := c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: targets.Targets[0].TurnID, |
| 249 | BoundarySequence: targets.Targets[0].BoundarySequence, OperationID: "op-running-turn"}, "from a running turn") |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if childID == "" || childID == parentRef.SessionID { |
| 254 | t.Fatalf("child id = %q, parent = %q", childID, parentRef.SessionID) |
| 255 | } |
| 256 | if ref, ok := c.SessionRef(); !ok || ref != parentRef { |
| 257 | t.Fatalf("controller session = %+v, want %+v", ref, parentRef) |
| 258 | } |
| 259 | if got := c.SessionPath(); got != parentPath { |
| 260 | t.Fatalf("controller session path = %q, want %q", got, parentPath) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestForkTargetsLegacyEngineReportsUnverifiable(t *testing.T) { |
| 265 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 266 | c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard}) |
| 267 | |
| 268 | targets, err := c.ForkTargets() |
| 269 | if err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if targets.Verifiable || len(targets.Targets) != 0 { |
| 273 | t.Fatalf("legacy targets = %+v, want an empty unverifiable set", targets) |
| 274 | } |
| 275 | // The checkpoint engine has no v3 identity, so a cut cannot be requested |
| 276 | // from it at all. |
| 277 | if _, err := c.CreateForkSession(session.ForkRequest{TurnID: "turn", BoundarySequence: 1, OperationID: "op-legacy"}, "name"); !errors.Is(err, session.ErrSessionNotRunning) { |
| 278 | t.Fatalf("legacy CreateForkSession = %v, want ErrSessionNotRunning", err) |
| 279 | } |
| 280 | } |
| 281 |