mirror of
https://github.com/ollama/ollama.git
synced 2026-07-15 20:54:54 -05:00
1965 lines
62 KiB
Go
1965 lines
62 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
coreagent "github.com/ollama/ollama/agent"
|
|
"github.com/ollama/ollama/api"
|
|
)
|
|
|
|
func TestChatAssistantEntryHasNoLabel(t *testing.T) {
|
|
m := chatModel{}
|
|
|
|
prefix, _ := m.renderEntry(chatEntry{role: "assistant", content: "hello"})
|
|
|
|
if strings.Contains(prefix, "Ollama:") {
|
|
t.Fatalf("prefix should not include Ollama label: %q", prefix)
|
|
}
|
|
if prefix != "" {
|
|
t.Fatalf("prefix = %q, want empty", prefix)
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersEmptyPromptHint(t *testing.T) {
|
|
m := chatModel{
|
|
chatID: "chat-a",
|
|
width: 80,
|
|
height: 12,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
lines := strings.Split(view, "\n")
|
|
hintLine := lineIndexContaining(lines, "what changed on this branch?")
|
|
if hintLine < 0 {
|
|
t.Fatalf("empty chat view missing prompt hint: %q", view)
|
|
}
|
|
if strings.Contains(view, "Start a conversation. Use /help for commands.") {
|
|
t.Fatalf("empty chat view should use rotating prompt hint: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatUserEntryHasNoLabel(t *testing.T) {
|
|
m := chatModel{entries: []chatEntry{{role: "user", content: "hello"}}}
|
|
|
|
prefix, body := m.renderEntry(m.entries[0])
|
|
|
|
if prefix != "" {
|
|
t.Fatalf("prefix = %q, want empty", prefix)
|
|
}
|
|
if body != "hello" {
|
|
t.Fatalf("body = %q, want hello", body)
|
|
}
|
|
|
|
transcript := stripANSI(m.renderTranscript(80))
|
|
if !strings.Contains(transcript, " hello") {
|
|
t.Fatalf("user transcript should render as user block: %q", transcript)
|
|
}
|
|
}
|
|
|
|
func TestChatSystemEntryHasNoLabel(t *testing.T) {
|
|
m := chatModel{entries: []chatEntry{{role: "system", content: "Available tools"}}}
|
|
|
|
prefix, body := m.renderEntry(m.entries[0])
|
|
|
|
if prefix != "" {
|
|
t.Fatalf("prefix = %q, want empty", prefix)
|
|
}
|
|
if body != "Available tools" {
|
|
t.Fatalf("body = %q, want Available tools", body)
|
|
}
|
|
if transcript := stripANSI(m.renderTranscript(80)); strings.Contains(transcript, "sys ") {
|
|
t.Fatalf("system transcript should not render sys prefix: %q", transcript)
|
|
}
|
|
}
|
|
|
|
func TestChatMixedToolGroupUsesMixedPrefix(t *testing.T) {
|
|
m := chatModel{}
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Tool calls (2)",
|
|
status: "error",
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", status: "done"}),
|
|
newChatEntry(chatEntry{role: "tool", status: "error", err: "failed"}),
|
|
},
|
|
})
|
|
|
|
prefix, body := m.renderEntry(entry)
|
|
|
|
if prefix != chatToolMixedStyle.Render("•")+" " {
|
|
t.Fatalf("prefix = %q, want mixed-styled bullet", prefix)
|
|
}
|
|
body = stripANSI(body)
|
|
if strings.Contains(body, "failed") || strings.Contains(body, "succeeded") || strings.Contains(body, "done") {
|
|
t.Fatalf("body = %q, should not show status words for mixed group", body)
|
|
}
|
|
}
|
|
|
|
func TestChatToolStatusLineDoesNotUseDisclosureGlyph(t *testing.T) {
|
|
startedAt := time.Date(2026, 6, 22, 13, 0, 0, 0, time.UTC)
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool",
|
|
label: `Web Search("who is parth sareen")`,
|
|
status: "done",
|
|
content: "hidden result",
|
|
startedAt: startedAt,
|
|
finishedAt: startedAt.Add(812 * time.Millisecond),
|
|
})
|
|
|
|
line := stripANSI(toolStatusLine(entry))
|
|
if strings.Contains(line, "▸") || strings.Contains(line, "▾") {
|
|
t.Fatalf("tool status line should not include disclosure glyph: %q", line)
|
|
}
|
|
if line != `Web Search("who is parth sareen")` {
|
|
t.Fatalf("tool status line = %q, want label only", line)
|
|
}
|
|
for _, word := range []string{"done", "in 812ms", "812ms"} {
|
|
if strings.Contains(line, word) {
|
|
t.Fatalf("tool status line should not include %q: %q", word, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatCompletedToolStatusLineUsesResultStyle(t *testing.T) {
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "bash",
|
|
label: `Bash("pwd")`,
|
|
status: "done",
|
|
})
|
|
|
|
if line := toolStatusLine(entry); line != `Bash("pwd")` {
|
|
t.Fatalf("tool status line = %q, want command label", line)
|
|
}
|
|
prefix, _ := (chatModel{}).renderEntry(entry)
|
|
if prefix != chatToolDoneStyle.Render("•")+" " {
|
|
t.Fatalf("tool prefix = %q, want done-styled marker", prefix)
|
|
}
|
|
}
|
|
|
|
func TestChatToolStatusMarkerUsesStateColors(t *testing.T) {
|
|
if got, want := chatToolRunningStyle.GetForeground(), lipgloss.Color(chatAnsiYellow); got != want {
|
|
t.Fatalf("running tool foreground = %v, want %v", got, want)
|
|
}
|
|
if got, want := chatToolDoneStyle.GetForeground(), lipgloss.Color(chatAnsiGreen); got != want {
|
|
t.Fatalf("done tool foreground = %v, want %v", got, want)
|
|
}
|
|
|
|
running := newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "bash",
|
|
label: `Bash("pwd")`,
|
|
status: "running",
|
|
})
|
|
if line := toolStatusLine(running); line != `Bash("pwd")` {
|
|
t.Fatalf("running tool status line = %q, want command label", line)
|
|
}
|
|
prefix, _ := (chatModel{}).renderEntry(running)
|
|
if prefix != chatToolRunningStyle.Render("•")+" " {
|
|
t.Fatalf("running tool prefix = %q, want running-styled marker", prefix)
|
|
}
|
|
|
|
child := newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "bash",
|
|
label: `Bash("pwd")`,
|
|
status: "done",
|
|
})
|
|
if line := toolGroupChildStatusLine(child); line != boldToolInvocationName(`Bash("pwd")`) {
|
|
t.Fatalf("tool group child line = %q, want neutral invocation", line)
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersInputBox(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("hello"),
|
|
width: 40,
|
|
height: 12,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, inputBoxTopBorderLine(40)) || !strings.Contains(view, inputBoxBottomBorderLine(40)) {
|
|
t.Fatalf("prompt input should render box borders: %q", view)
|
|
}
|
|
if !strings.Contains(view, "│ hello") {
|
|
t.Fatalf("view missing prompt input row: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersSentUserPromptWithoutPrefix(t *testing.T) {
|
|
m := chatModel{
|
|
width: 40,
|
|
height: 12,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "hello"},
|
|
},
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, "hello") {
|
|
t.Fatalf("submitted user message should render: %q", view)
|
|
}
|
|
if strings.Contains(view, "> hello") {
|
|
t.Fatalf("submitted user message should not include prompt prefix: %q", view)
|
|
}
|
|
if strings.Contains(view, "│ >") {
|
|
t.Fatalf("active input should not include prompt prefix: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatFlowViewStartsAtInputWhenEmpty(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("hello"),
|
|
width: 40,
|
|
}
|
|
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
if len(lines) == 0 || !strings.Contains(lines[0], inputBoxTopBorderLine(40)) {
|
|
t.Fatalf("empty flow view should start at input box:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestFlowTranscriptChangedPrefixStartDetectsToolGrouping(t *testing.T) {
|
|
firstArgs := map[string]any{"command": "pwd"}
|
|
secondArgs := map[string]any{"command": "ls"}
|
|
beforeModel := chatModel{
|
|
width: 120,
|
|
entries: []chatEntry{
|
|
{
|
|
role: "tool",
|
|
label: `Bash("pwd")`,
|
|
detail: "bash",
|
|
status: "done",
|
|
toolID: "call-1",
|
|
args: firstArgs,
|
|
},
|
|
},
|
|
}
|
|
before := beforeModel.transcriptLines(120)
|
|
|
|
afterModel := beforeModel
|
|
afterModel.entries = groupCompletedToolEntries([]chatEntry{
|
|
beforeModel.entries[0],
|
|
{
|
|
role: "tool",
|
|
label: `Bash("ls")`,
|
|
detail: "bash",
|
|
status: "done",
|
|
toolID: "call-2",
|
|
args: secondArgs,
|
|
},
|
|
})
|
|
|
|
after := afterModel.transcriptLines(120)
|
|
if start := flowTranscriptChangedPrefixStart(before, after, len(before)); start != 0 {
|
|
t.Fatalf("changed prefix start = %d, want 0; before=%q after=%q", start, before, after)
|
|
}
|
|
sequence := flowTranscriptRewriteSequence(len(before), after)
|
|
if !strings.HasPrefix(sequence, "\x1b[1A\r\x1b[J") {
|
|
t.Fatalf("rewrite sequence = %q, want cursor-up clear-below prefix", sequence)
|
|
}
|
|
if !strings.Contains(sequence, "Ran 2 commands") {
|
|
t.Fatalf("grouping should invalidate already printed tool row; before=%q after=%q", before, afterModel.transcriptLines(120))
|
|
}
|
|
}
|
|
|
|
func TestFlowTranscriptChangedPrefixStartIgnoresAppendedLines(t *testing.T) {
|
|
beforeModel := chatModel{
|
|
width: 120,
|
|
entries: []chatEntry{
|
|
{role: "assistant", content: "hello"},
|
|
},
|
|
}
|
|
before := beforeModel.transcriptLines(120)
|
|
afterModel := beforeModel
|
|
afterModel.entries = append(afterModel.entries, chatEntry{role: "assistant", content: "world"})
|
|
|
|
if start := flowTranscriptChangedPrefixStart(before, afterModel.transcriptLines(120), len(before)); start >= 0 {
|
|
t.Fatalf("appended transcript lines should not invalidate already printed prefix")
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersCursorWithEmptyPlaceholder(t *testing.T) {
|
|
m := chatModel{
|
|
chatID: "placeholder-chat",
|
|
width: 80,
|
|
height: 12,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, "summarize this file and suggest edits") {
|
|
t.Fatalf("empty placeholder should show hint: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersModelUnderInputBox(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("hello"),
|
|
width: 48,
|
|
height: 12,
|
|
opts: Options{
|
|
Model: "kimi-k2.7-code:cloud",
|
|
},
|
|
}
|
|
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
inputLine := lineIndexContaining(lines, "│ hello")
|
|
modelLine := lineIndexContaining(lines, "kimi-k2.7-code:cloud")
|
|
if inputLine < 0 || modelLine < 0 {
|
|
t.Fatalf("view missing input or model line:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if modelLine != inputLine+2 {
|
|
t.Fatalf("model line should sit directly under input: input=%d model=%d\n%s", inputLine, modelLine, strings.Join(lines, "\n"))
|
|
}
|
|
if strings.Contains(lines[modelLine], "model ") {
|
|
t.Fatalf("model line should not include a label:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestChatViewExpandsInputBoxForLongPrompt(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune(strings.Repeat("long prompt ", 8)),
|
|
width: 32,
|
|
height: 14,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if got := inputPromptLineCount(t, view); got < 2 {
|
|
t.Fatalf("input body lines = %d, want wrapped prompt:\n%s", got, view)
|
|
}
|
|
if !strings.Contains(view, "█") {
|
|
t.Fatalf("view missing cursor: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatViewCapsTallInputBox(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune(strings.Repeat("pasted text ", 80)),
|
|
width: 32,
|
|
height: 12,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if got := inputPromptLineCount(t, view); got > maxInputBoxBodyLines {
|
|
t.Fatalf("input body lines = %d, want <= %d:\n%s", got, maxInputBoxBodyLines, view)
|
|
}
|
|
if strings.Contains(view, "... ... ") {
|
|
t.Fatalf("truncated pasted prompt should not duplicate omission markers:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatViewWrapsNotificationWhenNarrow(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("hello"),
|
|
width: 28,
|
|
height: 14,
|
|
status: "cache will break by turning system prompt off",
|
|
approvalState: testApprovalState(true, nil),
|
|
opts: Options{
|
|
ContextWindowTokens: 262144,
|
|
},
|
|
contextTokens: 12345,
|
|
contextEstimate: true,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
for _, want := range []string{
|
|
"cache will break by",
|
|
"system prompt off",
|
|
} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("wrapped notification missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
for _, hidden := range []string{"enter", "send", "/model", "full", "access"} {
|
|
if strings.Contains(view, hidden) {
|
|
t.Fatalf("view should not render footer chrome %q:\n%s", hidden, view)
|
|
}
|
|
}
|
|
if strings.Contains(view, "ctx") {
|
|
t.Fatalf("view should hide distant context pressure:\n%s", view)
|
|
}
|
|
if strings.Contains(view, "ctrl+g") {
|
|
t.Fatalf("view should not include ctrl+g hint:\n%s", view)
|
|
}
|
|
for _, line := range strings.Split(view, "\n") {
|
|
if len([]rune(line)) > 28 {
|
|
t.Fatalf("line width = %d, want <= 28: %q\n%s", len([]rune(line)), line, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPromptTokenTextDoesNotUseCompactionFallback(t *testing.T) {
|
|
m := chatModel{}
|
|
if got := m.promptTokenText(702); got != "702 tokens" {
|
|
t.Fatalf("promptTokenText without model context = %q, want bare token count", got)
|
|
}
|
|
|
|
m.opts.ContextWindowTokens = 262144
|
|
if got := m.promptTokenText(702); got != "702 / 256k tokens" {
|
|
t.Fatalf("promptTokenText with model context = %q", got)
|
|
}
|
|
|
|
m.opts.Options = map[string]any{"num_ctx": 131072}
|
|
if got := m.promptTokenText(702); got != "702 / 128k tokens" {
|
|
t.Fatalf("promptTokenText with num_ctx = %q", got)
|
|
}
|
|
|
|
m.opts.Options = map[string]any{"num_ctx": 1000000}
|
|
if got := m.promptTokenText(702); got != "702 / 1M tokens" {
|
|
t.Fatalf("promptTokenText with large num_ctx = %q", got)
|
|
}
|
|
|
|
m.opts.Options = map[string]any{"num_ctx": 99999}
|
|
if got := m.promptTokenText(702); got != "702 / 99999 tokens" {
|
|
t.Fatalf("promptTokenText below compact threshold = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPreloadDoneUpdatesContextWindowTokens(t *testing.T) {
|
|
compactor := &coreagent.SimpleCompactor{}
|
|
m := chatModel{
|
|
preloadingModel: "ornith",
|
|
opts: Options{
|
|
ContextWindowTokens: 32768,
|
|
Compactor: compactor,
|
|
},
|
|
}
|
|
|
|
next, _ := m.Update(chatModelPreloadDoneMsg{model: "ornith", contextWindowTokens: 262144})
|
|
got := next.(chatModel)
|
|
if got.opts.ContextWindowTokens != 262144 {
|
|
t.Fatalf("context window = %d, want 262144", got.opts.ContextWindowTokens)
|
|
}
|
|
if compactor.Options.ContextWindowTokens != 262144 {
|
|
t.Fatalf("compactor context window = %d, want 262144", compactor.Options.ContextWindowTokens)
|
|
}
|
|
}
|
|
|
|
func TestChatViewRendersNotificationAboveInput(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("hello"),
|
|
width: 40,
|
|
height: 12,
|
|
status: "copied latest output",
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
lines := strings.Split(view, "\n")
|
|
borderLine := lineIndexContaining(lines, inputBoxTopBorderLine(40))
|
|
if borderLine < 0 {
|
|
t.Fatalf("view missing input box:\n%s", view)
|
|
}
|
|
if borderLine < 1 || !strings.Contains(lines[borderLine-1], "copied latest output") {
|
|
t.Fatalf("notification should sit directly above input box:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatNotificationsUseSecondaryStyle(t *testing.T) {
|
|
if !chatNotificationStyle.GetFaint() {
|
|
t.Fatal("notification style should use secondary/faint styling")
|
|
}
|
|
}
|
|
|
|
func TestChatSubmittedPromptUsesThemeSecondaryGrey(t *testing.T) {
|
|
if got, want := chatUserBlockStyle.GetForeground(), lipgloss.TerminalColor(lipgloss.AdaptiveColor{Light: "#777777", Dark: "#8a8a8a"}); got != want {
|
|
t.Fatalf("submitted prompt foreground = %v, want %v", got, want)
|
|
}
|
|
if chatUserBlockStyle.GetFaint() {
|
|
t.Fatal("submitted prompt should use secondary grey, not faint styling")
|
|
}
|
|
}
|
|
|
|
func TestChatToolOutputUsesDistinctSecondaryGrey(t *testing.T) {
|
|
if got, want := chatToolOutputStyle.GetForeground(), lipgloss.TerminalColor(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#a0a0a0"}); got != want {
|
|
t.Fatalf("tool output foreground = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestChatInlineCodeDoesNotLookSelected(t *testing.T) {
|
|
if chatInlineCodeStyle.GetReverse() {
|
|
t.Fatal("inline code should not use reverse-video styling")
|
|
}
|
|
if !chatInlineCodeStyle.GetBold() {
|
|
t.Fatal("inline code should still have lightweight emphasis")
|
|
}
|
|
}
|
|
|
|
func inputPromptLineCount(t *testing.T, view string) int {
|
|
t.Helper()
|
|
count := 0
|
|
inInputBox := false
|
|
for _, line := range strings.Split(view, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "╭") {
|
|
inInputBox = true
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "╰") {
|
|
inInputBox = false
|
|
continue
|
|
}
|
|
if inInputBox && strings.Contains(trimmed, "│") {
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
t.Fatalf("input prompt lines not found:\n%s", view)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("next"),
|
|
running: true,
|
|
thinking: true,
|
|
thinkingTokens: 42,
|
|
width: 40,
|
|
height: 12,
|
|
}
|
|
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, "│ next") {
|
|
t.Fatalf("running view should keep input row: %q", view)
|
|
}
|
|
if strings.Contains(view, "↑/↓ scroll") || strings.Contains(view, "/new chat") || strings.Contains(view, "/clear reset") {
|
|
t.Fatalf("footer should not include scroll/new/clear hints: %q", view)
|
|
}
|
|
lines := strings.Split(view, "\n")
|
|
borderLine := lineIndexContaining(lines, inputBoxTopBorderLine(40))
|
|
if borderLine < 0 {
|
|
t.Fatalf("view missing input box: %q", view)
|
|
}
|
|
if borderLine < 1 || !strings.Contains(lines[borderLine-1], "Thinking ↓ 42 tokens") {
|
|
t.Fatalf("thinking line should sit directly above input box:\n%s", view)
|
|
}
|
|
if strings.Contains(lines[borderLine-1], "...") {
|
|
t.Fatalf("thinking line should not show spinner dots:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatToolFinishedUpdatesLiveWorkingDirOnly(t *testing.T) {
|
|
root := t.TempDir()
|
|
subdir := filepath.Join(root, "sub")
|
|
if err := os.Mkdir(subdir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := chatModel{
|
|
width: 140,
|
|
height: 12,
|
|
opts: Options{
|
|
RootDir: root,
|
|
WorkingDir: root,
|
|
},
|
|
workingDir: root,
|
|
}
|
|
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolFinished,
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
WorkingDir: subdir,
|
|
})
|
|
|
|
if m.workingDir != subdir {
|
|
t.Fatalf("workingDir = %q, want %q", m.workingDir, subdir)
|
|
}
|
|
if m.opts.WorkingDir != root {
|
|
t.Fatalf("opts.WorkingDir mutated to %q, want %q", m.opts.WorkingDir, root)
|
|
}
|
|
}
|
|
|
|
func TestChatBoundedFramePinsInputAfterResize(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("next"),
|
|
width: 60,
|
|
height: 14,
|
|
}
|
|
for i := range 20 {
|
|
m.entries = append(m.entries, chatEntry{role: "assistant", content: fmt.Sprintf("line %02d", i)})
|
|
}
|
|
inputLine := renderedInputLine(m.View())
|
|
if inputLine < m.height-4 {
|
|
t.Fatalf("bounded frame should pin input near bottom, input line=%d height=%d\n%s", inputLine, m.height, stripANSI(m.View()))
|
|
}
|
|
}
|
|
|
|
func TestChatViewSeparatesActionStatusFromTranscript(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("next"),
|
|
width: 72,
|
|
height: 14,
|
|
running: true,
|
|
thinking: true,
|
|
entries: []chatEntry{
|
|
{role: "assistant", content: "Working through it."},
|
|
},
|
|
}
|
|
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
assistantLine := lineIndexContaining(lines, "Working through it.")
|
|
activityLine := lineIndexContaining(lines, "Thinking")
|
|
if assistantLine < 0 || activityLine < 0 {
|
|
t.Fatalf("view missing assistant/activity lines:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if gap := activityLine - assistantLine - 1; gap < 1 {
|
|
t.Fatalf("gap between transcript and action status = %d, want at least 1:\n%s", gap, strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestChatViewDoesNotReserveIdleActionSpacerAfterResponse(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("next"),
|
|
width: 72,
|
|
height: 12,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "hi"},
|
|
{role: "assistant", content: "Hello."},
|
|
},
|
|
}
|
|
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
assistantLine := lineIndexContaining(lines, "Hello.")
|
|
inputLine := lineIndexContaining(lines, "│ next")
|
|
if assistantLine < 0 || inputLine < 0 {
|
|
t.Fatalf("view missing assistant/input lines:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if gap := inputLine - assistantLine - 1; gap < 2 {
|
|
t.Fatalf("gap between finished response and input body = %d, want at least 2:\n%s", gap, strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestChatViewHidesEmptyHintWhileTyping(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("next"),
|
|
width: 72,
|
|
height: 12,
|
|
}
|
|
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
hintLine := lineIndexContaining(lines, "Try:")
|
|
inputLine := lineIndexContaining(lines, "│ next")
|
|
if hintLine >= 0 {
|
|
t.Fatalf("view should not show empty hint while typing:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if inputLine < 0 {
|
|
t.Fatalf("view missing input line:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func renderedInputLine(view string) int {
|
|
for i, line := range strings.Split(stripANSI(view), "\n") {
|
|
if strings.Contains(line, "│ next") {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func lineIndexContaining(lines []string, needle string) int {
|
|
for i, line := range lines {
|
|
if strings.Contains(line, needle) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestChatScrollsTranscript(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 10,
|
|
}
|
|
for range 12 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: "line"})
|
|
}
|
|
|
|
if m.maxScroll() == 0 {
|
|
t.Fatal("test setup should produce scrollable transcript")
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
|
|
m = updated.(chatModel)
|
|
if m.scroll == 0 {
|
|
t.Fatal("page up should scroll transcript")
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("scroll = %d, want 0", m.scroll)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome})
|
|
m = updated.(chatModel)
|
|
if m.scroll != m.maxScroll() {
|
|
t.Fatalf("scroll = %d, want max %d", m.scroll, m.maxScroll())
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlEnd})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("scroll = %d, want 0", m.scroll)
|
|
}
|
|
}
|
|
|
|
func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 12,
|
|
running: true,
|
|
events: make(chan tea.Msg),
|
|
}
|
|
|
|
updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "generated line 00"}})
|
|
m = updated.(chatModel)
|
|
if !strings.Contains(stripANSI(m.View()), "generated line 00") {
|
|
t.Fatalf("live output should be visible in flow view:\n%s", stripANSI(m.View()))
|
|
}
|
|
|
|
updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "\n\ngenerated line 01"}})
|
|
m = updated.(chatModel)
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, "generated line 00") {
|
|
t.Fatalf("live assistant output should remain visible while streaming:\n%s", view)
|
|
}
|
|
if !strings.Contains(view, "generated line 01") {
|
|
t.Fatalf("latest generated line should remain visible:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 10,
|
|
running: true,
|
|
input: []rune("current draft"),
|
|
promptHistory: []string{"previous one", "previous two"},
|
|
}
|
|
for range 12 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: "line"})
|
|
}
|
|
if m.maxScroll() == 0 {
|
|
t.Fatal("test setup should produce scrollable transcript")
|
|
}
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelUp})
|
|
m = updated.(chatModel)
|
|
if m.scroll == 0 {
|
|
t.Fatal("mouse wheel up should scroll transcript while running")
|
|
}
|
|
if got := string(m.input); got != "current draft" {
|
|
t.Fatalf("mouse wheel should not navigate prompt history, input = %q", got)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelDown})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("mouse wheel down should return to bottom, got scroll %d", m.scroll)
|
|
}
|
|
if got := string(m.input); got != "current draft" {
|
|
t.Fatalf("mouse wheel should leave draft alone, input = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestChatWindowsMouseWheelScrollsTranscript(t *testing.T) {
|
|
oldGOOS := chatRuntimeGOOS
|
|
chatRuntimeGOOS = "windows"
|
|
defer func() {
|
|
chatRuntimeGOOS = oldGOOS
|
|
}()
|
|
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 8,
|
|
}
|
|
for i := range 20 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)})
|
|
}
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelUp})
|
|
m = updated.(chatModel)
|
|
if m.scroll == 0 {
|
|
t.Fatal("windows mouse wheel up should scroll transcript")
|
|
}
|
|
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelDown})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("windows mouse wheel down should return to bottom, got scroll %d", m.scroll)
|
|
}
|
|
}
|
|
|
|
func TestChatMouseDragSelectsTranscriptWithoutAutoCopy(t *testing.T) {
|
|
oldGOOS := chatRuntimeGOOS
|
|
chatRuntimeGOOS = "darwin"
|
|
defer func() {
|
|
chatRuntimeGOOS = oldGOOS
|
|
}()
|
|
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 10,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "alpha beta"},
|
|
},
|
|
}
|
|
top, _ := m.transcriptLayout()
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top})
|
|
m = updated.(chatModel)
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top})
|
|
m = updated.(chatModel)
|
|
if got := m.selectedTranscriptText(80); got != "alpha" {
|
|
t.Fatalf("selected text = %q, want alpha", got)
|
|
}
|
|
if !m.selection.active {
|
|
t.Fatal("selection should stay active during drag")
|
|
}
|
|
if !m.selection.dragging {
|
|
t.Fatal("selection should track drag before release")
|
|
}
|
|
|
|
updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top})
|
|
m = updated.(chatModel)
|
|
if cmd != nil {
|
|
t.Fatal("mouse release should not auto-copy selected text")
|
|
}
|
|
if !m.selection.active {
|
|
t.Fatal("selection should stay visible on release")
|
|
}
|
|
if m.selection.dragging {
|
|
t.Fatal("selection should stop tracking drag on release")
|
|
}
|
|
if got := m.selectedTranscriptText(80); got != "alpha" {
|
|
t.Fatalf("selected text after release = %q, want alpha", got)
|
|
}
|
|
}
|
|
|
|
func TestChatWindowsMouseDragCopiesAndClearsTranscriptSelection(t *testing.T) {
|
|
oldGOOS := chatRuntimeGOOS
|
|
chatRuntimeGOOS = "windows"
|
|
defer func() {
|
|
chatRuntimeGOOS = oldGOOS
|
|
}()
|
|
|
|
var copied string
|
|
oldClipboard := writeClipboard
|
|
writeClipboard = func(_ context.Context, text string) error {
|
|
copied = text
|
|
return nil
|
|
}
|
|
defer func() {
|
|
writeClipboard = oldClipboard
|
|
}()
|
|
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 10,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "alpha beta"},
|
|
},
|
|
}
|
|
top, _ := m.transcriptLayout()
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top})
|
|
m = updated.(chatModel)
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top})
|
|
m = updated.(chatModel)
|
|
if got := m.selectedTranscriptText(80); got != "alpha" {
|
|
t.Fatalf("selected text = %q, want alpha", got)
|
|
}
|
|
|
|
updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top})
|
|
m = updated.(chatModel)
|
|
if cmd == nil {
|
|
t.Fatal("mouse release should copy selected text on windows")
|
|
}
|
|
if msg := cmd(); msg != nil {
|
|
t.Fatalf("clipboard command message = %#v, want nil", msg)
|
|
}
|
|
if copied != "alpha" {
|
|
t.Fatalf("copied text = %q, want alpha", copied)
|
|
}
|
|
if m.selection.active {
|
|
t.Fatal("windows selection should clear after copy")
|
|
}
|
|
if m.status != "copied" {
|
|
t.Fatalf("status = %q, want copied", m.status)
|
|
}
|
|
}
|
|
|
|
func TestChatMouseDragSelectionUsesDisplayColumns(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 10,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "a界b"},
|
|
},
|
|
}
|
|
top, _ := m.transcriptLayout()
|
|
contentX := 2
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: contentX, Y: top})
|
|
m = updated.(chatModel)
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: contentX + 3, Y: top})
|
|
m = updated.(chatModel)
|
|
|
|
if got := m.selectedTranscriptText(80); got != "a界" {
|
|
t.Fatalf("selected text = %q, want a界", got)
|
|
}
|
|
}
|
|
|
|
func TestChatBoundedViewDoesNotRenderScrollHeader(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 6,
|
|
}
|
|
for i := range 12 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)})
|
|
}
|
|
m.scroll = m.maxScroll()
|
|
top, _ := m.transcriptLayout()
|
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
|
if top != 0 {
|
|
t.Fatalf("transcript top = %d, want no status header", top)
|
|
}
|
|
if strings.Contains(lines[0], "more") {
|
|
t.Fatalf("view should not render scroll status header: %q", lines[0])
|
|
}
|
|
if strings.TrimSpace(lines[top]) == "" {
|
|
t.Fatalf("transcript should start at layout top %d, line=%q view=%q", top, lines[top], strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestChatMouseDragSelectionUsesScrolledTranscriptCoordinates(t *testing.T) {
|
|
oldGOOS := chatRuntimeGOOS
|
|
chatRuntimeGOOS = "darwin"
|
|
defer func() {
|
|
chatRuntimeGOOS = oldGOOS
|
|
}()
|
|
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 8,
|
|
}
|
|
for i := range 10 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)})
|
|
}
|
|
m.scroll = m.maxScroll()
|
|
top, _ := m.transcriptLayout()
|
|
|
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top})
|
|
m = updated.(chatModel)
|
|
updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseMotion, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 9, Y: top})
|
|
m = updated.(chatModel)
|
|
if got := m.selectedTranscriptText(80); got != "line-00" {
|
|
t.Fatalf("selected text = %q, want line-00", got)
|
|
}
|
|
updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 9, Y: top})
|
|
m = updated.(chatModel)
|
|
if cmd != nil {
|
|
t.Fatal("mouse release should not auto-copy selected text")
|
|
}
|
|
if !m.selection.active {
|
|
t.Fatal("selection should stay visible on release")
|
|
}
|
|
if m.selection.dragging {
|
|
t.Fatal("selection should stop tracking drag on release")
|
|
}
|
|
}
|
|
|
|
func TestChatArrowKeysNavigatePromptHistoryWhenTranscriptScrollable(t *testing.T) {
|
|
m := chatModel{
|
|
input: []rune("current draft"),
|
|
promptHistory: []string{"old prompt"},
|
|
width: 80,
|
|
height: 10,
|
|
running: true,
|
|
}
|
|
for range 12 {
|
|
m.entries = append(m.entries, chatEntry{role: "user", content: "line"})
|
|
}
|
|
if m.maxScroll() == 0 {
|
|
t.Fatal("test setup should produce scrollable transcript")
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("key up should not scroll transcript, got %d", m.scroll)
|
|
}
|
|
if got := string(m.input); got != "old prompt" {
|
|
t.Fatalf("key up should recall prompt history, got %q", got)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
m = updated.(chatModel)
|
|
if m.scroll != 0 {
|
|
t.Fatalf("key down should not scroll transcript, got %d", m.scroll)
|
|
}
|
|
if got := string(m.input); got != "current draft" {
|
|
t.Fatalf("key down should restore draft, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) {
|
|
args := api.NewToolCallFunctionArguments()
|
|
args.Set("command", "pwd")
|
|
messages := []api.Message{
|
|
{Role: "user", Content: "pwd"},
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []api.ToolCall{{
|
|
ID: "call-1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: args,
|
|
},
|
|
}},
|
|
},
|
|
{Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "/tmp/project\n"},
|
|
{Role: "assistant", Content: "The current directory is /tmp/project."},
|
|
}
|
|
|
|
entries := entriesFromMessages(messages)
|
|
if len(entries) != 3 {
|
|
t.Fatalf("entries = %d, want user/tool/assistant: %#v", len(entries), entries)
|
|
}
|
|
if entries[1].role != "tool" || entries[1].label != "Bash(\"pwd\")" {
|
|
t.Fatalf("tool entry = %#v", entries[1])
|
|
}
|
|
if line := stripANSI(toolStatusLine(entries[1])); line != `Bash("pwd")` {
|
|
t.Fatalf("tool status line = %q, want command label", line)
|
|
}
|
|
|
|
transcript := stripANSI((chatModel{entries: entries}).renderTranscript(120))
|
|
if strings.Contains(transcript, "• \n") || strings.Contains(transcript, "•\n") {
|
|
t.Fatalf("transcript has blank assistant bullet: %q", transcript)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
|
|
args := api.NewToolCallFunctionArguments()
|
|
args.Set("command", "pwd")
|
|
messages := []api.Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []api.ToolCall{{
|
|
ID: "call-1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: args,
|
|
},
|
|
}},
|
|
},
|
|
{Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "Tool execution denied."},
|
|
}
|
|
|
|
entries := entriesFromMessages(messages)
|
|
if len(entries) != 1 {
|
|
t.Fatalf("entries = %d, want one tool entry: %#v", len(entries), entries)
|
|
}
|
|
if entries[0].status != "denied" {
|
|
t.Fatalf("tool status = %q, want denied: %#v", entries[0].status, entries[0])
|
|
}
|
|
if line := stripANSI(toolStatusLine(entries[0])); line != `Bash("pwd") denied` {
|
|
t.Fatalf("tool status line = %q, want denied command label", line)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesRendersDisabledToolAsSkipped(t *testing.T) {
|
|
args := api.NewToolCallFunctionArguments()
|
|
args.Set("command", "pwd")
|
|
messages := []api.Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []api.ToolCall{{
|
|
ID: "call-1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: args,
|
|
},
|
|
}},
|
|
},
|
|
{Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "Tool execution disabled."},
|
|
}
|
|
|
|
entries := entriesFromMessages(messages)
|
|
if len(entries) != 1 {
|
|
t.Fatalf("entries = %d, want one tool entry: %#v", len(entries), entries)
|
|
}
|
|
if entries[0].status != "disabled" {
|
|
t.Fatalf("tool status = %q, want disabled: %#v", entries[0].status, entries[0])
|
|
}
|
|
if action := toolActionForEntry(entries[0]); action != "disabled_command" {
|
|
t.Fatalf("tool action = %q, want disabled_command", action)
|
|
}
|
|
if phrase := toolActionPhrase(toolActionForEntry(entries[0]), 1); phrase != "Skipped a command" {
|
|
t.Fatalf("tool action phrase = %q, want skipped command", phrase)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesRendersCompactionSummaryCollapsed(t *testing.T) {
|
|
entries := entriesFromMessages([]api.Message{
|
|
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
|
ID: coreagent.CompactionToolCallID,
|
|
Function: api.ToolCallFunction{
|
|
Name: coreagent.CompactionToolName,
|
|
},
|
|
}}},
|
|
{Role: "tool", ToolName: coreagent.CompactionToolName, ToolCallID: coreagent.CompactionToolCallID, Content: coreagent.CompactionSummaryMessagePrefix + "- old work\n- decisions"},
|
|
{Role: "user", Content: "recent request"},
|
|
})
|
|
if len(entries) != 2 {
|
|
t.Fatalf("entries = %d, want summary plus user: %#v", len(entries), entries)
|
|
}
|
|
if entries[0].role != "compaction_summary" || entries[0].content != "- old work\n- decisions" {
|
|
t.Fatalf("summary entry = %#v", entries[0])
|
|
}
|
|
|
|
m := chatModel{entries: entries}
|
|
transcript := stripANSI(m.renderTranscript(100))
|
|
if !strings.Contains(transcript, "Compacted summary") {
|
|
t.Fatalf("collapsed summary row missing: %q", transcript)
|
|
}
|
|
if strings.Contains(transcript, "Compacted summary done") {
|
|
t.Fatalf("summary row should not include done word: %q", transcript)
|
|
}
|
|
if strings.Contains(transcript, "old work") {
|
|
t.Fatalf("summary body should be collapsed: %q", transcript)
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
view := stripANSI(m.renderTranscript(100))
|
|
if !strings.Contains(view, "old work") || !strings.Contains(view, "decisions") {
|
|
t.Fatalf("inline summary body missing: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesHidesAutomaticCompactionInstruction(t *testing.T) {
|
|
entries := entriesFromMessages([]api.Message{
|
|
{
|
|
Role: "tool",
|
|
ToolName: coreagent.CompactionToolName,
|
|
ToolCallID: coreagent.CompactionToolCallID,
|
|
Content: coreagent.CompactionSummaryMessagePrefix + "old work summary\n\n" + coreagent.CompactionContinueInstruction,
|
|
},
|
|
})
|
|
if len(entries) != 1 || entries[0].role != "compaction_summary" || entries[0].content != "old work summary" {
|
|
t.Fatalf("entries = %#v", entries)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesRecognizesLegacySystemCompactionSummary(t *testing.T) {
|
|
entries := entriesFromMessages([]api.Message{
|
|
{Role: "system", Content: "Conversation summary:\nlegacy summary"},
|
|
})
|
|
if len(entries) != 1 || entries[0].role != "compaction_summary" || entries[0].content != "legacy summary" {
|
|
t.Fatalf("entries = %#v", entries)
|
|
}
|
|
}
|
|
|
|
func TestEntriesFromMessagesGroupsMultiToolHistoryInMiddle(t *testing.T) {
|
|
readArgs := api.NewToolCallFunctionArguments()
|
|
readArgs.Set("path", "feedback")
|
|
listArgs := api.NewToolCallFunctionArguments()
|
|
listArgs.Set("path", ".")
|
|
messages := []api.Message{
|
|
{Role: "user", Content: "read feedback file"},
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []api.ToolCall{
|
|
{
|
|
ID: "call-read",
|
|
Function: api.ToolCallFunction{
|
|
Name: "read",
|
|
Arguments: readArgs,
|
|
},
|
|
},
|
|
{
|
|
ID: "call-list",
|
|
Function: api.ToolCallFunction{
|
|
Name: "list",
|
|
Arguments: listArgs,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{Role: "tool", ToolName: "read", ToolCallID: "call-read", Content: "Error: no such file"},
|
|
{Role: "tool", ToolName: "list", ToolCallID: "call-list", Content: "feedback.md\n"},
|
|
{Role: "assistant", Content: "There is a feedback.md file."},
|
|
}
|
|
|
|
entries := entriesFromMessages(messages)
|
|
if len(entries) != 3 {
|
|
t.Fatalf("entries = %d, want user/tool-group/assistant: %#v", len(entries), entries)
|
|
}
|
|
if entries[1].role != "tool_group" || len(entries[1].tools) != 2 {
|
|
t.Fatalf("middle entry should be grouped tool history: %#v", entries[1])
|
|
}
|
|
if entries[1].tools[0].label != "Read(\"feedback\")" || entries[1].tools[1].label != "List(\".\")" {
|
|
t.Fatalf("group labels = %#v", entries[1].tools)
|
|
}
|
|
}
|
|
|
|
func TestChatToolCallDetectedDoesNotRenderQueuedRows(t *testing.T) {
|
|
args := api.NewToolCallFunctionArguments()
|
|
args.Set("command", "pwd")
|
|
m := chatModel{}
|
|
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolCallDetected,
|
|
ToolCalls: []api.ToolCall{{
|
|
ID: "call-1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: args,
|
|
},
|
|
}},
|
|
})
|
|
|
|
if len(m.entries) != 0 {
|
|
t.Fatalf("queued tool call should not create history entries: %#v", m.entries)
|
|
}
|
|
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolStarted,
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Args: args.ToMap(),
|
|
})
|
|
if len(m.entries) != 1 {
|
|
t.Fatalf("started tool should create one visible entry, got %d", len(m.entries))
|
|
}
|
|
if got := m.activityLabel(); got != "" {
|
|
t.Fatalf("activityLabel = %q, want no transient tool label", got)
|
|
}
|
|
if line := strings.TrimSpace(stripANSI(m.activityLine())); line != "" {
|
|
t.Fatalf("activityLine = %q, want no transient tool action line", line)
|
|
}
|
|
}
|
|
|
|
func TestChatToolOutputIsHiddenUntilExpanded(t *testing.T) {
|
|
fullOutput := strings.Repeat("x", maxCtrlOToolOutputRunes+25) + "tail-marker"
|
|
m := chatModel{}
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolFinished,
|
|
ToolName: "bash",
|
|
Content: fullOutput,
|
|
})
|
|
|
|
if len(m.entries) != 1 {
|
|
t.Fatalf("entries = %d, want 1", len(m.entries))
|
|
}
|
|
if m.entries[0].content != fullOutput {
|
|
t.Fatal("tool entry should keep full content before rendering")
|
|
}
|
|
|
|
transcript := m.renderTranscript(100)
|
|
body := stripANSI(transcript)
|
|
if strings.Contains(body, "tail-marker") || strings.Contains(body, strings.Repeat("x", 20)) {
|
|
t.Fatalf("collapsed tool output should be hidden: %q", body)
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
|
|
if !m.toolOutputMode || !m.toolOutputOpen || !m.entries[0].expanded {
|
|
t.Fatalf("ctrl+o should expand tool output inline: %#v", m.entries[0])
|
|
}
|
|
body = stripANSI(m.renderTranscript(100))
|
|
if strings.Contains(body, "tail-marker") {
|
|
t.Fatalf("expanded transcript should cap tool output: %q", body)
|
|
}
|
|
if !strings.Contains(body, "...") {
|
|
t.Fatalf("expanded transcript should show capped output ellipsis: %q", body)
|
|
}
|
|
if got := strings.Count(body, "x"); got != maxCtrlOToolOutputRunes-3 {
|
|
t.Fatalf("expanded transcript x count = %d, want %d:\n%s", got, maxCtrlOToolOutputRunes-3, body)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
body = stripANSI(m.renderTranscript(100))
|
|
if m.toolOutputOpen || m.entries[0].expanded || strings.Contains(body, strings.Repeat("x", 20)) {
|
|
t.Fatalf("second ctrl+o should collapse tool output: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletedToolsGroupWhenNextStepStarts(t *testing.T) {
|
|
firstArgs := map[string]any{"command": "pwd"}
|
|
secondArgs := map[string]any{"command": "ls"}
|
|
thirdArgs := map[string]any{"command": "date"}
|
|
m := chatModel{}
|
|
|
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs})
|
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "one"})
|
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs})
|
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "two"})
|
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-3", ToolName: "bash", Args: thirdArgs})
|
|
|
|
if len(m.entries) != 2 {
|
|
t.Fatalf("entries = %d, want grouped history plus active tool: %#v", len(m.entries), m.entries)
|
|
}
|
|
if m.entries[0].role != "tool_group" || len(m.entries[0].tools) != 2 {
|
|
t.Fatalf("first entry should be grouped tool history: %#v", m.entries[0])
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(m.entries[0])); line != "Ran 2 commands" {
|
|
t.Fatalf("grouped command line = %q", line)
|
|
}
|
|
if m.entries[1].status != "running" || m.entries[1].label != "Bash(\"date\")" {
|
|
t.Fatalf("second entry should be active tool: %#v", m.entries[1])
|
|
}
|
|
if line := stripANSI(toolStatusLine(m.entries[1])); line != `Bash("date")` {
|
|
t.Fatalf("running command line = %q", line)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletedToolsGroupAcrossEmptyAssistantEntries(t *testing.T) {
|
|
entries := groupCompletedToolEntries([]chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "done", content: "listed"}),
|
|
newChatEntry(chatEntry{role: "assistant"}),
|
|
newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("agent")`, status: "error", err: "is directory", content: "is directory"}),
|
|
newChatEntry(chatEntry{role: "assistant", content: " "}),
|
|
newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}),
|
|
newChatEntry(chatEntry{role: "assistant", content: "The files were listed."}),
|
|
})
|
|
|
|
if len(entries) != 2 {
|
|
t.Fatalf("entries = %d, want grouped tools plus assistant: %#v", len(entries), entries)
|
|
}
|
|
if entries[0].role != "tool_group" || len(entries[0].tools) != 3 {
|
|
t.Fatalf("first entry should group tools across empty assistant entries: %#v", entries[0])
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 1 command and read 2 files" {
|
|
t.Fatalf("grouped tool line = %q", line)
|
|
}
|
|
if entries[1].role != "assistant" || entries[1].content != "The files were listed." {
|
|
t.Fatalf("second entry should keep real assistant content: %#v", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) {
|
|
entries := groupCompletedToolEntries([]chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", content: "one"}),
|
|
newChatEntry(chatEntry{role: "thinking", label: "Thinking", content: "choose next tool", status: "done"}),
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "done", content: "two"}),
|
|
newChatEntry(chatEntry{role: "assistant"}),
|
|
newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}),
|
|
})
|
|
|
|
if len(entries) != 1 {
|
|
t.Fatalf("entries = %d, want one grouped tool entry: %#v", len(entries), entries)
|
|
}
|
|
if entries[0].role != "tool_group" || len(entries[0].tools) != 3 {
|
|
t.Fatalf("tools should group across collapsed thinking: %#v", entries[0])
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands and read a file" {
|
|
t.Fatalf("grouped tool line = %q", line)
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlOTogglesInlineToolOutput(t *testing.T) {
|
|
m := chatModel{
|
|
width: 100,
|
|
height: 20,
|
|
entries: []chatEntry{
|
|
{role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: "one"},
|
|
{role: "assistant", content: "between"},
|
|
{role: "tool", detail: "read", label: "Read(\"file\")", status: "error", err: "nope", content: "two"},
|
|
},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
for _, index := range []int{0, 2} {
|
|
if !m.entries[index].expanded {
|
|
t.Fatalf("tool entry %d should be expanded inline", index)
|
|
}
|
|
}
|
|
view := stripANSI(m.renderTranscript(100))
|
|
if strings.Contains(view, "Tool details") {
|
|
t.Fatalf("ctrl+o should keep tool output inline: %q", view)
|
|
}
|
|
if !strings.Contains(view, "one") || !strings.Contains(view, "two") {
|
|
t.Fatalf("view missing inline expanded tool output: %q", view)
|
|
}
|
|
if !strings.Contains(view, "between") {
|
|
t.Fatalf("inline tool output should keep surrounding chat visible: %q", view)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
for _, index := range []int{0, 2} {
|
|
if m.entries[index].expanded {
|
|
t.Fatalf("tool entry %d should remain collapsed", index)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlOTogglesInlineOutput(t *testing.T) {
|
|
m := chatModel{
|
|
width: 100,
|
|
height: 24,
|
|
entries: []chatEntry{
|
|
{role: "user", content: "who is parth sareen"},
|
|
{role: "tool", detail: "web_search", label: "Web Search(\"who is Parth Sareen\")", status: "done", content: "Search results"},
|
|
{role: "assistant", content: "Based on public search results, Parth Sareen works on AI tooling."},
|
|
},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
if !m.toolOutputOpen || !m.entries[1].expanded {
|
|
t.Fatalf("tool output should be expanded inline: %#v", m.entries[1])
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
if m.toolOutputOpen || m.entries[1].expanded {
|
|
t.Fatalf("tool output should be collapsed inline: %#v", m.entries[1])
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlODoesNotExpandThinking(t *testing.T) {
|
|
m := chatModel{
|
|
entries: []chatEntry{
|
|
newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning"}),
|
|
},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
if m.entries[0].expanded {
|
|
t.Fatalf("ctrl+o should not expand thinking entries: %#v", m.entries[0])
|
|
}
|
|
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "private reasoning") {
|
|
t.Fatalf("ctrl+o should not render thinking content:\n%s", view)
|
|
}
|
|
|
|
m.liveMessages = []api.Message{{Role: "assistant", Thinking: "live private reasoning"}}
|
|
m.syncThinkingEntry()
|
|
if m.entries[1].expanded {
|
|
t.Fatalf("live thinking should not inherit ctrl+o expansion: %#v", m.entries[1])
|
|
}
|
|
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "live private reasoning") {
|
|
t.Fatalf("ctrl+o should not render live thinking content:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) {
|
|
m := chatModel{
|
|
width: 80,
|
|
height: 8,
|
|
opts: Options{Model: "gemma4"},
|
|
entries: []chatEntry{
|
|
{role: "user", content: "inspect this"},
|
|
{role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: strings.Repeat("output\n", 20)},
|
|
{role: "assistant", content: "Done."},
|
|
},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, "│ █") {
|
|
t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) {
|
|
args := map[string]any{"command": "pwd"}
|
|
m := chatModel{
|
|
width: 100,
|
|
height: 20,
|
|
running: true,
|
|
opts: Options{Model: "test-model"},
|
|
}
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolStarted,
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Args: args,
|
|
})
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
if !m.entries[0].expanded {
|
|
t.Fatalf("ctrl+o should expand the running tool inline")
|
|
}
|
|
view := stripANSI(m.View())
|
|
if !strings.Contains(view, `Bash("pwd")`) || !strings.Contains(view, "$ pwd") {
|
|
t.Fatalf("expanded transcript missing running tool details:\n%s", view)
|
|
}
|
|
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolFinished,
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Args: args,
|
|
Content: "/tmp/project\n",
|
|
})
|
|
|
|
view = stripANSI(m.View())
|
|
if !strings.Contains(view, "/tmp/project") {
|
|
t.Fatalf("finished tool output should be visible inline: %q", view)
|
|
}
|
|
if !strings.Contains(view, "│ █") {
|
|
t.Fatalf("input prompt disappeared while tool output is expanded:\n%s", view)
|
|
}
|
|
if !strings.Contains(view, "test-model") {
|
|
t.Fatalf("footer/model line disappeared while tool output is expanded:\n%s", view)
|
|
}
|
|
|
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
view = stripANSI(m.View())
|
|
if !strings.Contains(view, "│ █") {
|
|
t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view)
|
|
}
|
|
if !strings.Contains(view, "test-model") {
|
|
t.Fatalf("footer/model line disappeared after collapsing tool output:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatLongCommandLabelTruncatesAndCtrlOShowsFullCommand(t *testing.T) {
|
|
command := strings.Repeat("x", 120)
|
|
m := chatModel{
|
|
width: 180,
|
|
height: 20,
|
|
}
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolStarted,
|
|
ToolCallID: "call-1",
|
|
ToolName: "bash",
|
|
Args: map[string]any{"command": command},
|
|
})
|
|
|
|
wantLabel := `Bash("` + strings.Repeat("x", 100) + `...")`
|
|
if m.entries[0].label != wantLabel {
|
|
t.Fatalf("label = %q, want %q", m.entries[0].label, wantLabel)
|
|
}
|
|
if strings.Contains(m.entries[0].label, strings.Repeat("x", 101)) {
|
|
t.Fatalf("collapsed label should truncate command: %q", m.entries[0].label)
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
view := stripANSI(m.renderTranscript(180))
|
|
if !strings.Contains(view, "$ "+command) {
|
|
t.Fatalf("expanded ctrl+o view should show full command:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatCtrlOInlineOutputSurvivesToolGrouping(t *testing.T) {
|
|
firstArgs := map[string]any{"command": "pwd"}
|
|
secondArgs := map[string]any{"command": "ls"}
|
|
m := chatModel{
|
|
width: 100,
|
|
height: 24,
|
|
entries: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: "one", args: firstArgs}),
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: "Bash(\"ls\")", status: "done", content: "two", args: secondArgs}),
|
|
},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolStarted,
|
|
ToolCallID: "call-3",
|
|
ToolName: "bash",
|
|
Args: map[string]any{"command": "date"},
|
|
})
|
|
|
|
if len(m.entries) != 2 {
|
|
t.Fatalf("entries = %d, want tool group plus active tool: %#v", len(m.entries), m.entries)
|
|
}
|
|
if m.entries[0].role != "tool_group" || !m.entries[0].expanded {
|
|
t.Fatalf("grouped tool history should stay expanded inline: %#v", m.entries[0])
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(m.entries[0])); line != "Ran 2 commands" {
|
|
t.Fatalf("grouped command line = %q", line)
|
|
}
|
|
|
|
view := stripANSI(m.renderTranscript(100))
|
|
for _, want := range []string{`Bash("pwd")`, `Bash("ls")`, `Bash("date")`, "one", "two"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Fatalf("grouped tool output missing %q:\n%s", want, view)
|
|
}
|
|
}
|
|
if strings.Contains(view, " Ran 1 command") {
|
|
t.Fatalf("expanded grouped children should show concrete invocations, not generic summaries:\n%s", view)
|
|
}
|
|
}
|
|
|
|
func TestChatExpandedToolGroupRendersIndentedToolBlocks(t *testing.T) {
|
|
startedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
|
m := chatModel{
|
|
entries: []chatEntry{
|
|
newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Tool calls (2)",
|
|
status: "done",
|
|
expanded: true,
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "web_search",
|
|
label: "Web Search(\"Parth Sareen\")",
|
|
status: "done",
|
|
content: "Search results for: Parth Sareen",
|
|
startedAt: startedAt,
|
|
finishedAt: startedAt.Add(823 * time.Millisecond),
|
|
}),
|
|
newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "read",
|
|
label: "Read(\"feedback.md\")",
|
|
status: "done",
|
|
content: "Looks good.",
|
|
startedAt: startedAt.Add(time.Second),
|
|
finishedAt: startedAt.Add(3 * time.Second),
|
|
}),
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
|
|
transcript := stripANSI(m.renderTranscript(120))
|
|
expected := strings.Join([]string{
|
|
" Web Search(\"Parth Sareen\")",
|
|
" Search results for: Parth Sareen",
|
|
" ",
|
|
" Read(\"feedback.md\")",
|
|
" Looks good.",
|
|
}, "\n")
|
|
if !strings.Contains(transcript, expected) {
|
|
t.Fatalf("expanded tool group did not render expected block spacing:\n%s", transcript)
|
|
}
|
|
}
|
|
|
|
func TestChatExpandedToolGroupCapsChildOutput(t *testing.T) {
|
|
longOutput := strings.Repeat("y", maxCtrlOToolOutputRunes+25) + "group-tail-marker"
|
|
m := chatModel{
|
|
entries: []chatEntry{
|
|
newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
status: "done",
|
|
expanded: true,
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "read",
|
|
label: "Read(\"big.log\")",
|
|
status: "done",
|
|
content: longOutput,
|
|
}),
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
|
|
transcript := stripANSI(m.renderTranscript(120))
|
|
if strings.Contains(transcript, "group-tail-marker") {
|
|
t.Fatalf("expanded grouped tool output should be capped:\n%s", transcript)
|
|
}
|
|
if !strings.Contains(transcript, "...") {
|
|
t.Fatalf("expanded grouped tool output should show capped output ellipsis:\n%s", transcript)
|
|
}
|
|
if got := strings.Count(transcript, "y"); got != maxCtrlOToolOutputRunes-3 {
|
|
t.Fatalf("expanded grouped tool output y count = %d, want %d:\n%s", got, maxCtrlOToolOutputRunes-3, transcript)
|
|
}
|
|
if got := m.entries[0].tools[0].content; got != longOutput {
|
|
t.Fatal("ctrl+o rendering should not mutate grouped tool content")
|
|
}
|
|
}
|
|
|
|
func TestChatToolGroupStatusOmitsResultCounts(t *testing.T) {
|
|
startedAt := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Tool calls (3)",
|
|
status: "error",
|
|
startedAt: startedAt,
|
|
finishedAt: startedAt.Add(3 * time.Second),
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", status: "done"}),
|
|
newChatEntry(chatEntry{role: "tool", status: "done"}),
|
|
newChatEntry(chatEntry{role: "tool", status: "error", err: "failed"}),
|
|
},
|
|
})
|
|
|
|
line := stripANSI(toolGroupStatusLine(entry))
|
|
for _, word := range []string{"succeeded", "failed", "done", "in 3s", "3s"} {
|
|
if strings.Contains(line, word) {
|
|
t.Fatalf("group status = %q, should not show status word or elapsed %q", line, word)
|
|
}
|
|
}
|
|
if line != "Used 3 tools" {
|
|
t.Fatalf("group status = %q, want action summary", line)
|
|
}
|
|
}
|
|
|
|
func TestChatToolGroupStatusShowsAllSuccessCount(t *testing.T) {
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Tool calls (2)",
|
|
status: "done",
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", status: "done"}),
|
|
newChatEntry(chatEntry{role: "tool", status: "done"}),
|
|
},
|
|
})
|
|
|
|
line := stripANSI(toolGroupStatusLine(entry))
|
|
if strings.Contains(line, "succeeded") || strings.Contains(line, "done") {
|
|
t.Fatalf("group status = %q, should not show success count or done", line)
|
|
}
|
|
if line != "Used 2 tools" {
|
|
t.Fatalf("group status = %q, want action summary", line)
|
|
}
|
|
}
|
|
|
|
func TestChatToolGroupStatusRecomputesCachedLabel(t *testing.T) {
|
|
entries := groupCompletedToolEntries([]chatEntry{
|
|
newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Ran 1 command",
|
|
status: "done",
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done"}),
|
|
},
|
|
}),
|
|
newChatEntry(chatEntry{
|
|
role: "tool",
|
|
detail: "bash",
|
|
label: `Bash("ls")`,
|
|
status: "done",
|
|
}),
|
|
})
|
|
|
|
if len(entries) != 1 || entries[0].role != "tool_group" {
|
|
t.Fatalf("entries = %#v, want regrouped tool group", entries)
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands" {
|
|
t.Fatalf("group status = %q, want recomputed action summary", line)
|
|
}
|
|
}
|
|
|
|
func TestChatToolGroupStatusKeepsHiddenDetectedCount(t *testing.T) {
|
|
args := map[string]any{"command": "ls"}
|
|
entry := newChatEntry(chatEntry{
|
|
role: "tool_group",
|
|
label: "Ran 2 commands",
|
|
status: "done",
|
|
tools: []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done"}),
|
|
},
|
|
})
|
|
entry.tools[0].args = map[string]any{"command": "pwd"}
|
|
detected := []chatEntry{
|
|
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "queued", args: args}),
|
|
}
|
|
|
|
grouped := groupCompletedToolEntries([]chatEntry{entry}, detected...)
|
|
if len(grouped) != 1 || grouped[0].role != "tool_group" || len(grouped[0].tools) != 1 {
|
|
t.Fatalf("grouped entries = %#v, want visible finished child only", grouped)
|
|
}
|
|
if line := stripANSI(toolGroupStatusLine(grouped[0])); line != "Ran 2 commands" {
|
|
t.Fatalf("group status = %q, want hidden detected command counted", line)
|
|
}
|
|
}
|
|
|
|
func TestChatToolOutputRendersUnifiedDiff(t *testing.T) {
|
|
diff := strings.Join([]string{
|
|
"diff --git a/file.go b/file.go",
|
|
"index 1111111..2222222 100644",
|
|
"--- a/file.go",
|
|
"+++ b/file.go",
|
|
"@@ -1,3 +1,3 @@",
|
|
" package main",
|
|
"-var old = true",
|
|
"+var newer = true",
|
|
}, "\n")
|
|
m := chatModel{
|
|
entries: []chatEntry{{
|
|
role: "tool",
|
|
detail: "bash",
|
|
label: "Bash(\"git diff\")",
|
|
status: "done",
|
|
content: diff,
|
|
}},
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
rendered := m.renderTranscript(100)
|
|
body := stripANSI(rendered)
|
|
if !strings.Contains(body, "diff --git a/file.go b/file.go") ||
|
|
!strings.Contains(body, "-var old = true") ||
|
|
!strings.Contains(body, "+var newer = true") {
|
|
t.Fatalf("rendered diff missing expected lines: %q", body)
|
|
}
|
|
if !looksLikeUnifiedDiff(diff) {
|
|
t.Fatal("diff output should be detected as a unified diff")
|
|
}
|
|
}
|
|
|
|
func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) {
|
|
args := api.NewToolCallFunctionArguments()
|
|
args.Set("query", "Parth Sareen Ollama software engineer")
|
|
|
|
m := chatModel{width: 100, height: 30}
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolCallDetected,
|
|
ToolCalls: []api.ToolCall{{
|
|
ID: "call-1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "web_search",
|
|
Arguments: args,
|
|
},
|
|
}},
|
|
})
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolStarted,
|
|
ToolCallID: "call-1",
|
|
ToolName: "web_search",
|
|
Args: args.ToMap(),
|
|
})
|
|
m.applyAgentEvent(coreagent.Event{
|
|
Type: coreagent.EventToolFinished,
|
|
ToolCallID: "call-1",
|
|
ToolName: "web_search",
|
|
Args: args.ToMap(),
|
|
Content: "**Search results for:** Parth Sareen\n\n1. Parth Sareen\n URL: https://parthsareen.com\n",
|
|
})
|
|
|
|
transcript := stripANSI(m.renderTranscript(100))
|
|
if !strings.Contains(transcript, "Web Search(\"Parth Sareen Ollama software engineer\")") {
|
|
t.Fatalf("transcript missing invocation: %q", transcript)
|
|
}
|
|
for _, word := range []string{"done", "in 6s", "6s"} {
|
|
if strings.Contains(transcript, word) {
|
|
t.Fatalf("transcript should not include status word/elapsed %q: %q", word, transcript)
|
|
}
|
|
}
|
|
if strings.Contains(transcript, "https://parthsareen.com") || strings.Contains(transcript, "Search results for:") {
|
|
t.Fatalf("tool output should be collapsed by default: %q", transcript)
|
|
}
|
|
if strings.Contains(transcript, "web_search") {
|
|
t.Fatalf("transcript should use display name instead of raw tool name: %q", transcript)
|
|
}
|
|
if m.entries[0].status != "done" {
|
|
t.Fatalf("tool status = %q, want done", m.entries[0].status)
|
|
}
|
|
|
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
|
m = updated.(chatModel)
|
|
view := stripANSI(m.renderTranscript(100))
|
|
if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") {
|
|
t.Fatalf("inline web output missing content: %q", view)
|
|
}
|
|
}
|
|
|
|
func TestChatToolOutputHidesInternalTruncationMarker(t *testing.T) {
|
|
entry := chatEntry{
|
|
role: "tool",
|
|
detail: "bash",
|
|
status: "done",
|
|
expanded: true,
|
|
content: "head\n\n[tool output truncated: showing first ~10 tokens and last ~10 tokens; omitted ~25 tokens. Use a narrower command, line range, or search query if more detail is needed.]\n\ntail",
|
|
}
|
|
|
|
rendered := stripANSI(strings.Join(renderToolResultLines(entry, 80), "\n"))
|
|
if strings.Contains(rendered, "tool output truncated") || strings.Contains(rendered, "omitted ~25 tokens") {
|
|
t.Fatalf("internal truncation marker should be hidden from tool output: %q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "head") || !strings.Contains(rendered, "tail") {
|
|
t.Fatalf("tool output should keep visible content: %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestChatRenderTranscriptCachesEntryLinesUntilDirty(t *testing.T) {
|
|
m := chatModel{
|
|
entries: []chatEntry{
|
|
newChatEntry(chatEntry{role: "assistant", content: "**hello**"}),
|
|
},
|
|
}
|
|
|
|
_ = m.renderTranscript(80)
|
|
if len(m.entries[0].renderLines) == 0 {
|
|
t.Fatal("rendered entry lines should be cached")
|
|
}
|
|
|
|
m.entries[0].renderLines = []string{"cached line"}
|
|
if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "cached line") {
|
|
t.Fatalf("render should reuse cached lines for unchanged entry: %q", got)
|
|
}
|
|
|
|
m.entries[0].content = "**changed**"
|
|
m.markEntryDirty(0)
|
|
if got := stripANSI(m.renderTranscript(80)); strings.Contains(got, "cached line") || !strings.Contains(got, "changed") {
|
|
t.Fatalf("dirty entry should re-render instead of using stale cache: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestWrapChatTextSplitsLongLines(t *testing.T) {
|
|
lines := wrapChatText("alpha beta gamma delta", 12)
|
|
if len(lines) < 2 {
|
|
t.Fatalf("lines = %#v, want split text", lines)
|
|
}
|
|
if strings.Contains(lines[0], "delta") {
|
|
t.Fatalf("first line was not wrapped: %#v", lines)
|
|
}
|
|
}
|
|
|
|
func TestWrapChatTextUsesDisplayWidth(t *testing.T) {
|
|
lines := wrapChatText(strings.Repeat("界", 20), 20)
|
|
if len(lines) < 2 {
|
|
t.Fatalf("lines = %#v, want full-width text split", lines)
|
|
}
|
|
for _, line := range lines {
|
|
if got := lipgloss.Width(line); got > 20 {
|
|
t.Fatalf("line %q width = %d, want <= 20", line, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderMarkdownTableWrapsLongCells(t *testing.T) {
|
|
markdown := strings.Join([]string{
|
|
"| # | Item | Why it matters |",
|
|
"|---|---|---|",
|
|
"| **C** | **Bash filesystem/network confinement** | Biggest asymmetry: file tools are sandboxed via `os.Root`, bash is not and this text should survive until tail-token. |",
|
|
}, "\n")
|
|
|
|
rendered := renderMarkdownForView(markdown, 72)
|
|
plain := stripANSI(rendered)
|
|
if !strings.Contains(plain, "tail-token") {
|
|
t.Fatalf("long table cell was truncated:\n%s", plain)
|
|
}
|
|
if strings.Contains(plain, "tail-toke...") {
|
|
t.Fatalf("long table cell should wrap, not ellipsize:\n%s", plain)
|
|
}
|
|
for _, line := range strings.Split(rendered, "\n") {
|
|
if got := lipgloss.Width(line); got > 72 {
|
|
t.Fatalf("rendered table line width = %d, want <= 72: %q\n%s", got, stripANSI(line), plain)
|
|
}
|
|
}
|
|
}
|