返回 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 != http.StatusOK {
158 t.Fatalf("history = %v / %v", resp, err)
159 }
160
161 if resp, _ := http.Get(srv.URL + "/context"); resp.StatusCode != http.StatusOK {
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 TestHistoryMessagesStripTransientReasoningLanguageBlock(t *testing.T) {
284 got := historyMessages([]provider.Message{
285 {Role: provider.RoleUser, Content: "<reasoning-language>\nVisible reasoning/thinking text preference: use English.\n</reasoning-language>\n\nExplain this module"},
286 {Role: provider.RoleAssistant, Content: "ok"},
287 })
288 if len(got) != 2 {
289 t.Fatalf("history length = %d, want 2: %+v", len(got), got)
290 }
291 if got[0].Role != "user" || got[0].Content != "Explain this module" {
292 t.Fatalf("user history = %+v, want plain user text without reasoning-language", got[0])
293 }
294 if strings.Contains(got[0].Content, "<reasoning-language>") {
295 t.Fatalf("reasoning-language leaked into /history user content: %q", got[0].Content)
296 }
297 }
298
299 func TestSessionsListPreviewStripsTransientReasoningLanguageBlock(t *testing.T) {
300 dir := t.TempDir()
301 path := filepath.Join(dir, "session.jsonl")
302 s := agent.NewSession("system")
303 s.Add(provider.Message{Role: provider.RoleUser, Content: "<reasoning-language>\nVisible reasoning/thinking text preference: use English.\n</reasoning-language>\n\nExplain this module"})
304 if err := s.Save(path); err != nil {
305 t.Fatal(err)
306 }
307
308 preview, turns := agent.SessionPreview(path)
309 if turns != 1 {
310 t.Errorf("turns = %d, want 1", turns)
311 }
312 if preview != "Explain this module" {
313 t.Errorf("preview = %q, want user prompt", preview)
314 }
315 }
316
317 func TestSessionsListPreviewSeesEventLogTurns(t *testing.T) {
318 dir := t.TempDir()
319 path := filepath.Join(dir, "session.jsonl")
320 s := agent.NewSession("system")
321 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
322 if err := s.SaveSnapshot(path); err != nil {
323 t.Fatal(err)
324 }
325 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
326 s.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
327 if err := s.SaveSnapshot(path); err != nil {
328 t.Fatal(err)
329 }
330
331 // The second turn lives only in the event log; a checkpoint-only reader
332 // would still report one turn.
333 if _, turns := agent.SessionPreview(path); turns != 2 {
334 t.Errorf("turns = %d, want 2 (event log turns visible)", turns)
335 }
336 if mod := agent.SessionContentModTime(path); mod.IsZero() {
337 t.Error("SessionContentModTime returned zero for a live session")
338 }
339 }
340
341 func TestServeCancelEndpoint(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 resp, err := http.Post(srv.URL+"/cancel", "application/json", nil)
348 if err != nil {
349 t.Fatal(err)
350 }
351 resp.Body.Close()
352 if resp.StatusCode != http.StatusNoContent {
353 t.Errorf("cancel status = %d, want 204", resp.StatusCode)
354 }
355 }
356
357 func TestServeCancelSessionReturnsIdempotentReceipt(t *testing.T) {
358 bc := NewBroadcaster()
359 ctrl := control.New(control.Options{Sink: bc})
360 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
361 defer srv.Close()
362
363 resp, err := http.Post(srv.URL+"/cancel-session", "application/json", nil)
364 if err != nil {
365 t.Fatal(err)
366 }
367 defer resp.Body.Close()
368 var receipt control.CancelReceipt
369 if err := json.NewDecoder(resp.Body).Decode(&receipt); err != nil {
370 t.Fatal(err)
371 }
372 if resp.StatusCode != http.StatusAccepted || !receipt.Accepted || !receipt.AlreadyIdle {
373 t.Fatalf("cancel receipt status=%d receipt=%+v", resp.StatusCode, receipt)
374 }
375 }
376
377 func TestServeApproveMissingID(t *testing.T) {
378 bc := NewBroadcaster()
379 ctrl := control.New(control.Options{Sink: bc})
380 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
381 defer srv.Close()
382
383 // Missing id should return 400.
384 resp, err := http.Post(srv.URL+"/approve", "application/json", strings.NewReader(`{"allow":true}`))
385 if err != nil {
386 t.Fatal(err)
387 }
388 resp.Body.Close()
389 if resp.StatusCode != http.StatusBadRequest {
390 t.Errorf("approve missing id = %d, want 400", resp.StatusCode)
391 }
392
393 // Malformed JSON should return 400.
394 resp2, _ := http.Post(srv.URL+"/approve", "application/json", strings.NewReader(`{bad`))
395 resp2.Body.Close()
396 if resp2.StatusCode != http.StatusBadRequest {
397 t.Errorf("approve bad json = %d, want 400", resp2.StatusCode)
398 }
399
400 // Permanent approval was removed from the protocol. Reject it before trying
401 // to resolve an ID so legacy clients cannot accidentally persist a grant.
402 resp3, err := http.Post(srv.URL+"/approve", "application/json", strings.NewReader(`{"id":"legacy","allow":true,"persist":true}`))
403 if err != nil {
404 t.Fatal(err)
405 }
406 resp3.Body.Close()
407 if resp3.StatusCode != http.StatusBadRequest {
408 t.Errorf("approve persistent grant = %d, want 400", resp3.StatusCode)
409 }
410 }
411
412 func TestServeCompactEndpoint(t *testing.T) {
413 bc := NewBroadcaster()
414 ctrl := control.New(control.Options{Sink: bc})
415 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
416 defer srv.Close()
417
418 resp, err := http.Post(srv.URL+"/compact", "application/json", nil)
419 if err != nil {
420 t.Fatal(err)
421 }
422 resp.Body.Close()
423 if resp.StatusCode != http.StatusNoContent {
424 t.Errorf("compact = %d, want 204", resp.StatusCode)
425 }
426 }
427
428 func TestServeIndexDefinesQueryHelpers(t *testing.T) {
429 html := string(indexHTML)
430 for _, want := range []string{
431 "const $ = s => document.querySelector(s);",
432 "const $$ = s => document.querySelectorAll(s);",
433 } {
434 if !strings.Contains(html, want) {
435 t.Fatalf("serve index missing query helper %q", want)
436 }
437 }
438 }
439
440 func TestServeIndexReportsSessionDeleteFailures(t *testing.T) {
441 html := string(indexHTML)
442 for _, want := range []string{
443 "'cannot_delete_active': 'Cannot delete the active session'",
444 "'cannot_delete_active': '无法删除当前会话'",
445 "'delete_failed': 'Could not delete the session. Check your connection and try again.'",
446 "'delete_failed': '无法删除会话,请检查连接后重试'",
447 "if(target&&target.current){showNotice(__('cannot_delete_active'),'warn');return;}",
448 "if(!r.ok){showNotice((await r.text()).trim()||('HTTP '+r.status),'warn');}",
449 "}).catch(()=>showNotice(__('delete_failed'),'warn'));",
450 } {
451 if !strings.Contains(html, want) {
452 t.Fatalf("serve index missing session delete failure handling %q", want)
453 }
454 }
455 }
456
457 func TestServeIndexHandlesRetryingEvents(t *testing.T) {
458 html := string(indexHTML)
459 for _, want := range []string{
460 "case 'retrying': setRetrying(e.retryAttempt,e.retryMax,e.recovery); break;",
461 "if(e.kind!=='retrying')clearRetrying();",
462 "'retrying_status': 'Retrying ({attempt}/{max})...'",
463 "'retrying_status': '正在重试 ({attempt}/{max})...'",
464 } {
465 if !strings.Contains(html, want) {
466 t.Fatalf("serve index missing retrying support %q", want)
467 }
468 }
469 }
470
471 func TestServeIndexPresentsRecoveryPauseAsNotice(t *testing.T) {
472 html := string(indexHTML)
473 for _, want := range []string{
474 "e.outcome==='recovery_paused'",
475 "showNotice('⏸ '+__('recovery_paused'))",
476 "'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.'",
477 "'recovery_paused': '已暂停自动重试。Reasonix 已停止重复尝试,并保留已完成的工作。发送“继续”即可开始新一轮,也可以补充要求来调整方向。'",
478 } {
479 if !strings.Contains(html, want) {
480 t.Fatalf("serve index missing recovery pause support %q", want)
481 }
482 }
483 }
484
485 func TestServeIndexRendersAndReloadsExtensions(t *testing.T) {
486 html := string(indexHTML)
487 for _, want := range []string{
488 "case 'extension_surface': if(e.extension)renderExtensionSurface(e.extension); break;",
489 "case 'extension_status': if(e.extension)renderExtensionSurface(e.extension); break;",
490 "const node=el('div','notice'",
491 "post('/extensions/reload',{})",
492 "{cmd:'reload',sig:'/reload'",
493 } {
494 if !strings.Contains(html, want) {
495 t.Fatalf("serve index missing extension support %q", want)
496 }
497 }
498 if strings.Contains(html, "p.card.markdown+'</") {
499 t.Fatal("extension Markdown must not be inserted as HTML")
500 }
501 }
502
503 func TestServeIndexPagePassesLanguagePreferenceToClient(t *testing.T) {
504 home := t.TempDir()
505 t.Setenv("HOME", home)
506 t.Setenv("USERPROFILE", home)
507 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
508
509 bc := NewBroadcaster()
510 ctrl := control.New(control.Options{Sink: bc})
511 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
512 defer srv.Close()
513
514 resp, err := http.Get(srv.URL + "/")
515 if err != nil {
516 t.Fatal(err)
517 }
518 body, err := io.ReadAll(resp.Body)
519 resp.Body.Close()
520 if err != nil {
521 t.Fatal(err)
522 }
523 html := string(body)
524 if !strings.Contains(html, "const __LANG_PREF = 'auto';") {
525 t.Fatalf("default language preference was not passed as auto:\n%s", html)
526 }
527 if !strings.Contains(html, "applyStaticI18n();") {
528 t.Fatal("index should translate static __('key') placeholders on the client")
529 }
530
531 cfgPath := config.UserConfigPath()
532 if cfgPath == "" {
533 t.Fatal("user config path is empty")
534 }
535 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
536 t.Fatal(err)
537 }
538 if err := os.WriteFile(cfgPath, []byte("[desktop]\nlanguage = \"en\"\n"), 0o644); err != nil {
539 t.Fatal(err)
540 }
541
542 resp, err = http.Get(srv.URL + "/")
543 if err != nil {
544 t.Fatal(err)
545 }
546 body, err = io.ReadAll(resp.Body)
547 resp.Body.Close()
548 if err != nil {
549 t.Fatal(err)
550 }
551 if !strings.Contains(string(body), "const __LANG_PREF = 'en';") {
552 t.Fatalf("pinned desktop language was not passed through:\n%s", string(body))
553 }
554 }
555
556 func TestServeModelsMarksActiveByModelRef(t *testing.T) {
557 writeServeModelConfig(t)
558
559 bc := NewBroadcaster()
560 ctrl := control.New(control.Options{
561 Sink: bc,
562 Label: "shared-chat",
563 ModelRef: "alternate/shared-chat",
564 })
565 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
566 defer srv.Close()
567
568 resp, err := http.Get(srv.URL + "/models")
569 if err != nil {
570 t.Fatal(err)
571 }
572 defer resp.Body.Close()
573 if resp.StatusCode != http.StatusOK {
574 t.Fatalf("models status = %d, want 200", resp.StatusCode)
575 }
576 var body struct {
577 Current string `json:"current"`
578 Models []struct {
579 Ref string `json:"ref"`
580 Active bool `json:"active"`
581 } `json:"models"`
582 }
583 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
584 t.Fatalf("decode models: %v", err)
585 }
586 if body.Current != "alternate/shared-chat" {
587 t.Fatalf("current = %q, want alternate/shared-chat", body.Current)
588 }
589 active := map[string]bool{}
590 for _, m := range body.Models {
591 active[m.Ref] = m.Active
592 }
593 if active["default/shared-chat"] {
594 t.Fatal("default provider was marked active even though the controller is on alternate/shared-chat")
595 }
596 if !active["alternate/shared-chat"] {
597 t.Fatal("alternate/shared-chat was not marked active")
598 }
599 }
600
601 func TestServeModelsIncludesExtensionProviderCatalog(t *testing.T) {
602 writeServeModelConfig(t)
603
604 bc := NewBroadcaster()
605 ref := "plugin/demo/cloud/extension-chat"
606 ctrl := control.New(control.Options{
607 Sink: bc,
608 Label: "extension-chat",
609 ModelRef: ref,
610 ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{{
611 Ref: ref, Model: "extension-chat", DisplayName: "Extension Chat",
612 }},
613 },
614 })
615 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
616 defer srv.Close()
617
618 resp, err := http.Get(srv.URL + "/models")
619 if err != nil {
620 t.Fatal(err)
621 }
622 defer resp.Body.Close()
623 var body struct {
624 Models []struct {
625 Ref string `json:"ref"`
626 Provider string `json:"provider"`
627 Kind string `json:"kind"`
628 Active bool `json:"active"`
629 } `json:"models"`
630 }
631 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
632 t.Fatal(err)
633 }
634 for _, model := range body.Models {
635 if model.Ref == ref {
636 if model.Provider != "plugin/demo/cloud" || model.Kind != "extension" || !model.Active {
637 t.Fatalf("extension model = %+v", model)
638 }
639 return
640 }
641 }
642 t.Fatalf("extension provider %q missing from models: %+v", ref, body.Models)
643 }
644
645 func TestServeExtensionReloadPublishesOnlySuccessfulReplacement(t *testing.T) {
646 bc := NewBroadcaster()
647 old := control.New(control.Options{Sink: bc, ModelRef: "default/model"})
648 s := New(old, bc, config.ServeConfig{})
649
650 wantErr := errors.New("sidecar did not initialize")
651 s.rebuildController = func(context.Context, *control.Controller, string) (*control.Controller, error) {
652 return nil, wantErr
653 }
654 if err := s.reloadExtensions(context.Background()); !errors.Is(err, wantErr) {
655 t.Fatalf("reload error = %v, want %v", err, wantErr)
656 }
657 if s.ctl() != old {
658 t.Fatal("failed reload replaced the working controller")
659 }
660
661 replacement := control.New(control.Options{Sink: bc, ModelRef: "default/model"})
662 s.rebuildController = func(_ context.Context, gotOld *control.Controller, ref string) (*control.Controller, error) {
663 if gotOld != old || ref != "default/model" {
664 t.Fatalf("rebuild inputs old=%p ref=%q", gotOld, ref)
665 }
666 return replacement, nil
667 }
668 if err := s.reloadExtensions(context.Background()); err != nil {
669 t.Fatalf("reload: %v", err)
670 }
671 if s.ctl() != replacement {
672 t.Fatal("successful reload did not publish the replacement")
673 }
674 }
675
676 func writeServeModelConfig(t *testing.T) {
677 t.Helper()
678 home := t.TempDir()
679 t.Setenv("REASONIX_HOME", home)
680 cfgPath := config.UserConfigPath()
681 if cfgPath == "" {
682 t.Fatal("user config path is empty")
683 }
684 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
685 t.Fatal(err)
686 }
687 body := `default_model = "default/shared-chat"
688
689 [[providers]]
690 name = "default"
691 kind = "openai"
692 base_url = "http://127.0.0.1:1/v1"
693 models = ["shared-chat"]
694 default = "shared-chat"
695 supported_efforts = ["low", "high"]
696
697 [[providers]]
698 name = "alternate"
699 kind = "openai"
700 base_url = "http://127.0.0.1:2/v1"
701 models = ["shared-chat"]
702 default = "shared-chat"
703 supported_efforts = ["low", "high"]
704 `
705 if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
706 t.Fatal(err)
707 }
708 }
709
710 func TestResumeRequiresSessionPathInsideSessionDir(t *testing.T) {
711 dir := t.TempDir()
712 active := filepath.Join(dir, "active.jsonl")
713 inside := filepath.Join(dir, "inside.jsonl")
714 outsideDir := t.TempDir()
715 outside := filepath.Join(outsideDir, "outside.jsonl")
716 for _, path := range []string{active, inside, outside} {
717 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
718 t.Fatal(err)
719 }
720 }
721
722 bc := NewBroadcaster()
723 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
724 srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, bc, config.ServeConfig{}).Handler())
725 defer srv.Close()
726
727 post := func(path string) int {
728 body, err := json.Marshal(map[string]string{"path": path})
729 if err != nil {
730 t.Fatal(err)
731 }
732 resp, err := http.Post(srv.URL+"/resume", "application/json", strings.NewReader(string(body)))
733 if err != nil {
734 t.Fatal(err)
735 }
736 resp.Body.Close()
737 return resp.StatusCode
738 }
739 if got := post(outside); got != http.StatusForbidden {
740 t.Fatalf("outside resume status = %d, want 403", got)
741 }
742 if got := post(inside); got != http.StatusNoContent {
743 t.Fatalf("inside resume status = %d, want 204", got)
744 }
745 want, err := filepath.EvalSymlinks(inside)
746 if err != nil {
747 t.Fatal(err)
748 }
749 if got := filepath.Clean(ctrl.SessionPath()); got != filepath.Clean(want) {
750 t.Fatalf("session path = %q, want %q", got, want)
751 }
752 }
753
754 func TestResumeRejectsCleanupPendingSession(t *testing.T) {
755 dir := t.TempDir()
756 active := filepath.Join(dir, "active.jsonl")
757 pending := filepath.Join(dir, "pending.jsonl")
758 for _, path := range []string{active, pending} {
759 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
760 t.Fatal(err)
761 }
762 }
763 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
764 t.Fatal(err)
765 }
766
767 bc := NewBroadcaster()
768 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
769 srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, bc, config.ServeConfig{}).Handler())
770 defer srv.Close()
771
772 body, err := json.Marshal(map[string]string{"path": pending})
773 if err != nil {
774 t.Fatal(err)
775 }
776 resp, err := http.Post(srv.URL+"/resume", "application/json", strings.NewReader(string(body)))
777 if err != nil {
778 t.Fatal(err)
779 }
780 resp.Body.Close()
781 if resp.StatusCode != http.StatusBadRequest {
782 t.Fatalf("cleanup-pending resume status = %d, want 400", resp.StatusCode)
783 }
784 if got := filepath.Clean(ctrl.SessionPath()); got != filepath.Clean(active) {
785 t.Fatalf("session path after rejected resume = %q, want active %q", got, active)
786 }
787 }
788
789 func TestSessionsSkipsCleanupPending(t *testing.T) {
790 dir := t.TempDir()
791 active := filepath.Join(dir, "active.jsonl")
792 pending := filepath.Join(dir, "pending.jsonl")
793 for _, path := range []string{active, pending} {
794 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
795 t.Fatal(err)
796 }
797 }
798 if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
799 t.Fatal(err)
800 }
801
802 bc := NewBroadcaster()
803 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
804 srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, bc, config.ServeConfig{}).Handler())
805 defer srv.Close()
806
807 resp, err := http.Get(srv.URL + "/sessions")
808 if err != nil {
809 t.Fatal(err)
810 }
811 defer resp.Body.Close()
812 var got []struct {
813 Name string `json:"name"`
814 Path string `json:"path"`
815 }
816 if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
817 t.Fatal(err)
818 }
819 if len(got) != 1 || got[0].Name != "active" || got[0].Path != agent.CanonicalSessionPath(active) {
820 t.Fatalf("/sessions = %+v, want only active session", got)
821 }
822 }
823
824 func TestDeleteSessionRequiresSessionNameInsideSessionDir(t *testing.T) {
825 dir := t.TempDir()
826 active := filepath.Join(dir, "active.jsonl")
827 old := filepath.Join(dir, "old.jsonl")
828 for _, path := range []string{active, old} {
829 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
830 t.Fatal(err)
831 }
832 }
833 ref := "sa_20260102_030405_000000000_aabbccddeeff"
834 writeServeSubagentArtifact(t, dir, ref, agent.BranchID(old))
835 oldJobsDir := jobs.ArtifactDir(old)
836 if err := os.MkdirAll(oldJobsDir, 0o755); err != nil {
837 t.Fatal(err)
838 }
839 if err := os.WriteFile(filepath.Join(oldJobsDir, "bash-1.log"), []byte("output"), 0o644); err != nil {
840 t.Fatal(err)
841 }
842 sibling := dir + "-other"
843 if err := os.MkdirAll(sibling, 0o755); err != nil {
844 t.Fatal(err)
845 }
846 escape := filepath.Join(sibling, "escape.jsonl")
847 if err := os.WriteFile(escape, []byte("keep\n"), 0o644); err != nil {
848 t.Fatal(err)
849 }
850
851 bc := NewBroadcaster()
852 ctrl := control.New(control.Options{Sink: bc, SessionDir: dir, SessionPath: active})
853 srv := httptest.NewServer(newLifecycleTestServer(t, ctrl, bc, config.ServeConfig{}).Handler())
854 defer srv.Close()
855
856 post := func(body string) int {
857 resp, err := http.Post(srv.URL+"/delete-session", "application/json", strings.NewReader(body))
858 if err != nil {
859 t.Fatal(err)
860 }
861 resp.Body.Close()
862 return resp.StatusCode
863 }
864 if got := post(`{"path":"` + escape + `"}`); got != http.StatusBadRequest {
865 t.Fatalf("legacy path delete status = %d, want 400", got)
866 }
867 if got := post(`{"name":"../` + filepath.Base(sibling) + `/escape"}`); got != http.StatusBadRequest {
868 t.Fatalf("sibling traversal status = %d, want 400", got)
869 }
870 if _, err := os.Stat(escape); err != nil {
871 t.Fatalf("sibling session was removed: %v", err)
872 }
873 if got := post(`{"name":"active"}`); got != http.StatusConflict {
874 t.Fatalf("active delete status = %d, want 409", got)
875 }
876 if got := post(`{"name":"old"}`); got != http.StatusNoContent {
877 t.Fatalf("valid delete status = %d, want 204", got)
878 }
879 if _, err := os.Stat(old); !os.IsNotExist(err) {
880 t.Fatalf("old session still exists or stat failed unexpectedly: %v", err)
881 }
882 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); !os.IsNotExist(err) {
883 t.Fatalf("old session subagent jsonl still exists or stat failed unexpectedly: %v", err)
884 }
885 if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); !os.IsNotExist(err) {
886 t.Fatalf("old session subagent meta still exists or stat failed unexpectedly: %v", err)
887 }
888 if _, err := os.Stat(oldJobsDir); !os.IsNotExist(err) {
889 t.Fatalf("old session jobs sidecar still exists or stat failed unexpectedly: %v", err)
890 }
891 }
892
893 func writeServeSubagentArtifact(t *testing.T, dir, ref, parentSession string) {
894 t.Helper()
895 subagentDir := filepath.Join(dir, "subagents")
896 if err := os.MkdirAll(subagentDir, 0o755); err != nil {
897 t.Fatal(err)
898 }
899 if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
900 t.Fatal(err)
901 }
902 data, err := json.Marshal(agent.SubagentMeta{
903 Ref: ref,
904 Status: agent.SubagentCompleted,
905 Kind: "task",
906 Name: "task",
907 ParentSession: parentSession,
908 })
909 if err != nil {
910 t.Fatal(err)
911 }
912 if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
913 t.Fatal(err)
914 }
915 }
916
917 func TestServeSubmitMalformedJSON(t *testing.T) {
918 bc := NewBroadcaster()
919 ctrl := control.New(control.Options{Sink: bc})
920 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
921 defer srv.Close()
922
923 resp, err := http.Post(srv.URL+"/submit", "application/json", strings.NewReader(`{not json`))
924 if err != nil {
925 t.Fatal(err)
926 }
927 resp.Body.Close()
928 if resp.StatusCode != http.StatusBadRequest {
929 t.Errorf("malformed submit = %d, want 400", resp.StatusCode)
930 }
931 }
932
933 func TestServePlanMalformedJSON(t *testing.T) {
934 bc := NewBroadcaster()
935 ctrl := control.New(control.Options{Sink: bc})
936 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
937 defer srv.Close()
938
939 resp, err := http.Post(srv.URL+"/plan", "application/json", strings.NewReader(`{bad`))
940 if err != nil {
941 t.Fatal(err)
942 }
943 resp.Body.Close()
944 if resp.StatusCode != http.StatusBadRequest {
945 t.Errorf("malformed plan = %d, want 400", resp.StatusCode)
946 }
947 }
948
949 func TestServeContextEndpoint(t *testing.T) {
950 bc := NewBroadcaster()
951 ctrl := control.New(control.Options{Sink: bc})
952 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
953 defer srv.Close()
954
955 resp, err := http.Get(srv.URL + "/context")
956 if err != nil {
957 t.Fatal(err)
958 }
959 defer resp.Body.Close()
960 if resp.StatusCode != http.StatusOK {
961 t.Errorf("context status = %d", resp.StatusCode)
962 }
963 var body map[string]int
964 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
965 t.Fatalf("decode context: %v", err)
966 }
967 // Before any turn, used should be 0.
968 if body["used"] != 0 {
969 t.Errorf("used = %d, want 0", body["used"])
970 }
971 }
972
973 // TestServeEventsReplaysPendingAskOnAttach proves a late /events subscriber
974 // receives a still-blocked ask_request. Without replay, the browser attaches to
975 // a healthy-looking session that never surfaces the parked prompt (#7643).
976 func TestServeEventsReplaysPendingAskOnAttach(t *testing.T) {
977 bc := NewBroadcaster()
978 ctrl := control.New(control.Options{Sink: bc})
979 ctrl.EnableInteractiveApproval()
980 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
981 defer srv.Close()
982
983 firstSub, cancelFirst := bc.Subscribe()
984 defer cancelFirst()
985
986 askCtx, cancelAsk := context.WithCancel(context.Background())
987 askDone := make(chan error, 1)
988 go func() {
989 _, err := ctrl.Ask(askCtx, []event.AskQuestion{{
990 ID: "q1", Prompt: "pick one", Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
991 }})
992 askDone <- err
993 }()
994
995 if frame := nextServeProtocolFrame(t, firstSub, nil); frame.Kind != "ask_request" {
996 t.Fatalf("initial subscriber got %+v, want ask_request", frame)
997 }
998
999 resp, err := http.Get(srv.URL + "/events")
1000 if err != nil {
1001 t.Fatal(err)
1002 }
1003 defer resp.Body.Close()
1004 if resp.StatusCode != http.StatusOK {
1005 t.Fatalf("/events status = %d", resp.StatusCode)
1006 }
1007
1008 replayed := make(chan string, 1)
1009 go func() {
1010 buf := make([]byte, 0, 4096)
1011 tmp := make([]byte, 512)
1012 for {
1013 n, readErr := resp.Body.Read(tmp)
1014 if n > 0 {
1015 buf = append(buf, tmp[:n]...)
1016 if strings.Contains(string(buf), `"kind":"ask_request"`) {
1017 replayed <- string(buf)
1018 return
1019 }
1020 }
1021 if readErr != nil {
1022 return
1023 }
1024 }
1025 }()
1026
1027 select {
1028 case <-replayed:
1029 case <-time.After(2 * time.Second):
1030 t.Fatal("late SSE attach never received replayed ask_request")
1031 }
1032
1033 select {
1034 case err := <-askDone:
1035 t.Fatalf("ask resolved before the late client answered: %v", err)
1036 default:
1037 }
1038
1039 // Reconnect recovery must be connection-local: the existing subscriber
1040 // must not receive the same prompt a second time.
1041 assertNoServeProtocolFrames(t, firstSub)
1042
1043 cancelAsk()
1044 select {
1045 case <-askDone:
1046 case <-time.After(2 * time.Second):
1047 t.Fatal("blocked ask did not exit after test cancellation")
1048 }
1049 }
1050
1051 // TestServeEventsReplayHandoffSerializesPromptEmission proves the controller's
1052 // attach handoff can register a subscriber and replay while prompt emission is
1053 // serialized, so a prompt cannot land between those two operations.
1054 func TestServeEventsReplayHandoffSerializesPromptEmission(t *testing.T) {
1055 bc := NewBroadcaster()
1056 ctrl := control.New(control.Options{Sink: bc})
1057 ctrl.EnableInteractiveApproval()
1058
1059 askCtx, cancelAsk := context.WithCancel(context.Background())
1060 defer cancelAsk()
1061 taskDone := make(chan struct{})
1062 var sub <-chan []byte
1063 var cancelSub func()
1064 ctrl.ReplayPendingPromptsWith(func() event.Sink {
1065 sub, cancelSub = bc.Subscribe()
1066 go func() {
1067 _, _ = ctrl.Ask(askCtx, []event.AskQuestion{{
1068 ID: "q1", Prompt: "pick one", Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
1069 }})
1070 close(taskDone)
1071 }()
1072 return event.FuncSink(func(e event.Event) { bc.EmitTo(sub, e) })
1073 })
1074 defer cancelSub()
1075
1076 if frame := nextServeProtocolFrame(t, sub, nil); frame.Kind != "ask_request" {
1077 t.Fatalf("handoff subscriber got %+v, want ask_request", frame)
1078 }
1079 assertNoServeProtocolFrames(t, sub)
1080
1081 cancelAsk()
1082 select {
1083 case <-taskDone:
1084 case <-time.After(2 * time.Second):
1085 t.Fatal("handoff ask did not exit after cancellation")
1086 }
1087 }
1088
1089 // TestServeEventsReplaysPendingApprovalOnAttach covers the actual approval
1090 // surface from #7643: a late browser must receive a parked ApprovalRequest and
1091 // be able to answer it through the serve HTTP endpoint.
1092 func TestServeEventsReplaysPendingApprovalOnAttach(t *testing.T) {
1093 reg := tool.NewRegistry()
1094 reg.Add(serveApprovalWriter{})
1095 ag := agent.New(&serveApprovalProvider{}, reg, agent.NewSession(""), agent.Options{}, event.Discard)
1096 bc := NewBroadcaster()
1097 ctrl := control.New(control.Options{
1098 Runner: ag,
1099 Executor: ag,
1100 Sink: bc,
1101 Policy: permission.New("ask", nil, nil, nil),
1102 })
1103 ctrl.EnableInteractiveApproval()
1104 srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{}).Handler())
1105 defer srv.Close()
1106
1107 runDone := make(chan error, 1)
1108 go func() { runDone <- ctrl.Executor().Run(context.Background(), "write a file") }()
1109
1110 deadline := time.After(2 * time.Second)
1111 for !ctrl.PendingPrompt() {
1112 select {
1113 case <-deadline:
1114 t.Fatal("timed out waiting for parked approval")
1115 default:
1116 time.Sleep(5 * time.Millisecond)
1117 }
1118 }
1119
1120 resp, err := http.Get(srv.URL + "/events")
1121 if err != nil {
1122 t.Fatal(err)
1123 }
1124 defer resp.Body.Close()
1125 if resp.StatusCode != http.StatusOK {
1126 t.Fatalf("/events status = %d", resp.StatusCode)
1127 }
1128
1129 replayed := make(chan eventwire.Event, 1)
1130 go func() {
1131 buf := make([]byte, 0, 4096)
1132 tmp := make([]byte, 512)
1133 for {
1134 n, readErr := resp.Body.Read(tmp)
1135 if n > 0 {
1136 buf = append(buf, tmp[:n]...)
1137 if strings.Contains(string(buf), `"kind":"approval_request"`) {
1138 frame := string(buf)
1139 start := strings.Index(frame, "data: ")
1140 if start < 0 {
1141 return
1142 }
1143 end := strings.IndexByte(frame[start:], '\n')
1144 if end < 0 {
1145 end = len(frame) - start
1146 }
1147 var wire eventwire.Event
1148 if json.Unmarshal([]byte(strings.TrimSpace(frame[start+len("data: "):start+end])), &wire) == nil {
1149 replayed <- wire
1150 }
1151 return
1152 }
1153 }
1154 if readErr != nil {
1155 return
1156 }
1157 }
1158 }()
1159
1160 var approval eventwire.Event
1161 select {
1162 case approval = <-replayed:
1163 case <-time.After(2 * time.Second):
1164 t.Fatal("late SSE attach never received replayed approval_request")
1165 }
1166 if approval.Kind != "approval_request" || approval.Approval == nil || approval.Approval.Tool != "serve_write" {
1167 t.Fatalf("replayed approval = %+v, want serve_write approval_request", approval)
1168 }
1169
1170 payload, err := json.Marshal(map[string]any{"id": approval.Approval.ID, "allow": true})
1171 if err != nil {
1172 t.Fatal(err)
1173 }
1174 req, err := http.NewRequest(http.MethodPost, srv.URL+"/approve", strings.NewReader(string(payload)))
1175 if err != nil {
1176 t.Fatal(err)
1177 }
1178 req.Header.Set("Content-Type", "application/json")
1179 answer, err := http.DefaultClient.Do(req)
1180 if err != nil {
1181 t.Fatal(err)
1182 }
1183 answer.Body.Close()
1184 if answer.StatusCode != http.StatusNoContent {
1185 t.Fatalf("/approve status = %d", answer.StatusCode)
1186 }
1187
1188 select {
1189 case err := <-runDone:
1190 if err != nil {
1191 t.Fatalf("executor run after approval: %v", err)
1192 }
1193 case <-time.After(2 * time.Second):
1194 t.Fatal("executor did not finish after approval")
1195 }
1196 }
1197
1197 lines GO