mirror of
https://github.com/ollama/ollama.git
synced 2026-07-16 13:19:40 -05:00
763 lines
23 KiB
Go
763 lines
23 KiB
Go
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 <filename>`: save request JSON; saved as <filename>.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 <filename>") {
|
|
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 <filename>.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)
|
|
}
|
|
}
|