返回 DeepSeek-Reasonix
protocol_recovery_test.go
根目录 / internal / agent / protocol_recovery_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/agent/testutil"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 func opaqueRecoveryError() error {
17 return &provider.APIError{Provider: "strict-replay", Status: 400, Body: `{"model":"deepseek-v4-pro"}`}
18 }
19 func recoveryTestAgent(turns ...testutil.Turn) (*Agent, *testutil.MockProvider) {
20 p := testutil.NewMock("strict-replay", turns...)
21 return New(strictAssistantReasoningProvider{p}, echoRegistry(), reasoningReplaySeededSession(), Options{}, event.Discard), p
22 }
23 func TestProtocolRecoveryManualAndRestart(t *testing.T) {
24 a, p := recoveryTestAgent(testutil.ErrorTurn(opaqueRecoveryError()), testutil.ErrorTurn(opaqueRecoveryError()), testutil.ErrorTurn(thinkingReplay400Error()))
25 if err := a.Run(withNoClosedLoop(context.Background()), "next"); err == nil {
26 t.Fatal("expected upstream error")
27 }
28 pending := a.PendingProtocolRecovery()
29 if pending == nil || p.CallCount() != 1 {
30 t.Fatalf("pending=%v requests=%d", pending, p.CallCount())
31 }
32 path := filepath.Join(t.TempDir(), "session.jsonl")
33 if err := a.Session().Save(path); err != nil {
34 t.Fatal(err)
35 }
36 loaded, err := LoadSession(path)
37 if err != nil {
38 t.Fatal(err)
39 }
40 a = New(strictAssistantReasoningProvider{p}, echoRegistry(), loaded, Options{}, event.Discard)
41 if a.PendingProtocolRecovery() == nil {
42 t.Fatal("restart lost pending action")
43 }
44 ctx := WithInputMessageOrigin(WithProtocolRecovery(withNoClosedLoop(context.Background()), pending.ID), provider.MessageOriginHost)
45 if err := a.Run(ctx, "recover"); err == nil {
46 t.Fatal("expected second upstream error")
47 }
48 if p.CallCount() != 2 || a.PendingProtocolRecovery() != nil {
49 t.Fatal("manual recovery renewed its budget")
50 }
51 for _, m := range p.Requests()[1].Messages {
52 if m.ReasoningContent != "" || len(m.ProtocolRecovery) > 0 {
53 t.Fatal("repair or local metadata projection failed")
54 }
55 }
56 if err := a.Session().Save(path); err != nil {
57 t.Fatal(err)
58 }
59 loaded, err = LoadSession(path)
60 if err != nil {
61 t.Fatal(err)
62 }
63 a = New(strictAssistantReasoningProvider{p}, echoRegistry(), loaded, Options{}, event.Discard)
64 if err := a.Run(ctx, "repeat"); !errors.Is(err, ErrProtocolRecoveryUnavailable) {
65 t.Fatalf("repeat=%v", err)
66 }
67 if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err == nil {
68 t.Fatal("expected protocol rejection")
69 }
70 if p.CallCount() != 3 {
71 t.Fatalf("restart renewed budget: %d", p.CallCount())
72 }
73 for _, m := range p.Requests()[2].Messages {
74 if m.ReasoningContent != "" {
75 t.Fatal("restart lost repaired view")
76 }
77 }
78 }
79 func TestProtocolRecoveryEligibilityAndStaleness(t *testing.T) {
80 for _, err := range []error{&provider.APIError{Status: 401}, &provider.APIError{Status: 400, Body: `{"error":"invalid temperature"}`}} {
81 a, _ := recoveryTestAgent(testutil.ErrorTurn(err))
82 _ = a.Run(withNoClosedLoop(context.Background()), "next")
83 if a.PendingProtocolRecovery() != nil {
84 t.Fatal("nonopaque failure offered repair")
85 }
86 }
87 a, p := recoveryTestAgent(testutil.ErrorTurn(opaqueRecoveryError()))
88 _ = a.Run(withNoClosedLoop(context.Background()), "next")
89 id := a.PendingProtocolRecovery().ID
90 ctx, cancel := context.WithCancel(WithProtocolRecovery(context.Background(), id))
91 cancel()
92 if err := a.Run(ctx, "recover"); !errors.Is(err, ErrProtocolRecoveryUnavailable) {
93 t.Fatal(err)
94 }
95 if a.PendingProtocolRecovery() == nil {
96 t.Fatal("preparation cancellation consumed repair")
97 }
98 a.Session().Add(provider.Message{Role: provider.RoleUser, Content: "different task"})
99 if a.PendingProtocolRecovery() != nil {
100 t.Fatal("new input did not stale token")
101 }
102 if err := a.Run(WithProtocolRecovery(context.Background(), id), "recover"); !errors.Is(err, ErrProtocolRecoveryUnavailable) {
103 t.Fatal(err)
104 }
105 if p.CallCount() != 1 {
106 t.Fatal("stale action invoked provider")
107 }
108 }
109 func TestProtocolRecoveryUnknownFieldsAndVersion(t *testing.T) {
110 a, _ := recoveryTestAgent(testutil.ErrorTurn(opaqueRecoveryError()))
111 _ = a.Run(withNoClosedLoop(context.Background()), "next")
112 r, _ := a.latestProtocolRecord()
113 raw, _ := json.Marshal(r)
114 raw = append(raw[:len(raw)-1], []byte(`,"future":{"keep":true}}`)...)
115 a.Session().storeProtocolRecord(r.ID, raw)
116 r.State = "consumed"
117 if err := a.saveProtocolRecord(r); err != nil {
118 t.Fatal(err)
119 }
120 for _, m := range a.Session().Snapshot() {
121 if len(m.ProtocolRecovery) > 0 && !strings.Contains(string(m.ProtocolRecovery), `"future"`) {
122 t.Fatal("lost unknown field")
123 }
124 }
125 a.Session().Add(provider.Message{LocalOnly: true, ProtocolRecovery: json.RawMessage(`{"version":99,"id":"future"}`)})
126 if a.PendingProtocolRecovery() != nil {
127 t.Fatal("unknown version actionable")
128 }
129 b, _ := json.Marshal(a.Session().Snapshot())
130 var round []provider.Message
131 if err := json.Unmarshal(b, &round); err != nil {
132 t.Fatal(err)
133 }
134 if !strings.Contains(string(round[len(round)-1].ProtocolRecovery), `99`) {
135 t.Fatal("unknown version lost")
136 }
137 }
138
139 type protocolCheckpointSink struct {
140 fail bool
141 records int
142 }
143
144 func (s *protocolCheckpointSink) Emit(event.Event) {}
145 func (s *protocolCheckpointSink) EmitChecked(e event.Event) error {
146 if e.RecoveryCheckpoint {
147 s.records++
148 if s.fail {
149 return errors.New("checkpoint unavailable")
150 }
151 }
152 return nil
153 }
154 func TestProtocolRecoveryCheckpointBeforeRequest(t *testing.T) {
155 a, p := recoveryTestAgent(testutil.ErrorTurn(opaqueRecoveryError()), testutil.Turn{Text: "should not run"})
156 sink := &protocolCheckpointSink{}
157 a.svc.sink = sink
158 _ = a.Run(withNoClosedLoop(context.Background()), "next")
159 id := a.PendingProtocolRecovery().ID
160 sink.fail = true
161 ctx := WithInputMessageOrigin(WithProtocolRecovery(context.Background(), id), provider.MessageOriginHost)
162 if err := a.Run(ctx, "recover"); err == nil || !strings.Contains(err.Error(), "checkpoint") {
163 t.Fatalf("error=%v", err)
164 }
165 if p.CallCount() != 1 || sink.records != 2 {
166 t.Fatalf("requests=%d checkpoints=%d", p.CallCount(), sink.records)
167 }
168 }
169
170 type lateProtocolProvider struct {
171 strictAssistantReasoningProvider
172 entered, release chan struct{}
173 count int
174 }
175
176 func (p *lateProtocolProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
177 p.count++
178 if p.count == 1 {
179 return p.MockProvider.Stream(ctx, req)
180 }
181 close(p.entered)
182 <-p.release
183 call := provider.ToolCall{ID: "late", Name: "echo", Arguments: `{"text":"must not execute"}`}
184 ch := make(chan provider.Chunk, 4)
185 ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: "proof"}
186 ch <- provider.Chunk{Type: provider.ChunkText, Text: "late response"}
187 ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &call}
188 ch <- provider.Chunk{Type: provider.ChunkDone}
189 close(ch)
190 return ch, nil
191 }
192 func TestProtocolRecoveryCancellationDiscardsLateResponseAndTool(t *testing.T) {
193 p := &lateProtocolProvider{strictAssistantReasoningProvider: strictAssistantReasoningProvider{testutil.NewMock("strict-replay", testutil.ErrorTurn(opaqueRecoveryError()))}, entered: make(chan struct{}), release: make(chan struct{})}
194 sink := &recordSink{}
195 a := New(p, echoRegistry(), reasoningReplaySeededSession(), Options{}, sink)
196 _ = a.Run(withNoClosedLoop(context.Background()), "next")
197 pending := a.PendingProtocolRecovery()
198 if pending == nil {
199 t.Fatal("no pending recovery")
200 }
201 ctx, cancel := context.WithCancel(WithInputMessageOrigin(WithProtocolRecovery(context.Background(), pending.ID), provider.MessageOriginHost))
202 done := make(chan error, 1)
203 go func() { done <- a.Run(ctx, "recover") }()
204 <-p.entered
205 cancel()
206 close(p.release)
207 if err := <-done; !errors.Is(err, context.Canceled) {
208 t.Fatalf("cancellation=%v", err)
209 }
210 if len(sink.kinds(event.ToolDispatch)) != 0 {
211 t.Fatal("late tool started")
212 }
213 for _, m := range a.Session().Snapshot() {
214 if m.Content == "late response" {
215 t.Fatal("late assistant committed")
216 }
217 }
218 if a.PendingProtocolRecovery() != nil {
219 t.Fatal("cancelled request renewed repair budget")
220 }
221 }
222
223 func TestProtocolRecoveryPendingTracksLocalExecutionEvidence(t *testing.T) {
224 a, _ := recoveryTestAgent(testutil.ErrorTurn(opaqueRecoveryError()))
225 a.Session().AddBatch(
226 provider.Message{Role: provider.RoleAssistant, ReasoningContent: "old reasoning", ToolCalls: []provider.ToolCall{{ID: "receipt", Name: "echo", Arguments: `{}`}}},
227 provider.Message{Role: provider.RoleTool, ToolCallID: "receipt", Name: "echo", Content: "done", ToolRunState: provider.ToolRunCompleted},
228 )
229 _ = a.Run(withNoClosedLoop(context.Background()), "next")
230 if a.PendingProtocolRecovery() == nil {
231 t.Fatal("missing initial action")
232 }
233 a.Session().mu.Lock()
234 changed := false
235 for i := range a.Session().Messages {
236 m := &a.Session().Messages[i]
237 if m.Role == provider.RoleTool && !m.LocalOnly {
238 m.ToolRunState = provider.ToolRunUnknown
239 changed = true
240 break
241 }
242 }
243 a.Session().mu.Unlock()
244 if !changed {
245 t.Fatal("fixture has no tool result")
246 }
247 if a.PendingProtocolRecovery() != nil {
248 t.Fatal("changed execution evidence left action usable")
249 }
250 }
251
251 lines GO