package chat import ( "context" "os" "path/filepath" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" coreagent "github.com/ollama/ollama/agent" "github.com/ollama/ollama/api" ) func TestChatHelpCommandShowsV1Commands(t *testing.T) { m := chatModel{input: []rune("/help")} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("help command should not return a command") } fm := updated.(chatModel) if len(fm.entries) != 1 { t.Fatalf("entries = %d, want 1", len(fm.entries)) } for _, want := range []string{ "**Commands**", "- `/model`: switch models", "- `/think`: set thinking mode", "- `/compact`: summarize older context", "- `/help`: show commands", "- `/bye`: exit", "- `/prompt`: show full prompt, tools, and messages", "- `/save `: save request JSON; saved as .json", "**Shortcuts**", "- `shift+enter`: insert a newline", "- `shift+tab`: toggle permission mode", } { if !strings.Contains(fm.entries[0].content, want) { t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content) } } for _, removed := range []string{"/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} { if strings.Contains(fm.entries[0].content, removed) { t.Fatalf("removed command %q should stay hidden from help:\n%s", removed, fm.entries[0].content) } } } func TestChatNewCommandRepaintsFromTop(t *testing.T) { m := chatModel{ input: []rune("/new"), flowPrintedLines: 4, entries: []chatEntry{{role: "assistant", content: "old transcript"}}, messages: []api.Message{{Role: "user", Content: "old prompt"}}, approvalState: testApprovalState(true, map[string]bool{"edit": true}), opts: Options{AllowAllTools: true}, permissionNotice: "full access enabled", } updated, cmd := m.handleSubmit() if cmd == nil { t.Fatal("/new should return a repaint command") } m = updated.(chatModel) if m.status != "new chat" { t.Fatalf("status = %q, want new chat", m.status) } if len(m.entries) != 0 || len(m.messages) != 0 { t.Fatalf("chat was not reset: entries=%#v messages=%#v", m.entries, m.messages) } if m.flowPrintedLines != 0 { t.Fatalf("flowPrintedLines = %d, want 0", m.flowPrintedLines) } if m.approvalState.AllowAll() || m.opts.AllowAllTools || m.approvalState.Allows("edit") || m.permissionNotice != "" { t.Fatalf("permissions were not reset: allowAll=%v opts=%v editAllowed=%v notice=%q", m.approvalState.AllowAll(), m.opts.AllowAllTools, m.approvalState.Allows("edit"), m.permissionNotice) } if msg := cmd(); msg == nil { t.Fatal("repaint command returned nil") } } func TestChatNewCommandPreservesLaunchFullAccessDefault(t *testing.T) { m := chatModel{ input: []rune("/new"), defaultAllowAll: true, approvalState: testApprovalState(false, map[string]bool{"edit": true}), } updated, _ := m.handleSubmit() fm := updated.(chatModel) if !fm.approvalState.AllowAll() || !fm.opts.AllowAllTools { t.Fatalf("full access default was not restored: allowAll=%v opts=%v", fm.approvalState.AllowAll(), fm.opts.AllowAllTools) } fm.approvalState.SetAllowAll(false) if fm.approvalState.Allows("edit") { t.Fatal("edit scope should be cleared") } } func TestChatSaveCommandRequiresFilename(t *testing.T) { m := chatModel{ input: []rune("/save"), opts: Options{Model: "llama3.2"}, } updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("/save should not start a command") } fm := updated.(chatModel) if fm.status != "error" { t.Fatalf("status = %q, want error", fm.status) } if len(fm.entries) != 1 || !strings.Contains(fm.entries[0].content, "usage: /save ") { t.Fatalf("entries = %#v, want usage error", fm.entries) } } func TestChatSaveCommandWritesRequestJSON(t *testing.T) { dir := t.TempDir() m := chatModel{ input: []rune("/save request"), workingDir: dir, opts: Options{ Model: "llama3.2", SystemPrompt: "You are Ollama.", }, messages: []api.Message{{Role: "user", Content: "hello"}}, } updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("/save redirect should not start a command") } fm := updated.(chatModel) if fm.status != "saved" { t.Fatalf("status = %q, want saved", fm.status) } data, err := os.ReadFile(filepath.Join(dir, "request.json")) if err != nil { t.Fatal(err) } raw := string(data) for _, want := range []string{ `"model": "llama3.2"`, `"role": "system"`, `"content": "You are Ollama."`, `"role": "user"`, `"content": "hello"`, } { if !strings.Contains(raw, want) { t.Fatalf("saved request missing %q:\n%s", want, raw) } } if got := fm.entries[len(fm.entries)-1].content; got != "saved as request.json" { t.Fatalf("save entry = %q, want saved filename", got) } } func TestChatSaveCommandRejectsPath(t *testing.T) { dir := t.TempDir() m := chatModel{ input: []rune("/save ../request"), workingDir: dir, opts: Options{Model: "llama3.2"}, } updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("invalid /save should not start a command") } fm := updated.(chatModel) if fm.status != "error" { t.Fatalf("status = %q, want error", fm.status) } if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "request.json")); !os.IsNotExist(err) { t.Fatalf("/save wrote outside working dir, stat err = %v", err) } } func TestChatPromptCommandOpensPromptDebugScreen(t *testing.T) { registry := &coreagent.Registry{} registry.Register(chatTestTool{}) toolArgs := api.NewToolCallFunctionArguments() toolArgs.Set("query", "show me everything") m := chatModel{ input: []rune("/prompt"), width: 100, height: 20, opts: Options{ Model: "llama3.2", SystemPrompt: "You are Ollama.", Tools: registry, ContextWindowTokens: 1024, }, messages: []api.Message{ {Role: "user", Content: "hello\nsecond line"}, { Role: "assistant", Content: "I'll call a tool.", Thinking: "I should call the fake tool first.", ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "fake_tool", Arguments: toolArgs, }, }}, }, {Role: "tool", ToolName: "fake_tool", ToolCallID: "call-1", Content: "tool result line 1\ntool result line 2"}, }, } updated, cmd := m.handleSubmit() if cmd == nil { t.Fatal("/prompt should enable managed prompt screen") } fm := updated.(chatModel) if fm.promptDebug == nil { t.Fatal("/prompt should open prompt debug screen") } if len(fm.entries) != 0 { t.Fatalf("/prompt should not append a transcript entry: %#v", fm.entries) } out := stripANSI(fm.View()) body := stripANSI(strings.Join(fm.promptDebugLines(160), "\n")) for _, want := range []string{ "Prompt", "full request preview", } { if !strings.Contains(out, want) { t.Fatalf("/prompt view missing %q:\n%s", want, out) } } for _, want := range []string{ "model: llama3.2", "estimated prompt:", "/ 1024 tokens", "messages: 4", "tools: 1", "Tools", "1. fake_tool", "description:", "does test work", "parameters: object", "1. system", "You are Ollama.", "2. user", "hello", "second line", "3. assistant", "thinking:", "I should call the fake tool first.", "content:", "I'll call a tool.", "tool call 1: fake_tool", "id: call-1", "arguments:", "query: show me everything", "4. tool:fake_tool", "tool_name: fake_tool", "tool_call_id: call-1", "tool result", "tool result line 1", "tool result line 2", } { if !strings.Contains(body, want) { t.Fatalf("/prompt output missing %q:\n%s", want, body) } } assistantStart := strings.Index(body, "3. assistant") if assistantStart < 0 { t.Fatalf("/prompt output missing assistant message:\n%s", body) } assistantBody := body[assistantStart:] thinkingIndex := strings.Index(assistantBody, "thinking:") contentIndex := strings.Index(assistantBody, "content:") if thinkingIndex < 0 || contentIndex < 0 { t.Fatalf("/prompt assistant output missing thinking/content labels:\n%s", body) } if thinkingIndex > contentIndex { t.Fatalf("/prompt assistant thinking should render before content:\n%s", body) } for _, unwanted := range []string{`"query":`, `"name": "fake_tool"`} { if strings.Contains(body, unwanted) { t.Fatalf("/prompt output should be rendered, not raw JSON; found %q:\n%s", unwanted, body) } } messagesIndex := strings.Index(body, "Messages") toolsIndex := strings.Index(body, "Tools") if messagesIndex < 0 || toolsIndex < 0 || toolsIndex < messagesIndex { t.Fatalf("/prompt should render tools after messages:\n%s", body) } updated, cmd = fm.Update(tea.KeyMsg{Type: tea.KeyEsc}) if cmd == nil { t.Fatal("closing prompt debug should disable prompt mouse mode") } fm = updated.(chatModel) if fm.promptDebug != nil { t.Fatal("esc should close prompt debug screen") } } func TestChatPromptDebugCapsToolResultPreview(t *testing.T) { longResult := strings.Repeat("x", maxPromptDebugToolResultRunes+25) + "tail-marker" m := chatModel{ width: 120, height: 20, promptDebug: &chatPromptDebug{ request: api.ChatRequest{ Model: "llama3.2", Messages: []api.Message{{ Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: longResult, }}, }, }, } body := stripANSI(strings.Join(m.promptDebugLines(120), "\n")) if strings.Contains(body, "tail-marker") { t.Fatalf("/prompt should cap rendered tool results:\n%s", body) } if !strings.Contains(body, "...") { t.Fatalf("/prompt capped tool result should show ellipsis:\n%s", body) } if got := strings.Count(body, "x"); got != maxPromptDebugToolResultRunes-3 { t.Fatalf("rendered tool result x count = %d, want %d:\n%s", got, maxPromptDebugToolResultRunes-3, body) } if got := m.promptDebug.request.Messages[0].Content; got != longResult { t.Fatal("/prompt rendering should not mutate the request") } } func TestChatPromptDebugMouseWheelScrolls(t *testing.T) { m := chatModel{ width: 80, height: 8, opts: Options{Model: "llama3.2", ContextWindowTokens: 1024}, promptDebug: &chatPromptDebug{ request: api.ChatRequest{Model: "llama3.2"}, tokens: 10, }, } for range 30 { m.promptDebug.request.Messages = append(m.promptDebug.request.Messages, api.Message{Role: "user", Content: "line"}) } if m.promptDebugMaxScroll() == 0 { t.Fatal("test setup should produce a scrollable prompt debug screen") } updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) m = updated.(chatModel) if m.promptDebug.scroll == 0 { t.Fatal("mouse wheel down should scroll prompt debug screen") } updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelUp}) m = updated.(chatModel) if m.promptDebug.scroll != 0 { t.Fatalf("mouse wheel up should return prompt debug screen to top, got scroll %d", m.promptDebug.scroll) } } func TestTruncateInputLineUsesDisplayWidth(t *testing.T) { line := truncateInputLine(strings.Repeat("界", 10), 10) if got := lipgloss.Width(line); got > 10 { t.Fatalf("line %q width = %d, want <= 10", line, got) } } func TestRenderInputBoxTruncationUsesSingleContinuationMarker(t *testing.T) { lines := renderInputBoxLines("one two three four five six seven", len("one two three four five six seven"), 16, 1, "") rendered := strings.Join(lines, "\n") if strings.Contains(rendered, "... ...") { t.Fatalf("input rendered duplicate continuation marker: %q", rendered) } if strings.Contains(rendered, "one two") { t.Fatalf("input should keep the latest truncated line: %q", rendered) } } type shiftEnterCSITestMsg string func (m shiftEnterCSITestMsg) String() string { return string(m) } func TestChatInputHandlesShiftEnterCSIMessage(t *testing.T) { m := chatModel{input: []rune("line one")} updated, _ := m.Update(shiftEnterCSITestMsg("?CSI[49 51 59 50 117]?")) m = updated.(chatModel) if got := string(m.input); got != "line one\n" { t.Fatalf("input = %q, want newline inserted", got) } } func TestChatInputAcceptsSpace(t *testing.T) { m := chatModel{} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}) m = updated.(chatModel) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) m = updated.(chatModel) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("world")}) m = updated.(chatModel) if got := string(m.input); got != "hello world" { t.Fatalf("input = %q, want hello world", got) } } func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) { pasted := strings.Repeat("line\n", pastedTextPlaceholderMinLines-1) + "line" m := chatModel{ ctx: context.Background(), opts: Options{ Model: "test", Client: chatTestClient{}, }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasted), Paste: true}) m = updated.(chatModel) if got, want := string(m.input), "[Pasted text #1 +8 lines]"; got != want { t.Fatalf("input = %q, want %q", got, want) } updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(chatModel) if cmd == nil { t.Fatal("enter should start a run") } done := waitForRunDone(t, m.events) if done.err != nil { t.Fatal(done.err) } if done.result == nil || len(done.result.Messages) < 1 || done.result.Messages[0].Content != pasted { t.Fatalf("messages = %#v, want expanded pasted text", done.result) } } func TestChatBackspaceDeletesWholePastedTextPlaceholder(t *testing.T) { m := chatModel{ input: []rune("use [Pasted text #1 +8 lines]"), inputPastedTexts: []chatInputPastedText{{ placeholder: "[Pasted text #1 +8 lines]", content: "hidden", }}, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) m = updated.(chatModel) if got := string(m.input); got != "use " { t.Fatalf("input after backspace = %q, want pasted text placeholder removed", got) } if got := len(m.inputPastedTexts); got != 0 { t.Fatalf("pasted texts after backspace = %d, want 0", got) } } func TestInitialPromptHistoryLoadsFromMessages(t *testing.T) { history := initialPromptHistory(context.Background(), Options{ Messages: []api.Message{ {Role: "user", Content: "old prompt"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "new prompt"}, }, }) if got, want := strings.Join(history, "|"), "old prompt|new prompt"; got != want { t.Fatalf("history = %#v, want %s", history, want) } } func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) { for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} { t.Run(command, func(t *testing.T) { m := chatModel{input: []rune(command)} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("deleted slash command should not return a command") } m = updated.(chatModel) if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "Unknown command") { t.Fatalf("entries = %#v, want unknown command error", m.entries) } }) } } func TestChatViewRendersSlashCommandSuggestions(t *testing.T) { m := chatModel{ input: []rune("/"), width: 80, height: 18, } view := stripANSI(m.View()) for _, want := range []string{"/clear", "/model", "/new", "/think", "/tools"} { if !strings.Contains(view, want) { t.Fatalf("view missing %s suggestion: %q", want, view) } } for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} { if strings.Contains(view, removed) { t.Fatalf("bare slash should hide removed command %s: %q", removed, view) } } if got := len(m.slashCommandLines(80)); got != maxSlashCompletions { t.Fatalf("slash suggestions = %d, want %d", got, maxSlashCompletions) } } func TestChatToolsCommandTogglesToolRegistry(t *testing.T) { registry := &coreagent.Registry{} registry.Register(chatTestTool{}) calls := 0 m := chatModel{ ctx: context.Background(), input: []rune("/tools"), opts: Options{ Model: "llama3.2", Tools: registry, ToolRegistryForModel: func(context.Context, string) *coreagent.Registry { calls++ return registry }, }, } updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("/tools should not start a command") } m = updated.(chatModel) if !m.opts.ToolsDisabled || m.opts.Tools == nil { t.Fatalf("tools off state = disabled:%v tools:%#v", m.opts.ToolsDisabled, m.opts.Tools) } if len(m.entries) != 0 { t.Fatalf("/tools should only update action space, got entries:%#v", m.entries) } req, _ := m.requestPreview() if got := len(req.Tools); got != 0 { t.Fatalf("request preview tools = %d, want 0", got) } m.input = []rune("/tools") updated, cmd = m.handleSubmit() if cmd != nil { t.Fatal("/tools should not start a command") } m = updated.(chatModel) if m.opts.ToolsDisabled || m.opts.Tools == nil { t.Fatalf("tools on state = disabled:%v tools:%#v", m.opts.ToolsDisabled, m.opts.Tools) } if calls != 1 { t.Fatalf("tool registry calls = %d, want 1", calls) } if len(m.entries) != 0 { t.Fatalf("/tools should only update action space, got entries:%#v", m.entries) } req, _ = m.requestPreview() if got := len(req.Tools); got != 1 { t.Fatalf("request preview tools = %d, want 1", got) } } func TestChatToolsCommandUsage(t *testing.T) { m := chatModel{input: []rune("/tools off")} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("invalid /tools should not start a command") } m = updated.(chatModel) if m.status != "error" || len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "usage: /tools") { t.Fatalf("invalid /tools result = status:%q entries:%#v", m.status, m.entries) } } func TestChatSlashCommandSuggestionsIncludePromptAndSave(t *testing.T) { for _, tt := range []struct { input string command string description string }{ {input: "/pr", command: "/prompt", description: "show full prompt, tools, and messages"}, {input: "/sa", command: "/save", description: "save request JSON; saved as .json"}, } { t.Run(tt.command, func(t *testing.T) { m := chatModel{input: []rune(tt.input)} lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n")) if !strings.Contains(lines, tt.command) || !strings.Contains(lines, tt.description) { t.Fatalf("suggestions missing %s: %q", tt.command, lines) } }) } } func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) { m := chatModel{input: []rune("/th")} lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n")) if !strings.Contains(lines, "/think") || !strings.Contains(lines, "set thinking mode") { t.Fatalf("suggestions missing /think: %q", lines) } } func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) { m := chatModel{input: []rune("/th")} updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(chatModel) if cmd != nil { t.Fatal("think command should not return a command") } if m.thinkPicker == nil { t.Fatal("selected /think command should open picker") } } func TestChatSlashCommandsRunWhileModelResponds(t *testing.T) { m := chatModel{running: true, input: []rune("/help")} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("help command should not return a command") } m = updated.(chatModel) if len(m.entries) != 1 || m.entries[0].role != "slash" { t.Fatalf("entries = %#v, want immediate slash output", m.entries) } } func TestChatMessageSubmitWhileRunningPreservesDraft(t *testing.T) { attachment := chatInputAttachment{placeholder: "[image 1]", kind: "image"} pasted := chatInputPastedText{placeholder: "[pasted 1]", content: "long paste"} m := chatModel{ running: true, input: []rune("next prompt [image 1] [pasted 1]"), inputCursor: 4, inputCursorSet: true, inputAttachments: []chatInputAttachment{attachment}, inputPastedTexts: []chatInputPastedText{pasted}, } updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("busy submit should not start a command") } m = updated.(chatModel) if got := string(m.input); got != "next prompt [image 1] [pasted 1]" { t.Fatalf("input = %q, want draft preserved", got) } if m.inputCursor != 4 || !m.inputCursorSet { t.Fatalf("cursor not preserved: cursor=%d set=%v", m.inputCursor, m.inputCursorSet) } if len(m.inputAttachments) != 1 || m.inputAttachments[0].placeholder != attachment.placeholder || m.inputAttachments[0].kind != attachment.kind { t.Fatalf("attachments not preserved: %#v", m.inputAttachments) } if len(m.inputPastedTexts) != 1 || m.inputPastedTexts[0] != pasted { t.Fatalf("pasted text not preserved: %#v", m.inputPastedTexts) } if len(m.entries) != 0 { t.Fatalf("busy submit should not add entries: %#v", m.entries) } } func TestChatThinkCommandOpensPicker(t *testing.T) { m := chatModel{input: []rune("/think")} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("think command should not return a command") } m = updated.(chatModel) if m.thinkPicker == nil { t.Fatal("think picker should open") } if view := stripANSI(m.renderThinkPicker(80)); !strings.Contains(view, "Thinking mode") || !strings.Contains(view, "high") { t.Fatalf("think picker view missing options: %q", view) } } func TestChatThinkCommandSetsModes(t *testing.T) { for _, tt := range []struct { input string want any }{ {input: "/think on", want: true}, {input: "/think off", want: false}, {input: "/think high", want: "high"}, } { t.Run(tt.input, func(t *testing.T) { m := chatModel{input: []rune(tt.input)} updated, cmd := m.handleSubmit() if cmd != nil { t.Fatal("think command should not return a command") } m = updated.(chatModel) if m.opts.Think == nil || m.opts.Think.Value != tt.want { t.Fatalf("think = %#v, want %#v", m.opts.Think, tt.want) } }) } } func TestChatViewRendersFileMentionSuggestions(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "cmd"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } m := chatModel{ workingDir: dir, input: []rune("open @"), width: 80, height: 18, } view := stripANSI(m.View()) if !strings.Contains(view, "@cmd/") || !strings.Contains(view, "@README.md") { t.Fatalf("file mention suggestions missing: %q", view) } } func TestChatFileMentionSuggestionsFilterAndComplete(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "cmd"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } m := chatModel{ workingDir: dir, input: []rune("open @REA"), } lines := stripANSI(strings.Join(m.completionLines(80), "\n")) if !strings.Contains(lines, "@README.md") || strings.Contains(lines, "@cmd/") { t.Fatalf("filtered file suggestions = %q", lines) } m.applyCompletion() if got := string(m.input); got != "open @README.md " { t.Fatalf("completed input = %q", got) } }