返回 DeepSeek-Reasonix
goal_lifecycle_binding_test.go
根目录 / internal / control / goal_lifecycle_binding_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/event"
13 goaldomain "reasonix/internal/goal"
14 "reasonix/internal/session"
15 "reasonix/internal/tool"
16 )
17
18 func TestGoalLifecycleProjectionRestoreIsDisarmed(t *testing.T) {
19 raw := json.RawMessage(`{"version":1,"current":{"id":"goal-1","revision":2,"objective":"ship","phase":"active","maxGoalRounds":null,"roundsStarted":5,"createdAt":"2026-09-13T10:00:00Z","updatedAt":"2026-09-13T10:00:00Z"}}`)
20 machine, err := goalLifecycleFromProjection(raw, "session-1", time.Time{})
21 if err != nil {
22 t.Fatal(err)
23 }
24 view := machine.Get()
25 if view == nil || view.ID != "goal-1" || view.RoundsStarted != 5 || view.Activation != goaldomain.ActivationDisarmed {
26 t.Fatalf("view = %+v", view)
27 }
28 }
29
30 func TestGoalLifecycleLegacyProjectionImportsWithoutTodoOrActivation(t *testing.T) {
31 raw := json.RawMessage(`{"goal":"finish migration","status":"running","turnsUsed":7,"todos":[{"content":"stale"}],"futurePolicy":{"mode":"adaptive"}}`)
32 machine, err := goalLifecycleFromProjection(raw, "session-legacy", time.Date(2026, 9, 13, 8, 0, 0, 0, time.UTC))
33 if err != nil {
34 t.Fatal(err)
35 }
36 view := machine.Get()
37 if view == nil || view.Objective != "finish migration" || view.RoundsStarted != 7 {
38 t.Fatalf("view = %+v", view)
39 }
40 if view.Activation != goaldomain.ActivationDisarmed || view.MaxGoalRounds != nil {
41 t.Fatalf("activation/limit = %s/%v", view.Activation, view.MaxGoalRounds)
42 }
43 encoded, err := machine.Encode()
44 if err != nil {
45 t.Fatal(err)
46 }
47 if json.Valid(encoded) == false || string(encoded) == string(raw) {
48 t.Fatalf("encoded migration = %s", encoded)
49 }
50 var document map[string]json.RawMessage
51 if err := json.Unmarshal(encoded, &document); err != nil {
52 t.Fatal(err)
53 }
54 if len(document["legacyState"]) == 0 {
55 t.Fatalf("legacy source was not preserved: %s", encoded)
56 }
57 }
58
59 func TestGoalLifecycleMalformedProjectionFailsClosed(t *testing.T) {
60 if _, err := goalLifecycleFromProjection(json.RawMessage(`{"version":99,"current":null}`), "session-1", time.Time{}); err == nil {
61 t.Fatal("unknown goal state version was accepted")
62 }
63 }
64
65 func TestExclusiveControllerLoadsGoalFromV3Projection(t *testing.T) {
66 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
67 if err != nil {
68 t.Fatal(err)
69 }
70 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
71 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "goal-session"})
72 if err != nil {
73 t.Fatal(err)
74 }
75 raw := json.RawMessage(`{"version":1,"current":{"id":"goal-v3","revision":3,"objective":"finish runtime","phase":"active","maxGoalRounds":null,"roundsStarted":2,"createdAt":"2026-09-13T10:00:00Z","updatedAt":"2026-09-13T10:00:00Z"}}`)
76 if _, err := runtime.Session().AppendBatch(context.Background(), "goal-seed", []session.Event{{Kind: "goal/state", Payload: raw}}); err != nil {
77 t.Fatal(err)
78 }
79 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
80 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
81 t.Cleanup(func() { c.Close() })
82 view, loadErr := c.goalLifecycleView()
83 if loadErr != nil {
84 t.Fatal(loadErr)
85 }
86 if view == nil || view.ID != "goal-v3" || view.Activation != goaldomain.ActivationDisarmed {
87 t.Fatalf("view = %+v", view)
88 }
89 }
90
91 func TestColdRestoredGoalComposeIncludesRecoverableGoalContext(t *testing.T) {
92 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v3")))
93 if err != nil {
94 t.Fatal(err)
95 }
96 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
97 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "goal-recovery-context"})
98 if err != nil {
99 t.Fatal(err)
100 }
101 raw := json.RawMessage(`{"version":1,"current":{"id":"goal-v3","revision":3,"objective":"finish runtime","phase":"active","maxGoalRounds":null,"roundsStarted":2,"createdAt":"2026-09-13T10:00:00Z","updatedAt":"2026-09-13T10:00:00Z"}}`)
102 if _, err := runtime.Session().AppendBatch(t.Context(), "goal-seed", []session.Event{{Kind: "goal/state", Payload: raw}}); err != nil {
103 t.Fatal(err)
104 }
105 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
106 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
107 t.Cleanup(c.Close)
108
109 composed := c.Compose("continue")
110 for _, want := range []string{"<goal-recovery>", `"goalId":"goal-v3"`, `"revision":3`, "finish runtime", "update_goal with action resume"} {
111 if !strings.Contains(composed, want) {
112 t.Fatalf("composed input missing %q:\n%s", want, composed)
113 }
114 }
115 }
116
117 func TestGoalLifecycleMutationAppendsToActiveV3Session(t *testing.T) {
118 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
119 if err != nil {
120 t.Fatal(err)
121 }
122 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
123 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "goal-mutation"})
124 if err != nil {
125 t.Fatal(err)
126 }
127 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
128 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
129 t.Cleanup(func() { c.Close() })
130 c.mu.Lock()
131 c.turns.phase = session.RuntimeRunning
132 c.noteExecutionLocked(session.RuntimeRunning, "turn")
133 c.mu.Unlock()
134 snapshot := runtime.Snapshot()
135 authority := tool.GoalAuthority{
136 Source: tool.GoalSourceDirectHuman, SessionID: runtime.Ref().SessionID,
137 RuntimeEpoch: snapshot.Epoch, ActivityID: snapshot.ActivityRevision,
138 }
139 created, err := c.CreateGoal(t.Context(), goaldomain.CreateRequest{Objective: "ship"}, authority)
140 if err != nil {
141 t.Fatal(err)
142 }
143 if created.Activation != goaldomain.ActivationArmed {
144 t.Fatalf("created = %+v", created)
145 }
146 projected := runtime.Session().Snapshot().Projection.GoalState
147 if len(projected) == 0 {
148 t.Fatal("goal mutation did not append goal/state")
149 }
150 loaded, err := goalLifecycleFromProjection(projected, runtime.Ref().SessionID, time.Time{})
151 if err != nil || loaded.Get() == nil || loaded.Get().ID != created.ID {
152 t.Fatalf("projected goal = %+v, err = %v", loaded.Get(), err)
153 }
154 c.mu.Lock()
155 c.turns.phase = session.RuntimeIdle
156 c.noteExecutionLocked(session.RuntimeIdle, "")
157 c.mu.Unlock()
158 }
159
160 func TestGoalLifecycleMutationRejectsStaleRuntimeAuthority(t *testing.T) {
161 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
162 if err != nil {
163 t.Fatal(err)
164 }
165 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
166 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "goal-stale"})
167 if err != nil {
168 t.Fatal(err)
169 }
170 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
171 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
172 t.Cleanup(func() { c.Close() })
173 _, err = c.CreateGoal(t.Context(), goaldomain.CreateRequest{Objective: "ship"}, tool.GoalAuthority{
174 Source: tool.GoalSourceDirectHuman, SessionID: "another-session", RuntimeEpoch: "old", ActivityID: 1,
175 })
176 if goaldomain.ErrorCodeOf(err) != goaldomain.ErrUserAuthorityRequired {
177 t.Fatalf("stale authority error = %v", err)
178 }
179 if len(runtime.Session().Snapshot().Projection.GoalState) != 0 {
180 t.Fatal("rejected mutation changed projection")
181 }
182 }
183
184 func TestModelCannotResumeUserPausedGoal(t *testing.T) {
185 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
186 if err != nil {
187 t.Fatal(err)
188 }
189 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
190 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "goal-paused"})
191 if err != nil {
192 t.Fatal(err)
193 }
194 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
195 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
196 t.Cleanup(c.Close)
197 c.mu.Lock()
198 c.turns.phase = session.RuntimeRunning
199 c.noteExecutionLocked(session.RuntimeRunning, "turn")
200 c.mu.Unlock()
201 defer func() {
202 c.mu.Lock()
203 c.turns.phase = session.RuntimeIdle
204 c.noteExecutionLocked(session.RuntimeIdle, "")
205 c.mu.Unlock()
206 }()
207 snapshot := runtime.Snapshot()
208 authority := tool.GoalAuthority{Source: tool.GoalSourceDirectHuman, SessionID: runtime.Ref().SessionID,
209 RuntimeEpoch: snapshot.Epoch, ActivityID: snapshot.ActivityRevision}
210 created, err := c.CreateGoal(t.Context(), goaldomain.CreateRequest{Objective: "stay paused"}, authority)
211 if err != nil {
212 t.Fatal(err)
213 }
214 paused, err := c.UpdateGoal(t.Context(), tool.GoalUpdateRequest{Ref: created.Ref(), Action: tool.GoalActionPause}, authority)
215 if err != nil {
216 t.Fatal(err)
217 }
218 _, err = c.UpdateGoal(t.Context(), tool.GoalUpdateRequest{Ref: paused.Ref(), Action: tool.GoalActionResume}, authority)
219 if goaldomain.ErrorCodeOf(err) != goaldomain.ErrUserAuthorityRequired {
220 t.Fatalf("model resume error = %v", err)
221 }
222 }
223
223 lines GO