返回 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 for _, m := range a.Session().Messages {
72 if text, ok := SteerText(m.Content); ok {
73 persisted = append(persisted, text)
74 localOnly = m.LocalOnly && m.Role == provider.RoleTool &&
75 m.ToolCallID == provider.LocalOnlyToolID && m.Name == provider.LocalOnlyToolName
76 }
77 }
78 if len(persisted) != 1 || persisted[0] != "use plan B" {
79 t.Fatalf("unconsumed steer should be persisted once and round-trip through SteerText, got %v", persisted)
80 }
81 if !localOnly {
82 t.Fatal("unconsumed steer must use the provider-excluded local-only sentinel")
83 }
84 for _, m := range provider.ModelMessages(a.Session().Snapshot()) {
85 if text, ok := SteerText(m.Content); ok {
86 t.Fatalf("unconsumed steer %q leaked into the next model context", text)
87 }
88 }
89 if len(notices) != 1 || notices[0].Level != event.LevelWarn ||
90 !strings.Contains(notices[0].Text, "use plan B") ||
91 !strings.Contains(notices[0].Text, "not applied") {
92 t.Fatalf("flushed steer should emit an explicit warning, got %+v", notices)
93 }
94 if n := a.steerQueueLen(); n != 0 {
95 t.Fatalf("steer queue should be empty after the turn, len=%d", n)
96 }
97 if !a.HasUnappliedSteer() {
98 t.Fatal("host should observe that the cancelled turn left unapplied guidance")
99 }
100 if a.Steer("after the turn") {
101 t.Fatalf("Steer must be rejected once the turn has exited")
102 }
103 }
104
105 // TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable pins the normal turn
106 // exit boundary: once the final queue check observes no pending guidance, a
107 // later steer must be rejected rather than accepted and flushed as unapplied.
108 func TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable(t *testing.T) {
109 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
110 a.steerMu.Lock()
111 a.steerRunActive = true
112 a.steerMu.Unlock()
113
114 if !a.closeSteerIntakeIfIdle() {
115 t.Fatal("empty steer intake should close")
116 }
117 if a.Steer("too late") {
118 t.Fatal("steer after the final queue check must be rejected")
119 }
120 if n := a.steerQueueLen(); n != 0 {
121 t.Fatalf("rejected steer remained queued, len=%d", n)
122 }
123 if a.HasUnappliedSteer() {
124 t.Fatal("closing an empty steer intake must not report unapplied guidance")
125 }
126 }
127
128 func TestWithdrawnDurableSteerDoesNotEmitUnappliedNotice(t *testing.T) {
129 var notices int
130 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.FuncSink(func(e event.Event) {
131 if e.Kind == event.Notice && e.Code == event.NoticeCodeUnappliedSteer {
132 notices++
133 }
134 }))
135 a.steerMu.Lock()
136 a.steerRunActive = true
137 a.steerMu.Unlock()
138 if !a.SteerItem("withdrawn-consume", func() (string, error) { return "", ErrSteerWithdrawn }) {
139 t.Fatal("active steer should be accepted")
140 }
141 if text, itemID, ok := a.consumeSteer(); ok || text != "" || itemID != "" {
142 t.Fatalf("withdrawn consume = (%q, %q, %v), want silent miss", text, itemID, ok)
143 }
144 if !a.SteerItem("withdrawn-flush", func() (string, error) { return "", ErrSteerWithdrawn }) {
145 t.Fatal("second active steer should be accepted")
146 }
147 a.flushSteerQueue()
148 if notices != 0 {
149 t.Fatalf("withdrawn steer emitted %d unapplied notices", notices)
150 }
151 if len(a.Session().Messages) != 0 {
152 t.Fatalf("withdrawn steer wrote transcript messages: %+v", a.Session().Messages)
153 }
154 }
155
156 // TestSteerTextSurvivesTurnPreferenceWrapping pins replay: steers are
157 // persisted through withTurnPreferences, which prepends transient language
158 // blocks (for Chinese text even in auto mode, and for any text under an
159 // explicit language) ahead of the steer prefix. SteerText must skip the
160 // wrapping and return the user's exact original text, or replay degrades the
161 // steer into a plain user message.
162 func TestSteerTextSurvivesTurnPreferenceWrapping(t *testing.T) {
163 plain := New(nil, nil, NewSession(""), Options{}, event.Discard)
164 explicit := New(nil, nil, NewSession(""), Options{}, event.Discard)
165 explicit.SetReasoningLanguage("zh")
166 explicit.SetResponseLanguage("zh")
167
168 cases := []struct {
169 name string
170 agent *Agent
171 text string
172 }{
173 {"english auto (no blocks)", plain, "use plan B"},
174 {"chinese auto (reasoning block)", plain, "请改用方案B"},
175 {"explicit zh (both blocks)", explicit, "switch to plan B"},
176 {"exact text preserved", plain, " spaced\ttext "},
177 }
178 for _, tc := range cases {
179 persisted := tc.agent.withTurnPreferences(midTurnSteerMessage(tc.text))
180 got, ok := SteerText(persisted)
181 if !ok {
182 t.Fatalf("%s: SteerText failed to recognize the persisted steer (head %.80q)", tc.name, persisted)
183 }
184 if got != tc.text {
185 t.Fatalf("%s: SteerText = %q, want %q", tc.name, got, tc.text)
186 }
187 }
188
189 if _, ok := SteerText(plain.withTurnPreferences("请总结一下这个文件")); ok {
190 t.Fatalf("a wrapped ordinary user message must not be detected as a steer")
191 }
192 }
193
194 // TestSteerRejectedWithoutActiveTurn proves a steer arriving when no turn is
195 // running is rejected instead of parked in a queue no loop will consume, so
196 // the controller can convert it into a regular turn.
197 func TestSteerRejectedWithoutActiveTurn(t *testing.T) {
198 a := New(testutil.NewMock("m", testutil.Turn{Text: "done"}), tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
199 if a.Steer("early") {
200 t.Fatalf("Steer with no active turn must be rejected")
201 }
202 if n := a.steerQueueLen(); n != 0 {
203 t.Fatalf("rejected steer must not linger in the queue, len=%d", n)
204 }
205 if err := a.Run(context.Background(), "go"); err != nil {
206 t.Fatalf("Run: %v", err)
207 }
208 if a.Steer("between turns") {
209 t.Fatalf("Steer between turns must be rejected")
210 }
211 }
212
212 lines GO