Files
2026-07-13 13:00:08 +08:00

445 lines
20 KiB
Go

package cli
import (
"strings"
"testing"
"charm.land/bubbles/v2/textarea"
"reasonix/internal/event"
)
// newTestChatTUI builds a chatTUI with just the pieces the streaming/commit and
// completion paths need, for unit tests that don't run the bubbletea loop.
func newTestChatTUI() chatTUI {
commit := []string{}
ti := textarea.New()
configureChatTextarea(&ti)
ti.SetWidth(80)
shellIdx := map[string]int{}
shellOut := map[string]string{}
shellExp := map[string]bool{}
return chatTUI{
input: ti,
width: 80,
statusLineCount: 2,
submittedInputCursor: -1,
queueEditCursor: -1,
nextPasteID: 1,
reasoningLineIdx: -1,
reasoningTextIdx: -1,
answerIdx: -1,
toolStreamIdx: -1,
reasoning: &strings.Builder{},
pending: &strings.Builder{},
pendingCommit: &commit,
renderer: newMarkdownRenderer(80),
shellOutputs: shellOut,
shellExpanded: shellExp,
shellTranscriptIdx: shellIdx,
toolLineCountByID: map[string]int{},
}
}
func TestCacheRateLabelKeepsTwoDecimals(t *testing.T) {
if got := cacheRateLabel("turn hit %s", 998, 1000); got != "turn hit 99.80%" {
t.Fatalf("cacheRateLabel = %q, want turn hit 99.80%%", got)
}
if got := cacheRateLabel("avg %s", 1, 3); got != "avg 33.33%" {
t.Fatalf("cacheRateLabel = %q, want avg 33.33%%", got)
}
if got := cacheRateLabel("avg %s", 1, 0); got != "" {
t.Fatalf("cacheRateLabel with zero denominator = %q, want empty", got)
}
}
// TestIngestSeparatesReasoningFromAnswer proves the thinking marker plus its live
// text appear as reasoning streams, collapse to a "thought for Ns" summary (the
// streamed text removed) when the answer begins, and the answer commits as its
// own distinct entry.
func TestIngestSeparatesReasoningFromAnswer(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "…reasoning…"}) // thinking → marker + live text
if len(m.transcript) != 2 || !strings.Contains(m.transcript[0], "thinking") {
t.Fatalf("thinking marker should appear at once, transcript=%v", m.transcript)
}
if !strings.Contains(m.transcript[1], "…reasoning…") {
t.Fatalf("reasoning text should stream live below the marker, transcript=%v", m.transcript)
}
m.ingestEvent(event.Event{Kind: event.Text, Text: "Hello answer"}) // answer begins → block collapses
if len(m.transcript) != 1 || !strings.Contains(m.transcript[0], "thought for") {
t.Fatalf("block should collapse to a duration summary, transcript=%v", m.transcript)
}
if strings.Contains(strings.Join(m.transcript, "\n"), "…reasoning…") {
t.Fatalf("collapsed reasoning text should be removed, transcript=%v", m.transcript)
}
if m.pending.String() != "Hello answer" {
t.Errorf("answer should be live in pending, got %q", m.pending.String())
}
if m.reasoning.Len() != 0 {
t.Errorf("reasoning buffer should be cleared after commit")
}
m.commitPending() // turn end
if len(m.transcript) != 2 || !strings.Contains(m.transcript[1], "Hello") {
t.Fatalf("answer should commit as a separate entry, transcript=%v", m.transcript)
}
}
// TestVerboseReasoningInsertsTextUnderSummary proves /verbose mode keeps the full
// thinking text, placed beneath the collapsed duration summary.
func TestVerboseReasoningInsertsTextUnderSummary(t *testing.T) {
m := newTestChatTUI()
m.showReasoning = true
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "step one "})
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "step two"})
m.ingestEvent(event.Event{Kind: event.Text, Text: "Answer"}) // closes the block
if len(m.transcript) != 2 {
t.Fatalf("verbose block should be summary + text, transcript=%v", m.transcript)
}
if !strings.Contains(m.transcript[0], "thought for") {
t.Errorf("first line should be the duration summary, got %q", m.transcript[0])
}
if !strings.Contains(m.transcript[1], "step one") || !strings.Contains(m.transcript[1], "step two") {
t.Errorf("verbose text should appear under the summary, got %q", m.transcript[1])
}
}
// TestIngestEventFlushesAnswer confirms an event line (e.g. a tool dispatch)
// finalizes the answer streamed before it, preserving order in scrollback.
func TestIngestEventFlushesAnswer(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.Text, Text: "partial answer "})
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{Name: "read_file", Args: `{"path":"x"}`}})
// answer, then a blank spacer, then the tool line.
if n := len(*m.pendingCommit); n != 3 {
t.Fatalf("answer + spacer + event line should be three commits, got %d: %v", n, *m.pendingCommit)
}
if !strings.Contains((*m.pendingCommit)[0], "partial answer") {
t.Errorf("first commit should be the buffered answer, got %q", (*m.pendingCommit)[0])
}
if strings.TrimSpace((*m.pendingCommit)[1]) != "" {
t.Errorf("second commit should be a blank spacer, got %q", (*m.pendingCommit)[1])
}
if !strings.Contains((*m.pendingCommit)[2], "Read(x)") {
t.Errorf("third commit should be the tool card, got %q", (*m.pendingCommit)[2])
}
if m.pending.Len() != 0 {
t.Errorf("answer buffer should be drained after the event line")
}
}
// TestStreamAnswerFlushesCompletedParagraphs proves a multi-paragraph answer
// appears chunk by chunk: a closed paragraph renders to scrollback while the
// still-streaming one stays buffered, and turn end flushes the remainder.
func TestStreamAnswerFlushesCompletedParagraphs(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.Text, Text: "First paragraph.\n\nSecond para "})
if m.answerIdx < 0 {
t.Fatalf("a completed paragraph should open a streamed answer block")
}
joined := strings.Join(m.transcript, "\n")
if !strings.Contains(joined, "First paragraph.") {
t.Errorf("completed paragraph should be on screen, transcript=%v", m.transcript)
}
if strings.Contains(joined, "Second para") {
t.Errorf("the still-streaming paragraph must stay buffered, transcript=%v", m.transcript)
}
m.ingestEvent(event.Event{Kind: event.Text, Text: "is done now."})
m.ingestEvent(event.Event{Kind: event.Message})
final := strings.Join(m.transcript, "\n")
if !strings.Contains(final, "First paragraph.") || !strings.Contains(final, "Second para is done now.") {
t.Errorf("turn end should flush the whole answer, transcript=%v", m.transcript)
}
if m.pending.Len() != 0 || m.answerIdx != -1 {
t.Errorf("answer state should reset after commit, pending=%d idx=%d", m.pending.Len(), m.answerIdx)
}
}
// TestFlushableMarkdownPrefixKeepsOpenFence proves a blank line inside an unclosed
// fenced code block is not a flush boundary — the half-written block stays buffered
// so it never renders mangled, while prose before the fence does flush.
func TestFlushableMarkdownPrefixKeepsOpenFence(t *testing.T) {
open := "intro line\n\n```go\nfunc f() {\n\n\t// still typing"
if got := flushableMarkdownPrefix(open); got != "intro line" {
t.Errorf("open fence: flushable prefix = %q, want %q", got, "intro line")
}
closed := "```go\ncode\n\nmore\n```\n\ntrailing"
if got := flushableMarkdownPrefix(closed); got != "```go\ncode\n\nmore\n```" {
t.Errorf("closed fence: flushable prefix = %q", got)
}
if got := flushableMarkdownPrefix("no boundary yet"); got != "" {
t.Errorf("no blank line should flush nothing, got %q", got)
}
}
// TestToolProgressStreamsThenCollapses proves a running tool's output streams
// live under its card via the ⎿ connector, then collapses to a line-count
// summary when the result lands.
func TestToolProgressStreamsThenCollapses(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"go test ./..."}`}})
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "ok pkg/a\n"}})
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "ok pkg/b\n"}})
joined := strings.Join(m.transcript, "\n")
if !strings.Contains(joined, "ok pkg/a") || !strings.Contains(joined, "ok pkg/b") {
t.Fatalf("live output should be visible while running:\n%s", joined)
}
if !strings.Contains(joined, "⎿") {
t.Fatalf("live output should use the ⎿ connector:\n%s", joined)
}
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "b1", Name: "bash", Output: "ok pkg/a\nok pkg/b\n"}})
joined = strings.Join(m.transcript, "\n")
if strings.Contains(joined, "ok pkg/a") {
t.Fatalf("output should collapse after completion:\n%s", joined)
}
if !strings.Contains(joined, "2 lines") {
t.Fatalf("collapsed block should summarize the line count:\n%s", joined)
}
}
// TestToolWorkingLineThenClears proves a dispatched tool that streams no output
// (e.g. symbol_context) shows a live "working · Ns" line so it doesn't look
// frozen, and that the line clears on the result instead of collapsing to
// "0 lines".
func TestToolWorkingLineThenClears(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "symbol_context", Args: `{"q":"x"}`}})
m.tickToolRunning() // one elapsed tick fills the placeholder
joined := strings.Join(m.transcript, "\n")
if !strings.Contains(joined, "⎿") || !strings.Contains(joined, "working") {
t.Fatalf("a running tool should show a 'working' progress line:\n%s", joined)
}
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "symbol_context"}})
joined = strings.Join(m.transcript, "\n")
if strings.Contains(joined, "working") {
t.Fatalf("working line should clear after the result:\n%s", joined)
}
if strings.Contains(joined, "0 lines") {
t.Fatalf("a no-output tool must not collapse to '0 lines':\n%s", joined)
}
if m.toolStreamIdx != -1 {
t.Fatalf("tool block should be closed after the result, idx=%d", m.toolStreamIdx)
}
}
// TestConsecutiveToolCallsKeepMarkersUnderOwnCard is a regression test for
// back-to-back Bash tool calls. Before the fix, the late ToolProgress for
// the first tool (already superseded in the controller by a second
// ToolDispatch) appended a fresh live block at the end of the transcript
// under the *second* tool's card. Both "⎿" markers then stacked at the
// end, hiding which run produced which output. The fix threads the
// transcript slot through shellTranscriptIdx so each tool's live block
// stays directly under its own card regardless of the dispatch/progress
// arrival order.
func TestConsecutiveToolCallsKeepMarkersUnderOwnCard(t *testing.T) {
m := newTestChatTUI()
// First bash: dispatched and gets one progress chunk before the second
// bash is dispatched, mirroring the model's parallel-tool-call pattern.
// The "shell-" prefix ensures streamToolOutput accumulates into
// shellOutputs, which collapseShellSlot uses to recover the line count
// after the live state has been reset by the second beginToolRunning.
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "shell-1", Name: "bash", Args: `{"command":"git status"}`}})
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-1", Output: "On branch main-v2\n"}})
// Second bash dispatched before the first finishes; this switches
// m.toolStreamID to "shell-2" and resets the live streaming state.
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "shell-2", Name: "bash", Args: `{"command":"git branch -a"}`}})
// The second bash also streams one chunk of output so its collapse
// produces a real ⎿ marker (not the zero-output blank fallback).
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-2", Output: "* main-v2\n"}})
// Late progress for the FIRST bash — the path that previously stacked
// its marker under the second card.
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "shell-1", Output: "nothing to commit\n"}})
// Now finish both; each should collapse in place under its own card.
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "shell-1", Name: "bash", Output: "On branch main-v2\nnothing to commit\n"}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "shell-2", Name: "bash", Output: "* main-v2\n"}})
// Locate each tool's card. With the fix the transcript is exactly
// [card1, marker1, "", card2, marker2] — 5 lines, one marker per
// card. Without the fix the late progress overwrites the last slot
// in place (or appends), so the first card's slot is left holding
// only the first live chunk, and both markers end up at the tail.
transcript := m.transcript
idx1, idx2 := -1, -1
for i, ln := range transcript {
if idx1 == -1 && strings.Contains(ln, "git status") {
idx1 = i
}
if idx2 == -1 && strings.Contains(ln, "git branch -a") {
idx2 = i
}
}
if idx1 < 0 || idx2 < 0 || idx2 <= idx1 {
t.Fatalf("expected two bash cards in dispatch order, got idx1=%d idx2=%d\n%s", idx1, idx2, strings.Join(transcript, "\n"))
}
// Each card must be followed by its own ⎿-prefixed marker slot —
// not just "some marker somewhere after the second card".
for _, pair := range []struct {
card string
idx int
}{
{card: "git status", idx: idx1},
{card: "git branch -a", idx: idx2},
} {
next := transcript[pair.idx+1]
if !strings.Contains(next, "⎿") {
t.Fatalf("%q's marker should be at transcript[%d] with the ⎿ connector, got %q\nfull transcript:\n%s",
pair.card, pair.idx+1, next, strings.Join(transcript, "\n"))
}
}
// The first card's marker must reflect the full output of the first
// run ("On branch main-v2" AND "nothing to commit"), not just the
// first chunk. The bug left only the pre-late-progress chunk in
// transcript[idx1+1], so the second line would be missing.
marker1 := transcript[idx1+1]
if !strings.Contains(marker1, "On branch main-v2") || !strings.Contains(marker1, "nothing to commit") {
t.Fatalf("first card's marker should preview the full output of shell-1, got %q", marker1)
}
}
// TestRepeatedShellCommandDoesNotAccumulateOutput is the regression test for a
// re-run of the same "!" command (e.g. !pwd three times). RunShell derives a
// stable id from the command text ("shell-pwd"), so streamToolOutput kept
// appending each run's output onto the previous run's in m.shellOutputs[id];
// beginToolRunning now clears the entry so each run starts from a clean slate.
func TestRepeatedShellCommandDoesNotAccumulateOutput(t *testing.T) {
m := newTestChatTUI()
const id = "shell-pwd"
const out = "/home/user/project\n"
for range 3 {
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: id, Name: "bash", Args: `{"command":"pwd"}`}})
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: id, Output: out}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out}})
}
if got := m.shellOutputs[id]; got != out {
t.Fatalf("a re-run must not accumulate prior output: shellOutputs[%q] = %q, want %q", id, got, out)
}
}
func TestCollapsedShellHintUsesKeyboardShortcutOnly(t *testing.T) {
m := newTestChatTUI()
const id = "shell-long"
lines := make([]string, shellPreviewLines+2)
for i := range lines {
lines[i] = "line"
}
output := strings.Join(lines, "\n") + "\n"
m.shellOutputs[id] = output
m.transcript = []string{""}
m.collapseShellSlot(id, 0, output)
got := m.transcript[0]
if !strings.Contains(got, "more lines (Ctrl+B)") {
t.Fatalf("collapsed shell hint should mention Ctrl+B, got %q", got)
}
if strings.Contains(got, "click/") {
t.Fatalf("collapsed shell hint must not advertise mouse click in default TUI mode, got %q", got)
}
}
// TestConsecutiveNonShellToolsDoNotRenderNegativeLineCount is the regression
// test for the review-blocking case. The original fix to back-to-back shell
// tools records every dispatched id in shellTranscriptIdx so a late
// ToolProgress/Result can land in the correct slot. But for non-shell-
// prefixed tools (e.g. read_file) the streaming state belongs to whichever
// id is current and the accumulator (shellOutputs) is never populated, so
// the late path's "n" stayed at -1 and the final else branch rendered
// "⎿ -1 lines". The fix in collapseShellSlot guards n < 0 by clearing the
// slot — a deliberate blank-line fallback rather than a misleading
// negative count.
func TestConsecutiveNonShellToolsDoNotRenderNegativeLineCount(t *testing.T) {
m := newTestChatTUI()
// Two back-to-back read_file tools; the first result lands AFTER
// the second dispatch (the model dispatched them in parallel and
// the first one finished last). This is the path the PR reviewer
// identified as the blocker.
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "read_file-1", Name: "read_file", Args: `{"path":"a.txt"}`}})
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "read_file-2", Name: "read_file", Args: `{"path":"b.txt"}`}})
// Late ToolResult for the FIRST tool — this used to render "-1 lines"
// under the first card.
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "read_file-1", Name: "read_file", Output: "a.txt contents"}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "read_file-2", Name: "read_file", Output: "b.txt contents"}})
transcript := m.transcript
// The "-1 lines" bug surfaced literally as that text, so assert its
// absence first as a clear regression marker.
if joined := strings.Join(transcript, "\n"); strings.Contains(joined, "-1 lines") {
t.Fatalf("transcript must not contain a negative line count:\n%s", joined)
}
// And the more general contract: no slot under a card should claim
// a non-positive line count either.
for _, line := range transcript {
if strings.Contains(line, "0 lines") || strings.Contains(line, "-1 lines") {
t.Fatalf("non-shell tool marker should be blank, got %q\nfull transcript:\n%s",
line, strings.Join(transcript, "\n"))
}
}
}
func TestTodoPanelKeepsLastSuccessfulTodoWrite(t *testing.T) {
m := newTestChatTUI()
initial := `{"todos":[{"content":"Sync main-v2","status":"in_progress"},{"content":"Push origin","status":"pending"}]}`
failed := `{"todos":[{"content":"Sync main-v2","status":"completed"},{"content":"Push origin","status":"in_progress"}]}`
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "todo-1", Name: "todo_write", Args: initial}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "todo-1", Name: "todo_write", Args: initial, Output: "Todos updated"}})
if m.todoArgs != initial {
t.Fatalf("todoArgs after successful result = %q, want initial args", m.todoArgs)
}
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "todo-2", Name: "todo_write", Args: failed}})
m.ingestEvent(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "todo-2", Name: "todo_write", Args: failed, Err: "missing complete_step"}})
if m.todoArgs != initial {
t.Fatalf("failed todo_write must not replace the panel: got %q, want %q", m.todoArgs, initial)
}
}
// TestToolProgressTailCap proves the live block only keeps the last
// toolStreamTailLines lines so a chatty build doesn't flood scrollback.
func TestToolProgressTailCap(t *testing.T) {
m := newTestChatTUI()
m.ingestEvent(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "b1", Name: "bash", Args: `{"command":"x"}`}})
for i := 0; i < toolStreamTailLines+5; i++ {
m.ingestEvent(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: "b1", Output: "line" + string(rune('A'+i)) + "\n"}})
}
block := m.transcript[m.toolStreamIdx]
if got := strings.Count(block, "\n") + 1; got > toolStreamTailLines {
t.Fatalf("live block kept %d lines, want <= %d:\n%s", got, toolStreamTailLines, block)
}
if strings.Contains(block, "lineA") {
t.Fatalf("oldest line should have scrolled out of the tail:\n%s", block)
}
}
// TestReasoningViewBounded proves the live thinking view stays bounded under a
// long stream — the fix for the O(n²)/multi-GB re-render of the full thought.
func TestReasoningViewBounded(t *testing.T) {
m := newTestChatTUI()
for i := 0; i < 5000; i++ {
m.ingestEvent(event.Event{Kind: event.Reasoning, Text: "some thinking text token "})
}
if len(m.reasoningView) > reasoningViewMax {
t.Fatalf("reasoningView unbounded: %d > %d", len(m.reasoningView), reasoningViewMax)
}
if c := strings.Count(m.transcript[m.reasoningTextIdx], "\n") + 1; c > reasoningTailLines {
t.Fatalf("live reasoning block kept %d lines, want <= %d", c, reasoningTailLines)
}
}