445 lines
20 KiB
Go
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)
|
|
}
|
|
}
|