返回 DeepSeek-Reasonix
steer_flush_test.go
根目录 / internal / agent / steer_flush_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent/testutil"
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 // steerThenCancelTool queues a steer while the turn is running, then cancels
17 // the turn so Run exits before the loop's per-iteration consume can deliver it.
18 type steerThenCancelTool struct {
19 agent *Agent
20 cancel context.CancelFunc
21 steerText string
22 accepted bool
23 }
24
25 func (t *steerThenCancelTool) Name() string { return "steer_then_cancel" }
26 func (t *steerThenCancelTool) Description() string { return "queues a steer and cancels the turn" }
27 func (t *steerThenCancelTool) Schema() json.RawMessage {
28 return json.RawMessage(`{"type":"object","properties":{}}`)
29 }
30 func (t *steerThenCancelTool) ReadOnly() bool { return true }
31 func (t *steerThenCancelTool) Execute(context.Context, json.RawMessage) (string, error) {
32 t.accepted = t.agent.Steer(t.steerText)
33 t.cancel()
34 return "ok", nil
35 }
36
37 // TestRunFlushesUnconsumedSteersOnCancel proves a steer that is still queued
38 // when the turn is cancelled survives in local history but not the next model
39 // context, and emits an explicit warning instead of presenting it as
40 // successfully applied guidance.
41 func TestRunFlushesUnconsumedSteersOnCancel(t *testing.T) {
42 mp := testutil.NewMock("m",
43 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "steer_then_cancel", Arguments: `{}`}}},
44 testutil.Turn{Text: "never reached"},
45 )
46 hijack := &steerThenCancelTool{steerText: "use plan B"}
47 reg := tool.NewRegistry()
48 reg.Add(hijack)
49 var notices []event.Event
50 sink := event.FuncSink(func(e event.Event) {
51 if e.Kind == event.Notice && e.Code == event.NoticeCodeUnappliedSteer {
52 notices = append(notices, e)
53 }
54 })
55 a := New(mp, reg, NewSession(""), Options{}, sink)
56 ctx, cancel := context.WithCancel(context.Background())
57 defer cancel()
58 hijack.agent = a
59 hijack.cancel = cancel
60
61 err := a.Run(ctx, "go")
62 if !errors.Is(err, context.Canceled) {
63 t.Fatalf("Run should exit on the cancelled context, got %v", err)
64 }
65 if !hijack.accepted {
66 t.Fatalf("Steer during an active turn should be accepted")
67 }
68
69 var persisted []string
70 var localOnly bool
71 var persistedID string
72 for _, m := range a.Session().Messages {
73 if text, ok := SteerText(m.Content); ok {
74 persisted = append(persisted, text)
75 persistedID = m.ID
76 localOnly = m.LocalOnly && m.Role == provider.RoleTool &&
77 m.ToolCallID == provider.LocalOnlyToolID && m.Name == provider.LocalOnlyToolName
78 }
79 }
80 if len(persisted) != 1 || persisted[0] != "use plan B" {
81 t.Fatalf("unconsumed steer should be persisted once and round-trip through SteerText, got %v", persisted)
82 }
83 if !localOnly {
84 t.Fatal("unconsumed steer must use the provider-excluded local-only sentinel")
85 }
86 for _, m := range provider.ModelMessages(a.Session().Snapshot()) {
87 if text, ok := SteerText(m.Content); ok {
88 t.Fatalf("unconsumed steer %q leaked into the next model context", text)
89 }
90 }
91 if len(notices) != 1 || notices[0].Level != event.LevelWarn ||
92 !strings.Contains(notices[0].Text, "use plan B") ||
93 !strings.Contains(notices[0].Text, "not applied") {
94 t.Fatalf("flushed steer should emit an explicit warning, got %+v", notices)
95 }
96 if persistedID == "" || notices[0].MessageID != persistedID {
97 t.Fatalf("warning and durable steer have different identities: message=%q notice=%q", persistedID, notices[0].MessageID)
98 }
99 if n := a.steerQueueLen(); n != 0 {
100 t.Fatalf("steer queue should be empty after the turn, len=%d", n)
101 }
102 if !a.HasUnappliedSteer() {
103 t.Fatal("host should observe that the cancelled turn left unapplied guidance")
104 }
105 if a.Steer("after the turn") {
106 t.Fatalf("Steer must be rejected once the turn has exited")
107 }
108 }
109
110 // TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable pins the normal turn
111 // exit boundary: once the final queue check observes no pending guidance, a
112 // later steer must be rejected rather than accepted and flushed as unapplied.
113 func TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable(t *testing.T) {
114 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
115 a.steerMu.Lock()
116 a.steerRunActive = true
117 a.steerMu.Unlock()
118
119 if !a.closeSteerIntakeIfIdle() {
120 t.Fatal("empty steer intake should close")
121 }
122 if a.Steer("too late") {
123 t.Fatal("steer after the final queue check must be rejected")
124 }
125 if n := a.steerQueueLen(); n != 0 {
126 t.Fatalf("rejected steer remained queued, len=%d", n)
127 }
128 if a.HasUnappliedSteer() {
129 t.Fatal("closing an empty steer intake must not report unapplied guidance")
130 }
131 }
132
133 func TestWithdrawnDurableSteerDoesNotEmitUnappliedNotice(t *testing.T) {
134 var notices int
135 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.FuncSink(func(e event.Event) {
136 if e.Kind == event.Notice && e.Code == event.NoticeCodeUnappliedSteer {
137 notices++
138 }
139 }))
140 a.steerMu.Lock()
141 a.steerRunActive = true
142 a.steerMu.Unlock()
143 if !a.SteerItem("withdrawn-consume", func() (string, error) { return "", ErrSteerWithdrawn }) {
144 t.Fatal("active steer should be accepted")
145 }
146 if text, itemID, ok := a.consumeSteer(); ok || text != "" || itemID != "" {
147 t.Fatalf("withdrawn consume = (%q, %q, %v), want silent miss", text, itemID, ok)
148 }
149 if !a.SteerItem("withdrawn-flush", func() (string, error) { return "", ErrSteerWithdrawn }) {
150 t.Fatal("second active steer should be accepted")
151 }
152 a.flushSteerQueue()
153 if notices != 0 {
154 t.Fatalf("withdrawn steer emitted %d unapplied notices", notices)
155 }
156 if len(a.Session().Messages) != 0 {
157 t.Fatalf("withdrawn steer wrote transcript messages: %+v", a.Session().Messages)
158 }
159 }
160
161 // TestSteerTextSurvivesTurnPreferenceWrapping pins replay: steers are
162 // persisted through withTurnPreferences, which prepends transient language
163 // blocks (for Chinese text even in auto mode, and for any text under an
164 // explicit language) ahead of the steer prefix. SteerText must skip the
165 // wrapping and return the user's exact original text, or replay degrades the
166 // steer into a plain user message.
167 func TestSteerTextSurvivesTurnPreferenceWrapping(t *testing.T) {
168 plain := New(nil, nil, NewSession(""), Options{}, event.Discard)
169 explicit := New(nil, nil, NewSession(""), Options{}, event.Discard)
170 explicit.SetReasoningLanguage("zh")
171 explicit.SetResponseLanguage("zh")
172
173 cases := []struct {
174 name string
175 agent *Agent
176 text string
177 }{
178 {"english auto (no blocks)", plain, "use plan B"},
179 {"chinese auto (reasoning block)", plain, "请改用方案B"},
180 {"explicit zh (both blocks)", explicit, "switch to plan B"},
181 {"exact text preserved", plain, " spaced\ttext "},
182 }
183 for _, tc := range cases {
184 persisted := tc.agent.withTurnPreferences(midTurnSteerMessage(tc.text))
185 got, ok := SteerText(persisted)
186 if !ok {
187 t.Fatalf("%s: SteerText failed to recognize the persisted steer (head %.80q)", tc.name, persisted)
188 }
189 if got != tc.text {
190 t.Fatalf("%s: SteerText = %q, want %q", tc.name, got, tc.text)
191 }
192 }
193
194 if _, ok := SteerText(plain.withTurnPreferences("请总结一下这个文件")); ok {
195 t.Fatalf("a wrapped ordinary user message must not be detected as a steer")
196 }
197 }
198
199 // TestSteerRejectedWithoutActiveTurn proves a steer arriving when no turn is
200 // running is rejected instead of parked in a queue no loop will consume, so
201 // the controller can convert it into a regular turn.
202 func TestSteerRejectedWithoutActiveTurn(t *testing.T) {
203 a := New(testutil.NewMock("m", testutil.Turn{Text: "done"}), tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
204 if a.Steer("early") {
205 t.Fatalf("Steer with no active turn must be rejected")
206 }
207 if n := a.steerQueueLen(); n != 0 {
208 t.Fatalf("rejected steer must not linger in the queue, len=%d", n)
209 }
210 if err := a.Run(context.Background(), "go"); err != nil {
211 t.Fatalf("Run: %v", err)
212 }
213 if a.Steer("between turns") {
214 t.Fatalf("Steer between turns must be rejected")
215 }
216 }
217
217 lines GO