agent: address review feedback

This commit is contained in:
ParthSareen 2026-06-23 13:31:18 -07:00
parent 5e9c48cc40
commit 183f03d997
25 changed files with 1324 additions and 614 deletions

View file

@ -10,6 +10,7 @@ import (
"slices"
"strings"
"sync"
"unicode"
)
type ApprovalDecision string
@ -29,13 +30,14 @@ const (
)
type ApprovalRequest struct {
ToolCallID string
ToolName string
Args map[string]any
WorkingDir string
Summary string
Risk ApprovalRisk
Reasons []string
ToolCallID string
ToolName string
Args map[string]any
WorkingDir string
ToolApprovalRequired bool
Summary string
Risk ApprovalRisk
Reasons []string
}
type ApprovalResult struct {
@ -125,16 +127,8 @@ func (m *ApprovalManager) RequiresApproval(ctx context.Context, tool Tool, req A
if m == nil || m.autoApprove {
return false
}
evaluation := m.evaluate(ctx, req)
if ToolRequiresApproval(tool, req.Args) && evaluation.Decision != ApprovalDeny {
evaluation.RequirePrompt = true
if evaluation.Summary == "" {
evaluation.Summary = fmt.Sprintf("%s wants to run", toolApprovalDisplayName(req.ToolName))
}
if len(evaluation.Reasons) == 0 {
evaluation.Reasons = []string{"tool requires approval"}
}
}
req.ToolApprovalRequired = req.ToolApprovalRequired || ToolRequiresApproval(tool, req.Args)
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
if evaluation.Decision == ApprovalDeny {
return true
}
@ -149,7 +143,7 @@ func (m *ApprovalManager) Approve(ctx context.Context, req ApprovalRequest) (App
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
}
evaluation := m.evaluate(ctx, req)
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
req = approvalRequestWithEvaluation(req, evaluation)
if evaluation.Decision == ApprovalDeny {
@ -198,6 +192,20 @@ func (m *ApprovalManager) evaluate(ctx context.Context, req ApprovalRequest) App
return evaluation
}
func applyToolApprovalRequirement(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalEvaluation {
if !req.ToolApprovalRequired || evaluation.Decision == ApprovalDeny {
return evaluation
}
evaluation.RequirePrompt = true
if evaluation.Summary == "" {
evaluation.Summary = fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName))
}
if len(evaluation.Reasons) == 0 {
evaluation.Reasons = []string{"tool requires approval"}
}
return evaluation
}
func (m *ApprovalManager) sessionAllowedFor(key string) bool {
if m == nil || key == "" {
return false
@ -256,9 +264,9 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq
return denyApproval(req.ToolName, ApprovalRiskHigh, reason)
}
}
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", toolApprovalDisplayName(req.ToolName))}
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
case "web_search", "web_fetch":
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", toolApprovalDisplayName(req.ToolName))}
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
case "edit":
return evaluateEditApproval(req)
case "bash":
@ -267,7 +275,7 @@ func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalReq
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("%s wants to run", toolApprovalDisplayName(req.ToolName)),
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
Reasons: []string{"unknown tool effects"},
SessionKey: approvalSessionKey(req),
}
@ -290,12 +298,31 @@ func evaluateEditApproval(req ApprovalRequest) ApprovalEvaluation {
return ApprovalEvaluation{
RequirePrompt: true,
Risk: ApprovalRiskMedium,
Summary: fmt.Sprintf("Edit wants to modify %s", path),
Summary: fmt.Sprintf("Edit wants to modify %s", sanitizeApprovalDisplay(path)),
Reasons: reasons,
SessionKey: "edit:" + path,
}
}
func sanitizeApprovalDisplay(value string) string {
value = approvalANSIEscapePattern.ReplaceAllString(value, "")
value = strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', '\t':
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, value)
value = strings.Join(strings.Fields(value), " ")
if value == "" {
return "(empty)"
}
return value
}
func evaluateBashApproval(req ApprovalRequest) ApprovalEvaluation {
command, ok := stringApprovalArg(req.Args, "command")
if !ok || strings.TrimSpace(command) == "" {
@ -319,7 +346,7 @@ func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEva
return ApprovalEvaluation{
Decision: ApprovalDeny,
Risk: risk,
Summary: fmt.Sprintf("%s cannot run", toolApprovalDisplayName(toolName)),
Summary: fmt.Sprintf("%s cannot run", ToolDisplayName(toolName)),
Reasons: []string{reason},
SessionKey: approvalSessionKey(ApprovalRequest{ToolName: toolName}),
}
@ -552,8 +579,9 @@ type bashToken struct {
}
var (
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
approvalANSIEscapePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`)
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
)
func scanBashTokens(command string) ([]bashToken, []string, bool) {
@ -846,23 +874,3 @@ func stableApprovalArgs(args map[string]any) string {
}
return b.String()
}
func toolApprovalDisplayName(name string) string {
switch name {
case "web_search":
return "Web Search"
case "web_fetch":
return "Web Fetch"
case "bash":
return "Bash"
case "read":
return "Read"
case "edit":
return "Edit"
default:
if name == "" {
return "Tool"
}
return name
}
}

View file

@ -4,6 +4,8 @@ import (
"context"
"strings"
"testing"
"github.com/ollama/ollama/api"
)
type recordingApprovalPrompter struct {
@ -11,6 +13,10 @@ type recordingApprovalPrompter struct {
results []ApprovalResult
}
type allowWithoutPromptPolicy struct{}
type approvalRequiredTestTool struct{}
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, request ApprovalRequest) (ApprovalResult, error) {
p.requests = append(p.requests, request)
if len(p.results) == 0 {
@ -21,6 +27,30 @@ func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, request Ap
return result, nil
}
func (allowWithoutPromptPolicy) EvaluateApproval(context.Context, ApprovalRequest) ApprovalEvaluation {
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow}
}
func (approvalRequiredTestTool) Name() string {
return "approval_required"
}
func (approvalRequiredTestTool) Description() string {
return "requires approval"
}
func (approvalRequiredTestTool) Schema() api.ToolFunction {
return api.ToolFunction{Name: "approval_required"}
}
func (approvalRequiredTestTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
return ToolResult{Content: "ok"}, nil
}
func (approvalRequiredTestTool) RequiresApproval(map[string]any) bool {
return true
}
func TestApprovalManagerAllowsSafeToolsWithoutPrompt(t *testing.T) {
prompter := &recordingApprovalPrompter{}
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
@ -41,6 +71,31 @@ func TestApprovalManagerAllowsSafeToolsWithoutPrompt(t *testing.T) {
}
}
func TestApprovalManagerToolRequiredOverridePromptsInApprove(t *testing.T) {
prompter := &recordingApprovalPrompter{}
manager := NewApprovalManager(ApprovalManagerOptions{Policy: allowWithoutPromptPolicy{}, Prompter: prompter})
tool := approvalRequiredTestTool{}
request := ApprovalRequest{
ToolName: tool.Name(),
Args: map[string]any{},
ToolApprovalRequired: ToolRequiresApproval(tool, nil),
}
if !manager.RequiresApproval(context.Background(), tool, request) {
t.Fatal("tool-required approval should require a prompt")
}
result, err := manager.Approve(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.Decision != ApprovalAllowOnce {
t.Fatalf("decision = %q, want allow_once", result.Decision)
}
if len(prompter.requests) != 1 {
t.Fatalf("prompts = %d, want 1", len(prompter.requests))
}
}
func TestApprovalManagerDeniesEscapingPath(t *testing.T) {
manager := NewApprovalManager(ApprovalManagerOptions{})
@ -60,6 +115,20 @@ func TestApprovalManagerDeniesEscapingPath(t *testing.T) {
}
}
func TestApprovalManagerSanitizesEditSummary(t *testing.T) {
evaluation := evaluateEditApproval(ApprovalRequest{
ToolName: "edit",
Args: map[string]any{"path": "notes/\x1b[31mred\nfile.txt"},
WorkingDir: t.TempDir(),
})
if strings.ContainsAny(evaluation.Summary, "\n\r\x1b") {
t.Fatalf("summary contains control characters: %q", evaluation.Summary)
}
if !strings.Contains(evaluation.Summary, "notes/red file.txt") {
t.Fatalf("summary = %q, want sanitized path", evaluation.Summary)
}
}
func TestApprovalManagerPromptsForEdit(t *testing.T) {
prompter := &recordingApprovalPrompter{}
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})

View file

@ -351,24 +351,16 @@ func (s *Store) Chat(ctx context.Context, id string) (*Chat, error) {
func (s *Store) LatestChat(ctx context.Context) (*Chat, error) {
var chatID string
if err := s.db.QueryRowContext(ctx, `
SELECT c.id
FROM chats c
JOIN messages m ON m.chat_id = c.id
GROUP BY c.id
HAVING COALESCE(
NULLIF(c.model_name, ''),
(
SELECT lm.model_name
FROM messages lm
WHERE lm.chat_id = c.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
ORDER BY lm.updated_at DESC, lm.id DESC
LIMIT 1
)
) IS NOT NULL
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
LIMIT 1
`).Scan(&chatID); err != nil {
query := fmt.Sprintf(`
SELECT c.id
FROM chats c
JOIN messages m ON m.chat_id = c.id
GROUP BY c.id
HAVING %[1]s IS NOT NULL
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
LIMIT 1
`, currentModelSelectExpr("c"))
if err := s.db.QueryRowContext(ctx, query).Scan(&chatID); err != nil {
return nil, err
}
return s.Chat(ctx, chatID)
@ -380,24 +372,16 @@ func (s *Store) LatestChatForModel(ctx context.Context, model string) (*Chat, er
}
var chatID string
if err := s.db.QueryRowContext(ctx, `
SELECT c.id
FROM chats c
JOIN messages m ON m.chat_id = c.id
GROUP BY c.id
HAVING COALESCE(
NULLIF(c.model_name, ''),
(
SELECT lm.model_name
FROM messages lm
WHERE lm.chat_id = c.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
ORDER BY lm.updated_at DESC, lm.id DESC
LIMIT 1
)
) = ?
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
LIMIT 1
`, model).Scan(&chatID); err != nil {
query := fmt.Sprintf(`
SELECT c.id
FROM chats c
JOIN messages m ON m.chat_id = c.id
GROUP BY c.id
HAVING %[1]s = ?
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
LIMIT 1
`, currentModelSelectExpr("c"))
if err := s.db.QueryRowContext(ctx, query, model).Scan(&chatID); err != nil {
return nil, err
}
return s.Chat(ctx, chatID)
@ -408,26 +392,28 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT
c.id,
c.title,
c.created_at,
MAX(m.updated_at) AS updated_at,
COUNT(m.id) AS message_count,
COALESCE(SUM(
LENGTH(m.role) +
LENGTH(m.content) +
LENGTH(m.thinking) +
LENGTH(m.tool_name) +
LENGTH(m.tool_call_id)
), 0) AS approx_bytes
FROM chats c
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
GROUP BY c.id
ORDER BY updated_at DESC, MAX(m.id) DESC
LIMIT ?
`, limit)
query := fmt.Sprintf(`
SELECT
c.id,
c.title,
c.created_at,
MAX(m.updated_at) AS updated_at,
COUNT(m.id) AS message_count,
COALESCE(SUM(
LENGTH(m.role) +
LENGTH(m.content) +
LENGTH(m.thinking) +
LENGTH(m.tool_name) +
LENGTH(m.tool_call_id)
), 0) AS approx_bytes,
%[1]s AS current_model
FROM chats c
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
GROUP BY c.id
ORDER BY updated_at DESC, MAX(m.id) DESC
LIMIT ?
`, currentModelSelectExpr("c"))
rows, err := s.db.QueryContext(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("list chats: %w", err)
}
@ -437,9 +423,13 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
for rows.Next() {
var summary ChatSummary
var updatedAt string
if err := rows.Scan(&summary.ID, &summary.Title, &summary.CreatedAt, &updatedAt, &summary.MessageCount, &summary.ApproxBytes); err != nil {
var modelName sql.NullString
if err := rows.Scan(&summary.ID, &summary.Title, &summary.CreatedAt, &updatedAt, &summary.MessageCount, &summary.ApproxBytes, &modelName); err != nil {
return nil, fmt.Errorf("scan chat summary: %w", err)
}
if modelName.Valid {
summary.Model = modelName.String
}
summary.UpdatedAt, err = parseSQLiteTime(updatedAt)
if err != nil {
return nil, fmt.Errorf("parse chat updated_at: %w", err)
@ -450,13 +440,6 @@ func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error)
return nil, fmt.Errorf("read chat summaries: %w", err)
}
for i := range summaries {
model, err := currentModelForChat(ctx, s.db, summaries[i].ID)
if err != nil {
return nil, err
}
summaries[i].Model = model
}
return summaries, nil
}
@ -536,15 +519,17 @@ func latestModelForChat(ctx context.Context, db *sql.DB, chatID string) (string,
return modelName, nil
}
func currentModelForChat(ctx context.Context, db *sql.DB, chatID string) (string, error) {
var modelName string
if err := db.QueryRowContext(ctx, `SELECT model_name FROM chats WHERE id = ?`, chatID).Scan(&modelName); err != nil {
return "", err
}
if strings.TrimSpace(modelName) != "" {
return modelName, nil
}
return latestModelForChat(ctx, db, chatID)
func currentModelSelectExpr(chatAlias string) string {
return fmt.Sprintf(`COALESCE(
NULLIF(%[1]s.model_name, ''),
(
SELECT lm.model_name
FROM messages lm
WHERE lm.chat_id = %[1]s.id AND lm.model_name IS NOT NULL AND lm.model_name != ''
ORDER BY lm.updated_at DESC, lm.id DESC
LIMIT 1
)
)`, chatAlias)
}
func (s *Store) ArchiveForCompaction(ctx context.Context, chatID string, keepUserTurns int, summary string) error {

View file

@ -362,6 +362,22 @@ func estimateCompactionTokens(text string) int {
return max(1, (len([]rune(text))+3)/4)
}
// EstimateTokens returns the agent's lightweight token estimate for UI hints.
func EstimateTokens(text string) int {
return estimateCompactionTokens(text)
}
// EstimatePromptTokens returns the agent's lightweight estimate for the prompt
// payload sent to /api/chat.
func EstimatePromptTokens(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
return estimateCompactionRequestTokens(CompactionRequest{
SystemPrompt: systemPrompt,
Messages: messages,
Tools: tools,
Format: format,
})
}
func estimateMessagesTokens(messages []api.Message) int {
var total int
for _, msg := range messages {

View file

@ -80,6 +80,14 @@ type toolOutputOverflow struct {
content string
}
type toolExecutionStop string
const (
toolExecutionContinue toolExecutionStop = ""
toolExecutionDenied toolExecutionStop = "denied"
toolExecutionCanceled toolExecutionStop = "canceled"
)
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
if s == nil {
return nil, errors.New("nil session")
@ -217,7 +225,7 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, err
}
toolMessages, denied, overflows, err := s.executeToolCalls(ctx, runID, opts, messages, pendingToolCalls)
toolMessages, stopReason, overflows, err := s.executeToolCalls(ctx, runID, opts, messages, pendingToolCalls)
if err != nil {
emit(s.Events, Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
return nil, err
@ -233,11 +241,17 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
emit(s.Events, Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: compactErr.Error()})
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, compactErr
}
if denied {
switch stopReason {
case toolExecutionDenied:
if err := emit(s.Events, Event{Type: EventRunFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", FinishedAt: time.Now(), Response: &latest}); err != nil {
return nil, err
}
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, nil
case toolExecutionCanceled:
if err := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventRunFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "canceled", FinishedAt: time.Now(), Response: &latest}); err != nil {
return nil, err
}
return &RunResult{Messages: messages, Latest: latest, WorkingDir: s.WorkingDir}, nil
}
toolRounds++
}
@ -372,7 +386,7 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
return assistant, pendingToolCalls, false, nil
}
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) ([]api.Message, bool, []toolOutputOverflow, error) {
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) ([]api.Message, toolExecutionStop, []toolOutputOverflow, error) {
approval := s.Approval
if approval == nil {
approval = AutoAllowApproval{}
@ -388,17 +402,17 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
if ctx.Err() != nil {
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i:], "Tool execution skipped because the run was canceled.")
if skipErr != nil {
return nil, false, nil, skipErr
return nil, toolExecutionContinue, nil, skipErr
}
toolMessages = append(toolMessages, skipped...)
return toolMessages, true, overflows, nil
return toolMessages, toolExecutionCanceled, overflows, nil
}
tool, ok := s.Tools.Get(toolName)
if !ok {
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
toolMessages = append(toolMessages, msg)
projectedMessages = append(projectedMessages, msg)
@ -408,7 +422,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
}
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: fmt.Sprintf("unknown tool: %s", toolName), FinishedAt: finishedAt}); emitErr != nil {
return nil, false, nil, emitErr
return nil, toolExecutionContinue, nil, emitErr
}
continue
}
@ -419,18 +433,19 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
Args: args,
WorkingDir: s.currentWorkingDir(),
}
approvalRequest.ToolApprovalRequired = ToolRequiresApproval(tool, args)
if toolNeedsApproval(ctx, approval, tool, approvalRequest) {
result, err := approval.Approve(ctx, approvalRequest)
if err != nil {
if ctx.Err() != nil {
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i:], "Tool execution skipped because the run was canceled.")
if skipErr != nil {
return nil, false, nil, skipErr
return nil, toolExecutionContinue, nil, skipErr
}
toolMessages = append(toolMessages, skipped...)
return toolMessages, true, overflows, nil
return toolMessages, toolExecutionCanceled, overflows, nil
}
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
if result.Decision == ApprovalDeny {
content := result.Reason
@ -439,13 +454,13 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
}
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
toolMessages = append(toolMessages, msg)
projectedMessages = append(projectedMessages, msg)
content = msg.Content
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: content, FinishedAt: time.Now()}); emitErr != nil {
return nil, false, nil, emitErr
return nil, toolExecutionContinue, nil, emitErr
}
for _, skipped := range calls[i+1:] {
skippedToolName := skipped.Function.Name
@ -453,22 +468,22 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
skippedContent := "Tool execution skipped because a previous tool call in this assistant message was denied."
skippedMsg := s.toolMessageForContext(skippedToolName, skipped.ID, skippedContent, opts, projectedMessages)
if err := s.appendToolMessage(persistCtx, opts.ChatID, skippedMsg); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
toolMessages = append(toolMessages, skippedMsg)
projectedMessages = append(projectedMessages, skippedMsg)
skippedContent = skippedMsg.Content
if emitErr := emit(s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "skipped", ToolCallID: skipped.ID, ToolName: skippedToolName, Args: skippedArgs, Content: skippedContent, Error: skippedContent, FinishedAt: time.Now()}); emitErr != nil {
return nil, false, nil, emitErr
return nil, toolExecutionContinue, nil, emitErr
}
}
return toolMessages, true, overflows, nil
return toolMessages, toolExecutionDenied, overflows, nil
}
}
startedAt := time.Now()
if err := emit(s.Events, Event{Type: EventToolStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "running", ToolCallID: call.ID, ToolName: toolName, WorkingDir: s.currentWorkingDir(), Args: args, StartedAt: startedAt}); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
result, err := s.Tools.Execute(ctx, s.toolContext(), call)
@ -476,7 +491,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
rawContent := fmt.Sprintf("Error: %v", err)
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
if appendErr := s.appendToolMessage(persistCtx, opts.ChatID, msg); appendErr != nil {
return nil, false, nil, appendErr
return nil, toolExecutionContinue, nil, appendErr
}
toolMessages = append(toolMessages, msg)
projectedMessages = append(projectedMessages, msg)
@ -486,15 +501,15 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
}
if emitErr := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: err.Error(), FinishedAt: finishedAt}); emitErr != nil {
return nil, false, nil, emitErr
return nil, toolExecutionContinue, nil, emitErr
}
if ctx.Err() != nil {
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
if skipErr != nil {
return nil, false, nil, skipErr
return nil, toolExecutionContinue, nil, skipErr
}
toolMessages = append(toolMessages, skipped...)
return toolMessages, true, overflows, nil
return toolMessages, toolExecutionCanceled, overflows, nil
}
continue
}
@ -504,7 +519,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
if err := s.appendToolMessage(persistCtx, opts.ChatID, msg); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
toolMessages = append(toolMessages, msg)
projectedMessages = append(projectedMessages, msg)
@ -515,18 +530,18 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
overflows = append(overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
}
if err := emitIgnoringCanceled(ctx, s.Events, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "done", ToolCallID: call.ID, ToolName: toolName, WorkingDir: s.WorkingDir, Args: args, Content: content, FinishedAt: finishedAt}); err != nil {
return nil, false, nil, err
return nil, toolExecutionContinue, nil, err
}
if ctx.Err() != nil {
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
if skipErr != nil {
return nil, false, nil, skipErr
return nil, toolExecutionContinue, nil, skipErr
}
toolMessages = append(toolMessages, skipped...)
return toolMessages, true, overflows, nil
return toolMessages, toolExecutionCanceled, overflows, nil
}
}
return toolMessages, false, overflows, nil
return toolMessages, toolExecutionContinue, overflows, nil
}
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
@ -615,25 +630,10 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
if s.Compactor == nil {
return messages, skipNotified, nil
}
req := CompactionRequest{
ChatID: opts.ChatID,
Model: opts.Model,
SystemPrompt: opts.SystemPrompt,
Messages: messages,
Tools: s.runTools(opts),
Format: opts.Format,
Latest: latest,
Options: opts.Options,
KeepAlive: opts.KeepAlive,
Think: opts.Think,
ContinueTask: true,
Progress: func(progress CompactionProgress) {
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
},
}
req := s.compactionRequest(runID, opts, messages, latest)
trigger := s.autoCompactionTrigger(req)
if trigger != "" {
_ = emit(s.Events, Event{Type: EventCompactionStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), StartedAt: time.Now(), Response: &latest})
s.emitCompactionStarted(runID, opts, messages, latest, trigger)
}
result, err := s.Compactor.MaybeCompact(ctx, req)
if err != nil {
@ -641,7 +641,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
if trigger == "" {
trigger = "error"
}
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompactionSkipped(runID, opts, messages, latest, trigger, result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
@ -651,12 +651,12 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
if trigger == "" {
trigger = "due"
}
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompactionSkipped(runID, opts, messages, latest, trigger, result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
}
emit(s.Events, Event{Type: EventCompacted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: trigger, Content: result.Summary, Messages: result.Messages, PromptTokens: s.estimateRunPromptTokens(opts, result.Messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompacted(runID, opts, result.Messages, latest, trigger, result.Summary)
if err := s.checkPostCompactionPromptBudget(opts, result.Messages); err != nil {
return result.Messages, skipNotified, err
}
@ -669,37 +669,22 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
}
keepUserTurns := 0
req := CompactionRequest{
ChatID: opts.ChatID,
Model: opts.Model,
SystemPrompt: opts.SystemPrompt,
Messages: messages,
Tools: s.runTools(opts),
Format: opts.Format,
Latest: latest,
Options: opts.Options,
KeepAlive: opts.KeepAlive,
Think: opts.Think,
Force: true,
ContinueTask: true,
KeepUserTurns: &keepUserTurns,
Progress: func(progress CompactionProgress) {
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
},
}
_ = emit(s.Events, Event{Type: EventCompactionStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), StartedAt: time.Now(), Response: &latest})
req := s.compactionRequest(runID, opts, messages, latest)
req.Force = true
req.KeepUserTurns = &keepUserTurns
s.emitCompactionStarted(runID, opts, messages, latest, "tool_output")
result, err := s.Compactor.MaybeCompact(ctx, req)
if err != nil {
if result.Due && !skipNotified {
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompactionSkipped(runID, opts, messages, latest, "tool_output", result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
}
if !result.Compacted {
if result.Due && !skipNotified {
emit(s.Events, Event{Type: EventCompactionSkipped, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: CompactionSkippedMessage(result.Reason), PromptTokens: s.estimateRunPromptTokens(opts, messages), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompactionSkipped(runID, opts, messages, latest, "tool_output", result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
@ -736,13 +721,78 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
compacted = append(compacted, refit)
}
emit(s.Events, Event{Type: EventCompacted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "tool_output", Content: result.Summary, Messages: compacted, PromptTokens: s.estimateRunPromptTokens(opts, compacted), ContextWindowTokens: s.contextWindowTokens(opts), CompactionThresholdTokens: s.compactionThresholdTokens(opts), Response: &latest})
s.emitCompacted(runID, opts, compacted, latest, "tool_output", result.Summary)
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
return compacted, skipNotified, err
}
return compacted, skipNotified, nil
}
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
return CompactionRequest{
ChatID: opts.ChatID,
Model: opts.Model,
SystemPrompt: opts.SystemPrompt,
Messages: messages,
Tools: s.runTools(opts),
Format: opts.Format,
Latest: latest,
Options: opts.Options,
KeepAlive: opts.KeepAlive,
Think: opts.Think,
ContinueTask: true,
Progress: func(progress CompactionProgress) {
_ = emit(s.Events, Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
},
}
}
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status string) {
_ = emit(s.Events, Event{
Type: EventCompactionStarted,
RunID: runID,
ChatID: opts.ChatID,
Model: opts.Model,
Status: status,
PromptTokens: s.estimateRunPromptTokens(opts, messages),
ContextWindowTokens: s.contextWindowTokens(opts),
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
StartedAt: time.Now(),
Response: &latest,
})
}
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status, reason string) {
_ = emit(s.Events, Event{
Type: EventCompactionSkipped,
RunID: runID,
ChatID: opts.ChatID,
Model: opts.Model,
Status: status,
Content: CompactionSkippedMessage(reason),
PromptTokens: s.estimateRunPromptTokens(opts, messages),
ContextWindowTokens: s.contextWindowTokens(opts),
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
Response: &latest,
})
}
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, status, summary string) {
_ = emit(s.Events, Event{
Type: EventCompacted,
RunID: runID,
ChatID: opts.ChatID,
Model: opts.Model,
Status: status,
Content: summary,
Messages: messages,
PromptTokens: s.estimateRunPromptTokens(opts, messages),
ContextWindowTokens: s.contextWindowTokens(opts),
CompactionThresholdTokens: s.compactionThresholdTokens(opts),
Response: &latest,
})
}
func (s *Session) autoCompactionTrigger(req CompactionRequest) string {
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
if req.Force {

View file

@ -749,6 +749,7 @@ func TestSessionCancellationAfterToolCallAppendsSkippedToolMessage(t *testing.T)
func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
store := &contextAwareStore{}
events := &recordingEventSink{}
registry := NewRegistry()
registry.Register(cancelingTool{cancel: cancel})
client := &fakeClient{responses: [][]api.ChatResponse{{
@ -763,6 +764,7 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
Client: client,
Store: store,
Tools: registry,
Events: events,
}
result, err := session.Run(ctx, RunOptions{
@ -786,6 +788,18 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
if !strings.Contains(result.Messages[2].Content, "context canceled") {
t.Fatalf("tool content = %q", result.Messages[2].Content)
}
var finished *Event
for i := range events.events {
if events.events[i].Type == EventRunFinished {
finished = &events.events[i]
}
}
if finished == nil {
t.Fatalf("run finished event missing: %#v", events.events)
}
if finished.Status != "canceled" {
t.Fatalf("run status = %q, want canceled", finished.Status)
}
}
func TestSessionToolLoopAllowsRoundsUnderDefaultCap(t *testing.T) {

73
agent/tool_display.go Normal file
View file

@ -0,0 +1,73 @@
package agent
import (
"fmt"
"sort"
"strconv"
"strings"
)
// ToolDisplayName returns the user-facing label for a tool name.
func ToolDisplayName(name string) string {
switch name {
case "web_search":
return "Web Search"
case "web_fetch":
return "Web Fetch"
case "bash":
return "Bash"
case "read":
return "Read"
case "list":
return "List"
case "edit":
return "Edit"
case "skill":
return "Skill"
default:
if name == "" {
return "Tool"
}
return name
}
}
// ToolInvocationLabel returns a compact user-facing label for a tool call.
func ToolInvocationLabel(name string, args map[string]any) string {
displayName := ToolDisplayName(name)
for _, key := range []string{"query", "url", "command", "path", "name"} {
if value, ok := displayStringArg(args, key); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
}
}
if len(args) == 0 {
return displayName
}
return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args))
}
func displayStringArg(args map[string]any, key string) (string, bool) {
value, ok := args[key].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", false
}
return value, true
}
func formatDisplayArgs(args map[string]any) string {
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
value := fmt.Sprintf("%v", args[key])
if len([]rune(value)) > 100 {
value = string([]rune(value)[:100]) + "..."
}
parts = append(parts, fmt.Sprintf("%s=%s", key, strconv.Quote(value)))
}
return strings.Join(parts, ", ")
}

View file

@ -9,7 +9,6 @@ import (
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
"time"
@ -346,7 +345,7 @@ func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
}
}()
headlessSink := &agentHeadlessEventSink{}
headlessSink := &agentHeadlessEventSink{hideThinking: opts.HideThinking}
eventSink := coreagent.EventSink(headlessSink)
traceSink, err := coreagent.NewJSONLTraceSinkFromEnv()
if err != nil {
@ -385,6 +384,9 @@ func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
if headlessSink.wroteContent {
fmt.Fprintln(os.Stdout)
}
if headlessSink.denied {
return errors.New("tool execution denied")
}
verbose := opts.Verbose
if cmd != nil && cmd.Flags().Lookup("verbose") != nil {
@ -414,26 +416,45 @@ func skillFromPrompt(catalog *skills.Catalog, prompt string) (skills.Skill, stri
}
type agentHeadlessEventSink struct {
wroteContent bool
hideThinking bool
wroteContent bool
wroteThinking bool
thinkingEndedWithNewline bool
denied bool
}
func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
switch event.Type {
case coreagent.EventThinkingDelta:
if event.Thinking != "" && !s.hideThinking {
fmt.Fprint(os.Stdout, event.Thinking)
s.wroteContent = true
s.wroteThinking = true
s.thinkingEndedWithNewline = strings.HasSuffix(event.Thinking, "\n")
}
case coreagent.EventMessageDelta:
if event.Content != "" {
if s.wroteThinking {
if !s.thinkingEndedWithNewline {
fmt.Fprintln(os.Stdout)
}
s.wroteThinking = false
}
fmt.Fprint(os.Stdout, event.Content)
s.wroteContent = true
}
case coreagent.EventToolStarted:
fmt.Fprintf(os.Stderr, "• %s in progress\n", agentHeadlessToolLabel(event.ToolName, event.Args))
case coreagent.EventToolFinished:
status := "done"
if event.Error != "" {
if event.Status != "done" || event.Error != "" {
status = "failed"
}
fmt.Fprintf(os.Stderr, "• %s %s\n", agentHeadlessToolLabel(event.ToolName, event.Args), status)
fmt.Fprintf(os.Stderr, "• %s %s\n", coreagent.ToolInvocationLabel(event.ToolName, event.Args), status)
case coreagent.EventToolsUnavailable:
fmt.Fprintln(os.Stderr, "Tools are unavailable for this model.")
case coreagent.EventRunFinished:
if event.Status == "denied" {
s.denied = true
}
case coreagent.EventCompactionSkipped:
if event.Content != "" {
fmt.Fprintf(os.Stderr, "%s\n", event.Content)
@ -446,29 +467,6 @@ func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
return nil
}
func agentHeadlessToolLabel(name string, args map[string]any) string {
displayName := name
switch name {
case "web_search":
displayName = "Web Search"
case "web_fetch":
displayName = "Web Fetch"
case "bash":
displayName = "Bash"
case "read":
displayName = "Read"
case "edit":
displayName = "Edit"
}
for _, key := range []string{"query", "url", "command", "path"} {
if value, ok := args[key].(string); ok && strings.TrimSpace(value) != "" {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
}
}
return displayName
}
func loadAgentSkills() *skills.Catalog {
catalog, err := skills.LoadDefault()
if err != nil {

View file

@ -3,14 +3,17 @@ package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"strings"
"testing"
"time"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/agent/skills"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/tui"
@ -42,6 +45,146 @@ func TestAgentSystemPromptIncludesModel(t *testing.T) {
}
}
func TestAgentHeadlessEventSinkPrintsThinkingUnlessHidden(t *testing.T) {
t.Run("visible", func(t *testing.T) {
output := captureStdout(t, func() {
sink := &agentHeadlessEventSink{}
if err := sink.Emit(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}); err != nil {
t.Fatal(err)
}
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"}); err != nil {
t.Fatal(err)
}
})
if output != "thinking\nanswer" {
t.Fatalf("output = %q, want thinking followed by answer", output)
}
})
t.Run("hidden", func(t *testing.T) {
output := captureStdout(t, func() {
sink := &agentHeadlessEventSink{hideThinking: true}
if err := sink.Emit(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}); err != nil {
t.Fatal(err)
}
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"}); err != nil {
t.Fatal(err)
}
})
if output != "answer" {
t.Fatalf("output = %q, want answer only", output)
}
})
}
func TestAgentHeadlessEventSinkPrintsOnlyFinishedToolEvents(t *testing.T) {
output := captureStderr(t, func() {
sink := &agentHeadlessEventSink{}
if err := sink.Emit(coreagent.Event{
Type: coreagent.EventToolStarted,
ToolName: "bash",
Args: map[string]any{"command": "pwd"},
}); err != nil {
t.Fatal(err)
}
if err := sink.Emit(coreagent.Event{
Type: coreagent.EventToolFinished,
Status: "done",
ToolName: "bash",
Args: map[string]any{"command": "pwd"},
}); err != nil {
t.Fatal(err)
}
if err := sink.Emit(coreagent.Event{
Type: coreagent.EventToolFinished,
Status: "denied",
ToolName: "edit",
Args: map[string]any{"path": "main.go"},
}); err != nil {
t.Fatal(err)
}
if err := sink.Emit(coreagent.Event{
Type: coreagent.EventToolFinished,
Status: "done",
ToolName: "web_fetch",
Args: map[string]any{"url": "https://example.com"},
Error: "timeout",
}); err != nil {
t.Fatal(err)
}
})
if strings.Contains(output, "in progress") {
t.Fatalf("headless output should not include in-progress tool events:\n%s", output)
}
for _, want := range []string{
`• Bash("pwd") done`,
`• Edit("main.go") failed`,
`• Web Fetch("https://example.com") failed`,
} {
if !strings.Contains(output, want) {
t.Fatalf("headless output missing %q:\n%s", want, output)
}
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
t.Cleanup(func() {
os.Stdout = oldStdout
})
fn()
if err := w.Close(); err != nil {
t.Fatal(err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
os.Stdout = oldStdout
return string(out)
}
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
oldStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = w
t.Cleanup(func() {
os.Stderr = oldStderr
})
fn()
if err := w.Close(); err != nil {
t.Fatal(err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
os.Stderr = oldStderr
return string(out)
}
func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) {
t.Setenv("OLLAMA_NO_CLOUD", "1")
t.Setenv("OLLAMA_AGENT_DISABLE_BASH", "")

View file

@ -1072,6 +1072,96 @@ func TestRunHandlerPromptRunsAgentHeadless(t *testing.T) {
}
}
func TestRunHandlerHeadlessDeniedApprovalReturnsError(t *testing.T) {
var chatCalls int
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
if err := json.NewEncoder(w).Encode(api.ShowResponse{
Capabilities: []model.Capability{model.CapabilityTools},
}); err != nil {
t.Fatal(err)
}
case "/api/generate":
if err := json.NewEncoder(w).Encode(api.GenerateResponse{Done: true}); err != nil {
t.Fatal(err)
}
case "/api/ps":
if err := json.NewEncoder(w).Encode(api.ProcessResponse{
Models: []api.ProcessModelResponse{{
Name: "test-model:latest",
Model: "test-model:latest",
ContextLength: 8192,
}},
}); err != nil {
t.Fatal(err)
}
case "/api/chat":
chatCalls++
if chatCalls > 1 {
t.Fatalf("chat calls = %d, want denied tool run to stop after first call", chatCalls)
}
args := api.NewToolCallFunctionArguments()
args.Set("command", "pwd")
w.Header().Set("Content-Type", "application/x-ndjson")
enc := json.NewEncoder(w)
if err := enc.Encode(api.ChatResponse{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: "call-1",
Function: api.ToolCallFunction{
Name: "bash",
Arguments: args,
},
}}}}); err != nil {
t.Fatal(err)
}
if err := enc.Encode(api.ChatResponse{Done: true, DoneReason: "stop"}); err != nil {
t.Fatal(err)
}
case "/api/status":
if err := json.NewEncoder(w).Encode(api.StatusResponse{}); err != nil {
t.Fatal(err)
}
default:
http.NotFound(w, r)
}
}))
t.Cleanup(mockServer.Close)
t.Setenv("OLLAMA_HOST", mockServer.URL)
t.Setenv("HOME", t.TempDir())
t.Setenv("LOCALAPPDATA", t.TempDir())
cmd := &cobra.Command{}
cmd.SetContext(t.Context())
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
cmd.Flags().Bool("resume", false, "")
cmd.Flags().String("keepalive", "", "")
cmd.Flags().Bool("nowordwrap", false, "")
cmd.Flags().Bool("verbose", false, "")
oldStdout := os.Stdout
stdoutR, stdoutW, _ := os.Pipe()
os.Stdout = stdoutW
oldStderr := os.Stderr
stderrR, stderrW, _ := os.Pipe()
os.Stderr = stderrW
err := RunHandler(cmd, []string{"test-model", "run pwd"})
stdoutW.Close()
stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
_, _ = io.Copy(io.Discard, stdoutR)
_, _ = io.Copy(io.Discard, stderrR)
if err == nil || !strings.Contains(err.Error(), "tool execution denied") {
t.Fatalf("RunHandler error = %v, want tool execution denied", err)
}
if chatCalls != 1 {
t.Fatalf("chat calls = %d, want 1", chatCalls)
}
}
func TestRunHandlerHeadlessBudgetsAgainstLoadedContext(t *testing.T) {
var chatCalled bool
var generateCalled bool

View file

@ -141,6 +141,7 @@ type chatModel struct {
width int
height int
boundedFrame bool
fullScreen bool
status string
spinner int
tickActive bool
@ -165,6 +166,44 @@ type chatSelection struct {
cursor chatSelectionPoint
}
func startChatSelection(selection *chatSelection, msg tea.MouseMsg, contains func(tea.MouseMsg) bool, point func(tea.MouseMsg) chatSelectionPoint) {
if !contains(msg) {
*selection = chatSelection{}
return
}
p := point(msg)
*selection = chatSelection{active: true, anchor: p, cursor: p}
}
func dragChatSelection(selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint, scrollEdge func(tea.MouseMsg)) {
if !selection.active {
return
}
selection.cursor = point(msg)
scrollEdge(msg)
}
func finishChatSelection(m chatModel, selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint, selectedText func() string) (tea.Model, tea.Cmd) {
if !selection.active {
return m, nil
}
selection.cursor = point(msg)
selected := selectedText()
if strings.TrimSpace(selected) == "" {
*selection = chatSelection{}
return m, nil
}
return m, func() tea.Msg {
if m.opts.Clipboard == nil {
return nil
}
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
return chatClipboardErrorMsg{err: err}
}
return nil
}
}
type chatInputAttachment struct {
placeholder string
kind string
@ -194,6 +233,8 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
reviewApproval: reviewApproval,
permissionMode: newChatPermissionMode(autoApproveTools),
promptHistory: initialPromptHistory(ctx, opts),
boundedFrame: true,
fullScreen: true,
status: "ready",
}
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
@ -205,7 +246,7 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
m.preloadingModel = strings.TrimSpace(opts.Model)
}
p := tea.NewProgram(m, tea.WithReportFocus())
p := tea.NewProgram(m, tea.WithReportFocus(), tea.WithMouseCellMotion())
finalModel, err := p.Run()
if err != nil {
return nil, err
@ -219,22 +260,28 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) {
}
func (m chatModel) Init() tea.Cmd {
cmds := []tea.Cmd{tea.EnterAltScreen}
if m.preloadingModel != "" && m.opts.PreloadModel != nil {
return tea.Batch(preloadModelCmd(m.ctx, m.opts.PreloadModel, m.preloadingModel, m.opts.Think), chatTickCmd())
cmds = append(cmds, preloadModelCmd(m.ctx, m.opts.PreloadModel, m.preloadingModel, m.opts.Think), chatTickCmd())
}
return nil
return tea.Batch(cmds...)
}
func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.canEditInput() && isShiftEnterCSI(msg) {
m.insertInputNewline()
return m, nil
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
wasSet := m.width > 0 || m.height > 0
resized := m.width != msg.Width || m.height != msg.Height
m.width = msg.Width
m.height = msg.Height
if wasSet {
m.boundedFrame = true
m.scroll = m.maxScroll()
return m, tea.ClearScreen
if wasSet && resized {
m.resetRenderAfterResize()
return m, tea.Batch(tea.EnterAltScreen, tea.ClearScreen)
}
return m.withFlowTranscriptFlush(nil)
@ -278,6 +325,12 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.applyAgentEvent(msg.event)
return m.withFlowTranscriptFlush(waitForChatMsg(m.events))
case chatClipboardErrorMsg:
if msg.err != nil {
m.status = "clipboard error: " + msg.err.Error()
}
return m, nil
case chatApprovalPromptMsg:
m.resumePicker = nil
m.modelPicker = nil
@ -310,10 +363,10 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.groupCompletedToolHistory()
if msg.result == nil {
m.finishLiveMessagesForStoppedRun(msg.newMessagesPersisted, msg.persistedMessages)
}
if wasCanceling || isChatContextCanceledError(msg.err) {
if msg.result == nil {
m.promoteLiveMessagesForCanceledRun()
}
m.status = "Tell the model what to do instead."
return m.withFlowTranscriptFlush(m.startNextQueued())
}
@ -344,7 +397,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.Update(chatCompactDoneMsg{err: context.Canceled})
}
if m.running {
return m.Update(chatRunDoneMsg{err: context.Canceled})
return m.Update(chatRunDoneMsg{err: context.Canceled, newMessagesPersisted: true})
}
return m, nil
@ -361,6 +414,19 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *chatModel) resetRenderAfterResize() {
m.enterFullScreen()
m.scroll = m.maxScroll()
m.toolDetailsScroll = clamp(m.toolDetailsScroll, 0, m.maxToolDetailsScroll())
}
func (m *chatModel) enterFullScreen() {
m.boundedFrame = true
m.fullScreen = true
m.flowPrintedLines = 0
m.selection = chatSelection{}
}
func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.toolDetailsOpen {
switch msg.Type {
@ -433,49 +499,24 @@ func (m chatModel) mouseTranscriptPoint(msg tea.MouseMsg) chatSelectionPoint {
}
func (m *chatModel) startTranscriptSelection(msg tea.MouseMsg) {
if !m.mouseInTranscript(msg) {
m.selection = chatSelection{}
return
}
point := m.mouseTranscriptPoint(msg)
m.selection = chatSelection{active: true, anchor: point, cursor: point}
startChatSelection(&m.selection, msg, m.mouseInTranscript, m.mouseTranscriptPoint)
}
func (m *chatModel) dragTranscriptSelection(msg tea.MouseMsg) {
if !m.selection.active {
return
}
point := m.mouseTranscriptPoint(msg)
m.selection.cursor = point
top, height := m.transcriptLayout()
if msg.Y <= top {
m.scrollBy(1)
} else if msg.Y >= top+height-1 {
m.scrollBy(-1)
}
dragChatSelection(&m.selection, msg, m.mouseTranscriptPoint, func(msg tea.MouseMsg) {
top, height := m.transcriptLayout()
if msg.Y <= top {
m.scrollBy(1)
} else if msg.Y >= top+height-1 {
m.scrollBy(-1)
}
})
}
func (m chatModel) finishTranscriptSelection(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if !m.selection.active {
return m, nil
}
point := m.mouseTranscriptPoint(msg)
m.selection.cursor = point
selected := m.selectedTranscriptText(m.viewWidth())
if strings.TrimSpace(selected) == "" {
m.selection = chatSelection{}
return m, nil
}
m.status = "selection copied"
return m, func() tea.Msg {
if m.opts.Clipboard == nil {
return nil
}
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
return chatAgentMsg{event: coreagent.Event{Type: coreagent.EventError, Error: err.Error()}}
}
return nil
}
return finishChatSelection(m, &m.selection, msg, m.mouseTranscriptPoint, func() string {
return m.selectedTranscriptText(m.viewWidth())
})
}
func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@ -483,8 +524,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updateToolDetailsKey(msg)
}
if msg.Type == tea.KeyCtrlO {
m.toolDetailsOpen = true
m.toolDetailsScroll = 0
m.toggleInlineToolOutput()
m.disarmQuit()
m.disarmEsc()
return m, nil
@ -594,6 +634,19 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *chatModel) toggleInlineToolOutput() {
m.toolOutputMode = true
m.toolOutputOpen = !m.toolOutputOpen
m.applyToolOutputMode()
m.selection = chatSelection{}
m.scroll = clamp(m.scroll, 0, m.maxScroll())
if m.toolOutputOpen {
m.status = "tool output shown"
return
}
m.status = "tool output hidden"
}
func (m chatModel) updateToolDetailsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlO, tea.KeyEsc:
@ -615,16 +668,10 @@ func (m chatModel) updateToolDetailsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m chatModel) closeToolDetailsWindow() (tea.Model, tea.Cmd) {
wasFlowMode := !m.boundedFrame
m.toolDetailsOpen = false
m.toolDetailsScroll = 0
if !wasFlowMode {
return m, nil
}
m.boundedFrame = true
m.scroll = m.maxScroll()
m.flowPrintedLines = 0
m.enterFullScreen()
return m, tea.ClearScreen
}
@ -647,7 +694,7 @@ func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) {
return m, nil
}
m.quitting = true
return m, tea.Quit
return m, m.quitCmd()
}
func (m chatModel) updateCtrlD() (tea.Model, tea.Cmd) {
@ -662,7 +709,14 @@ func (m chatModel) updateCtrlD() (tea.Model, tea.Cmd) {
if (m.running || m.compacting) && m.cancel != nil {
m.cancel()
}
return m, tea.Quit
return m, m.quitCmd()
}
func (m chatModel) quitCmd() tea.Cmd {
if m.fullScreen {
return tea.Batch(tea.ExitAltScreen, tea.Quit)
}
return tea.Quit
}
func (m *chatModel) armQuit(key, status string) {
@ -778,7 +832,7 @@ func (m chatModel) View() string {
return m.flowView(width)
}
headerLines := []string{}
headerLines := m.headerLines()
allTranscriptLines := m.transcriptLines(width)
contentLineCount := len(allTranscriptLines)
@ -801,7 +855,7 @@ func (m chatModel) View() string {
lines = append(lines, "")
}
lines = append(lines, bottomLines...)
return strings.Join(lines, "\n")
return renderFrameLines(lines, width, height)
}
func (m chatModel) flowView(width int) string {
@ -1037,7 +1091,7 @@ func (m *chatModel) startRun(input string) (tea.Model, tea.Cmd) {
m.status = "error"
return *m, nil
}
return m.startRunWithMessages(displayInput, []api.Message{message}, "")
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "")
}
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
@ -1096,10 +1150,10 @@ func pluralSuffix(count int) string {
return "s"
}
func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
m.ensurePermissionMode()
m.refreshContextWindowTokens(m.opts.Model)
m.addPromptHistory(displayInput)
m.addPromptHistory(historyInput)
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
if len(newMessages) > 1 {
m.entries = append(m.entries, entriesFromMessages(newMessages[1:])...)
@ -1122,7 +1176,8 @@ func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.
events := make(chan tea.Msg, 128)
m.events = events
eventSink := coreagent.EventSink(chatEventSink{ctx: runCtx, ch: events})
var newMessagesPersisted bool
eventSink := coreagent.EventSink(chatEventSink{ctx: runCtx, ch: events, newMessagesPersisted: &newMessagesPersisted})
if m.opts.EventSink != nil {
eventSink = coreagent.MultiEventSink{eventSink, m.opts.EventSink}
}
@ -1149,10 +1204,13 @@ func (m *chatModel) startRunWithMessages(displayInput string, newMessages []api.
UseTools: m.opts.Tools != nil,
}
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
persistedMessages = append(persistedMessages, slices.Clone(m.messages)...)
persistedMessages = append(persistedMessages, slices.Clone(newMessages)...)
go func() {
defer close(events)
result, err := session.Run(runCtx, opts)
events <- chatRunDoneMsg{result: result, err: err}
events <- chatRunDoneMsg{result: result, err: err, newMessagesPersisted: newMessagesPersisted, persistedMessages: persistedMessages}
}()
tickCmd := m.scheduleTick()
@ -1185,11 +1243,17 @@ func (m *chatModel) startNextQueued() tea.Cmd {
return nil
}
func (m *chatModel) promoteLiveMessagesForCanceledRun() {
if len(m.liveMessages) == 0 || messagesHavePendingToolCalls(m.liveMessages) {
func (m *chatModel) finishLiveMessagesForStoppedRun(promote bool, persistedMessages []api.Message) {
if len(m.liveMessages) == 0 {
return
}
m.messages = slices.Clone(m.liveMessages)
if promote {
if len(persistedMessages) > 0 {
m.messages = slices.Clone(persistedMessages)
} else if !messagesHavePendingToolCalls(m.liveMessages) {
m.messages = slices.Clone(m.liveMessages)
}
}
m.liveMessages = nil
m.contextTokens = m.estimatePromptTokens(m.messages, "")
m.contextEstimate = true

View file

@ -250,22 +250,22 @@ func (m chatModel) renderApprovalPromptLines(width int) []string {
func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string {
switch request.ToolName {
case "bash":
command, ok := stringArg(request.Args, "command")
command, ok := rawStringArg(request.Args, "command")
if !ok {
return ""
}
return strings.Join(wrapChatText("$ "+command, width), "\n")
case "edit":
path, ok := stringArg(request.Args, "path")
path, ok := rawStringArg(request.Args, "path")
if !ok {
return ""
}
var lines []string
lines = append(lines, "path: "+path)
if oldText, ok := stringArg(request.Args, "old_text"); ok {
if oldText, ok := rawStringArg(request.Args, "old_text"); ok {
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
}
if newText, ok := stringArg(request.Args, "new_text"); ok {
if newText, ok := rawStringArg(request.Args, "new_text"); ok {
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
}
return chatMetaStyle.Render(strings.Join(lines, "\n"))
@ -273,7 +273,7 @@ func approvalRequestDetail(request coreagent.ApprovalRequest, width int) string
if len(request.Args) == 0 {
return ""
}
return chatMetaStyle.Render(formatToolArgs(request.Args))
return strings.Join(renderToolCallArgs(request.Args, width), "\n")
}
}
@ -319,7 +319,7 @@ func (h chatPermissionApprovalHandler) RequiresApproval(ctx context.Context, too
if h.review != nil {
return h.review.RequiresApproval(ctx, tool, req)
}
return coreagent.ToolRequiresApproval(tool, req.Args)
return req.ToolApprovalRequired || coreagent.ToolRequiresApproval(tool, req.Args)
}
func (h chatPermissionApprovalHandler) Approve(ctx context.Context, req coreagent.ApprovalRequest) (coreagent.ApprovalResult, error) {

View file

@ -77,12 +77,14 @@ func TestChatApprovalPromptRendersAndApprovesOnce(t *testing.T) {
}
}
func TestChatApprovalPromptCtrlOOpensToolDetails(t *testing.T) {
func TestChatApprovalPromptCtrlOExpandsToolDetailsInline(t *testing.T) {
reply := make(chan coreagent.ApprovalResult, 1)
m := chatModel{
width: 100,
height: 24,
events: make(chan tea.Msg),
width: 100,
height: 24,
boundedFrame: true,
fullScreen: true,
events: make(chan tea.Msg),
}
m.openApprovalPrompt(chatApprovalPromptMsg{
request: coreagent.ApprovalRequest{
@ -100,20 +102,19 @@ func TestChatApprovalPromptCtrlOOpensToolDetails(t *testing.T) {
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
if cmd != nil {
t.Fatal("ctrl+o should open tool details without starting a command")
}
m = updated.(chatModel)
if !m.toolDetailsOpen {
t.Fatal("ctrl+o should open tool details")
if cmd != nil {
t.Fatal("ctrl+o should not switch screens")
}
if m.toolDetailsOpen {
t.Fatal("ctrl+o should not open a separate tool details view")
}
if !m.fullScreen || !m.boundedFrame {
t.Fatal("ctrl+o should keep managed fullscreen rendering")
}
transcript = stripANSI(m.renderTranscript(100))
if strings.Contains(transcript, "$ git status --short --branch --untracked-files=all") {
t.Fatalf("main transcript should stay collapsed: %q", transcript)
}
view := stripANSI(m.View())
if !strings.Contains(view, "Bash") || !strings.Contains(view, "$ git status --short --branch --untracked-files=all") {
t.Fatalf("approval tool details view should show full command: %q", view)
if !strings.Contains(transcript, "Bash") || !strings.Contains(transcript, "$ git status --short --branch --untracked-files=all") {
t.Fatalf("approval tool details should show inline: %q", transcript)
}
}

View file

@ -16,14 +16,20 @@ type chatAgentMsg struct {
event coreagent.Event
}
type chatClipboardErrorMsg struct {
err error
}
type chatApprovalPromptMsg struct {
request coreagent.ApprovalRequest
reply chan<- coreagent.ApprovalResult
}
type chatRunDoneMsg struct {
result *coreagent.RunResult
err error
result *coreagent.RunResult
err error
newMessagesPersisted bool
persistedMessages []api.Message
}
type chatCompactDoneMsg struct {
@ -65,7 +71,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
m.thinking = true
m.thinkingTokens = max(m.thinkingTokens, eventEvalCount(event))
if eventEvalCount(event) <= 0 {
m.thinkingTokens += estimateTokenCount(event.Thinking)
m.thinkingTokens += coreagent.EstimateTokens(event.Thinking)
}
idx := m.ensureLiveAssistantMessage()
m.liveMessages[idx].Thinking += event.Thinking
@ -247,11 +253,15 @@ func (m *chatModel) refreshLiveContextEstimate() {
//nolint:containedctx // event sinks need the session context to unblock sends on cancellation.
type chatEventSink struct {
ctx context.Context
ch chan<- tea.Msg
ctx context.Context
ch chan<- tea.Msg
newMessagesPersisted *bool
}
func (s chatEventSink) Emit(event coreagent.Event) error {
if event.Type == coreagent.EventLoopStep && s.newMessagesPersisted != nil {
*s.newMessagesPersisted = true
}
select {
case s.ch <- chatAgentMsg{event: event}:
return nil

View file

@ -515,7 +515,7 @@ func TestChatRunDoneSuppressesDuplicateEventError(t *testing.T) {
m := chatModel{running: true}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventError, Error: eventErr.Error()})
updated, _ := m.Update(chatRunDoneMsg{err: err})
updated, _ := m.Update(chatRunDoneMsg{err: err, newMessagesPersisted: true})
fm := updated.(chatModel)
var errorEntries int
@ -532,6 +532,75 @@ func TestChatRunDoneSuppressesDuplicateEventError(t *testing.T) {
}
}
func TestChatRunDoneKeepsOnlyPersistedMessagesAfterRunError(t *testing.T) {
err := errors.New("model connection failed")
persisted := []api.Message{
{Role: "user", Content: "old prompt"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "new prompt"},
}
m := chatModel{
running: true,
messages: []api.Message{
{Role: "user", Content: "old prompt"},
{Role: "assistant", Content: "old answer"},
},
liveMessages: []api.Message{
{Role: "user", Content: "old prompt"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "new prompt"},
{Role: "assistant", Content: "partial assistant not persisted"},
},
}
updated, _ := m.Update(chatRunDoneMsg{err: err, newMessagesPersisted: true, persistedMessages: persisted})
fm := updated.(chatModel)
if len(fm.messages) != 3 || fm.messages[2].Content != "new prompt" {
t.Fatalf("messages = %#v, want persisted submitted messages after error", fm.messages)
}
for _, msg := range fm.messages {
if strings.Contains(msg.Content, "partial assistant") {
t.Fatalf("messages = %#v, should not include unpersisted assistant text", fm.messages)
}
}
if fm.liveMessages != nil {
t.Fatalf("liveMessages = %#v, want cleared after error", fm.liveMessages)
}
if fm.status != "error" {
t.Fatalf("status = %q, want error", fm.status)
}
}
func TestChatRunDoneDoesNotPromoteLiveMessagesBeforePersistence(t *testing.T) {
err := errors.New("prompt is too large for the current context")
m := chatModel{
running: true,
messages: []api.Message{
{Role: "user", Content: "old prompt"},
{Role: "assistant", Content: "old answer"},
},
liveMessages: []api.Message{
{Role: "user", Content: "old prompt"},
{Role: "assistant", Content: "old answer"},
{Role: "user", Content: "oversized prompt"},
},
}
updated, _ := m.Update(chatRunDoneMsg{err: err})
fm := updated.(chatModel)
if len(fm.messages) != 2 || fm.messages[1].Content != "old answer" {
t.Fatalf("messages = %#v, want previous persisted messages only", fm.messages)
}
if fm.liveMessages != nil {
t.Fatalf("liveMessages = %#v, want cleared after failed preflight", fm.liveMessages)
}
if fm.status != "error" {
t.Fatalf("status = %q, want error", fm.status)
}
}
func TestChatStreamingMetricsDoNotDropLiveContextEstimate(t *testing.T) {
m := chatModel{
running: true,
@ -1349,9 +1418,9 @@ func TestChatCompactCommandShowsSummary(t *testing.T) {
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
fm = updated.(chatModel)
view := stripANSI(fm.renderToolDetailsWindow(100, 24))
view := stripANSI(fm.renderTranscript(100))
if !strings.Contains(view, "old work summary") {
t.Fatalf("details view should show compacted summary body: %q", view)
t.Fatalf("expanded transcript should show compacted summary body: %q", view)
}
}

View file

@ -14,6 +14,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"github.com/ollama/ollama/agent/skills"
agenttools "github.com/ollama/ollama/agent/tools"
@ -24,6 +25,7 @@ import (
type chatSlashCommand struct {
name string
description string
aliases []string
hidden bool
}
@ -54,8 +56,11 @@ var chatSlashCommands = []chatSlashCommand{
{name: "/think", description: "set thinking mode"},
{name: "/verbose", description: "toggle model metrics"},
{name: "/compact", description: "summarize older context"},
{name: "/help", description: "show commands"},
{name: "/bye", description: "exit"},
{name: "/help", description: "show commands", aliases: []string{"/?"}},
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
{name: "/load", hidden: true},
{name: "/set", hidden: true},
{name: "/show", hidden: true},
}
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
@ -103,47 +108,52 @@ func (m chatModel) selectedSlashCommand() (string, bool) {
}
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
command, args, hasSlashCommand := slashCommandInvocation(input)
if hasSlashCommand {
input = strings.TrimSpace(command + " " + args)
}
switch {
case input == "/bye" || input == "/exit":
case command == "/bye":
m.quitting = true
return *m, tea.Quit
case input == "/?" || input == "/help":
return *m, m.quitCmd()
case command == "/help" && args == "":
m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: m.helpSummary()}))
return *m, nil
case strings.HasPrefix(input, "/? ") || strings.HasPrefix(input, "/help "):
case command == "/help":
return m.handleLegacyHelpCommand(input)
case input == "/clear":
case command == "/clear" && args == "":
return m.resetChat("cleared")
case input == "/model" || strings.HasPrefix(input, "/model "):
filter := strings.TrimSpace(strings.TrimPrefix(input, "/model"))
return m.openModelPicker(filter)
case input == "/load" || strings.HasPrefix(input, "/load "):
case command == "/model":
return m.openModelPicker(args)
case command == "/load":
return m.handleLegacyLoadCommand(input)
case input == "/think":
case command == "/think" && args == "":
return m.openThinkPicker()
case strings.HasPrefix(input, "/think "):
return m.handleThinkCommand(strings.TrimSpace(strings.TrimPrefix(input, "/think")))
case input == "/set" || strings.HasPrefix(input, "/set "):
case command == "/think":
return m.handleThinkCommand(args)
case command == "/set":
return m.handleLegacySetCommand(input)
case input == "/show" || strings.HasPrefix(input, "/show "):
case command == "/show":
return m.handleLegacyShowCommand(input)
case input == "/history":
case command == "/history" && args == "":
return m.openHistoryPopup()
case input == "/skills" || strings.HasPrefix(input, "/skills "):
case command == "/skills":
m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: m.handleSkillsCommand(input)}))
return *m, nil
case input == "/new":
case command == "/new" && args == "":
return m.resetChat("new chat")
case input == "/resume":
case command == "/resume" && args == "":
return m.openResumePicker()
case input == "/verbose" || strings.HasPrefix(input, "/verbose "):
case command == "/verbose":
return m.handleVerboseCommand(input)
case input == "/compact":
case command == "/compact" && args == "":
return m.startManualCompaction()
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
return m.startRun(input)
case strings.HasPrefix(input, "/"):
if skill, request, ok := m.skillTrigger(input); ok {
historyInput := m.expandPastedTextPlaceholders(input)
displayInput, userMessage, err := m.userMessageFromInput(input, request)
if err != nil {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
@ -158,7 +168,7 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
manualMessages[0].Content = userMessage.Content
}
manualMessages[0].Images = userMessage.Images
return m.startRunWithMessages(displayInput, manualMessages, "")
return m.startRunWithMessages(displayInput, historyInput, manualMessages, "")
}
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Unknown command %q", strings.Fields(input)[0])}))
return *m, nil
@ -270,6 +280,7 @@ func (m *chatModel) movePromptHistory(delta int) bool {
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.inputAttachments = nil
m.inputPastedTexts = nil
m.resetPromptHistoryCursor()
m.complete = 0
return true
@ -279,7 +290,12 @@ func (m *chatModel) movePromptHistory(delta int) bool {
}
}
m.input = []rune(m.promptHistory[m.promptCursor])
m.inputPastedTexts = nil
input := m.promptHistory[m.promptCursor]
if placeholder, ok := m.pastedTextPlaceholder(input); ok {
input = placeholder
}
m.input = []rune(input)
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.inputAttachments = nil
@ -848,7 +864,7 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac
}
if len(raw) > maxBodyLines {
raw = slices.Clone(raw[len(raw)-maxBodyLines:])
raw[0] = truncateInputLine(continuationPrefix+"... "+trimInputPromptPrefix(raw[0]), width)
raw[0] = truncateInputLine(continuationPrefix+trimInputPromptPrefix(raw[0]), width)
}
lines := make([]string, 0, len(raw))
@ -926,11 +942,10 @@ func truncateInputLine(line string, width int) string {
if width <= 0 {
return line
}
runes := []rune(line)
if len(runes) <= width {
if runewidth.StringWidth(line) <= width {
return line
}
return string(runes[:width])
return runewidth.Truncate(line, width, "")
}
func renderPromptRow(text string, width int) []string {
@ -1045,13 +1060,40 @@ func matchingSlashCommands(input string) []chatSlashCommand {
if command.hidden {
continue
}
if strings.HasPrefix(command.name, prefix) {
if command.matchesPrefix(prefix) {
commands = append(commands, command)
}
}
return commands
}
func (c chatSlashCommand) matchesPrefix(prefix string) bool {
if strings.HasPrefix(c.name, prefix) {
return true
}
for _, alias := range c.aliases {
if strings.HasPrefix(alias, prefix) {
return true
}
}
return false
}
func slashCommandInvocation(input string) (string, string, bool) {
input = strings.TrimSpace(input)
if !strings.HasPrefix(input, "/") {
return "", "", false
}
token, args, _ := strings.Cut(input, " ")
token = strings.ToLower(token)
for _, command := range chatSlashCommands {
if command.name == token || slices.Contains(command.aliases, token) {
return command.name, strings.TrimSpace(args), true
}
}
return "", "", false
}
func (m chatModel) mentionCompletions() []chatCompletion {
input := string(m.input)
_, query, ok := activeMentionToken(input)
@ -1212,29 +1254,30 @@ func completionIsSelectable(completions []chatCompletion) bool {
}
func (m chatModel) helpSummary() string {
return strings.Join([]string{
lines := []string{
"**Commands**",
"",
"- `/model`: switch models",
"- `/think`: set thinking mode",
"- `/skills`: show or import skills",
"- `/<skill>`: run the next message with a skill",
"- `/new`: start a new chat",
"- `/resume`: resume a saved chat",
"- `/verbose`: toggle model metrics",
"- `/compact`: summarize older context",
"- `/clear`: clear this chat",
"- `/help`: show commands",
"- `/bye`: exit",
}
for _, command := range chatSlashCommands {
if command.hidden || strings.TrimSpace(command.description) == "" {
continue
}
lines = append(lines, fmt.Sprintf("- `%s`: %s", command.name, command.description))
if command.name == "/skills" {
lines = append(lines, "- `/<skill>`: run the next message with a skill")
}
}
lines = append(lines,
"",
"**Shortcuts**",
"",
"- `ctrl+o`: open tool details",
"- `ctrl+o`: toggle tool output",
"- `shift+enter`: insert a newline",
"- `shift+tab`: toggle permission mode",
"- `↑/↓`: previous or next prompt",
"- `ctrl+a/e`: move to line start or end",
}, "\n")
)
return strings.Join(lines, "\n")
}
func (m chatModel) historyMessages() []api.Message {

View file

@ -9,6 +9,7 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
coreagent "github.com/ollama/ollama/agent"
@ -36,7 +37,7 @@ func TestChatHelpCommandShowsCommands(t *testing.T) {
!strings.Contains(fm.entries[0].content, "- `/help`: show commands") ||
!strings.Contains(fm.entries[0].content, "- `/bye`: exit") ||
!strings.Contains(fm.entries[0].content, "**Shortcuts**") ||
!strings.Contains(fm.entries[0].content, "- `ctrl+o`: open tool details") ||
!strings.Contains(fm.entries[0].content, "- `ctrl+o`: toggle tool output") ||
!strings.Contains(fm.entries[0].content, "- `shift+enter`: insert a newline") ||
!strings.Contains(fm.entries[0].content, "- `shift+tab`: toggle permission mode") ||
!strings.Contains(fm.entries[0].content, "- `ctrl+a/e`: move to line start or end") {
@ -49,6 +50,40 @@ func TestChatHelpCommandShowsCommands(t *testing.T) {
}
}
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, "...") {
t.Fatalf("input should include continuation marker: %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{}
@ -94,6 +129,21 @@ func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
if got := m.liveMessages[0].Content; got != pasted {
t.Fatalf("model content = %q, want pasted text", got)
}
if len(m.promptHistory) != 1 || m.promptHistory[0] != pasted {
t.Fatalf("prompt history = %#v, want expanded pasted text", m.promptHistory)
}
m.running = false
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
if got := string(m.input); got != "[Pasted text #2 +8 lines]" {
t.Fatalf("recalled input = %q, want pasted text placeholder", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if got := m.liveMessages[0].Content; got != pasted {
t.Fatalf("recalled model content = %q, want pasted text", got)
}
}
func TestChatBackspaceDeletesWholePastedTextPlaceholder(t *testing.T) {
@ -896,6 +946,9 @@ func TestChatSkillSlashCompletionAndTrigger(t *testing.T) {
if len(m.entries) == 0 || m.entries[0].content != "/go-code write a test" {
t.Fatalf("displayed entries = %#v", m.entries)
}
if len(m.promptHistory) != 1 || m.promptHistory[0] != "/go-code write a test" {
t.Fatalf("prompt history = %#v, want slash skill command preserved", m.promptHistory)
}
runDone := waitForRunDone(t, m.events)
if runDone.err != nil {
t.Fatal(runDone.err)

View file

@ -292,7 +292,7 @@ func legacyShortcutUsage() string {
return strings.Join([]string{
"**Shortcuts**",
"",
"- `ctrl+o`: open tool details",
"- `ctrl+o`: toggle tool output",
"- `shift+enter`: insert a newline",
"- `shift+tab`: toggle permission mode",
"- `cmd+backspace`, `option+backspace`, `ctrl+w`: delete previous word",

View file

@ -525,13 +525,7 @@ func wrapTableCell(text string, width int) []string {
line := strings.TrimRight(rawLine, "\r")
for lipgloss.Width(line) > width {
runes := []rune(line)
cut := min(width, len(runes))
for i := cut; i > max(1, cut/2); i-- {
if unicode.IsSpace(runes[i-1]) {
cut = i
break
}
}
cut := tableCellWrapCut(runes, width)
out = append(out, strings.TrimSpace(string(runes[:cut])))
line = strings.TrimSpace(string(runes[cut:]))
}
@ -543,6 +537,34 @@ func wrapTableCell(text string, width int) []string {
return out
}
func tableCellWrapCut(runes []rune, width int) int {
if len(runes) == 0 {
return 0
}
cut := 0
lineWidth := 0
for i, r := range runes {
nextWidth := lipgloss.Width(string(r))
if cut > 0 && lineWidth+nextWidth > width {
break
}
lineWidth += nextWidth
cut = i + 1
}
if cut <= 0 {
return 1
}
preferred := cut
spaceWidth := 0
for i := 0; i < cut; i++ {
spaceWidth += lipgloss.Width(string(runes[i]))
if unicode.IsSpace(runes[i]) && spaceWidth >= max(1, width/2) {
preferred = i + 1
}
}
return preferred
}
func markdownTableRenderedWidth(widths []int) int {
if len(widths) == 0 {
return 0

View file

@ -3,6 +3,8 @@ package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
)
func TestChatMarkdownRendersAssistantAndSystemOutput(t *testing.T) {
@ -109,6 +111,15 @@ func TestChatMarkdownRendersTableWithinWidth(t *testing.T) {
}
}
func TestWrapTableCellUsesDisplayWidth(t *testing.T) {
lines := wrapTableCell(strings.Repeat("界", 6), 4)
for _, line := range lines {
if width := lipgloss.Width(line); width > 4 {
t.Fatalf("line %q width = %d, want <= 4", line, width)
}
}
}
func TestChatMarkdownRendersTableWithoutOuterPipes(t *testing.T) {
markdown := strings.Join([]string{
"Here is a summary table:",

View file

@ -9,7 +9,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/agent/chatstore"
"github.com/ollama/ollama/api"
)
@ -58,6 +57,9 @@ func (m chatModel) updateHistoryPopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyCtrlC, tea.KeyEsc:
m.historyPopup = nil
m.status = "ready"
if m.fullScreen {
return m, nil
}
return m, tea.ExitAltScreen
case tea.KeyUp:
m.moveHistoryPopup(-1)
@ -141,47 +143,28 @@ func (m *chatModel) startHistoryPopupSelection(msg tea.MouseMsg) {
if m.historyPopup == nil {
return
}
if !m.mouseInHistoryPopupBody(msg) {
m.historyPopup.selection = chatSelection{}
return
}
point := m.mouseHistoryPopupPoint(msg)
m.historyPopup.selection = chatSelection{active: true, anchor: point, cursor: point}
startChatSelection(&m.historyPopup.selection, msg, m.mouseInHistoryPopupBody, m.mouseHistoryPopupPoint)
}
func (m *chatModel) dragHistoryPopupSelection(msg tea.MouseMsg) {
if m.historyPopup == nil || !m.historyPopup.selection.active {
if m.historyPopup == nil {
return
}
m.historyPopup.selection.cursor = m.mouseHistoryPopupPoint(msg)
top, height := m.historyPopupLayout()
if msg.Y <= top {
m.moveHistoryPopup(-1)
} else if msg.Y >= top+height-1 {
m.moveHistoryPopup(1)
}
dragChatSelection(&m.historyPopup.selection, msg, m.mouseHistoryPopupPoint, func(msg tea.MouseMsg) {
top, height := m.historyPopupLayout()
if msg.Y <= top {
m.moveHistoryPopup(-1)
} else if msg.Y >= top+height-1 {
m.moveHistoryPopup(1)
}
})
}
func (m chatModel) finishHistoryPopupSelection(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.historyPopup == nil || !m.historyPopup.selection.active {
if m.historyPopup == nil {
return m, nil
}
m.historyPopup.selection.cursor = m.mouseHistoryPopupPoint(msg)
selected := m.selectedHistoryPopupText()
if strings.TrimSpace(selected) == "" {
m.historyPopup.selection = chatSelection{}
return m, nil
}
m.status = "selection copied"
return m, func() tea.Msg {
if m.opts.Clipboard == nil {
return nil
}
if err := m.opts.Clipboard(m.ctx, selected); err != nil {
return chatAgentMsg{event: coreagent.Event{Type: coreagent.EventError, Error: err.Error()}}
}
return nil
}
return finishChatSelection(m, &m.historyPopup.selection, msg, m.mouseHistoryPopupPoint, m.selectedHistoryPopupText)
}
func (m chatModel) historyPopupMaxScroll() int {

View file

@ -221,8 +221,8 @@ func TestChatHistoryMouseDragSelectsAndCopiesText(t *testing.T) {
if copied != "alpha" {
t.Fatalf("copied = %q, want alpha", copied)
}
if m.status != "selection copied" {
t.Fatalf("status = %q, want selection copied", m.status)
if m.status == "selection copied" {
t.Fatalf("selection should not surface a copied status")
}
}

View file

@ -12,8 +12,10 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
@ -1209,55 +1211,11 @@ func toolStatusStyle(status string) lipgloss.Style {
}
func toolInvocationLabel(name string, args map[string]any) string {
displayName := toolDisplayName(name)
switch name {
case "web_search":
if query, ok := stringArg(args, "query"); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(query))
}
case "web_fetch":
if targetURL, ok := stringArg(args, "url"); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(targetURL))
}
case "bash":
if command, ok := stringArg(args, "command"); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(command))
}
case "read", "list":
if path, ok := stringArg(args, "path"); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(path))
}
case "edit":
if path, ok := stringArg(args, "path"); ok {
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(path))
}
}
if len(args) == 0 {
return displayName
}
return fmt.Sprintf("%s(%s)", displayName, formatToolArgs(args))
return coreagent.ToolInvocationLabel(name, args)
}
func toolDisplayName(name string) string {
switch name {
case "web_search":
return "Web Search"
case "web_fetch":
return "Web Fetch"
case "bash":
return "Bash"
case "read":
return "Read"
case "list":
return "List"
case "edit":
return "Edit"
default:
if name == "" {
return "Tool"
}
return name
}
return coreagent.ToolDisplayName(name)
}
func toolElapsedSuffix(startedAt, finishedAt time.Time) string {
@ -1280,20 +1238,6 @@ func toolOutputUsesMarkdown(name string) bool {
}
}
func formatToolArgs(args map[string]any) string {
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", key, quoteToolArg(args[key])))
}
return strings.Join(parts, ", ")
}
func renderToolCallDetailLines(entry chatEntry, width int) []string {
if len(entry.args) == 0 {
return nil
@ -1357,23 +1301,6 @@ func toolArgDisplayValue(value any) string {
return fmt.Sprint(value)
}
func quoteToolArg(value any) string {
switch v := value.(type) {
case string:
return strconv.Quote(truncateRunes(v, 100))
default:
return fmt.Sprint(v)
}
}
func stringArg(args map[string]any, key string) (string, bool) {
value, ok := args[key].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", false
}
return truncateRunes(value, 120), true
}
func rawStringArg(args map[string]any, key string) (string, bool) {
value, ok := args[key].(string)
if !ok || strings.TrimSpace(value) == "" {
@ -1709,82 +1636,7 @@ func (m chatModel) estimatePromptTokens(messages []api.Message, systemPrompt str
}
func estimatePromptTokenCount(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
requestMessages := slices.Clone(messages)
if strings.TrimSpace(systemPrompt) != "" {
requestMessages = make([]api.Message, 0, len(messages)+1)
requestMessages = append(requestMessages, api.Message{Role: "system", Content: strings.TrimSpace(systemPrompt)})
requestMessages = append(requestMessages, messages...)
}
if len(requestMessages) == 0 && len(tools) == 0 && strings.TrimSpace(format) == "" {
return 0
}
payload := struct {
Messages []api.Message `json:"messages,omitempty"`
Tools api.Tools `json:"tools,omitempty"`
Format json.RawMessage `json:"format,omitempty"`
}{
Messages: requestMessages,
Tools: tools,
}
if rawFormat, ok := promptFormatForEstimate(format); ok {
payload.Format = rawFormat
}
if b, err := json.Marshal(payload); err == nil {
return estimateTokenCount(string(b))
}
var runes int
for _, msg := range requestMessages {
runes += estimateMessageRunes(msg)
}
runes += len([]rune(tools.String()))
runes += len([]rune(strings.TrimSpace(format)))
if runes == 0 {
return 0
}
return max(1, (runes+3)/4)
}
func promptFormatForEstimate(format string) (json.RawMessage, bool) {
format = strings.TrimSpace(format)
if format == "" {
return nil, false
}
if format == "json" {
format = `"` + format + `"`
}
if !json.Valid([]byte(format)) {
return nil, false
}
return json.RawMessage(format), true
}
func estimateMessageRunes(msg api.Message) int {
var runes int
runes += len([]rune(msg.Role))
runes += len([]rune(msg.Content))
runes += len([]rune(msg.Thinking))
runes += len([]rune(msg.ToolName))
runes += len([]rune(msg.ToolCallID))
for _, image := range msg.Images {
runes += len(image)
}
for _, call := range msg.ToolCalls {
runes += len([]rune(call.ID))
runes += len([]rune(call.Function.Name))
runes += len([]rune(fmt.Sprint(call.Function.Arguments.ToMap())))
}
return runes
}
func estimateTokenCount(text string) int {
text = strings.TrimSpace(text)
if text == "" {
return 0
}
return max(1, (len([]rune(text))+3)/4)
return coreagent.EstimatePromptTokens(systemPrompt, messages, tools, format)
}
func formatTokenCount(count int) string {
@ -1922,6 +1774,26 @@ func renderFullFrame(content string, width, height int) string {
return strings.Join(lines, "\n")
}
func renderFrameLines(lines []string, width, height int) string {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
if len(lines) > height {
lines = lines[:height]
}
out := make([]string, 0, height)
for _, line := range lines {
out = append(out, padRenderedLine(clipRenderedLine(line, width), width))
}
for len(out) < height {
out = append(out, strings.Repeat(" ", width))
}
return strings.Join(out, "\n")
}
func truncateRenderedLine(line string, width int) string {
if width <= 0 || lipgloss.Width(line) <= width {
return line
@ -2187,17 +2059,10 @@ func wrapChatText(text string, width int) []string {
var out []string
for _, rawLine := range strings.Split(text, "\n") {
line := strings.TrimRight(rawLine, "\r")
for len([]rune(line)) > width {
runes := []rune(line)
cut := width
for i := width; i > width/2; i-- {
if runes[i-1] == ' ' || runes[i-1] == '\t' {
cut = i
break
}
}
out = append(out, strings.TrimSpace(string(runes[:cut])))
line = strings.TrimSpace(string(runes[cut:]))
for runewidth.StringWidth(line) > width {
cut := chatDisplayWidthCut(line, width)
out = append(out, strings.TrimSpace(line[:cut]))
line = strings.TrimSpace(line[cut:])
}
out = append(out, line)
}
@ -2206,3 +2071,32 @@ func wrapChatText(text string, width int) []string {
}
return out
}
func chatDisplayWidthCut(line string, width int) int {
hardCut := 0
currentWidth := 0
spaceCut := 0
spaceWidth := 0
for i := 0; i < len(line); {
r, size := utf8.DecodeRuneInString(line[i:])
nextWidth := currentWidth + runewidth.RuneWidth(r)
if nextWidth > width {
break
}
currentWidth = nextWidth
hardCut = i + size
if (r == ' ' || r == '\t') && currentWidth > width/2 {
spaceCut = i
spaceWidth = currentWidth
}
i += size
}
if spaceCut > 0 && spaceWidth > 0 {
return spaceCut
}
if hardCut > 0 {
return hardCut
}
_, size := utf8.DecodeRuneInString(line)
return size
}

View file

@ -2,6 +2,7 @@ package tui
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@ -10,6 +11,7 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
@ -207,7 +209,7 @@ func TestChatViewCapsTallInputBox(t *testing.T) {
if got := inputPromptLineCount(t, view); got > maxInputBoxBodyLines {
t.Fatalf("input body lines = %d, want <= %d:\n%s", got, maxInputBoxBodyLines, view)
}
if !strings.Contains(view, "... ... ") {
if !strings.Contains(view, "... ") || strings.Contains(view, "... ... ") {
t.Fatalf("truncated pasted prompt should show an omission marker:\n%s", view)
}
}
@ -671,12 +673,19 @@ func TestChatResizeAndScrollsLongAssistantOutput(t *testing.T) {
if cmd == nil || !m.boundedFrame {
t.Fatal("terminal resize should switch to bounded rendering and clear the stale flow view")
}
if !m.fullScreen {
t.Fatal("terminal resize should enter fullscreen managed rendering")
}
if m.flowPrintedLines != 0 {
t.Fatalf("resize should clear flow state, printed=%d", m.flowPrintedLines)
}
if m.maxScroll() == 0 {
t.Fatal("long assistant output should be scrollable after resize")
}
if !strings.Contains(stripANSI(m.View()), "generated line 00") {
t.Fatalf("bounded view should reset to earliest generated content after resize:\n%s", stripANSI(m.View()))
}
assertChatFrameSize(t, m.View(), 72, 10)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome})
m = updated.(chatModel)
@ -689,6 +698,20 @@ func TestChatResizeAndScrollsLongAssistantOutput(t *testing.T) {
if !strings.Contains(stripANSI(m.View()), "generated line 79") {
t.Fatalf("scrolling back to bottom should restore latest generated content:\n%s", stripANSI(m.View()))
}
assertChatFrameSize(t, m.View(), 72, 10)
}
func assertChatFrameSize(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(view, "\n")
if len(lines) != height {
t.Fatalf("frame rendered %d lines, want %d:\n%s", len(lines), height, stripANSI(view))
}
for i, line := range lines {
if got := lipgloss.Width(line); got > width {
t.Fatalf("frame line %d width = %d, want <= %d: %q", i, got, width, stripANSI(line))
}
}
}
func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
@ -734,10 +757,12 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
m := chatModel{
width: 80,
height: 10,
boundedFrame: true,
running: true,
width: 80,
height: 10,
boundedFrame: true,
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"})
@ -751,12 +776,18 @@ func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
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 TestChatMouseDragSelectsAndCopiesTranscriptText(t *testing.T) {
@ -799,8 +830,68 @@ func TestChatMouseDragSelectsAndCopiesTranscriptText(t *testing.T) {
if copied != "alpha" {
t.Fatalf("copied = %q, want alpha", copied)
}
if m.status != "selection copied" {
t.Fatalf("status = %q, want selection copied", m.status)
if m.status == "selection copied" {
t.Fatalf("selection should not surface a copied status")
}
}
func TestChatBoundedViewHeaderMatchesTranscriptLayout(t *testing.T) {
m := chatModel{
width: 80,
height: 6,
boundedFrame: true,
}
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 header offset", top)
}
if !strings.Contains(lines[0], "↓ more") {
t.Fatalf("view should render status header at top: %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 TestChatMouseCopyFailureDoesNotClearRunningState(t *testing.T) {
m := chatModel{
ctx: context.Background(),
opts: ChatOptions{
Clipboard: func(context.Context, string) error {
return errors.New("copy failed")
},
},
width: 80,
height: 10,
running: true,
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)
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 return clipboard command")
}
msg := cmd()
updated, _ = m.Update(msg)
m = updated.(chatModel)
if !m.running {
t.Fatal("clipboard failure should not clear running state")
}
if m.status != "clipboard error: copy failed" {
t.Fatalf("status = %q, want clipboard error", m.status)
}
}
@ -1079,15 +1170,19 @@ func TestChatToolOutputIsHiddenUntilExpanded(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
body = stripANSI(m.renderToolDetailsWindow(100, 40))
if !strings.Contains(body, "line 24") || !strings.Contains(body, "↑ more") {
t.Fatalf("details window should show latest output with scroll affordance: %q", body)
if !m.toolOutputMode || !m.toolOutputOpen || !m.entries[0].expanded {
t.Fatalf("ctrl+o should expand tool output inline: %#v", m.entries[0])
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome})
body = stripANSI(m.renderTranscript(100))
if !strings.Contains(body, "line 00") || !strings.Contains(body, "line 24") {
t.Fatalf("expanded transcript should show full tool output: %q", body)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
body = stripANSI(m.renderToolDetailsWindow(100, 40))
if !strings.Contains(body, "line 00") {
t.Fatalf("details window should scroll to earlier output: %q", body)
body = stripANSI(m.renderTranscript(100))
if m.toolOutputOpen || m.entries[0].expanded || strings.Contains(body, "line 00") {
t.Fatalf("second ctrl+o should collapse tool output: %q", body)
}
}
@ -1114,7 +1209,7 @@ func TestChatCompletedToolsGroupWhenNextStepStarts(t *testing.T) {
}
}
func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
func TestChatCtrlOTogglesInlineToolOutput(t *testing.T) {
m := chatModel{
width: 100,
height: 20,
@ -1127,26 +1222,29 @@ func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if !m.toolDetailsOpen {
t.Fatal("ctrl+o should open tool details")
if m.toolDetailsOpen {
t.Fatal("ctrl+o should not open a separate tool details view")
}
for _, index := range []int{0, 2} {
if m.entries[index].expanded {
t.Fatalf("tool entry %d should not be mutated by ctrl+o", index)
if !m.entries[index].expanded {
t.Fatalf("tool entry %d should be expanded inline", index)
}
}
view := stripANSI(m.View())
if !strings.Contains(view, "Tool details") || !strings.Contains(view, "one") || !strings.Contains(view, "two") {
t.Fatalf("details view missing expanded tool output: %q", view)
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("details window should keep surrounding chat visible: %q", view)
t.Fatalf("inline tool output should keep surrounding chat visible: %q", view)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if m.toolDetailsOpen {
t.Fatal("second ctrl+o should close tool details")
t.Fatal("second ctrl+o should not open tool details")
}
for _, index := range []int{0, 2} {
if m.entries[index].expanded {
@ -1155,10 +1253,12 @@ func TestChatCtrlOOpensToolDetailsWindow(t *testing.T) {
}
}
func TestChatCtrlOReturnsToManagedRedrawWithoutReprinting(t *testing.T) {
func TestChatCtrlOTogglesInlineOutputWithoutLeavingFullscreen(t *testing.T) {
m := chatModel{
width: 100,
height: 24,
boundedFrame: true,
fullScreen: true,
flowPrintedLines: 4,
entries: []chatEntry{
{role: "user", content: "who is parth sareen"},
@ -1170,32 +1270,38 @@ func TestChatCtrlOReturnsToManagedRedrawWithoutReprinting(t *testing.T) {
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("opening tool details should not force a redraw command")
t.Fatal("inline tool toggle should not switch screens")
}
if !m.toolDetailsOpen {
t.Fatal("ctrl+o should open tool details")
if m.toolDetailsOpen {
t.Fatal("ctrl+o should not open tool details")
}
if m.boundedFrame {
t.Fatal("opening tool details from terminal-flow mode should not permanently enter bounded mode")
if !m.fullScreen || !m.boundedFrame {
t.Fatal("inline tool toggle should keep managed fullscreen mode")
}
if !m.toolOutputOpen || !m.entries[1].expanded {
t.Fatalf("tool output should be expanded inline: %#v", m.entries[1])
}
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if m.toolDetailsOpen {
t.Fatal("second ctrl+o should close tool details")
t.Fatal("second ctrl+o should not open tool details")
}
if !m.boundedFrame {
t.Fatal("closing tool details should enter managed redraw mode")
t.Fatal("closing tool details should keep managed redraw mode")
}
if cmd == nil {
t.Fatal("closing tool details from terminal-flow mode should clear stale output")
if !m.fullScreen {
t.Fatal("closing tool details should stay fullscreen")
}
if m.flowPrintedLines != 0 {
t.Fatalf("closing tool details should not reprint transcript into scrollback, printed=%d", m.flowPrintedLines)
if cmd != nil {
t.Fatal("inline tool toggle should not switch screens")
}
if m.toolOutputOpen || m.entries[1].expanded {
t.Fatalf("tool output should be collapsed inline: %#v", m.entries[1])
}
}
func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) {
args := map[string]any{"command": "pwd"}
m := chatModel{width: 100, height: 20, running: true}
m.applyAgentEvent(coreagent.Event{
@ -1207,11 +1313,11 @@ func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
if !m.toolDetailsOpen {
t.Fatalf("ctrl+o should open tool details while tool is running")
if m.toolDetailsOpen {
t.Fatalf("ctrl+o should not open a tool details screen")
}
if m.entries[0].expanded {
t.Fatalf("ctrl+o should not record expanded state on the running tool")
if !m.entries[0].expanded {
t.Fatalf("ctrl+o should expand the running tool inline")
}
m.applyAgentEvent(coreagent.Event{
@ -1222,17 +1328,13 @@ func TestChatCtrlOShowsRunningToolOutputInWindow(t *testing.T) {
Content: "/tmp/project\n",
})
view := stripANSI(m.View())
view := stripANSI(m.renderTranscript(100))
if !strings.Contains(view, "/tmp/project") {
t.Fatalf("finished tool output should be visible in details window: %q", view)
}
transcript := stripANSI(m.renderTranscript(100))
if strings.Contains(transcript, "/tmp/project") {
t.Fatalf("main transcript should stay collapsed after ctrl+o: %q", transcript)
t.Fatalf("finished tool output should be visible inline: %q", view)
}
}
func TestChatCtrlODetailsSurvivesToolGrouping(t *testing.T) {
func TestChatCtrlOInlineOutputSurvivesToolGrouping(t *testing.T) {
firstArgs := map[string]any{"command": "pwd"}
secondArgs := map[string]any{"command": "ls"}
m := chatModel{
@ -1251,13 +1353,13 @@ func TestChatCtrlODetailsSurvivesToolGrouping(t *testing.T) {
if len(m.entries) != 2 {
t.Fatalf("entries = %d, want tool group plus assistant: %#v", len(m.entries), m.entries)
}
if m.entries[0].role != "tool_group" || m.entries[0].expanded {
t.Fatalf("grouped tool history should stay collapsed in main transcript: %#v", m.entries[0])
if m.entries[0].role != "tool_group" || !m.entries[0].expanded {
t.Fatalf("grouped tool history should stay expanded inline: %#v", m.entries[0])
}
view := stripANSI(m.View())
if !strings.Contains(view, "one") || !strings.Contains(view, "two") {
t.Fatalf("grouped tool output should be visible in details window: %q", view)
t.Fatalf("grouped tool output should be visible inline: %q", view)
}
}
@ -1531,3 +1633,15 @@ func TestWrapChatTextSplitsLongLines(t *testing.T) {
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)
}
}
}

View file

@ -140,7 +140,7 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
}
view := menu.View()
for _, want := range []string{"Agent", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
for _, want := range []string{"Chat and Code", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view)
}