返回 DeepSeek-Reasonix
steer_fallback_test.go
根目录 / internal / control / steer_fallback_test.go
1 package control
2
3 import (
4 "context"
5 "strings"
6 "testing"
7 "time"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/skill"
13 "reasonix/internal/tool"
14 )
15
16 func steerFallbackController(t *testing.T) (*Controller, *agent.Agent) {
17 t.Helper()
18 prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("ok")}}
19 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
20 return New(Options{Runner: ag, Executor: ag, Sink: event.Discard}), ag
21 }
22
23 func sessionHasUserText(ag *agent.Agent, text string) bool {
24 for _, m := range ag.Session().Snapshot() {
25 if strings.Contains(m.Content, text) {
26 return true
27 }
28 }
29 return false
30 }
31
32 // TestSteerFallbackParksWhileRunning forces the turn-exit window: the
33 // controller still reports running (the previous body has not returned), but
34 // the agent's steer intake is already closed, so exec.Steer rejects the text.
35 // The compatibility fallback must park the steer and record an explicit
36 // unapplied warning when the window closes — runGuarded's deliberately-silent
37 // running drop would lose the user's words.
38 func TestSteerFallbackParksWhileRunning(t *testing.T) {
39 c, ag := steerFallbackController(t)
40
41 block := make(chan struct{})
42 started := make(chan struct{})
43 c.runGuarded(func(context.Context) error {
44 close(started)
45 <-block
46 return nil
47 })
48 <-started
49
50 c.Steer("queued while exiting")
51
52 c.mu.Lock()
53 parked := len(c.parkedTurns)
54 c.mu.Unlock()
55 if parked != 1 {
56 t.Fatalf("steer fallback should park while running, parked=%d", parked)
57 }
58
59 close(block)
60 waitIdleAdmission(t, c)
61 deadline := time.Now().Add(30 * time.Second)
62 for !sessionHasUserText(ag, "queued while exiting") {
63 if time.Now().After(deadline) {
64 t.Fatalf("parked steer was never delivered as a turn")
65 }
66 time.Sleep(time.Millisecond)
67 waitIdleAdmission(t, c)
68 }
69 for _, m := range ag.Session().Snapshot() {
70 if strings.Contains(m.Content, "queued while exiting") {
71 if got, ok := agent.SteerText(m.Content); !ok || got != "queued while exiting" {
72 t.Fatalf("parked fallback = %q, want persisted steer guidance", m.Content)
73 }
74 if !m.LocalOnly {
75 t.Fatal("parked fallback must not enter a later model request")
76 }
77 }
78 }
79 }
80
81 // TestSteerBetweenTurnsRecordsUnappliedGuidance pins the compatibility path:
82 // with no turn running the executor rejects the steer, and the controller must
83 // persist a provider-excluded warning instead of returning silently.
84 func TestSteerBetweenTurnsRecordsUnappliedGuidance(t *testing.T) {
85 c, ag := steerFallbackController(t)
86
87 c.Steer("late steer")
88
89 deadline := time.Now().Add(30 * time.Second)
90 for !sessionHasUserText(ag, "late steer") {
91 if time.Now().After(deadline) {
92 t.Fatalf("idle steer was never delivered as a turn")
93 }
94 time.Sleep(time.Millisecond)
95 waitIdleAdmission(t, c)
96 }
97 for _, m := range ag.Session().Snapshot() {
98 if strings.Contains(m.Content, "late steer") {
99 if got, ok := agent.SteerText(m.Content); !ok || got != "late steer" {
100 t.Fatalf("idle fallback = %q, want persisted steer guidance", m.Content)
101 }
102 if !m.LocalOnly {
103 t.Fatal("idle fallback must not enter a later model request")
104 }
105 }
106 }
107 }
108
109 func TestSteerFallbackDoesNotInjectCapabilityRoute(t *testing.T) {
110 runner := &capabilityRecordingRunner{}
111 var notices []event.Event
112 sink := event.FuncSink(func(e event.Event) {
113 if e.Kind == event.Notice && e.Code == event.NoticeCodeUnappliedSteer {
114 notices = append(notices, e)
115 }
116 })
117 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, sink)
118 reg := tool.NewRegistry()
119 reg.Add(capabilityTestTool{name: "run_skill"})
120 c := New(Options{
121 Runner: runner,
122 Executor: ag,
123 Skills: []skill.Skill{{
124 Name: "code-edit",
125 Description: "modify source code",
126 Scope: skill.ScopeBuiltin,
127 }},
128 Registry: reg,
129 Sink: sink,
130 })
131
132 c.Steer("modify polish_client.py")
133 waitIdleAdmission(t, c)
134
135 if runner.input != "" {
136 t.Fatalf("steer fallback unexpectedly opened a model turn:\n%s", runner.input)
137 }
138 if strings.Contains(runner.input, "<capability-route") {
139 t.Fatalf("steer fallback received capability routing:\n%s", runner.input)
140 }
141 msgs := ag.Session().Snapshot()
142 if len(msgs) != 1 {
143 t.Fatalf("unapplied steer messages = %d, want 1 local record", len(msgs))
144 }
145 if got, ok := agent.SteerText(msgs[0].Content); !ok || got != "modify polish_client.py" || !msgs[0].LocalOnly {
146 t.Fatalf("unapplied steer = %+v, want stable local-only guidance", msgs[0])
147 }
148 if len(notices) != 1 || notices[0].Level != event.LevelWarn ||
149 !strings.Contains(notices[0].Text, "not applied") ||
150 !strings.Contains(notices[0].Text, "modify polish_client.py") {
151 t.Fatalf("unapplied steer notice = %+v, want explicit warning", notices)
152 }
153 }
154
154 lines GO