返回 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.Steer("after the turn") {
98 t.Fatalf("Steer must be rejected once the turn has exited")
99 }
100 }
101
102 // TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable pins the normal turn
103 // exit boundary: once the final queue check observes no pending guidance, a
104 // later steer must be rejected rather than accepted and flushed as unapplied.
105 func TestCloseSteerIntakeIfIdleMakesAdmissionLinearizable(t *testing.T) {
106 a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
107 a.steerMu.Lock()
108 a.steerRunActive = true
109 a.steerMu.Unlock()
110
111 if !a.closeSteerIntakeIfIdle() {
112 t.Fatal("empty steer intake should close")
113 }
114 if a.Steer("too late") {
115 t.Fatal("steer after the final queue check must be rejected")
116 }
117 if n := a.steerQueueLen(); n != 0 {
118 t.Fatalf("rejected steer remained queued, len=%d", n)
119 }
120 }
121
122 // TestSteerTextSurvivesTurnPreferenceWrapping pins replay: steers are
123 // persisted through withTurnPreferences, which prepends transient language
124 // blocks (for Chinese text even in auto mode, and for any text under an
125 // explicit language) ahead of the steer prefix. SteerText must skip the
126 // wrapping and return the user's exact original text, or replay degrades the
127 // steer into a plain user message.
128 func TestSteerTextSurvivesTurnPreferenceWrapping(t *testing.T) {
129 plain := New(nil, nil, NewSession(""), Options{}, event.Discard)
130 explicit := New(nil, nil, NewSession(""), Options{}, event.Discard)
131 explicit.SetReasoningLanguage("zh")
132 explicit.SetResponseLanguage("zh")
133
134 cases := []struct {
135 name string
136 agent *Agent
137 text string
138 }{
139 {"english auto (no blocks)", plain, "use plan B"},
140 {"chinese auto (reasoning block)", plain, "请改用方案B"},
141 {"explicit zh (both blocks)", explicit, "switch to plan B"},
142 {"exact text preserved", plain, " spaced\ttext "},
143 }
144 for _, tc := range cases {
145 persisted := tc.agent.withTurnPreferences(midTurnSteerMessage(tc.text))
146 got, ok := SteerText(persisted)
147 if !ok {
148 t.Fatalf("%s: SteerText failed to recognize the persisted steer (head %.80q)", tc.name, persisted)
149 }
150 if got != tc.text {
151 t.Fatalf("%s: SteerText = %q, want %q", tc.name, got, tc.text)
152 }
153 }
154
155 if _, ok := SteerText(plain.withTurnPreferences("请总结一下这个文件")); ok {
156 t.Fatalf("a wrapped ordinary user message must not be detected as a steer")
157 }
158 }
159
160 // TestSteerRejectedWithoutActiveTurn proves a steer arriving when no turn is
161 // running is rejected instead of parked in a queue no loop will consume, so
162 // the controller can convert it into a regular turn.
163 func TestSteerRejectedWithoutActiveTurn(t *testing.T) {
164 a := New(testutil.NewMock("m", testutil.Turn{Text: "done"}), tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
165 if a.Steer("early") {
166 t.Fatalf("Steer with no active turn must be rejected")
167 }
168 if n := a.steerQueueLen(); n != 0 {
169 t.Fatalf("rejected steer must not linger in the queue, len=%d", n)
170 }
171 if err := a.Run(context.Background(), "go"); err != nil {
172 t.Fatalf("Run: %v", err)
173 }
174 if a.Steer("between turns") {
175 t.Fatalf("Steer between turns must be rejected")
176 }
177 }
178
178 lines GO