返回 DeepSeek-Reasonix
textsink_test.go
根目录 / internal / agent / textsink_test.go
1 package agent
2
3 import (
4 "strings"
5 "testing"
6
7 "reasonix/internal/event"
8 "reasonix/internal/provider"
9 )
10
11 // TestTextSinkReproducesInlineOutput pins the byte-exact rendering of a turn's
12 // event stream with no markdown renderer (the piped / chat-bridge path).
13 func TestTextSinkReproducesInlineOutput(t *testing.T) {
14 var b strings.Builder
15 s := NewTextSink(&b, nil, 80)
16
17 s.Emit(event.Event{Kind: event.TurnStarted})
18 s.Emit(event.Event{Kind: event.Reasoning, Text: "let me think"})
19 s.Emit(event.Event{Kind: event.Text, Text: "Hello"})
20 s.Emit(event.Event{Kind: event.Message, Text: "Hello", Reasoning: "let me think"})
21 s.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
22 PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200,
23 CacheHitTokens: 900, CacheMissTokens: 100,
24 }})
25 s.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"a"}`}})
26 s.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "read_file", Output: "contents"}})
27 s.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "bash", Err: "blocked by permission policy"}})
28 s.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "tool output truncated: 5 of 100 bytes elided"})
29 s.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "response truncated: hit max output tokens"})
30
31 want := "\x1b[2m ▎ thinking\x1b[0m\n" + // reasoning header
32 "Hello" + // answer delta
33 "\n" + // Message close (no renderer)
34 " · 1200 tok · in 1000 (900 cached / 100 new) · out 200\n" + // usage
35 " -> read_file {\"path\":\"a\"}\n" + // tool dispatch
36 // successful read_file result is silent
37 " ⊘ bash blocked by permission policy\n" + // blocked result
38 " · tool output truncated: 5 of 100 bytes elided\n" + // info notice
39 " ! response truncated: hit max output tokens\n" // warn notice
40
41 if got := b.String(); got != want {
42 t.Errorf("TextSink output mismatch:\n got: %q\nwant: %q", got, want)
43 }
44 }
45
46 func TestTextSinkCanShowReasoningInVerboseMode(t *testing.T) {
47 var b strings.Builder
48 s := NewTextSink(&b, nil, 80)
49 s.SetShowReasoning(true)
50
51 s.Emit(event.Event{Kind: event.TurnStarted})
52 s.Emit(event.Event{Kind: event.Reasoning, Text: "let me think"})
53 s.Emit(event.Event{Kind: event.Text, Text: "Hello"})
54 s.Emit(event.Event{Kind: event.Message, Text: "Hello", Reasoning: "let me think"})
55
56 want := "\x1b[2m ▎ thinking\x1b[0m\n" +
57 "\x1b[2mlet me think\x1b[0m" +
58 "\n" +
59 "Hello\n"
60 if got := b.String(); got != want {
61 t.Errorf("verbose TextSink output mismatch:\n got: %q\nwant: %q", got, want)
62 }
63 }
64
65 func TestTextSinkFormatsCapabilityProxyForHeadlessCLI(t *testing.T) {
66 var b strings.Builder
67 s := NewTextSink(&b, nil, 80)
68 args := `{"action":"call","capability_id":"mcp-tool:github/search_issues","arguments":{"query":"bug"}}`
69
70 s.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "use_capability", Args: args}})
71 s.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
72 Name: "use_capability", Args: args, Err: "blocked by permission policy",
73 }})
74 s.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
75 Name: "use_capability", Args: `{"action":"list"}`,
76 }})
77
78 want := " -> MCP(mcp-tool:github/search_issues)\n" +
79 " ⊘ MCP(mcp-tool:github/search_issues) blocked by permission policy\n" +
80 " -> MCP(list)\n"
81 if got := b.String(); got != want {
82 t.Fatalf("TextSink capability output mismatch:\n got: %q\nwant: %q", got, want)
83 }
84 }
85
86 // fakeRenderer returns a fixed string so the redraw escape sequence is testable
87 // without a real markdown library.
88 type fakeRenderer struct{}
89
90 func (fakeRenderer) Render(string) string { return "RENDERED" }
91
92 // TestTextSinkRedrawsWithRenderer pins the cursor-control redraw: with a
93 // renderer wired in, a completed text stream is moved over and replaced by the
94 // styled output. "ab\ncd" occupies one extra row (one newline), so the cursor
95 // moves up 1 and clears to end of screen.
96 func TestTextSinkRedrawsWithRenderer(t *testing.T) {
97 var b strings.Builder
98 s := NewTextSink(&b, fakeRenderer{}, 80)
99
100 s.Emit(event.Event{Kind: event.TurnStarted})
101 s.Emit(event.Event{Kind: event.Text, Text: "ab\ncd"})
102 s.Emit(event.Event{Kind: event.Message, Text: "ab\ncd"})
103
104 want := "ab\ncd" + "\r\033[1A\033[0J" + "RENDERED"
105 if got := b.String(); got != want {
106 t.Errorf("redraw output mismatch:\n got: %q\nwant: %q", got, want)
107 }
108 }
109
109 lines GO