| 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 |