返回 DeepSeek-Reasonix
runtime_state_test.go
根目录 / internal / serve / runtime_state_test.go
1 package serve
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "path/filepath"
12 "reflect"
13 "strings"
14 "testing"
15 "time"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/eventwire"
22 "reasonix/internal/sessioninbox"
23 )
24
25 type runtimeStateServeRunner struct{ started chan struct{} }
26
27 type runtimeStateServeSink struct {
28 states chan event.RuntimeStateSnapshot
29 }
30
31 func (*runtimeStateServeSink) Emit(event.Event) {}
32 func (s *runtimeStateServeSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
33 s.states <- state
34 }
35
36 func (r runtimeStateServeRunner) Run(ctx context.Context, _ string) error {
37 close(r.started)
38 <-ctx.Done()
39 return ctx.Err()
40 }
41
42 func runtimeStateServeController(t *testing.T, dir, name string, runner interface {
43 Run(context.Context, string) error
44 }) *control.Controller {
45 t.Helper()
46 path := filepath.Join(dir, name+".jsonl")
47 if err := os.WriteFile(path, nil, 0o600); err != nil {
48 t.Fatal(err)
49 }
50 sink := &runtimeStateServeSink{states: make(chan event.RuntimeStateSnapshot, 64)}
51 c := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: name, Runner: runner, Sink: sink})
52 t.Cleanup(func() {
53 defer c.Close()
54 before := c.RuntimeStateSnapshot()
55 if !c.Running() && before.Phase != "executing" && before.Phase != "finishing" {
56 return
57 }
58 // Stop dispatching the isolated follow-up queue, then let the real
59 // finishing notification establish a barrier before TempDir removal.
60 _ = c.SetInboxPaused(true)
61 c.Cancel()
62 // Cleanup must join the finishing boundary before deleting its files.
63 // The package alarm bounds deadlocks; elapsed host I/O is not an assertion.
64 for {
65 state := <-sink.states
66 if state.Phase == "idle" && state.Revision > before.Revision {
67 return
68 }
69 }
70 })
71 return c
72 }
73
74 func runtimeStateHTTPGet(t *testing.T, endpoint string, target any) {
75 t.Helper()
76 client := &http.Client{Timeout: 5 * time.Second}
77 response, err := client.Get(endpoint)
78 if err != nil {
79 t.Fatal(err)
80 }
81 defer response.Body.Close()
82 if response.StatusCode != http.StatusOK {
83 t.Fatalf("GET %s status=%d", endpoint, response.StatusCode)
84 }
85 if err := json.NewDecoder(response.Body).Decode(target); err != nil {
86 t.Fatal(err)
87 }
88 }
89
90 func TestRuntimeStateHTTPIncludesDetachedAndKeepsSnapshotImmutable(t *testing.T) {
91 dir := t.TempDir()
92 foreground := runtimeStateServeController(t, dir, "foreground", nil)
93 detached := runtimeStateServeController(t, dir, "detached", nil)
94 server := New(foreground, nil, config.ServeConfig{})
95 detachedPath := agent.CanonicalSessionPath(detached.SessionPath())
96 server.detached[detachedPath] = &detachedSession{path: detachedPath, ctrl: detached}
97 httpServer := httptest.NewServer(server.Handler())
98 defer httpServer.Close()
99 // This endpoint reads managed runtimes, so removing a transcript cannot
100 // make it disappear or cause a catalog/history scan to fail.
101 if err := os.Remove(detached.SessionPath()); err != nil {
102 t.Fatal(err)
103 }
104 var first, second runtimeStatesView
105 runtimeStateHTTPGet(t, httpServer.URL+"/runtime-states", &first)
106 runtimeStateHTTPGet(t, httpServer.URL+"/runtime-states", &second)
107 if first.SchemaVersion != 1 || first.Epoch == "" || first.Revision == 0 || len(first.Sessions) != 2 {
108 t.Fatalf("invalid full snapshot: %+v", first)
109 }
110 if !reflect.DeepEqual(first, second) {
111 t.Fatalf("unchanged memory produced different versions/content: first=%+v second=%+v", first, second)
112 }
113 byPath := map[string]runtimeSessionView{}
114 for _, session := range first.Sessions {
115 byPath[session.SessionPath] = session
116 }
117 if got := byPath[agent.CanonicalSessionPath(foreground.SessionPath())]; !got.Current || !reflect.DeepEqual(got.State, foreground.RuntimeStateSnapshot()) {
118 t.Fatalf("foreground snapshot mismatch: %+v", got)
119 }
120 if got := byPath[detachedPath]; got.Current || !reflect.DeepEqual(got.State, detached.RuntimeStateSnapshot()) {
121 t.Fatalf("detached snapshot mismatch: %+v", got)
122 }
123 read := server.runtimeStatesSnapshot()
124 read.Sessions[0].State.Phase = "corrupt-test-copy"
125 if reflect.DeepEqual(read, server.runtimeStatesSnapshot()) {
126 t.Fatal("caller mutated the stored projection through its returned slice")
127 }
128 }
129
130 func TestRuntimeStateHTTPStatusUsesRequestedDetachedController(t *testing.T) {
131 dir := t.TempDir()
132 foreground := runtimeStateServeController(t, dir, "foreground", nil)
133 runner := runtimeStateServeRunner{started: make(chan struct{})}
134 detached := runtimeStateServeController(t, dir, "detached", runner)
135 server := New(foreground, nil, config.ServeConfig{})
136 detachedPath := agent.CanonicalSessionPath(detached.SessionPath())
137 server.detached[detachedPath] = &detachedSession{path: detachedPath, ctrl: detached}
138 detached.Send("isolated running turn")
139 select {
140 case <-runner.started:
141 case <-time.After(5 * time.Second):
142 t.Fatal("detached runner did not start")
143 }
144 httpServer := httptest.NewServer(server.Handler())
145 defer httpServer.Close()
146 var status struct {
147 SessionPath string `json:"sessionPath"`
148 Running bool `json:"running"`
149 RuntimeState event.RuntimeStateSnapshot `json:"runtimeState"`
150 }
151 runtimeStateHTTPGet(t, httpServer.URL+"/status?runtime=1&session="+url.QueryEscape(detachedPath), &status)
152 if status.SessionPath != detachedPath {
153 t.Fatalf("detached status path = %q, want canonical identity %q", status.SessionPath, detachedPath)
154 }
155 if !status.Running || status.RuntimeState.Phase != "executing" || status.RuntimeState.RuntimeEpoch != detached.RuntimeStateSnapshot().RuntimeEpoch {
156 t.Fatalf("detached status was borrowed from foreground: %+v", status)
157 }
158 if foreground.RuntimeStateSnapshot().Running {
159 t.Fatal("fixture foreground unexpectedly running")
160 }
161 }
162
163 func TestOwnedRuntimeStatusCanonicalizesResponsePath(t *testing.T) {
164 dir := t.TempDir()
165 foreground := runtimeStateServeController(t, dir, "foreground", nil)
166 detached := runtimeStateServeController(t, dir, "detached", nil)
167 server := New(foreground, nil, config.ServeConfig{})
168 path := agent.CanonicalSessionPath(detached.SessionPath())
169 server.detached[path] = &detachedSession{path: path, ctrl: detached}
170 // A noncanonical spelling must not escape through the status response,
171 // even when lookup correctly resolves it to the detached owner.
172 raw := filepath.Dir(path) + string(filepath.Separator) + "." + string(filepath.Separator) + filepath.Base(path)
173 status, ok := server.ownedRuntimeStatusView(raw)
174 if !ok || status["sessionPath"] != path {
175 t.Fatalf("owned status did not preserve canonical identity: ok=%v path=%v want=%q", ok, status["sessionPath"], path)
176 }
177 state := status["runtimeState"].(event.RuntimeStateSnapshot)
178 if state.RuntimeEpoch != detached.RuntimeStateSnapshot().RuntimeEpoch {
179 t.Fatal("canonical response borrowed the foreground runtime")
180 }
181 }
182
183 func readRuntimeSSEFrame(t *testing.T, reader *bufio.Reader) eventwire.Event {
184 t.Helper()
185 for {
186 line, err := reader.ReadString('\n')
187 if err != nil {
188 t.Fatal(err)
189 }
190 if !strings.HasPrefix(line, "data: ") {
191 continue
192 }
193 var frame eventwire.Event
194 if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &frame); err != nil {
195 t.Fatal(err)
196 }
197 return frame
198 }
199 }
200
201 // Runtime snapshots are an additive host-only channel. Existing transcript
202 // tests still assert their exact protocol sequence after filtering that kind.
203 func nextServeProtocolFrame(t *testing.T, frames <-chan []byte, beforeRuntime func(eventwire.Event)) eventwire.Event {
204 t.Helper()
205 timer := time.NewTimer(5 * time.Second)
206 defer timer.Stop()
207 for {
208 select {
209 case raw := <-frames:
210 var frame eventwire.Event
211 if err := json.Unmarshal(raw, &frame); err != nil {
212 t.Fatal(err)
213 }
214 if frame.Kind == "runtime_state" {
215 if beforeRuntime != nil {
216 beforeRuntime(frame)
217 }
218 continue
219 }
220 return frame
221 case <-timer.C:
222 t.Fatal("expected protocol frame was not delivered")
223 }
224 }
225 }
226
227 func assertNoServeProtocolFrames(t *testing.T, frames <-chan []byte) {
228 t.Helper()
229 for {
230 select {
231 case raw := <-frames:
232 var frame eventwire.Event
233 if err := json.Unmarshal(raw, &frame); err != nil {
234 t.Fatal(err)
235 }
236 if frame.Kind != "runtime_state" {
237 t.Fatalf("unexpected duplicate protocol frame: %s", raw)
238 }
239 default:
240 return
241 }
242 }
243 }
244
245 func TestRuntimeStateSSEFiltersSessionsAndPreservesHostOnlyPayload(t *testing.T) {
246 dir := t.TempDir()
247 foreground := runtimeStateServeController(t, dir, "foreground", nil)
248 broadcaster := NewBroadcaster()
249 server := New(foreground, broadcaster, config.ServeConfig{})
250 httpServer := httptest.NewServer(server.Handler())
251 defer httpServer.Close()
252 client := &http.Client{Timeout: 5 * time.Second}
253 currentResponse, err := client.Get(httpServer.URL + "/events")
254 if err != nil {
255 t.Fatal(err)
256 }
257 defer currentResponse.Body.Close()
258 allResponse, err := client.Get(httpServer.URL + "/events?all=1")
259 if err != nil {
260 t.Fatal(err)
261 }
262 defer allResponse.Body.Close()
263 backgroundPath := agent.CanonicalSessionPath(filepath.Join(dir, "background.jsonl"))
264 background := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "background", Revision: 2, Phase: "idle"}
265 current := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "foreground", Revision: 3, Phase: "finishing", Running: true}
266 backgroundSink := newSessionTagSink(broadcaster)
267 backgroundSink.SetPath(backgroundPath)
268 backgroundSink.RuntimeStateChanged(background)
269 foregroundSink := newSessionTagSink(broadcaster)
270 foregroundSink.SetPath(foreground.SessionPath())
271 foregroundSink.RuntimeStateChanged(current)
272 currentFrame := readRuntimeSSEFrame(t, bufio.NewReader(currentResponse.Body))
273 if currentFrame.Kind != "runtime_state" || !currentFrame.SessionCurrent || currentFrame.RuntimeState == nil || !reflect.DeepEqual(*currentFrame.RuntimeState, current) {
274 t.Fatalf("current stream received background or changed payload: %+v", currentFrame)
275 }
276 allReader := bufio.NewReader(allResponse.Body)
277 backgroundFrame := readRuntimeSSEFrame(t, allReader)
278 if backgroundFrame.Kind != "runtime_state" || backgroundFrame.SessionCurrent || backgroundFrame.SessionPath != backgroundPath || backgroundFrame.RuntimeState == nil || !reflect.DeepEqual(*backgroundFrame.RuntimeState, background) {
279 t.Fatalf("all-session background frame mismatch: %+v", backgroundFrame)
280 }
281 if next := readRuntimeSSEFrame(t, allReader); next.SessionPath != agent.CanonicalSessionPath(foreground.SessionPath()) || !next.SessionCurrent {
282 t.Fatalf("all-session foreground frame mismatch: %+v", next)
283 }
284 }
285
286 func TestRuntimeStateInboxReceiptRetainsRichFollowupAndSessionFence(t *testing.T) {
287 dir := t.TempDir()
288 runner := runtimeStateServeRunner{started: make(chan struct{})}
289 ctrl := runtimeStateServeController(t, dir, "foreground", runner)
290 ctrl.Send("hold isolated foreground turn")
291 select {
292 case <-runner.started:
293 case <-time.After(5 * time.Second):
294 t.Fatal("foreground runner did not start")
295 }
296 server := New(ctrl, nil, config.ServeConfig{})
297 httpServer := httptest.NewServer(server.Handler())
298 defer httpServer.Close()
299 request := map[string]any{
300 "input": "run fixture-skill", "display": "rich display", "intent": "followup", "idempotencyKey": "stable-key",
301 "invocations": []control.InvocationRequest{{Name: "fixture-skill", Kind: "skill", Offset: 4}},
302 }
303 raw, err := json.Marshal(request)
304 if err != nil {
305 t.Fatal(err)
306 }
307 req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/inbox/items", strings.NewReader(string(raw)))
308 if err != nil {
309 t.Fatal(err)
310 }
311 req.Header.Set("Content-Type", "application/json")
312 req.Header.Set(sessionPathHeader, ctrl.SessionPath())
313 response, err := http.DefaultClient.Do(req)
314 if err != nil {
315 t.Fatal(err)
316 }
317 defer response.Body.Close()
318 if response.StatusCode != http.StatusAccepted {
319 t.Fatalf("enqueue status=%d", response.StatusCode)
320 }
321 var enqueued sessioninbox.InboxReceipt
322 if err := json.NewDecoder(response.Body).Decode(&enqueued); err != nil {
323 t.Fatal(err)
324 }
325 _, envelope, err := ctrl.ReadInboxItem(enqueued.ItemID)
326 if err != nil {
327 t.Fatal(err)
328 }
329 if envelope.DisplayText != "rich display" || len(envelope.Invocations) != 1 || envelope.Invocations[0].Name != "fixture-skill" || envelope.Invocations[0].Kind != "skill" || envelope.Invocations[0].Offset != 4 {
330 t.Fatalf("HTTP enqueue lost rich prompt: %+v", envelope)
331 }
332 var recovered sessioninbox.InboxReceipt
333 runtimeStateHTTPGet(t, httpServer.URL+"/inbox/receipt?key=stable-key&session="+url.QueryEscape(ctrl.SessionPath()), &recovered)
334 if recovered.ItemID != enqueued.ItemID {
335 t.Fatalf("idempotency receipt changed item: enqueued=%+v recovered=%+v", enqueued, recovered)
336 }
337 var snapshot sessioninbox.InboxSnapshot
338 runtimeStateHTTPGet(t, httpServer.URL+"/inbox?session="+url.QueryEscape(ctrl.SessionPath()), &snapshot)
339 if snapshot.SessionPath != ctrl.SessionPath() || len(snapshot.Items) != 1 || snapshot.Items[0].ID != enqueued.ItemID {
340 t.Fatalf("remote inbox snapshot does not match receipt: %+v", snapshot)
341 }
342 for _, endpoint := range []string{"/inbox", "/inbox/receipt?key=stable-key"} {
343 for _, fence := range []string{"query", "header", "conflicting"} {
344 t.Run(endpoint+"/"+fence, func(t *testing.T) {
345 u, err := url.Parse(httpServer.URL + endpoint)
346 if err != nil {
347 t.Fatal(err)
348 }
349 query := u.Query()
350 wrongPath := filepath.Join(dir, "wrong.jsonl")
351 if fence == "query" || fence == "conflicting" {
352 query.Set("session", wrongPath)
353 } else {
354 query.Set("session", ctrl.SessionPath())
355 }
356 u.RawQuery = query.Encode()
357 req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
358 switch fence {
359 case "header":
360 req.Header.Set(expectedSessionPathHeader, wrongPath)
361 case "conflicting":
362 req.Header.Set(expectedSessionPathHeader, ctrl.SessionPath())
363 }
364 response, err := http.DefaultClient.Do(req)
365 if err != nil {
366 t.Fatal(err)
367 }
368 defer response.Body.Close()
369 if response.StatusCode != http.StatusConflict {
370 t.Fatalf("inbox read crossed %s fence: status=%d", fence, response.StatusCode)
371 }
372 })
373 }
374 }
375 wrong, err := http.Get(httpServer.URL + "/inbox/receipt?key=stable-key&session=" + url.QueryEscape(filepath.Join(dir, "wrong.jsonl")))
376 if err != nil {
377 t.Fatal(err)
378 }
379 defer wrong.Body.Close()
380 if wrong.StatusCode != http.StatusConflict {
381 t.Fatalf("receipt lookup crossed session fence: status=%d", wrong.StatusCode)
382 }
383 }
384
384 lines GO