| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "charm.land/bubbles/v2/textarea" |
| 8 | "github.com/charmbracelet/x/ansi" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/provider" |
| 12 | ) |
| 13 | |
| 14 | // newTestChatTUI builds a chatTUI with just the pieces the streaming/commit and |
| 15 | // completion paths need, for unit tests that don't run the bubbletea loop. |
| 16 | func newTestChatTUI() chatTUI { |
| 17 | commit := []string{} |
| 18 | ti := textarea.New() |
| 19 | configureChatTextarea(&ti) |
| 20 | ti.SetWidth(80) |
| 21 | shellIdx := map[string]int{} |
| 22 | shellOut := map[string]string{} |
| 23 | shellExp := map[string]bool{} |
| 24 | return chatTUI{ |
| 25 | input: ti, |
| 26 | width: 80, |
| 27 | statusLineCount: 2, |
| 28 | submittedInputCursor: -1, |
| 29 | queueEditCursor: -1, |
| 30 | nextPasteID: 1, |
| 31 | reasoningLineIdx: -1, |
| 32 | reasoningTextIdx: -1, |
| 33 | answerIdx: -1, |
| 34 | toolStreamIdx: -1, |
| 35 | reasoning: &strings.Builder{}, |
| 36 | pending: &strings.Builder{}, |
| 37 | pendingCommit: &commit, |
| 38 | shellOutputs: shellOut, |
| 39 | shellExpanded: shellExp, |
| 40 | shellTranscriptIdx: shellIdx, |
| 41 | toolLineCountByID: map[string]int{}, |
| 42 | subagentProgressIdx: map[string]int{}, |
| 43 | subagentProgress: map[string]*cliSubagentProgress{}, |
| 44 | showTurnUsage: true, |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // subagentStatus / subagentPreview build reserved ToolProgress events the same |
| 49 | // way the agent tracker emits them. |
| 50 | func subagentStatus(id, phase string) event.Event { |
| 51 | return event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: id, Name: event.SubagentProgressStatusName, Output: phase}} |
| 52 | } |
| 53 | |
| 54 | func subagentPreview(id, channel, text string, truncated bool) event.Event { |
| 55 | return event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: id, Name: channel, Output: text, Truncated: truncated}} |
| 56 | } |
| 57 | |
| 58 | func TestCacheRateLabelKeepsTwoDecimals(t *testing.T) { |
| 59 | if got := cacheRateLabel("turn hit %s", 998, 1000); got != "turn hit 99.80%" { |
| 60 | t.Fatalf("cacheRateLabel = %q, want turn hit 99.80%%", got) |
| 61 | } |
| 62 | if got := cacheRateLabel("avg %s", 1, 3); got != "avg 33.33%" { |
| 63 | t.Fatalf("cacheRateLabel = %q, want avg 33.33%%", got) |
| 64 | } |
| 65 | if got := cacheRateLabel("avg %s", 1, 0); got != "" { |
| 66 | t.Fatalf("cacheRateLabel with zero denominator = %q, want empty", got) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // TestIngestSeparatesReasoningFromAnswer proves the thinking marker plus its live |
| 71 | // text appear as reasoning streams, collapse to a "thought for Ns" summary (the |
| 72 | // streamed text removed) when the answer begins, and the answer commits as its |
| 73 | // own distinct entry. |
| 74 | func TestIngestSeparatesReasoningFromAnswer(t *testing.T) { |
| 75 | m := newTestChatTUI() |
| 76 | |
| 77 | m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "…reasoning…"}) // thinking → marker + live text |
| 78 | if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thinking") { |
| 79 | t.Fatalf("thinking marker should appear at once, transcript=%v", m.transcript) |
| 80 | } |
| 81 | if !strings.Contains(m.transcript[1], "…reasoning…") { |
| 82 | t.Fatalf("reasoning text should stream live below the marker, transcript=%v", m.transcript) |
| 83 | } |
| 84 | |
| 85 | m.ingestEvent(event.Event{Kind: event.Text, Text: "Hello answer"}) // answer begins → block collapses |
| 86 | if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thought for") { |
| 87 | t.Fatalf("block should collapse to a duration summary plus answer separator, transcript=%v", m.transcript) |
| 88 | } |
| 89 | if strings.TrimSpace(m.transcript[1]) != "" { |
| 90 | t.Fatalf("reasoning/answer separator = %q, want one blank block", m.transcript[1]) |
| 91 | } |
| 92 | if strings.Contains(strings.Join(m.transcript, "\n"), "…reasoning…") { |
| 93 | t.Fatalf("collapsed reasoning text should be removed, transcript=%v", m.transcript) |
| 94 | } |
| 95 | if m.pending.String() != "Hello answer" { |
| 96 | t.Errorf("answer should be live in pending, got %q", m.pending.String()) |
| 97 | } |
| 98 | if m.reasoning.Len() != 0 { |
| 99 | t.Errorf("reasoning buffer should be cleared after commit") |
| 100 | } |
| 101 | |
| 102 | m.commitPending() // turn end |
| 103 | if len(m.transcript) != 3 || !strings.Contains(m.transcript[2], "Hello") { |
| 104 | t.Fatalf("answer should commit as a separate entry, transcript=%v", m.transcript) |
| 105 | } |
| 106 | if plain := ansi.Strip(m.transcript[2]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Hello answer") { |
| 107 | t.Fatalf("answer should have an explicit assistant identity and indented body, got %q", plain) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestAssistantAnswerWithoutReasoningHasNoLeadingSpacer(t *testing.T) { |
| 112 | m := newTestChatTUI() |
| 113 | m.ingestEvent(event.Event{Kind: event.Text, Text: "Direct answer"}) |
| 114 | m.ingestEvent(event.Event{Kind: event.Message}) |
| 115 | |
| 116 | if len(m.transcript) != 1 { |
| 117 | t.Fatalf("direct answer should remain one compact block, got %d: %v", len(m.transcript), m.transcript) |
| 118 | } |
| 119 | if plain := ansi.Strip(m.transcript[0]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Direct answer") { |
| 120 | t.Fatalf("direct answer block = %q", plain) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestTurnReceiptLeavesOneBlankRowAfterAssistantAnswer(t *testing.T) { |
| 125 | m := newTestChatTUI() |
| 126 | m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) |
| 127 | m.ingestEvent(event.Event{Kind: event.Message}) |
| 128 | m.ingestEvent(event.Event{Kind: event.Usage, Usage: &provider.Usage{ |
| 129 | PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, |
| 130 | }}) |
| 131 | |
| 132 | if len(m.transcript) != 3 { |
| 133 | t.Fatalf("answer + spacer + receipt should be three blocks, got %d: %v", len(m.transcript), m.transcript) |
| 134 | } |
| 135 | if strings.TrimSpace(m.transcript[1]) != "" { |
| 136 | t.Fatalf("answer/receipt separator = %q, want one blank block", m.transcript[1]) |
| 137 | } |
| 138 | if !strings.Contains(ansi.Strip(m.transcript[2]), "TURN") { |
| 139 | t.Fatalf("last block should be the turn receipt, got %q", m.transcript[2]) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestTurnReceiptCanBeHiddenWithoutDisablingUsageAccounting(t *testing.T) { |
| 144 | m := newTestChatTUI() |
| 145 | m.showTurnUsage = false |
| 146 | m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) |
| 147 | m.ingestEvent(event.Event{Kind: event.Message}) |
| 148 | m.ingestEvent(event.Event{Kind: event.Usage, Usage: &provider.Usage{ |
| 149 | PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, |
| 150 | }}) |
| 151 | |
| 152 | if len(m.transcript) != 1 { |
| 153 | t.Fatalf("hidden turn receipt should not add transcript blocks, got %d: %v", len(m.transcript), m.transcript) |
| 154 | } |
| 155 | if m.turnTokens != 2 { |
| 156 | t.Fatalf("hidden turn receipt should still account for completion tokens, got %d", m.turnTokens) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // TestVerboseReasoningInsertsTextUnderSummary proves /verbose mode keeps the full |
| 161 | // thinking text, placed beneath the collapsed duration summary. |
| 162 | func TestVerboseReasoningInsertsTextUnderSummary(t *testing.T) { |
| 163 | m := newTestChatTUI() |
| 164 | m.showReasoning = true |
| 165 | |
| 166 | m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "step one "}) |
| 167 | m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "step two"}) |
| 168 | m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) // closes the block |
| 169 | |
| 170 | if len(m.transcript) != 3 { |
| 171 | t.Fatalf("verbose block should be summary + text + answer separator, transcript=%v", m.transcript) |
| 172 | } |
| 173 | if !strings.Contains(m.transcript[0], "thought for") { |
| 174 | t.Errorf("first line should be the duration summary, got %q", m.transcript[0]) |
| 175 | } |
| 176 | if !strings.Contains(m.transcript[1], "step one") || !strings.Contains(m.transcript[1], "step two") { |
| 177 | t.Errorf("verbose text should appear under the summary, got %q", m.transcript[1]) |
| 178 | } |
| 179 | if strings.TrimSpace(m.transcript[2]) != "" { |
| 180 | t.Errorf("verbose reasoning/answer separator = %q, want blank block", m.transcript[2]) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // TestIngestEventFlushesAnswer confirms an event line (e.g. a tool dispatch) |
| 185 | // finalizes the answer streamed before it, preserving order in scrollback. |
| 186 | func TestIngestEventFlushesAnswer(t *testing.T) { |
| 187 | m := newTestChatTUI() |
| 188 | m.ingestEvent(event.Event{Kind: event.Text, Text: "partial answer "}) |
| 189 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}}) |
| 190 | // answer, then a blank spacer, then the tool line. |
| 191 | if n := len(*m.pendingCommit); n != 3 { |
| 192 | t.Fatalf("answer + spacer + event line should be three commits, got %d: %v", n, *m.pendingCommit) |
| 193 | } |
| 194 | if !strings.Contains((*m.pendingCommit)[0], "partial answer") { |
| 195 | t.Errorf("first commit should be the buffered answer, got %q", (*m.pendingCommit)[0]) |
| 196 | } |
| 197 | if strings.TrimSpace((*m.pendingCommit)[1]) != "" { |
| 198 | t.Errorf("second commit should be a blank spacer, got %q", (*m.pendingCommit)[1]) |
| 199 | } |
| 200 | if !strings.Contains((*m.pendingCommit)[2], "Read(x)") { |
| 201 | t.Errorf("third commit should be the tool card, got %q", (*m.pendingCommit)[2]) |
| 202 | } |
| 203 | if m.pending.Len() != 0 { |
| 204 | t.Errorf("answer buffer should be drained after the event line") |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // TestStreamAnswerFlushesCompletedParagraphs proves a multi-paragraph answer |
| 209 | // appears chunk by chunk: a closed paragraph renders to scrollback while the |
| 210 | // still-streaming one stays buffered, and turn end flushes the remainder. |
| 211 | func TestStreamAnswerFlushesCompletedParagraphs(t *testing.T) { |
| 212 | m := newTestChatTUI() |
| 213 | |
| 214 | m.ingestEvent(event.Event{Kind: event.Text, Text: "First paragraph.\n\nSecond para "}) |
| 215 | if m.answerIdx < 0 { |
| 216 | t.Fatalf("a completed paragraph should open a streamed answer block") |
| 217 | } |
| 218 | joined := strings.Join(m.transcript, "\n") |
| 219 | if !strings.Contains(joined, "First paragraph.") { |
| 220 | t.Errorf("completed paragraph should be on screen, transcript=%v", m.transcript) |
| 221 | } |
| 222 | if strings.Contains(joined, "Second para") { |
| 223 | t.Errorf("the still-streaming paragraph must stay buffered, transcript=%v", m.transcript) |
| 224 | } |
| 225 | |
| 226 | m.ingestEvent(event.Event{Kind: event.Text, Text: "is done now."}) |
| 227 | m.ingestEvent(event.Event{Kind: event.Message}) |
| 228 | final := strings.Join(m.transcript, "\n") |
| 229 | if !strings.Contains(final, "First paragraph.") || !strings.Contains(final, "Second para is done now.") { |
| 230 | t.Errorf("turn end should flush the whole answer, transcript=%v", m.transcript) |
| 231 | } |
| 232 | if m.pending.Len() != 0 || m.answerIdx != -1 { |
| 233 | t.Errorf("answer state should reset after commit, pending=%d idx=%d", m.pending.Len(), m.answerIdx) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // TestFlushableMarkdownPrefixKeepsOpenFence proves a blank line inside an unclosed |
| 238 | // fenced code block is not a flush boundary — the half-written block stays buffered |
| 239 | // so it never renders mangled, while prose before the fence does flush. |
| 240 | func TestFlushableMarkdownPrefixKeepsOpenFence(t *testing.T) { |
| 241 | open := "intro line\n\n```go\nfunc f() {\n\n\t// still typing" |
| 242 | if got := flushableMarkdownPrefix(open); got != "intro line" { |
| 243 | t.Errorf("open fence: flushable prefix = %q, want %q", got, "intro line") |
| 244 | } |
| 245 | |
| 246 | closed := "```go\ncode\n\nmore\n```\n\ntrailing" |
| 247 | if got := flushableMarkdownPrefix(closed); got != "```go\ncode\n\nmore\n```" { |
| 248 | t.Errorf("closed fence: flushable prefix = %q", got) |
| 249 | } |
| 250 | |
| 251 | if got := flushableMarkdownPrefix("no boundary yet"); got != "" { |
| 252 | t.Errorf("no blank line should flush nothing, got %q", got) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // TestToolProgressStreamsThenCollapses proves a running tool's output streams |
| 257 | // live under its card via the ⎿ connector, then collapses to a line-count |
| 258 | // summary when the result lands. |
| 259 | func TestToolProgressStreamsThenCollapses(t *testing.T) { |
| 260 | m := newTestChatTUI() |
| 261 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"go test ./..."}`}}) |
| 262 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "ok pkg/a\n"}}) |
| 263 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "ok pkg/b\n"}}) |
| 264 | |
| 265 | joined := strings.Join(m.transcript, "\n") |
| 266 | if !strings.Contains(joined, "ok pkg/a") || !strings.Contains(joined, "ok pkg/b") { |
| 267 | t.Fatalf("live output should be visible while running:\n%s", joined) |
| 268 | } |
| 269 | if !strings.Contains(joined, "⎿") { |
| 270 | t.Fatalf("live output should use the ⎿ connector:\n%s", joined) |
| 271 | } |
| 272 | |
| 273 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "b1", Name: "bash", Output: "ok pkg/a\nok pkg/b\n"}}) |
| 274 | joined = strings.Join(m.transcript, "\n") |
| 275 | if strings.Contains(joined, "ok pkg/a") { |
| 276 | t.Fatalf("output should collapse after completion:\n%s", joined) |
| 277 | } |
| 278 | if !strings.Contains(joined, "2 lines") { |
| 279 | t.Fatalf("collapsed block should summarize the line count:\n%s", joined) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // TestToolWorkingLineThenClears proves a dispatched tool that streams no output |
| 284 | // (e.g. symbol_context) shows a live "working · Ns" line so it doesn't look |
| 285 | // frozen, and that the line clears on the result instead of collapsing to |
| 286 | // "0 lines". |
| 287 | func TestToolWorkingLineThenClears(t *testing.T) { |
| 288 | m := newTestChatTUI() |
| 289 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "symbol_context", Args: `{"q":"x"}`}}) |
| 290 | |
| 291 | m.tickToolRunning() // one elapsed tick fills the placeholder |
| 292 | joined := strings.Join(m.transcript, "\n") |
| 293 | if !strings.Contains(joined, "⎿") || !strings.Contains(joined, "working") { |
| 294 | t.Fatalf("a running tool should show a 'working' progress line:\n%s", joined) |
| 295 | } |
| 296 | |
| 297 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "symbol_context"}}) |
| 298 | joined = strings.Join(m.transcript, "\n") |
| 299 | if strings.Contains(joined, "working") { |
| 300 | t.Fatalf("working line should clear after the result:\n%s", joined) |
| 301 | } |
| 302 | if strings.Contains(joined, "0 lines") { |
| 303 | t.Fatalf("a no-output tool must not collapse to '0 lines':\n%s", joined) |
| 304 | } |
| 305 | if m.toolStreamIdx != -1 { |
| 306 | t.Fatalf("tool block should be closed after the result, idx=%d", m.toolStreamIdx) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | // TestConsecutiveToolCallsKeepMarkersUnderOwnCard is a regression test for |
| 311 | // back-to-back Bash tool calls. Before the fix, the late ToolProgress for |
| 312 | // the first tool (already superseded in the controller by a second |
| 313 | // ToolDispatch) appended a fresh live block at the end of the transcript |
| 314 | // under the *second* tool's card. Both "⎿" markers then stacked at the |
| 315 | // end, hiding which run produced which output. The fix threads the |
| 316 | // transcript slot through shellTranscriptIdx so each tool's live block |
| 317 | // stays directly under its own card regardless of the dispatch/progress |
| 318 | // arrival order. |
| 319 | func TestConsecutiveToolCallsKeepMarkersUnderOwnCard(t *testing.T) { |
| 320 | m := newTestChatTUI() |
| 321 | // First bash: dispatched and gets one progress chunk before the second |
| 322 | // bash is dispatched, mirroring the model's parallel-tool-call pattern. |
| 323 | // The "shell-" prefix ensures streamToolOutput accumulates into |
| 324 | // shellOutputs, which collapseShellSlot uses to recover the line count |
| 325 | // after the live state has been reset by the second beginToolRunning. |
| 326 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "shell-1", Name: "bash", Args: `{"command":"git status"}`}}) |
| 327 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-1", Output: "On branch main-v2\n"}}) |
| 328 | // Second bash dispatched before the first finishes; this switches |
| 329 | // m.toolStreamID to "shell-2" and resets the live streaming state. |
| 330 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "shell-2", Name: "bash", Args: `{"command":"git branch -a"}`}}) |
| 331 | // The second bash also streams one chunk of output so its collapse |
| 332 | // produces a real ⎿ marker (not the zero-output blank fallback). |
| 333 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-2", Output: "* main-v2\n"}}) |
| 334 | // Late progress for the FIRST bash — the path that previously stacked |
| 335 | // its marker under the second card. |
| 336 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-1", Output: "nothing to commit\n"}}) |
| 337 | // Now finish both; each should collapse in place under its own card. |
| 338 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "shell-1", Name: "bash", Output: "On branch main-v2\nnothing to commit\n"}}) |
| 339 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "shell-2", Name: "bash", Output: "* main-v2\n"}}) |
| 340 | |
| 341 | // Locate each tool's card. With the fix the transcript is exactly |
| 342 | // [card1, marker1, "", card2, marker2] — 5 lines, one marker per |
| 343 | // card. Without the fix the late progress overwrites the last slot |
| 344 | // in place (or appends), so the first card's slot is left holding |
| 345 | // only the first live chunk, and both markers end up at the tail. |
| 346 | transcript := m.transcript |
| 347 | idx1, idx2 := -1, -1 |
| 348 | for i, ln := range transcript { |
| 349 | if idx1 == -1 && strings.Contains(ln, "git status") { |
| 350 | idx1 = i |
| 351 | } |
| 352 | if idx2 == -1 && strings.Contains(ln, "git branch -a") { |
| 353 | idx2 = i |
| 354 | } |
| 355 | } |
| 356 | if idx1 < 0 || idx2 < 0 || idx2 <= idx1 { |
| 357 | t.Fatalf("expected two bash cards in dispatch order, got idx1=%d idx2=%d\n%s", idx1, idx2, strings.Join(transcript, "\n")) |
| 358 | } |
| 359 | |
| 360 | // Each card must be followed by its own ⎿-prefixed marker slot — |
| 361 | // not just "some marker somewhere after the second card". |
| 362 | for _, pair := range []struct { |
| 363 | card string |
| 364 | idx int |
| 365 | }{ |
| 366 | {card: "git status", idx: idx1}, |
| 367 | {card: "git branch -a", idx: idx2}, |
| 368 | } { |
| 369 | next := transcript[pair.idx+1] |
| 370 | if !strings.Contains(next, "⎿") { |
| 371 | t.Fatalf("%q's marker should be at transcript[%d] with the ⎿ connector, got %q\nfull transcript:\n%s", |
| 372 | pair.card, pair.idx+1, next, strings.Join(transcript, "\n")) |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // The first card's marker must reflect the full output of the first |
| 377 | // run ("On branch main-v2" AND "nothing to commit"), not just the |
| 378 | // first chunk. The bug left only the pre-late-progress chunk in |
| 379 | // transcript[idx1+1], so the second line would be missing. |
| 380 | marker1 := transcript[idx1+1] |
| 381 | if !strings.Contains(marker1, "On branch main-v2") || !strings.Contains(marker1, "nothing to commit") { |
| 382 | t.Fatalf("first card's marker should preview the full output of shell-1, got %q", marker1) |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | // TestRepeatedShellCommandDoesNotAccumulateOutput is the regression test for a |
| 387 | // re-run of the same "!" command (e.g. !pwd three times). RunShell derives a |
| 388 | // stable id from the command text ("shell-pwd"), so streamToolOutput kept |
| 389 | // appending each run's output onto the previous run's in m.shellOutputs[id]; |
| 390 | // beginToolRunning now clears the entry so each run starts from a clean slate. |
| 391 | func TestRepeatedShellCommandDoesNotAccumulateOutput(t *testing.T) { |
| 392 | m := newTestChatTUI() |
| 393 | const id = "shell-pwd" |
| 394 | const out = "/home/user/project\n" |
| 395 | |
| 396 | for range 3 { |
| 397 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: id, Name: "bash", Args: `{"command":"pwd"}`}}) |
| 398 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: id, Output: out}}) |
| 399 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out}}) |
| 400 | } |
| 401 | |
| 402 | if got := m.shellOutputs[id]; got != out { |
| 403 | t.Fatalf("a re-run must not accumulate prior output: shellOutputs[%q] = %q, want %q", id, got, out) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | func TestCollapsedShellHintUsesKeyboardShortcutOnly(t *testing.T) { |
| 408 | m := newTestChatTUI() |
| 409 | const id = "shell-long" |
| 410 | lines := make([]string, shellPreviewLines+2) |
| 411 | for i := range lines { |
| 412 | lines[i] = "line" |
| 413 | } |
| 414 | output := strings.Join(lines, "\n") + "\n" |
| 415 | m.shellOutputs[id] = output |
| 416 | m.transcript = []string{""} |
| 417 | |
| 418 | m.collapseShellSlot(id, 0, output) |
| 419 | |
| 420 | got := m.transcript[0] |
| 421 | if !strings.Contains(got, "more lines (Ctrl+B)") { |
| 422 | t.Fatalf("collapsed shell hint should mention Ctrl+B, got %q", got) |
| 423 | } |
| 424 | if strings.Contains(got, "click/") { |
| 425 | t.Fatalf("collapsed shell hint must not advertise mouse click in default TUI mode, got %q", got) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // TestConsecutiveNonShellToolsDoNotRenderNegativeLineCount is the regression |
| 430 | // test for the review-blocking case. The original fix to back-to-back shell |
| 431 | // tools records every dispatched id in shellTranscriptIdx so a late |
| 432 | // ToolProgress/Result can land in the correct slot. But for non-shell- |
| 433 | // prefixed tools (e.g. read_file) the streaming state belongs to whichever |
| 434 | // id is current and the accumulator (shellOutputs) is never populated, so |
| 435 | // the late path's "n" stayed at -1 and the final else branch rendered |
| 436 | // "⎿ -1 lines". The fix in collapseShellSlot guards n < 0 by clearing the |
| 437 | // slot — a deliberate blank-line fallback rather than a misleading |
| 438 | // negative count. |
| 439 | func TestConsecutiveNonShellToolsDoNotRenderNegativeLineCount(t *testing.T) { |
| 440 | m := newTestChatTUI() |
| 441 | // Two back-to-back read_file tools; the first result lands AFTER |
| 442 | // the second dispatch (the model dispatched them in parallel and |
| 443 | // the first one finished last). This is the path the PR reviewer |
| 444 | // identified as the blocker. |
| 445 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "read_file-1", Name: "read_file", Args: `{"path":"a.txt"}`}}) |
| 446 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "read_file-2", Name: "read_file", Args: `{"path":"b.txt"}`}}) |
| 447 | // Late ToolResult for the FIRST tool — this used to render "-1 lines" |
| 448 | // under the first card. |
| 449 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "read_file-1", Name: "read_file", Output: "a.txt contents"}}) |
| 450 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "read_file-2", Name: "read_file", Output: "b.txt contents"}}) |
| 451 | |
| 452 | transcript := m.transcript |
| 453 | // The "-1 lines" bug surfaced literally as that text, so assert its |
| 454 | // absence first as a clear regression marker. |
| 455 | if joined := strings.Join(transcript, "\n"); strings.Contains(joined, "-1 lines") { |
| 456 | t.Fatalf("transcript must not contain a negative line count:\n%s", joined) |
| 457 | } |
| 458 | // And the more general contract: no slot under a card should claim |
| 459 | // a non-positive line count either. |
| 460 | for _, line := range transcript { |
| 461 | if strings.Contains(line, "0 lines") || strings.Contains(line, "-1 lines") { |
| 462 | t.Fatalf("non-shell tool marker should be blank, got %q\nfull transcript:\n%s", |
| 463 | line, strings.Join(transcript, "\n")) |
| 464 | } |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | func TestTodoPanelKeepsLastSuccessfulTodoWrite(t *testing.T) { |
| 469 | m := newTestChatTUI() |
| 470 | initial := `{"todos":[{"content":"Sync main-v2","status":"in_progress"},{"content":"Push origin","status":"pending"}]}` |
| 471 | failed := `{"todos":[{"content":"Sync main-v2","status":"completed"},{"content":"Push origin","status":"in_progress"}]}` |
| 472 | |
| 473 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "todo-1", Name: "todo_write", Args: initial}}) |
| 474 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "todo-1", Name: "todo_write", Args: initial, Output: "Todos updated"}}) |
| 475 | if m.todoArgs != initial { |
| 476 | t.Fatalf("todoArgs after successful result = %q, want initial args", m.todoArgs) |
| 477 | } |
| 478 | |
| 479 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "todo-2", Name: "todo_write", Args: failed}}) |
| 480 | m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "todo-2", Name: "todo_write", Args: failed, Err: "missing complete_step"}}) |
| 481 | if m.todoArgs != initial { |
| 482 | t.Fatalf("failed todo_write must not replace the panel: got %q, want %q", m.todoArgs, initial) |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | // TestToolProgressTailCap proves the live block only keeps the last |
| 487 | // toolStreamTailLines lines so a chatty build doesn't flood scrollback. |
| 488 | func TestToolProgressTailCap(t *testing.T) { |
| 489 | m := newTestChatTUI() |
| 490 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"x"}`}}) |
| 491 | for i := 0; i < toolStreamTailLines+5; i++ { |
| 492 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "line" + string(rune('A'+i)) + "\n"}}) |
| 493 | } |
| 494 | block := m.transcript[m.toolStreamIdx] |
| 495 | if got := strings.Count(block, "\n") + 1; got > toolStreamTailLines { |
| 496 | t.Fatalf("live block kept %d lines, want <= %d:\n%s", got, toolStreamTailLines, block) |
| 497 | } |
| 498 | if strings.Contains(block, "lineA") { |
| 499 | t.Fatalf("oldest line should have scrolled out of the tail:\n%s", block) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | // TestReasoningViewBounded proves the live thinking view stays bounded under a |
| 504 | // long stream — the fix for the O(n²)/multi-GB re-render of the full thought. |
| 505 | func TestReasoningViewBounded(t *testing.T) { |
| 506 | m := newTestChatTUI() |
| 507 | for i := 0; i < 5000; i++ { |
| 508 | m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "some thinking text token "}) |
| 509 | } |
| 510 | if len(m.reasoningView) > reasoningViewMax { |
| 511 | t.Fatalf("reasoningView unbounded: %d > %d", len(m.reasoningView), reasoningViewMax) |
| 512 | } |
| 513 | if c := strings.Count(m.transcript[m.reasoningTextIdx], "\n") + 1; c > reasoningTailLines { |
| 514 | t.Fatalf("live reasoning block kept %d lines, want <= %d", c, reasoningTailLines) |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | // TestSubagentProgressBlockShowsPhaseElapsedActivity proves the default block |
| 519 | // shows phase, elapsed, and recent activity — never the reasoning body. |
| 520 | func TestSubagentProgressBlockShowsPhaseElapsedActivity(t *testing.T) { |
| 521 | m := newTestChatTUI() |
| 522 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task", Args: `{"prompt":"work"}`}}) |
| 523 | m.ingestEvent(subagentStatus("task-1", "running")) |
| 524 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressReasoningName, "secret thinking", false)) |
| 525 | m.ingestEvent(subagentStatus("task-1", "reasoning")) |
| 526 | |
| 527 | joined := strings.Join(m.transcript, "\n") |
| 528 | if !strings.Contains(joined, "running") && !strings.Contains(joined, "reasoning") { |
| 529 | t.Fatalf("progress block should show the phase:\n%s", joined) |
| 530 | } |
| 531 | if strings.Contains(joined, "secret thinking") { |
| 532 | t.Fatalf("default block must not print the reasoning body:\n%s", joined) |
| 533 | } |
| 534 | if !strings.Contains(joined, "ago") { |
| 535 | t.Fatalf("progress block should show recent activity:\n%s", joined) |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | // TestSubagentProgressVerboseShowsBoundedTails proves verbose mode renders the |
| 540 | // reasoning/text tails and marks truncation. |
| 541 | func TestSubagentProgressVerboseShowsBoundedTails(t *testing.T) { |
| 542 | m := newTestChatTUI() |
| 543 | m.showReasoning = true |
| 544 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task", Args: `{"prompt":"work"}`}}) |
| 545 | m.ingestEvent(subagentStatus("task-1", "running")) |
| 546 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressReasoningName, "chain of thought", false)) |
| 547 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressTextName, "draft answer", false)) |
| 548 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressNoticeName, "heads up", true)) |
| 549 | |
| 550 | joined := strings.Join(m.transcript, "\n") |
| 551 | for _, want := range []string{"chain of thought", "draft answer", "heads up", "truncated"} { |
| 552 | if !strings.Contains(joined, want) { |
| 553 | t.Fatalf("verbose block should show %q:\n%s", want, joined) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | // Tails are bounded: a huge reasoning body keeps only the recent tail. |
| 558 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressReasoningName, strings.Repeat("x", subagentPreviewMax*2)+"END", false)) |
| 559 | joined = strings.Join(m.transcript, "\n") |
| 560 | if !strings.Contains(joined, "END") || strings.Contains(joined, strings.Repeat("x", subagentPreviewMax)) { |
| 561 | t.Fatalf("verbose reasoning should keep a bounded tail:\n%s", joined) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | // TestSubagentProgressTerminalCollapsesToOneLine proves terminal children fold |
| 566 | // to a one-line summary (no recent-activity suffix), while the preview stays |
| 567 | // available in verbose mode. |
| 568 | func TestSubagentProgressTerminalCollapsesToOneLine(t *testing.T) { |
| 569 | m := newTestChatTUI() |
| 570 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task", Args: `{"prompt":"work"}`}}) |
| 571 | m.ingestEvent(subagentStatus("task-1", "running")) |
| 572 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressTextName, "answer body", false)) |
| 573 | m.ingestEvent(subagentStatus("task-1", "completed")) |
| 574 | |
| 575 | joined := strings.Join(m.transcript, "\n") |
| 576 | if strings.Contains(joined, "answer body") { |
| 577 | t.Fatalf("terminal block must collapse the preview away:\n%s", joined) |
| 578 | } |
| 579 | if !strings.Contains(joined, "completed") || strings.Contains(joined, "ago") { |
| 580 | t.Fatalf("terminal block should be a one-line summary:\n%s", joined) |
| 581 | } |
| 582 | |
| 583 | // Verbose keeps the preview after terminal. |
| 584 | m2 := newTestChatTUI() |
| 585 | m2.showReasoning = true |
| 586 | m2.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "task-1", Name: "task", Args: `{"prompt":"work"}`}}) |
| 587 | m2.ingestEvent(subagentPreview("task-1", event.SubagentProgressTextName, "answer body", false)) |
| 588 | m2.ingestEvent(subagentStatus("task-1", "failed")) |
| 589 | joined = strings.Join(m2.transcript, "\n") |
| 590 | if !strings.Contains(joined, "answer body") || !strings.Contains(joined, "failed") { |
| 591 | t.Fatalf("verbose terminal block should keep the preview:\n%s", joined) |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // TestSubagentProgressChildrenDoNotCrossStream proves concurrent children keep |
| 596 | // their own fixed slots: each child's content stays under its own ID, and a |
| 597 | // late event for one child never appends to another child's block. |
| 598 | func TestSubagentProgressChildrenDoNotCrossStream(t *testing.T) { |
| 599 | m := newTestChatTUI() |
| 600 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "p-1", Name: "parallel_tasks", Args: `{}`}}) |
| 601 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "p-1/sub-1", Name: "task", Args: `{}`, ParentID: "p-1"}}) |
| 602 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "p-1/sub-2", Name: "task", Args: `{}`, ParentID: "p-1"}}) |
| 603 | |
| 604 | m.ingestEvent(subagentStatus("p-1/sub-1", "running")) |
| 605 | m.ingestEvent(subagentStatus("p-1/sub-2", "running")) |
| 606 | m.ingestEvent(subagentPreview("p-1/sub-1", event.SubagentProgressReasoningName, "AAAA", false)) |
| 607 | m.ingestEvent(subagentPreview("p-1/sub-2", event.SubagentProgressReasoningName, "BBBB", false)) |
| 608 | m.ingestEvent(subagentStatus("p-1/sub-1", "completed")) |
| 609 | // A late event for child 2 must land in child 2's own slot. |
| 610 | m.ingestEvent(subagentPreview("p-1/sub-2", event.SubagentProgressTextName, "child two text", false)) |
| 611 | m.ingestEvent(subagentStatus("p-1/sub-2", "completed")) |
| 612 | |
| 613 | idx1, ok1 := m.subagentProgressIdx["p-1/sub-1"] |
| 614 | idx2, ok2 := m.subagentProgressIdx["p-1/sub-2"] |
| 615 | if !ok1 || !ok2 || idx1 == idx2 { |
| 616 | t.Fatalf("children should own distinct fixed slots: %d %d", idx1, idx2) |
| 617 | } |
| 618 | if strings.Contains(m.transcript[idx1], "BBBB") || strings.Contains(m.transcript[idx2], "AAAA") { |
| 619 | t.Fatalf("children cross-streamed:\nidx1=%s\nidx2=%s", m.transcript[idx1], m.transcript[idx2]) |
| 620 | } |
| 621 | if strings.Contains(m.transcript[idx1], "child two text") { |
| 622 | t.Fatalf("late child-2 content must never land in child-1's block:\n%s", m.transcript[idx1]) |
| 623 | } |
| 624 | // The late preview is attributed to the right child in memory (the default |
| 625 | // collapsed view hides bodies after terminal, verbose shows them again). |
| 626 | if got := m.subagentProgress["p-1/sub-2"]; got == nil || got.text != "child two text" { |
| 627 | t.Fatalf("late child-2 text = %+v, want it stored on child 2", got) |
| 628 | } |
| 629 | if strings.Contains(m.transcript[idx2], "BBBB") || !strings.Contains(m.transcript[idx2], "completed") { |
| 630 | t.Fatalf("child-2 terminal block = %q, want its own completed summary", m.transcript[idx2]) |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | // TestSubagentProgressOrdinaryToolProgressUnaffected proves non-reserved |
| 635 | // ToolProgress still streams through the single live tool stream. |
| 636 | func TestSubagentProgressOrdinaryToolProgressUnaffected(t *testing.T) { |
| 637 | m := newTestChatTUI() |
| 638 | m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"ls"}`}}) |
| 639 | m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "file.txt\n"}}) |
| 640 | if joined := strings.Join(m.transcript, "\n"); !strings.Contains(joined, "file.txt") { |
| 641 | t.Fatalf("ordinary tool progress must still stream:\n%s", joined) |
| 642 | } |
| 643 | if len(m.subagentProgress) != 0 { |
| 644 | t.Fatalf("ordinary progress must not create sub-agent state") |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | // TestSubagentProgressUnknownReservedChannelIgnored locks forward compatibility: |
| 649 | // an older CLI must suppress a future reasonix.subagent.* channel instead of |
| 650 | // treating its body as ordinary tool output. |
| 651 | func TestSubagentProgressUnknownReservedChannelIgnored(t *testing.T) { |
| 652 | m := newTestChatTUI() |
| 653 | m.ingestEvent(subagentPreview("task-1", event.SubagentProgressPrefix+"future", "must stay hidden", false)) |
| 654 | |
| 655 | if got := strings.Join(m.transcript, "\n"); got != "" { |
| 656 | t.Fatalf("unknown reserved progress entered the transcript: %q", got) |
| 657 | } |
| 658 | if m.toolStreamID != "" || m.toolLineCount != 0 || m.toolPartial != "" { |
| 659 | t.Fatalf("unknown reserved progress opened ordinary tool output: id=%q lines=%d partial=%q", m.toolStreamID, m.toolLineCount, m.toolPartial) |
| 660 | } |
| 661 | if len(m.subagentProgress) != 0 { |
| 662 | t.Fatalf("unknown reserved progress allocated known-channel state: %+v", m.subagentProgress) |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | // TestSubagentProgressNativeScrollbackPrintsOnPhaseChange proves Termux-style |
| 667 | // native scrollback (which cannot rewrite printed output) queues a status line |
| 668 | // on phase changes and terminal only — same-phase repeats stay quiet. |
| 669 | func TestSubagentProgressNativeScrollbackPrintsOnPhaseChange(t *testing.T) { |
| 670 | m := newTestChatTUI() |
| 671 | m.nativeScrollback = true |
| 672 | m.ingestEvent(subagentStatus("task-1", "running")) |
| 673 | m.ingestEvent(subagentStatus("task-1", "running")) // repeat phase: no print |
| 674 | m.ingestEvent(subagentStatus("task-1", "reasoning")) |
| 675 | m.ingestEvent(subagentStatus("task-1", "completed")) |
| 676 | got := strings.Join(*m.pendingCommit, "\n") |
| 677 | for _, want := range []string{"running", "reasoning", "completed"} { |
| 678 | if strings.Count(got, want) != 1 { |
| 679 | t.Fatalf("scrollback output should print each phase exactly once, got %q (count %q = %d)", got, want, strings.Count(got, want)) |
| 680 | } |
| 681 | } |
| 682 | if len(m.subagentProgressIdx) != 0 { |
| 683 | t.Fatalf("scrollback mode must not allocate fixed transcript slots") |
| 684 | } |
| 685 | } |
| 686 |