返回 DeepSeek-Reasonix
chat_render_test.go
根目录 / internal / cli / chat_render_test.go
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
686 lines GO