返回 DeepSeek-Reasonix
shell_test.go
根目录 / internal / control / shell_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/event"
13 "reasonix/internal/i18n"
14 "reasonix/internal/sandbox"
15 )
16
17 // collectSink returns a Sink that collects events and a channel that receives
18 // the TurnDone event when the turn finishes. The channel lets tests wait for
19 // the runGuarded goroutine to complete.
20 func collectSink() (event.Sink, chan event.Event, *[]event.Event) {
21 var events []event.Event
22 done := make(chan event.Event, 1)
23 sink := event.FuncSink(func(e event.Event) {
24 events = append(events, e)
25 if e.Kind == event.TurnDone {
26 done <- e
27 }
28 })
29 return sink, done, &events
30 }
31
32 func waitForDone(t *testing.T, done chan event.Event) event.Event {
33 t.Helper()
34 return waitForDoneWithin(t, done, 5*time.Second)
35 }
36
37 func waitForDoneWithin(t *testing.T, done chan event.Event, d time.Duration) event.Event {
38 t.Helper()
39 select {
40 case e := <-done:
41 return e
42 case <-time.After(d):
43 t.Fatal("timed out waiting for TurnDone")
44 return event.Event{}
45 }
46 }
47
48 func TestRunShell_EmitsEvents(t *testing.T) {
49 sink, done, events := collectSink()
50 ctrl := &Controller{sink: sink}
51
52 ctrl.RunShell("echo hello")
53 waitForDone(t, done)
54
55 if len(*events) < 3 {
56 t.Fatalf("expected at least 3 events, got %d: %v", len(*events), *events)
57 }
58
59 // First event: ToolDispatch
60 if (*events)[0].Kind != event.ToolDispatch {
61 t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
62 }
63 if (*events)[0].Tool.Name != "bash" {
64 t.Errorf("tool name: want bash, got %s", (*events)[0].Tool.Name)
65 }
66
67 // Last event: TurnDone
68 td := (*events)[len(*events)-1]
69 if td.Kind != event.TurnDone {
70 t.Errorf("last event: want TurnDone, got %v", td.Kind)
71 }
72
73 // Penultimate event: ToolResult
74 last := (*events)[len(*events)-2]
75 if last.Kind != event.ToolResult {
76 t.Errorf("penultimate event: want ToolResult, got %v", last.Kind)
77 }
78 if last.Tool.Err != "" {
79 t.Errorf("unexpected error: %s", last.Tool.Err)
80 }
81 if !strings.Contains(last.Tool.Output, "hello") {
82 t.Errorf("output should contain 'hello', got: %s", last.Tool.Output)
83 }
84 }
85
86 func TestSubmit_BangPrefix(t *testing.T) {
87 sink, done, events := collectSink()
88 ctrl := &Controller{sink: sink}
89
90 ctrl.Submit("!echo test")
91 waitForDone(t, done)
92
93 if len(*events) == 0 {
94 t.Fatal("expected events from !echo, got none")
95 }
96 if (*events)[0].Kind != event.ToolDispatch {
97 t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
98 }
99 }
100
101 func TestSubmit_BangEmpty(t *testing.T) {
102 var notices []string
103 sink := event.FuncSink(func(e event.Event) {
104 if e.Kind == event.Notice {
105 notices = append(notices, e.Text)
106 }
107 })
108
109 ctrl := &Controller{sink: sink}
110 ctrl.Submit("!")
111
112 if len(notices) == 0 {
113 t.Fatal("expected a notice for bare !")
114 }
115 if !strings.Contains(notices[0], "!") {
116 t.Errorf("notice should mention usage, got: %s", notices[0])
117 }
118 }
119
120 func TestSubmit_BangNotFirstChar(t *testing.T) {
121 // "! " not at position 0 should NOT trigger shell. Submit routes to
122 // runRefTurn for normal text, which needs a runner — so we test the
123 // prefix-check condition directly.
124 input := "tell me about !important"
125 trimmed := strings.TrimSpace(input)
126 if strings.HasPrefix(trimmed, "!") {
127 t.Error("trimmed input should not start with !")
128 }
129 }
130
131 func TestRunShell_FailingCommand(t *testing.T) {
132 sink, done, events := collectSink()
133 ctrl := &Controller{sink: sink}
134
135 ctrl.RunShell("false") // exits 1
136 waitForDone(t, done)
137
138 // Find the ToolResult
139 var result *event.Event
140 for i := range *events {
141 if (*events)[i].Kind == event.ToolResult {
142 result = &(*events)[i]
143 break
144 }
145 }
146 if result == nil {
147 t.Fatal("expected a ToolResult event")
148 } else if result.Tool.Err == "" {
149 t.Error("failing command should produce an error string")
150 }
151 }
152
153 func TestRunShell_CancelStopsCommand(t *testing.T) {
154 sink, done, events := collectSink()
155 ctrl := &Controller{sink: sink}
156
157 command := "sleep 30"
158 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
159 command = "Start-Sleep -Seconds 30"
160 }
161 ctrl.RunShell(command)
162 time.Sleep(100 * time.Millisecond)
163 ctrl.Cancel()
164
165 // Cancel kills the shell via the run context, but cmd.Wait honours
166 // shellWaitDelay (and on Windows cmd.Cancel spawns taskkill /F /T), so
167 // TurnDone can arrive almost a full shellWaitDelay after Cancel. Wait
168 // comfortably longer than that grace — a flat 5s budget equalled
169 // shellWaitDelay and lost the race on a loaded windows runner.
170 e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
171 if e.Kind != event.TurnDone {
172 t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
173 }
174 if e.Err != nil {
175 t.Fatalf("cancelled shell TurnDone err = %v, want nil", e.Err)
176 }
177 var result *event.Event
178 for i := range *events {
179 if (*events)[i].Kind == event.ToolResult {
180 result = &(*events)[i]
181 break
182 }
183 }
184 if result == nil {
185 t.Fatal("expected ToolResult for cancelled shell")
186 }
187 if result.Tool.Err != i18n.M.TurnCancelled {
188 t.Fatalf("cancelled shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
189 }
190 }
191
192 func TestRunShell_HeredocCancelReleasesTurn(t *testing.T) {
193 sh := requireRunShellHereDocBash(t)
194 sink, done, events := collectSink()
195 root := t.TempDir()
196 target := filepath.Join(root, "test_redact.go")
197 ctrl := &Controller{sink: sink, shell: sh, workspaceRoot: root}
198
199 command := strings.Join([]string{
200 "cat > " + controlShellQuote(filepath.ToSlash(target)) + " <<'EOF'",
201 "package main",
202 "",
203 "import (",
204 "\t\"encoding/json\"",
205 "\t\"fmt\"",
206 ")",
207 "",
208 "func main() {",
209 "\tdata := []byte(`{\"accounts\":[{\"id\":\"a1\",\"username\":\"alice\",\"token\":\"TOKEN_EXAMPLE\"}]}`)",
210 "\tvar v any",
211 "\tjson.Unmarshal(data, &v)",
212 "\tfmt.Printf(\"before: %v\\n\", v)",
213 "}",
214 "EOF",
215 "sleep 30",
216 }, "\n")
217
218 ctrl.RunShell(command)
219 if !waitForFileContainingWithin(target, "TOKEN_EXAMPLE", 2*time.Second) {
220 ctrl.Cancel()
221 waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
222 t.Fatalf("heredoc target body was not written before cancel: %s", target)
223 }
224 ctrl.Cancel()
225
226 e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
227 if e.Kind != event.TurnDone {
228 t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
229 }
230 if e.Err != nil {
231 t.Fatalf("cancelled heredoc shell TurnDone err = %v, want nil", e.Err)
232 }
233 var result *event.Event
234 for i := range *events {
235 if (*events)[i].Kind == event.ToolResult {
236 result = &(*events)[i]
237 break
238 }
239 }
240 if result == nil {
241 t.Fatal("expected ToolResult for cancelled heredoc shell")
242 }
243 if result.Tool.Err != i18n.M.TurnCancelled {
244 t.Fatalf("cancelled heredoc shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
245 }
246 data, err := os.ReadFile(target)
247 if err != nil {
248 t.Fatalf("read heredoc target: %v", err)
249 }
250 if !strings.Contains(string(data), "TOKEN_EXAMPLE") {
251 t.Fatalf("heredoc target missing expected body:\n%s", data)
252 }
253 }
254
255 func requireRunShellHereDocBash(t *testing.T) sandbox.Shell {
256 t.Helper()
257 sh := sandbox.ResolveShell("bash", "", nil)
258 if sh.Kind != sandbox.ShellBash {
259 t.Skipf("bash heredoc regression requires bash, got %s", sh.Kind.String())
260 }
261 path := sh.Path
262 if path == "" {
263 path = "bash"
264 }
265 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
266 defer cancel()
267 if err := exec.CommandContext(ctx, path, "-c", "true").Run(); err != nil {
268 t.Skipf("bash heredoc regression requires a runnable bash: %v", err)
269 }
270 sh.Path = path
271 return sh
272 }
273
274 func waitForFileContainingWithin(path, want string, d time.Duration) bool {
275 deadline := time.Now().Add(d)
276 for time.Now().Before(deadline) {
277 if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), want) {
278 return true
279 }
280 time.Sleep(20 * time.Millisecond)
281 }
282 return false
283 }
284
285 func controlShellQuote(s string) string {
286 return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
287 }
288
288 lines GO