返回 DeepSeek-Reasonix
serve_test.go
根目录 / internal / serve / serve_test.go
1 package serve
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "reasonix/internal/agent"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/eventwire"
22 "reasonix/internal/jobs"
23 "reasonix/internal/permission"
24 "reasonix/internal/provider"
25 "reasonix/internal/tool"
26 )
27
28 func TestTitlePromptRequiresUserMessageLanguage(t *testing.T) {
29 if !strings.Contains(titlePrompt, "same language as the user's message") {
30 t.Fatalf("title prompt does not preserve the user's language: %q", titlePrompt)
31 }
32 }
33
34 type titleUsageProvider struct{}
35
36 func (titleUsageProvider) Name() string { return "title" }
37 func (titleUsageProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
38 ch := make(chan provider.Chunk, 3)
39 ch <- provider.Chunk{Type: provider.ChunkText, Text: "Short title"}
40 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12}}
41 ch <- provider.Chunk{Type: provider.ChunkDone}
42 close(ch)
43 return ch, nil
44 }
45
46 type titleUsageSink struct{ events []event.Event }
47
48 func (s *titleUsageSink) Emit(e event.Event) { s.events = append(s.events, e) }
49
50 func TestGenerateTitleRecordsUsageWithModelIdentity(t *testing.T) {
51 sink := &titleUsageSink{}
52 s := &Server{
53 titleProv: titleUsageProvider{},
54 titleModelRef: "deepseek/deepseek-v4-flash",
55 titleUsageSink: sink,
56 }
57 if got := s.generateTitle(context.Background(), "hello"); got != "Short title" {
58 t.Fatalf("title = %q", got)
59 }
60 if len(sink.events) != 1 || sink.events[0].Kind != event.Usage || sink.events[0].ModelRef != "deepseek/deepseek-v4-flash" {
61 t.Fatalf("title usage event = %+v", sink.events)
62 }
63 }
64
65 // fakeRunner stands in for an agent.Runner: it records the composed input and
66 // returns without emitting model events, so the controller's TurnDone is the
67 // observable signal.
68 type fakeRunner struct{ got chan string }
69
70 func (f fakeRunner) Run(_ context.Context, input string) error { f.got <- input; return nil }
71
72 type serveApprovalWriter struct{}
73
74 func (serveApprovalWriter) Name() string { return "serve_write" }
75 func (serveApprovalWriter) Description() string { return "write a test file" }
76 func (serveApprovalWriter) Schema() json.RawMessage {
77 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
78 }
79 func (serveApprovalWriter) ReadOnly() bool { return false }
80 func (serveApprovalWriter) Execute(context.Context, json.RawMessage) (string, error) {
81 return "ok", nil
82 }
83
84 type serveApprovalProvider struct {
85 mu sync.Mutex
86 turn int
87 }
88
89 func (p *serveApprovalProvider) Name() string { return "serve-approval-test" }
90 func (p *serveApprovalProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
91 p.mu.Lock()
92 turn := p.turn
93 p.turn++
94 p.mu.Unlock()
95
96 ch := make(chan provider.Chunk, 2)
97 if turn == 0 {
98 ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
99 ID: "serve-approval-1", Name: "serve_write", Arguments: `{"path":"a.txt"}`,
100 }}
101 } else {
102 ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
103 }
104 ch <- provider.Chunk{Type: provider.ChunkDone}
105 close(ch)
106 return ch, nil
107 }
108
109 func TestServeSubmitRunsAndBroadcastsTurnDone(t *testing.T) {
110 bc := NewBroadcaster()
111 got := make(chan string, 1)
112 ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
113 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
114 defer srv.Close()
115
116 sub, cancel := bc.Subscribe() // observe the broadcast deterministically
117 defer cancel()
118
119 resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{"input":"hi"}`))
120 if err != nil {
121 t.Fatal(err)
122 }
123 resp.Body.Close()
124 if resp.StatusCode != http.StatusAccepted {
125 t.Fatalf("submit status = %d, want 202", resp.StatusCode)
126 }
127
128 select {
129 case in := <-got:
130 if in != "hi" {
131 t.Errorf("runner ran %q, want hi", in)
132 }
133 case <-time.After(2 * time.Second):
134 t.Fatal("runner never ran")
135 }
136
137 deadline := time.After(2 * time.Second)
138 for {
139 select {
140 case data := <-sub:
141 var w eventwire.Event
142 if err := json.Unmarshal(data, &w); err == nil && w.Kind == "turn_done" {
143 return
144 }
145 case <-deadline:
146 t.Fatal("never saw turn_done on the stream")
147 }
148 }
149 }
150
151 func TestServeEndpoints(t *testing.T) {
152 bc := NewBroadcaster()
153 ctrl := control.New(control.Options{Sink: bc}) // no runner needed for these
154 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
155 defer srv.Close()
156
157 if resp, err := http.Get(srv.URL + "/history"); err != nil || resp.StatusCode != 200 {
158 t.Fatalf("history = %v / %v", resp, err)
159 }
160
161 if resp, _ := http.Get(srv.URL + "/context"); resp.StatusCode != 200 {
162 t.Errorf("context status = %d", resp.StatusCode)
163 }
164
165 resp, err := http.Post(srv.URL+"/plan", "application/json", strings.NewReader(`{"on":true}`))
166 if err != nil || resp.StatusCode != http.StatusNoContent {
167 t.Fatalf("plan = %v / status %d", err, resp.StatusCode)
168 }
169 if c := ctrl.Compose("x"); !strings.Contains(c, "Plan mode") {
170 t.Error("/plan {on:true} should have enabled plan mode (Compose would prepend the marker)")
171 }
172
173 resp, err = http.Post(srv.URL+"/tool-approval-mode", "application/json", strings.NewReader(`{"mode":"auto"}`))
174 if err != nil {
175 t.Fatal(err)
176 }
177 if resp.StatusCode != http.StatusNoContent {
178 t.Fatalf("tool approval mode auto status = %d, want 204", resp.StatusCode)
179 }
180 resp.Body.Close()
181 if got := ctrl.ToolApprovalMode(); got != control.ToolApprovalAuto {
182 t.Fatalf("tool approval mode = %q, want auto", got)
183 }
184 resp, err = http.Post(srv.URL+"/tool-approval-mode", "application/json", strings.NewReader(`{"mode":"surprise"}`))
185 if err != nil {
186 t.Fatal(err)
187 }
188 resp.Body.Close()
189 if resp.StatusCode != http.StatusBadRequest {
190 t.Fatalf("invalid tool approval mode status = %d, want 400", resp.StatusCode)
191 }
192
193 if resp, _ := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{}`)); resp.StatusCode != http.StatusBadRequest {
194 t.Errorf("empty submit should be 400, got %d", resp.StatusCode)
195 }
196 }
197
198 func TestServeSubmitRejectsShellShortcut(t *testing.T) {
199 bc := NewBroadcaster()
200 got := make(chan string, 1)
201 ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
202 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
203 defer srv.Close()
204
205 resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{"input":"!echo nope"}`))
206 if err != nil {
207 t.Fatal(err)
208 }
209 resp.Body.Close()
210 if resp.StatusCode != http.StatusForbidden {
211 t.Fatalf("shell submit status = %d, want 403", resp.StatusCode)
212 }
213 select {
214 case in := <-got:
215 t.Fatalf("runner should not run shell submit, got %q", in)
216 default:
217 }
218 }
219
220 func TestServeSubmitValidatesFormat(t *testing.T) {
221 bc := NewBroadcaster()
222 got := make(chan string, 1)
223 ctrl := control.New(control.Options{Runner: fakeRunner{got: got}, Sink: bc})
224 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
225 defer srv.Close()
226
227 post := func(body string) int {
228 resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(body))
229 if err != nil {
230 t.Fatal(err)
231 }
232 defer resp.Body.Close()
233 return resp.StatusCode
234 }
235
236 // Unsupported format is rejected with 400 and the runner never runs.
237 if code := post(`{"input":"hi","format":"xml"}`); code != http.StatusBadRequest {
238 t.Fatalf("unsupported format status = %d, want 400", code)
239 }
240 select {
241 case in := <-got:
242 t.Fatalf("runner must not run for rejected format, got %q", in)
243 default:
244 }
245
246 // Whitespace-padded json_object is normalized and accepted.
247 if code := post(`{"input":"hi","format":" json_object "}`); code != http.StatusAccepted {
248 t.Fatalf("padded json_object status = %d, want 202", code)
249 }
250 select {
251 case in := <-got:
252 if in != "hi" {
253 t.Fatalf("runner ran %q, want hi", in)
254 }
255 case <-time.After(2 * time.Second):
256 t.Fatal("runner never ran for padded json_object")
257 }
258 }
259
260 func TestHistoryMessagesPreserveToolDetails(t *testing.T) {
261 got := historyMessages([]provider.Message{
262 {Role: provider.RoleUser, Content: "run command"},
263 {Role: provider.RoleAssistant, Content: "checking", ReasoningContent: "think", ToolCalls: []provider.ToolCall{{
264 ID: "call_1", Name: "bash", Arguments: `{"command":"pwd"}`,
265 }}},
266 {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "/tmp/project\n"},
267 })
268
269 if len(got) != 3 {
270 t.Fatalf("history length = %d, want 3", len(got))
271 }
272 if got[1].Reasoning != "think" {
273 t.Fatalf("assistant reasoning = %q, want think", got[1].Reasoning)
274 }
275 if len(got[1].ToolCalls) != 1 || got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "bash" || got[1].ToolCalls[0].Arguments != `{"command":"pwd"}` {
276 t.Fatalf("assistant tool calls not preserved: %+v", got[1].ToolCalls)
277 }
278 if got[2].ToolCallID != "call_1" || got[2].ToolName != "bash" || got[2].Content != "/tmp/project\n" {
279 t.Fatalf("tool result details not preserved: %+v", got[2])
280 }
281 }
282
283 func TestSessionsListPreviewStripsTransientReasoningLanguageBlock(t *testing.T) {
284 dir := t.TempDir()
285 path := filepath.Join(dir, "session.jsonl")
286 s := agent.NewSession("system")
287 s.Add(provider.Message{Role: provider.RoleUser, Content: "<reasoning-language>\nVisible reasoning/thinking text preference: use English.\n</reasoning-language>\n\nExplain this module"})
288 if err := s.Save(path); err != nil {
289 t.Fatal(err)
290 }
291
292 preview, turns := agent.SessionPreview(path)
293 if turns != 1 {
294 t.Errorf("turns = %d, want 1", turns)
295 }
296 if preview != "Explain this module" {
297 t.Errorf("preview = %q, want user prompt", preview)
298 }
299 }
300
301 func TestSessionsListPreviewSeesEventLogTurns(t *testing.T) {
302 dir := t.TempDir()
303 path := filepath.Join(dir, "session.jsonl")
304 s := agent.NewSession("system")
305 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
306 if err := s.SaveSnapshot(path); err != nil {
307 t.Fatal(err)
308 }
309 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
310 s.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
311 if err := s.SaveSnapshot(path); err != nil {
312 t.Fatal(err)
313 }
314
315 // The second turn lives only in the event log; a checkpoint-only reader
316 // would still report one turn.
317 if _, turns := agent.SessionPreview(path); turns != 2 {
318 t.Errorf("turns = %d, want 2 (event log turns visible)", turns)
319 }
320 if mod := agent.SessionContentModTime(path); mod.IsZero() {
321 t.Error("SessionContentModTime returned zero for a live session")
322 }
323 }
324
325 func TestServeCancelEndpoint(t *testing.T) {
326 bc := NewBroadcaster()
327 ctrl := control.New(control.Options{Sink: bc})
328 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
329 defer srv.Close()
330
331 resp, err := http.Post(srv.URL+"/cancel", "application/json", nil)
332 if err != nil {
333 t.Fatal(err)
334 }
335 resp.Body.Close()
336 if resp.StatusCode != http.StatusNoContent {
337 t.Errorf("cancel status = %d, want 204", resp.StatusCode)
338 }
339 }
340
341 func TestServeApproveMissingID(t *testing.T) {
342 bc := NewBroadcaster()
343 ctrl := control.New(control.Options{Sink: bc})
344 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
345 defer srv.Close()
346
347 // Missing id should return 400.
348 resp, err := http.Post(srv.URL+"/approve", "application/json", strings.NewReader(`{"allow":true}`))
349 if err != nil {
350 t.Fatal(err)
351 }
352 resp.Body.Close()
353 if resp.StatusCode != http.StatusBadRequest {
354 t.Errorf("approve missing id = %d, want 400", resp.StatusCode)
355 }
356
357 // Malformed JSON should return 400.
358 resp2, _ := http.Post(srv.URL+"/approve", "application/json", strings.NewReader(`{bad`))
359 resp2.Body.Close()
360 if resp2.StatusCode != http.StatusBadRequest {
361 t.Errorf("approve bad json = %d, want 400", resp2.StatusCode)
362 }
363 }
364
365 func TestServeNewSessionEndpoint(t *testing.T) {
366 bc := NewBroadcaster()
367 ctrl := control.New(control.Options{Sink: bc})
368 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
369 defer srv.Close()
370
371 resp, err := http.Post(srv.URL+"/new", "application/json", nil)
372 if err != nil {
373 t.Fatal(err)
374 }
375 resp.Body.Close()
376 if resp.StatusCode != http.StatusNoContent {
377 t.Errorf("new session = %d, want 204", resp.StatusCode)
378 }
379 }
380
381 func TestServeCompactEndpoint(t *testing.T) {
382 bc := NewBroadcaster()
383 ctrl := control.New(control.Options{Sink: bc})
384 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
385 defer srv.Close()
386
387 resp, err := http.Post(srv.URL+"/compact", "application/json", nil)
388 if err != nil {
389 t.Fatal(err)
390 }
391 resp.Body.Close()
392 if resp.StatusCode != http.StatusNoContent {
393 t.Errorf("compact = %d, want 204", resp.StatusCode)
394 }
395 }
396
397 func TestServeIndexPage(t *testing.T) {
398 bc := NewBroadcaster()
399 ctrl := control.New(control.Options{Sink: bc})
400 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
401 defer srv.Close()
402
403 resp, err := http.Get(srv.URL + "/")
404 if err != nil {
405 t.Fatal(err)
406 }
407 defer resp.Body.Close()
408 if resp.StatusCode != 200 {
409 t.Errorf("index status = %d", resp.StatusCode)
410 }
411 ct := resp.Header.Get("Content-Type")
412 if !strings.Contains(ct, "text/html") {
413 t.Errorf("index content-type = %q, want text/html", ct)
414 }
415 }
416
417 func TestServeIndexDefinesQueryHelpers(t *testing.T) {
418 html := string(indexHTML)
419 for _, want := range []string{
420 "const $ = s => document.querySelector(s);",
421 "const $$ = s => document.querySelectorAll(s);",
422 } {
423 if !strings.Contains(html, want) {
424 t.Fatalf("serve index missing query helper %q", want)
425 }
426 }
427 }
428
429 func TestServeIndexReportsSessionDeleteFailures(t *testing.T) {
430 html := string(indexHTML)
431 for _, want := range []string{
432 "'cannot_delete_active': 'Cannot delete the active session'",
433 "'cannot_delete_active': '无法删除当前会话'",
434 "'delete_failed': 'Could not delete the session. Check your connection and try again.'",
435 "'delete_failed': '无法删除会话,请检查连接后重试'",
436 "if(target&&target.current){showNotice(__('cannot_delete_active'),'warn');return;}",
437 "if(!r.ok){showNotice((await r.text()).trim()||('HTTP '+r.status),'warn');}",
438 "}).catch(()=>showNotice(__('delete_failed'),'warn'));",
439 } {
440 if !strings.Contains(html, want) {
441 t.Fatalf("serve index missing session delete failure handling %q", want)
442 }
443 }
444 }
445
446 func TestServeIndexHandlesRetryingEvents(t *testing.T) {
447 html := string(indexHTML)
448 for _, want := range []string{
449 "case 'retrying': setRetrying(e.retryAttempt,e.retryMax); break;",
450 "if(e.kind!=='retrying')clearRetrying();",
451 "'retrying_status': 'Retrying ({attempt}/{max})...'",
452 "'retrying_status': '正在重试 ({attempt}/{max})...'",
453 } {
454 if !strings.Contains(html, want) {
455 t.Fatalf("serve index missing retrying support %q", want)
456 }
457 }
458 }
459
460 func TestServeIndexPresentsRecoveryPauseAsNotice(t *testing.T) {
461 html := string(indexHTML)
462 for _, want := range []string{
463 "e.outcome==='recovery_paused'",
464 "showNotice('⏸ '+__('recovery_paused'))",
465 "'recovery_paused': 'Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.'",
466 "'recovery_paused': '已暂停自动重试。Reasonix 已停止重复尝试,并保留已完成的工作。发送“继续”即可开始新一轮,也可以补充要求来调整方向。'",
467 } {
468 if !strings.Contains(html, want) {
469 t.Fatalf("serve index missing recovery pause support %q", want)
470 }
471 }
472 }
473
474 func TestServeIndexRendersAndReloadsExtensions(t *testing.T) {
475 html := string(indexHTML)
476 for _, want := range []string{
477 "case 'extension_surface': if(e.extension)renderExtensionSurface(e.extension); break;",
478 "case 'extension_status': if(e.extension)renderExtensionSurface(e.extension); break;",
479 "const node=el('div','notice'",
480 "post('/extensions/reload',{})",
481 "{cmd:'reload',sig:'/reload'",
482 } {
483 if !strings.Contains(html, want) {
484 t.Fatalf("serve index missing extension support %q", want)
485 }
486 }
487 if strings.Contains(html, "p.card.markdown+'</") {
488 t.Fatal("extension Markdown must not be inserted as HTML")
489 }
490 }
491
492 func TestServeIndexPagePassesLanguagePreferenceToClient(t *testing.T) {
493 home := t.TempDir()
494 t.Setenv("HOME", home)
495 t.Setenv("USERPROFILE", home)
496 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
497
498 bc := NewBroadcaster()
499 ctrl := control.New(control.Options{Sink: bc})
500 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
501 defer srv.Close()
502
503 resp, err := http.Get(srv.URL + "/")
504 if err != nil {
505 t.Fatal(err)
506 }
507 body, err := io.ReadAll(resp.Body)
508 resp.Body.Close()
509 if err != nil {
510 t.Fatal(err)
511 }
512 html := string(body)
513 if !strings.Contains(html, "const __LANG_PREF = 'auto';") {
514 t.Fatalf("default language preference was not passed as auto:\n%s", html)
515 }
516 if !strings.Contains(html, "applyStaticI18n();") {
517 t.Fatal("index should translate static __('key') placeholders on the client")
518 }
519
520 cfgPath := config.UserConfigPath()
521 if cfgPath == "" {
522 t.Fatal("user config path is empty")
523 }
524 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
525 t.Fatal(err)
526 }
527 if err := os.WriteFile(cfgPath, []byte("[desktop]\nlanguage = \"en\"\n"), 0o644); err != nil {
528 t.Fatal(err)
529 }
530
531 resp, err = http.Get(srv.URL + "/")
532 if err != nil {
533 t.Fatal(err)
534 }
535 body, err = io.ReadAll(resp.Body)
536 resp.Body.Close()
537 if err != nil {
538 t.Fatal(err)
539 }
540 if !strings.Contains(string(body), "const __LANG_PREF = 'en';") {
541 t.Fatalf("pinned desktop language was not passed through:\n%s", string(body))
542 }
543 }
544
545 func TestServeModelsMarksActiveByModelRef(t *testing.T) {
546 writeServeModelConfig(t)
547
548 bc := NewBroadcaster()
549 ctrl := control.New(control.Options{
550 Sink: bc,
551 Label: "shared-chat",
552 ModelRef: "alternate/shared-chat",
553 })
554 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
555 defer srv.Close()
556
557 resp, err := http.Get(srv.URL + "/models")
558 if err != nil {
559 t.Fatal(err)
560 }
561 defer resp.Body.Close()
562 if resp.StatusCode != http.StatusOK {
563 t.Fatalf("models status = %d, want 200", resp.StatusCode)
564 }
565 var body struct {
566 Current string `json:"current"`
567 Models []struct {
568 Ref string `json:"ref"`
569 Active bool `json:"active"`
570 } `json:"models"`
571 }
572 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
573 t.Fatalf("decode models: %v", err)
574 }
575 if body.Current != "alternate/shared-chat" {
576 t.Fatalf("current = %q, want alternate/shared-chat", body.Current)
577 }
578 active := map[string]bool{}
579 for _, m := range body.Models {
580 active[m.Ref] = m.Active
581 }
582 if active["default/shared-chat"] {
583 t.Fatal("default provider was marked active even though the controller is on alternate/shared-chat")
584 }
585 if !active["alternate/shared-chat"] {
586 t.Fatal("alternate/shared-chat was not marked active")
587 }
588 }
589
590 func TestServeModelsIncludesExtensionProviderCatalog(t *testing.T) {
591 writeServeModelConfig(t)
592
593 bc := NewBroadcaster()
594 ref := "plugin/demo/cloud/extension-chat"
595 ctrl := control.New(control.Options{
596 Sink: bc,
597 Label: "extension-chat",
598 ModelRef: ref,
599 ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{{
600 Ref: ref, Model: "extension-chat", DisplayName: "Extension Chat",
601 }},
602 },
603 })
604 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
605 defer srv.Close()
606
607 resp, err := http.Get(srv.URL + "/models")
608 if err != nil {
609 t.Fatal(err)
610 }
611 defer resp.Body.Close()
612 var body struct {
613 Models []struct {
614 Ref string `json:"ref"`
615 Provider string `json:"provider"`
616 Kind string `json:"kind"`
617 Active bool `json:"active"`
618 } `json:"models"`
619 }
620 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
621 t.Fatal(err)
622 }
623 for _, model := range body.Models {
624 if model.Ref == ref {
625 if model.Provider != "plugin/demo/cloud" || model.Kind != "extension" || !model.Active {
626 t.Fatalf("extension model = %+v", model)
627 }
628 return
629 }
630 }
631 t.Fatalf("extension provider %q missing from models: %+v", ref, body.Models)
632 }
633
634 func TestServeExtensionReloadPublishesOnlySuccessfulReplacement(t *testing.T) {
635 bc := NewBroadcaster()
636 old := control.New(control.Options{Sink: bc, ModelRef: "default/model"})
637 s := New(old, bc, config.ServeConfig{})
638
639 wantErr := errors.New("sidecar did not initialize")
640 s.rebuildController = func(context.Context, *control.Controller, string) (*control.Controller, error) {
641 return nil, wantErr
642 }
643 if err := s.reloadExtensions(context.Background()); !errors.Is(err, wantErr) {
644 t.Fatalf("reload error = %v, want %v", err, wantErr)
645 }
646 if s.ctl() != old {
647 t.Fatal("failed reload replaced the working controller")
648 }
649
650 replacement := control.New(control.Options{Sink: bc, ModelRef: "default/model"})
651 s.rebuildController = func(_ context.Context, gotOld *control.Controller, ref string) (*control.Controller, error) {
652 if gotOld != old || ref != "default/model" {
653 t.Fatalf("rebuild inputs old=%p ref=%q", gotOld, ref)
654 }
655 return replacement, nil
656 }
657 if err := s.reloadExtensions(context.Background()); err != nil {
658 t.Fatalf("reload: %v", err)
659 }
660 if s.ctl() != replacement {
661 t.Fatal("successful reload did not publish the replacement")
662 }
663 }
664
665 func TestServeSwitchEffortUsesModelRefForDuplicateModelNames(t *testing.T) {
666 writeServeModelConfig(t)
667
668 bc := NewBroadcaster()
669 ctrl := control.New(control.Options{
670 Sink: bc,
671 Label: "shared-chat",
672 ModelRef: "alternate/shared-chat",
673 SessionDir: t.TempDir(),
674 })
675 server := New(ctrl, bc, config.ServeConfig{})
676 var builtRef string
677 server.buildController = func(_ context.Context, ref string) (*control.Controller, error) {
678 builtRef = ref
679 return control.New(control.Options{
680 Sink: bc,
681 Label: "shared-chat",
682 ModelRef: ref,
683 SessionDir: t.TempDir(),
684 }), nil
685 }
686
687 if err := server.switchEffort(context.Background(), "high"); err != nil {
688 t.Fatalf("switchEffort: %v", err)
689 }
690 if builtRef != "alternate/shared-chat" {
691 t.Fatalf("rebuilt model ref = %q, want alternate/shared-chat", builtRef)
692 }
693 edit := config.LoadForEdit(config.UserConfigPath())
694 def, _ := edit.Provider("default")
695 if def.Effort != "" {
696 t.Fatalf("default effort = %q, want unchanged", def.Effort)
697 }
698 alt, _ := edit.Provider("alternate")
699 if alt.Effort != "high" {
700 t.Fatalf("alternate effort = %q, want high", alt.Effort)
701 }
702 }
703
704 func writeServeModelConfig(t *testing.T) {
705 t.Helper()
706 home := t.TempDir()
707 t.Setenv("REASONIX_HOME", home)
708 cfgPath := config.UserConfigPath()
709 if cfgPath == "" {
710 t.Fatal("user config path is empty")
711 }
712 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
713 t.Fatal(err)
714 }
715 body := `default_model = "default/shared-chat"
716
717 [[providers]]
718 name = "default"
719 kind = "openai"
720 base_url = "http://127.0.0.1:1/v1"
721 models = ["shared-chat"]
722 default = "shared-chat"
723 supported_efforts = ["low", "high"]
724
725 [[providers]]
726 name = "alternate"
727 kind = "openai"
728 base_url = "http://127.0.0.1:2/v1"
729 models = ["shared-chat"]
730 default = "shared-chat"
731 supported_efforts = ["low", "high"]
732 `
733 if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
734 t.Fatal(err)
735 }
736 }
737
738 func TestResumeRequiresSessionPathInsideSessionDir(t *testing.T) {
739 dir := t.TempDir()
740 active := filepath.Join(dir, "active.jsonl")
741 inside := filepath.Join(dir, "inside.jsonl")
742 outsideDir := t.TempDir()
743 outside := filepath.Join(outsideDir, "outside.jsonl")
744 for _, path := range []string{active, inside, outside} {
745 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
746 t.Fatal(err)
747 }
748 }
749
750 bc := NewBroadcaster()
751 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
752 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
753 defer srv.Close()
754
755 post := func(path string) int {
756 body, err := json.Marshal(map[string]string{"path": path})
757 if err != nil {
758 t.Fatal(err)
759 }
760 resp, err := http.Post(srv.URL+"/resume", "application/json", strings.NewReader(string(body)))
761 if err != nil {
762 t.Fatal(err)
763 }
764 resp.Body.Close()
765 return resp.StatusCode
766 }
767 if got := post(outside); got != http.StatusForbidden {
768 t.Fatalf("outside resume status = %d, want 403", got)
769 }
770 if got := post(inside); got != http.StatusNoContent {
771 t.Fatalf("inside resume status = %d, want 204", got)
772 }
773 want, err := filepath.EvalSymlinks(inside)
774 if err != nil {
775 t.Fatal(err)
776 }
777 if got := filepath.Clean(ctrl.SessionPath()); got != filepath.Clean(want) {
778 t.Fatalf("session path = %q, want %q", got, want)
779 }
780 }
781
782 func TestResumeRejectsCleanupPendingSession(t *testing.T) {
783 dir := t.TempDir()
784 active := filepath.Join(dir, "active.jsonl")
785 pending := filepath.Join(dir, "pending.jsonl")
786 for _, path := range []string{active, pending} {
787 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
788 t.Fatal(err)
789 }
790 }
791 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
792 t.Fatal(err)
793 }
794
795 bc := NewBroadcaster()
796 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
797 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
798 defer srv.Close()
799
800 body, err := json.Marshal(map[string]string{"path": pending})
801 if err != nil {
802 t.Fatal(err)
803 }
804 resp, err := http.Post(srv.URL+"/resume", "application/json", strings.NewReader(string(body)))
805 if err != nil {
806 t.Fatal(err)
807 }
808 resp.Body.Close()
809 if resp.StatusCode != http.StatusBadRequest {
810 t.Fatalf("cleanup-pending resume status = %d, want 400", resp.StatusCode)
811 }
812 if got := filepath.Clean(ctrl.SessionPath()); got != filepath.Clean(active) {
813 t.Fatalf("session path after rejected resume = %q, want active %q", got, active)
814 }
815 }
816
817 func TestSessionsSkipsCleanupPending(t *testing.T) {
818 dir := t.TempDir()
819 active := filepath.Join(dir, "active.jsonl")
820 pending := filepath.Join(dir, "pending.jsonl")
821 for _, path := range []string{active, pending} {
822 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
823 t.Fatal(err)
824 }
825 }
826 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
827 t.Fatal(err)
828 }
829
830 bc := NewBroadcaster()
831 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
832 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
833 defer srv.Close()
834
835 resp, err := http.Get(srv.URL + "/sessions")
836 if err != nil {
837 t.Fatal(err)
838 }
839 defer resp.Body.Close()
840 var got []struct {
841 Name string `json:"name"`
842 Path string `json:"path"`
843 }
844 if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
845 t.Fatal(err)
846 }
847 if len(got) != 1 || got[0].Name != "active" || filepath.Clean(got[0].Path) != filepath.Clean(active) {
848 t.Fatalf("/sessions = %+v, want only active session", got)
849 }
850 }
851
852 func TestDeleteSessionRequiresSessionNameInsideSessionDir(t *testing.T) {
853 dir := t.TempDir()
854 active := filepath.Join(dir, "active.jsonl")
855 old := filepath.Join(dir, "old.jsonl")
856 for _, path := range []string{active, old} {
857 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
858 t.Fatal(err)
859 }
860 }
861 ref := "sa_20260102_030405_000000000_aabbccddeeff"
862 writeServeSubagentArtifact(t, dir, ref, agent.BranchID(old))
863 oldJobsDir := jobs.ArtifactDir(old)
864 if err := os.MkdirAll(oldJobsDir, 0o755); err != nil {
865 t.Fatal(err)
866 }
867 if err := os.WriteFile(filepath.Join(oldJobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
868 t.Fatal(err)
869 }
870 sibling := dir + "-other"
871 if err := os.MkdirAll(sibling, 0o755); err != nil {
872 t.Fatal(err)
873 }
874 escape := filepath.Join(sibling, "escape.jsonl")
875 if err := os.WriteFile(escape, []byte("keep\n"), 0o644); err != nil {
876 t.Fatal(err)
877 }
878
879 bc := NewBroadcaster()
880 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
881 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
882 defer srv.Close()
883
884 post := func(body string) int {
885 resp, err := http.Post(srv.URL+"/delete-session", "application/json", strings.NewReader(body))
886 if err != nil {
887 t.Fatal(err)
888 }
889 resp.Body.Close()
890 return resp.StatusCode
891 }
892 if got := post(`{"path":"` + escape + `"}`); got != http.StatusBadRequest {
893 t.Fatalf("legacy path delete status = %d, want 400", got)
894 }
895 if got := post(`{"name":"../` + filepath.Base(sibling) + `/escape"}`); got != http.StatusBadRequest {
896 t.Fatalf("sibling traversal status = %d, want 400", got)
897 }
898 if _, err := os.Stat(escape); err != nil {
899 t.Fatalf("sibling session was removed: %v", err)
900 }
901 if got := post(`{"name":"active"}`); got != http.StatusConflict {
902 t.Fatalf("active delete status = %d, want 409", got)
903 }
904 if got := post(`{"name":"old"}`); got != http.StatusNoContent {
905 t.Fatalf("valid delete status = %d, want 204", got)
906 }
907 if _, err := os.Stat(old); !os.IsNotExist(err) {
908 t.Fatalf("old session still exists or stat failed unexpectedly: %v", err)
909 }
910 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); !os.IsNotExist(err) {
911 t.Fatalf("old session subagent jsonl still exists or stat failed unexpectedly: %v", err)
912 }
913 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); !os.IsNotExist(err) {
914 t.Fatalf("old session subagent meta still exists or stat failed unexpectedly: %v", err)
915 }
916 if _, err := os.Stat(oldJobsDir); !os.IsNotExist(err) {
917 t.Fatalf("old session jobs sidecar still exists or stat failed unexpectedly: %v", err)
918 }
919 }
920
921 func writeServeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
922 t.Helper()
923 subagentDir := filepath.Join(dir, "subagents")
924 if err := os.MkdirAll(subagentDir, 0o755); err != nil {
925 t.Fatal(err)
926 }
927 if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
928 t.Fatal(err)
929 }
930 data, err := json.Marshal(agent.SubagentMeta{
931 Ref: ref,
932 Status: agent.SubagentCompleted,
933 Kind: "task",
934 Name: "task",
935 ParentSession: parentSession,
936 })
937 if err != nil {
938 t.Fatal(err)
939 }
940 if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
941 t.Fatal(err)
942 }
943 }
944
945 func TestServeSubmitMalformedJSON(t *testing.T) {
946 bc := NewBroadcaster()
947 ctrl := control.New(control.Options{Sink: bc})
948 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
949 defer srv.Close()
950
951 resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{not json`))
952 if err != nil {
953 t.Fatal(err)
954 }
955 resp.Body.Close()
956 if resp.StatusCode != http.StatusBadRequest {
957 t.Errorf("malformed submit = %d, want 400", resp.StatusCode)
958 }
959 }
960
961 func TestServePlanMalformedJSON(t *testing.T) {
962 bc := NewBroadcaster()
963 ctrl := control.New(control.Options{Sink: bc})
964 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
965 defer srv.Close()
966
967 resp, err := http.Post(srv.URL+"/plan", "application/json", strings.NewReader(`{bad`))
968 if err != nil {
969 t.Fatal(err)
970 }
971 resp.Body.Close()
972 if resp.StatusCode != http.StatusBadRequest {
973 t.Errorf("malformed plan = %d, want 400", resp.StatusCode)
974 }
975 }
976
977 func TestServeContextEndpoint(t *testing.T) {
978 bc := NewBroadcaster()
979 ctrl := control.New(control.Options{Sink: bc})
980 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
981 defer srv.Close()
982
983 resp, err := http.Get(srv.URL + "/context")
984 if err != nil {
985 t.Fatal(err)
986 }
987 defer resp.Body.Close()
988 if resp.StatusCode != 200 {
989 t.Errorf("context status = %d", resp.StatusCode)
990 }
991 var body map[string]int
992 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
993 t.Fatalf("decode context: %v", err)
994 }
995 // Before any turn, used should be 0.
996 if body["used"] != 0 {
997 t.Errorf("used = %d, want 0", body["used"])
998 }
999 }
1000
1001 // TestServeEventsReplaysPendingAskOnAttach proves a late /events subscriber
1002 // receives a still-blocked ask_request. Without replay, the browser attaches to
1003 // a healthy-looking session that never surfaces the parked prompt (#7643).
1004 func TestServeEventsReplaysPendingAskOnAttach(t *testing.T) {
1005 bc := NewBroadcaster()
1006 ctrl := control.New(control.Options{Sink: bc})
1007 ctrl.EnableInteractiveApproval()
1008 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
1009 defer srv.Close()
1010
1011 firstSub, cancelFirst := bc.Subscribe()
1012 defer cancelFirst()
1013
1014 askCtx, cancelAsk := context.WithCancel(context.Background())
1015 askDone := make(chan error, 1)
1016 go func() {
1017 _, err := ctrl.Ask(askCtx, []event.AskQuestion{{
1018 ID: "q1", Prompt: "pick one", Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
1019 }})
1020 askDone <- err
1021 }()
1022
1023 select {
1024 case data := <-firstSub:
1025 if !strings.Contains(string(data), `"kind":"ask_request"`) {
1026 t.Fatalf("initial subscriber got %s, want ask_request", data)
1027 }
1028 case <-time.After(2 * time.Second):
1029 t.Fatal("timed out waiting for initial ask_request")
1030 }
1031
1032 resp, err := http.Get(srv.URL + "/events")
1033 if err != nil {
1034 t.Fatal(err)
1035 }
1036 defer resp.Body.Close()
1037 if resp.StatusCode != http.StatusOK {
1038 t.Fatalf("/events status = %d", resp.StatusCode)
1039 }
1040
1041 replayed := make(chan string, 1)
1042 go func() {
1043 buf := make([]byte, 0, 4096)
1044 tmp := make([]byte, 512)
1045 for {
1046 n, readErr := resp.Body.Read(tmp)
1047 if n > 0 {
1048 buf = append(buf, tmp[:n]...)
1049 if strings.Contains(string(buf), `"kind":"ask_request"`) {
1050 replayed <- string(buf)
1051 return
1052 }
1053 }
1054 if readErr != nil {
1055 return
1056 }
1057 }
1058 }()
1059
1060 select {
1061 case <-replayed:
1062 case <-time.After(2 * time.Second):
1063 t.Fatal("late SSE attach never received replayed ask_request")
1064 }
1065
1066 select {
1067 case err := <-askDone:
1068 t.Fatalf("ask resolved before the late client answered: %v", err)
1069 default:
1070 }
1071
1072 // Reconnect recovery must be connection-local: the existing subscriber
1073 // must not receive the same prompt a second time.
1074 select {
1075 case data := <-firstSub:
1076 t.Fatalf("existing subscriber got duplicate replay: %s", data)
1077 default:
1078 }
1079
1080 cancelAsk()
1081 select {
1082 case <-askDone:
1083 case <-time.After(2 * time.Second):
1084 t.Fatal("blocked ask did not exit after test cancellation")
1085 }
1086 }
1087
1088 // TestServeEventsReplayHandoffSerializesPromptEmission proves the controller's
1089 // attach handoff can register a subscriber and replay while prompt emission is
1090 // serialized, so a prompt cannot land between those two operations.
1091 func TestServeEventsReplayHandoffSerializesPromptEmission(t *testing.T) {
1092 bc := NewBroadcaster()
1093 ctrl := control.New(control.Options{Sink: bc})
1094 ctrl.EnableInteractiveApproval()
1095
1096 askCtx, cancelAsk := context.WithCancel(context.Background())
1097 defer cancelAsk()
1098 taskDone := make(chan struct{})
1099 var sub <-chan []byte
1100 var cancelSub func()
1101 ctrl.ReplayPendingPromptsWith(func() event.Sink {
1102 sub, cancelSub = bc.Subscribe()
1103 go func() {
1104 _, _ = ctrl.Ask(askCtx, []event.AskQuestion{{
1105 ID: "q1", Prompt: "pick one", Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
1106 }})
1107 close(taskDone)
1108 }()
1109 return event.FuncSink(func(e event.Event) { bc.EmitTo(sub, e) })
1110 })
1111 defer cancelSub()
1112
1113 select {
1114 case data := <-sub:
1115 if !strings.Contains(string(data), `"kind":"ask_request"`) {
1116 t.Fatalf("handoff subscriber got %s, want ask_request", data)
1117 }
1118 case <-time.After(2 * time.Second):
1119 t.Fatal("handoff subscriber never received ask_request")
1120 }
1121 select {
1122 case data := <-sub:
1123 t.Fatalf("handoff subscriber got duplicate ask_request: %s", data)
1124 default:
1125 }
1126
1127 cancelAsk()
1128 select {
1129 case <-taskDone:
1130 case <-time.After(2 * time.Second):
1131 t.Fatal("handoff ask did not exit after cancellation")
1132 }
1133 }
1134
1135 // TestServeEventsReplaysPendingApprovalOnAttach covers the actual approval
1136 // surface from #7643: a late browser must receive a parked ApprovalRequest and
1137 // be able to answer it through the serve HTTP endpoint.
1138 func TestServeEventsReplaysPendingApprovalOnAttach(t *testing.T) {
1139 reg := tool.NewRegistry()
1140 reg.Add(serveApprovalWriter{})
1141 ag := agent.New(&serveApprovalProvider{}, reg, agent.NewSession(""), agent.Options{}, event.Discard)
1142 bc := NewBroadcaster()
1143 ctrl := control.New(control.Options{
1144 Runner: ag,
1145 Executor: ag,
1146 Sink: bc,
1147 Policy: permission.New("ask", nil, nil, nil),
1148 })
1149 ctrl.EnableInteractiveApproval()
1150 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
1151 defer srv.Close()
1152
1153 runDone := make(chan error, 1)
1154 go func() { runDone <- ctrl.Executor().Run(context.Background(), "write a file") }()
1155
1156 deadline := time.After(2 * time.Second)
1157 for !ctrl.PendingPrompt() {
1158 select {
1159 case <-deadline:
1160 t.Fatal("timed out waiting for parked approval")
1161 default:
1162 time.Sleep(5 * time.Millisecond)
1163 }
1164 }
1165
1166 resp, err := http.Get(srv.URL + "/events")
1167 if err != nil {
1168 t.Fatal(err)
1169 }
1170 defer resp.Body.Close()
1171 if resp.StatusCode != http.StatusOK {
1172 t.Fatalf("/events status = %d", resp.StatusCode)
1173 }
1174
1175 replayed := make(chan eventwire.Event, 1)
1176 go func() {
1177 buf := make([]byte, 0, 4096)
1178 tmp := make([]byte, 512)
1179 for {
1180 n, readErr := resp.Body.Read(tmp)
1181 if n > 0 {
1182 buf = append(buf, tmp[:n]...)
1183 if strings.Contains(string(buf), `"kind":"approval_request"`) {
1184 frame := string(buf)
1185 start := strings.Index(frame, "data: ")
1186 if start < 0 {
1187 return
1188 }
1189 end := strings.IndexByte(frame[start:], '\n')
1190 if end < 0 {
1191 end = len(frame) - start
1192 }
1193 var wire eventwire.Event
1194 if json.Unmarshal([]byte(strings.TrimSpace(frame[start+len("data: "):start+end])), &wire) == nil {
1195 replayed <- wire
1196 }
1197 return
1198 }
1199 }
1200 if readErr != nil {
1201 return
1202 }
1203 }
1204 }()
1205
1206 var approval eventwire.Event
1207 select {
1208 case approval = <-replayed:
1209 case <-time.After(2 * time.Second):
1210 t.Fatal("late SSE attach never received replayed approval_request")
1211 }
1212 if approval.Kind != "approval_request" || approval.Approval == nil || approval.Approval.Tool != "serve_write" {
1213 t.Fatalf("replayed approval = %+v, want serve_write approval_request", approval)
1214 }
1215
1216 payload, err := json.Marshal(map[string]any{"id": approval.Approval.ID, "allow": true})
1217 if err != nil {
1218 t.Fatal(err)
1219 }
1220 req, err := http.NewRequest(http.MethodPost, srv.URL+"/approve", strings.NewReader(string(payload)))
1221 if err != nil {
1222 t.Fatal(err)
1223 }
1224 req.Header.Set("Content-Type", "application/json")
1225 answer, err := http.DefaultClient.Do(req)
1226 if err != nil {
1227 t.Fatal(err)
1228 }
1229 answer.Body.Close()
1230 if answer.StatusCode != http.StatusNoContent {
1231 t.Fatalf("/approve status = %d", answer.StatusCode)
1232 }
1233
1234 select {
1235 case err := <-runDone:
1236 if err != nil {
1237 t.Fatalf("executor run after approval: %v", err)
1238 }
1239 case <-time.After(2 * time.Second):
1240 t.Fatal("executor did not finish after approval")
1241 }
1242 }
1243
1243 lines GO