返回 DeepSeek-Reasonix
in_flight_turn_dag_test.go
根目录 / internal / control / in_flight_turn_dag_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 "reasonix/internal/tool"
15 )
16
17 func dagLogEntryTypes(t *testing.T, path string) []string {
18 t.Helper()
19 b, err := os.ReadFile(store.SessionEventLog(path))
20 if err != nil {
21 t.Fatal(err)
22 }
23 var types []string
24 for line := range strings.SplitSeq(strings.TrimSpace(string(b)), "\n") {
25 _, rest, _ := strings.Cut(line, `"type":"`)
26 typ, _, _ := strings.Cut(rest, `"`)
27 types = append(types, typ)
28 }
29 return types
30 }
31
32 // TestInterruptedTurnRecoveryUsesLogMarkersForSchemaTwo pins the schema-2
33 // crash contract: a turn left open in the log is closed on resume by
34 // dropping its tail through a rewind marker, keeping the user prompt, and
35 // the earlier bytes are never truncated.
36 func TestInterruptedTurnRecoveryUsesLogMarkersForSchemaTwo(t *testing.T) {
37 dir := t.TempDir()
38 path := filepath.Join(dir, "session.jsonl")
39 sess := agent.NewSession("sys")
40 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
41 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
42 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
43 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
44 if err := c.Snapshot(); err != nil {
45 t.Fatalf("Snapshot: %v", err)
46 }
47 if _, ok := sess.Head(); !ok {
48 t.Fatal("session must be schema 2 after its first save")
49 }
50 start := sess.Len()
51 marker := c.markInFlightTurn(start, true)
52 if marker.ID == "" || marker.HeadID != agent.SessionMainHead {
53 t.Fatalf("marker = %+v, want a log-backed marker", marker)
54 }
55 sess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
56 sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{}`}}})
57 if err := c.Snapshot(); err != nil {
58 t.Fatalf("mid-turn snapshot: %v", err)
59 }
60 if meta, ok, _ := agent.LoadBranchMeta(path); ok && meta.InFlightTurn != nil {
61 t.Fatal("schema-2 turns must not write the sidecar marker")
62 }
63 logBefore, _ := os.ReadFile(store.SessionEventLog(path))
64
65 // "Crash": a fresh runtime resumes the same path.
66 recovered, err := agent.LoadSession(path)
67 if err != nil {
68 t.Fatal(err)
69 }
70 if open, ok := recovered.OpenTurn(); !ok || open.TurnID != marker.ID {
71 t.Fatalf("open turn after crash = %+v ok=%v", open, ok)
72 }
73 exec2 := agent.New(nil, nil, recovered, agent.Options{}, event.Discard)
74 sink := &noticeSink{}
75 c2 := newOwnedTestController(t, Options{Executor: exec2, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
76 c2.recoverInterruptedTurn(path)
77
78 got := exec2.Session().Snapshot()
79 if len(got) < 4 || got[3].Content != "second" || got[3].Role != provider.RoleUser {
80 t.Fatalf("recovered transcript = %+v, want the user prompt kept", got)
81 }
82 last := got[len(got)-1]
83 if !last.LocalOnly || last.InterruptedTurn == nil {
84 t.Fatalf("recovery must leave an interrupted-turn display record, got %+v", last)
85 }
86 logAfter, _ := os.ReadFile(store.SessionEventLog(path))
87 if !strings.HasPrefix(string(logAfter), string(logBefore)) {
88 t.Fatal("recovery must append, never rewrite earlier bytes")
89 }
90 types := dagLogEntryTypes(t, path)
91 if types[len(types)-1] != "turn_end" {
92 t.Fatalf("recovery entries = %v, want turn_end last", types)
93 }
94 reloaded, err := agent.LoadSession(path)
95 if err != nil {
96 t.Fatal(err)
97 }
98 if _, ok := reloaded.OpenTurn(); ok {
99 t.Fatal("recovery must close the open turn")
100 }
101 if len(reloaded.Messages) != len(got) {
102 t.Fatalf("reloaded transcript length %d, want %d", len(reloaded.Messages), len(got))
103 }
104 // A second resume finds nothing to recover and changes nothing.
105 c2.recoverInterruptedTurn(path)
106 if again := dagLogEntryTypes(t, path); len(again) != len(types) {
107 t.Fatalf("idempotent recovery appended entries: %v", again)
108 }
109 }
110
111 func TestFinishedTurnClosesLogMarkerInOneBatch(t *testing.T) {
112 dir := t.TempDir()
113 path := filepath.Join(dir, "session.jsonl")
114 sess := agent.NewSession("sys")
115 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
116 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", Sink: event.Discard})
117 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
118 if err := c.Snapshot(); err != nil {
119 t.Fatal(err)
120 }
121 start := sess.Len()
122 marker := c.markInFlightTurn(start, true)
123 sess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
124 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
125 c.finishInFlightTurn(start, marker)
126 types := dagLogEntryTypes(t, path)
127 if got := strings.Join(types[len(types)-4:], ","); got != "message,message,turn_begin,turn_end" {
128 t.Fatalf("tail entries = %v", types)
129 }
130 reloaded, err := agent.LoadSession(path)
131 if err != nil {
132 t.Fatal(err)
133 }
134 if _, ok := reloaded.OpenTurn(); ok {
135 t.Fatal("finished turn must not stay open")
136 }
137 }
138
139 func TestConcurrentWriterEmitsNoticeOnBothSides(t *testing.T) {
140 dir := t.TempDir()
141 path := filepath.Join(dir, "shared.jsonl")
142 const systemPrompt = "SYS"
143 reply := [][]provider.Chunk{{{Type: provider.ChunkText, Text: "ok"}, {Type: provider.ChunkDone}}}
144 sinkA, sinkB := &noticeSink{}, &noticeSink{}
145 execA := agent.New(&recordingProvider{streams: reply}, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
146 ctrlA := newOwnedTestController(t, Options{Runner: execA, Executor: execA, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: path, Label: "a", Sink: sinkA})
147 if err := ctrlA.RunTurn(context.Background(), "first from A"); err != nil {
148 t.Fatal(err)
149 }
150 loaded, err := agent.LoadSession(path)
151 if err != nil {
152 t.Fatal(err)
153 }
154 execB := agent.New(&recordingProvider{streams: reply}, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
155 ctrlB := newOwnedTestController(t, Options{Runner: execB, Executor: execB, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: path, Label: "b", Sink: sinkB})
156 ctrlB.Resume(loaded, path)
157 if err := ctrlA.RunTurn(context.Background(), "second from A"); err != nil {
158 t.Fatal(err)
159 }
160 if err := ctrlB.RunTurn(context.Background(), "second from B"); err != nil {
161 t.Fatal(err)
162 }
163 notice, ok := sinkB.lastNotice()
164 if !ok || notice.Code != event.NoticeCodeSessionConcurrentWriter {
165 t.Fatalf("B notice = %+v ok=%v, want concurrent writer notice", notice, ok)
166 }
167 // A reopen after both wrote lands on the newest head and reports the other.
168 reopened, err := agent.LoadSession(path)
169 if err != nil {
170 t.Fatal(err)
171 }
172 execC := agent.New(&recordingProvider{streams: reply}, tool.NewRegistry(), agent.NewSession(systemPrompt), agent.Options{}, event.Discard)
173 sinkC := &noticeSink{}
174 ctrlC := newOwnedTestController(t, Options{Runner: execC, Executor: execC, SystemPrompt: systemPrompt, SessionDir: dir, SessionPath: path, Label: "c", Sink: sinkC})
175 ctrlC.Resume(reopened, path)
176 notice, ok = sinkC.lastNotice()
177 if !ok || notice.Code != event.NoticeCodeSessionHeadSwitched {
178 t.Fatalf("C notice = %+v ok=%v, want head switched notice", notice, ok)
179 }
180 }
181
181 lines GO