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