remove all tool calling permission checks and approval infrastructure

Strip the three-layer permission system (hook-based blocking, user approval
dialogs, PostToolUse output suppression) so every tool call executes
unconditionally. This removes ~1100 lines across 14 files including the
--approve-tool-run and --no-hooks CLI flags, ToolApprovalHandler/Func types,
PreToolUse/UserPromptSubmit/PostToolUse/Stop hook firing, HookBlockedEvent,
ToolApprovalNeededEvent, the ApprovalComponent UI, and all associated tests.
This commit is contained in:
Ed Zynda
2026-02-26 09:47:10 +03:00
parent 0c3d240519
commit 80d5300feb
14 changed files with 13 additions and 1107 deletions
+1 -53
View File
@@ -7,14 +7,12 @@ import (
"log"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/app"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/session"
"github.com/mark3labs/mcphost/internal/tools"
@@ -38,7 +36,6 @@ var (
streamFlag bool // Enable streaming output
compactMode bool // Enable compact output mode
scriptMCPConfig *config.Config // Used to override config in script mode
approveToolRun bool
// Session management
saveSessionPath string
@@ -57,7 +54,6 @@ var (
mainGPU int32
// Hooks control
noHooks bool
// TLS configuration
tlsSkipVerify bool
@@ -201,19 +197,6 @@ func InitConfig() {
viper.SetEnvPrefix("MCPHOST")
viper.AutomaticEnv()
// Load hooks configuration unless disabled
if !viper.GetBool("no-hooks") {
hooksConfig, err := hooks.LoadHooksConfig()
if err != nil {
// Hooks are optional, so just log a warning
if debugMode {
fmt.Fprintf(os.Stderr, "Warning: Failed to load hooks configuration: %v\n", err)
}
} else {
viper.Set("hooks", hooksConfig)
}
}
}
// LoadConfigWithEnvSubstitution loads a config file with environment variable substitution.
@@ -301,12 +284,6 @@ func init() {
BoolVar(&streamFlag, "stream", true, "enable streaming output for faster response display")
rootCmd.PersistentFlags().
BoolVar(&compactMode, "compact", false, "enable compact output mode without fancy styling")
rootCmd.PersistentFlags().
BoolVar(&noHooks, "no-hooks", false, "disable all hooks execution")
rootCmd.PersistentFlags().
BoolVar(&approveToolRun, "approve-tool-run", false, "enable requiring user approval for every tool call")
// Session management flags
rootCmd.PersistentFlags().
StringVar(&saveSessionPath, "save-session", "", "save session to file after each message")
rootCmd.PersistentFlags().
@@ -339,7 +316,7 @@ func init() {
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
_ = viper.BindPFlag("no-hooks", rootCmd.PersistentFlags().Lookup("no-hooks"))
_ = viper.BindPFlag("provider-url", rootCmd.PersistentFlags().Lookup("provider-url"))
_ = viper.BindPFlag("provider-api-key", rootCmd.PersistentFlags().Lookup("provider-api-key"))
_ = viper.BindPFlag("max-tokens", rootCmd.PersistentFlags().Lookup("max-tokens"))
@@ -350,7 +327,6 @@ func init() {
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
_ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
_ = viper.BindPFlag("approve-tool-run", rootCmd.PersistentFlags().Lookup("approve-tool-run"))
// Defaults are already set in flag definitions, no need to duplicate in viper
@@ -470,20 +446,6 @@ func runNormalMode(ctx context.Context) error {
modelName = "Unknown"
}
var hookExecutor *hooks.Executor
if hooksConfig := viper.Get("hooks"); hooksConfig != nil {
if hc, ok := hooksConfig.(*hooks.HookConfig); ok {
// Generate a session ID for this run
sessionID := fmt.Sprintf("mcphost-%d", time.Now().Unix())
transcriptPath := "" // We could add transcript logging later
hookExecutor = hooks.NewExecutor(hc, sessionID, transcriptPath)
// Set model and interactive mode
hookExecutor.SetModel(modelString)
hookExecutor.SetInteractive(promptFlag == "") // Interactive if no prompt flag
}
}
// Create CLI for non-interactive mode only. SetupCLI is the factory for the
// non-interactive (quiet and non-quiet) path; interactive mode uses the full
// Bubble Tea TUI (AppModel) which handles its own rendering.
@@ -738,23 +700,9 @@ func runNormalMode(ctx context.Context) error {
})
}
// Determine approval function per mode.
// Non-interactive: auto-approve unless --approve-tool-run is set (interactive TUI approval
// is handled by app.buildApprovalFunc when a tea.Program is registered via SetProgram).
approveToolRun := viper.GetBool("approve-tool-run")
var toolApprovalFunc app.ToolApprovalFunc
if promptFlag != "" && !approveToolRun {
// Non-interactive and no explicit approval required → auto-approve.
toolApprovalFunc = app.AutoApproveFunc
}
// In interactive mode (promptFlag == "") the TUI handles approval via program.Send();
// toolApprovalFunc remains nil here and is ignored when a tea.Program is set.
// Create the app.App instance now that session messages are loaded.
appOpts := app.Options{
Agent: mcpAgent,
ToolApprovalFunc: toolApprovalFunc,
HookExecutor: hookExecutor,
SessionManager: sessionManager,
MCPConfig: mcpConfig,
ModelName: modelName,
+4 -176
View File
@@ -9,12 +9,10 @@ import (
"os"
"regexp"
"strings"
"time"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/session"
"github.com/mark3labs/mcphost/internal/tools"
@@ -677,21 +675,6 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
cli.DisplayDebugConfig(debugConfig)
}
// Initialize hooks
var hookExecutor *hooks.Executor
if hooksConfig := viper.Get("hooks"); hooksConfig != nil {
if hc, ok := hooksConfig.(*hooks.HookConfig); ok {
// Generate a session ID for this run
sessionID := fmt.Sprintf("mcphost-%d", time.Now().Unix())
transcriptPath := "" // We could add transcript logging later
hookExecutor = hooks.NewExecutor(hc, sessionID, transcriptPath)
// Set model and interactive mode
hookExecutor.SetModel(finalModel)
hookExecutor.SetInteractive(prompt == "")
}
}
// Prepare data for slash commands
var serverNames []string
for name := range mcpConfig.MCPServers {
@@ -718,7 +701,7 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
MCPConfig: mcpConfig,
}
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
return runAgenticLoop(ctx, mcpAgent, cli, messages, config)
}
// AgenticLoopConfig configures the behavior of the unified agentic loop.
@@ -729,7 +712,6 @@ type AgenticLoopConfig struct {
IsInteractive bool // true for interactive mode, false for non-interactive
InitialPrompt string // initial prompt for non-interactive mode
ContinueAfterRun bool // true to continue to interactive mode after initial run (--no-exit)
ApproveToolRun bool // only used in interactive mode
// UI configuration
Quiet bool // suppress all output except final response
@@ -760,30 +742,9 @@ func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session
}
// runAgenticLoop handles all execution modes with a single unified loop
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig) error {
// Handle initial prompt for non-interactive modes
if !config.IsInteractive && config.InitialPrompt != "" {
// Execute UserPromptSubmit hooks for non-interactive mode
if hookExecutor != nil {
input := &hooks.UserPromptSubmitInput{
CommonInput: hookExecutor.PopulateCommonFields(hooks.UserPromptSubmit),
Prompt: config.InitialPrompt,
}
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.UserPromptSubmit, input)
if err != nil {
// Log error but don't fail
if debugMode {
fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err)
}
}
// Check if hook blocked the prompt
if hookOutput != nil && hookOutput.Decision == "block" {
return fmt.Errorf("prompt blocked by hook: %s", hookOutput.Reason)
}
}
// Display user message (skip if quiet)
if !config.Quiet && cli != nil {
cli.DisplayUserMessage(config.InitialPrompt)
@@ -793,7 +754,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt))
// Process the initial prompt with tool calls
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config)
if err != nil {
// Check if this was a user cancellation
if err.Error() == "generation cancelled by user" && cli != nil {
@@ -824,7 +785,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
}
// runAgenticStep processes a single step of the agentic loop (handles tool calls)
func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*fantasy.Response, []fantasy.Message, error) {
func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig) (*fantasy.Response, []fantasy.Message, error) {
var currentSpinner *ui.Spinner
// Start initial spinner (skip if quiet)
@@ -863,19 +824,9 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
streamingStarted = false
streamingContent.Reset()
// Variables to store tool information for hooks
var currentToolName string
var currentToolArgs string
var toolIsBlocked bool
var blockReason string
result, err := mcpAgent.GenerateWithLoopAndStreaming(ctx, messages,
// Tool call handler - called when a tool is about to be executed
func(toolName, toolArgs string) {
// Store tool info for use in execution handler
currentToolName = toolName
currentToolArgs = toolArgs
if !config.Quiet && cli != nil {
// Stop spinner before displaying tool call
if currentSpinner != nil {
@@ -888,35 +839,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
// Tool execution handler - called when tool execution starts/ends
func(toolName string, isStarting bool) {
if isStarting {
// Execute PreToolUse hooks
if hookExecutor != nil {
input := &hooks.PreToolUseInput{
CommonInput: hookExecutor.PopulateCommonFields(hooks.PreToolUse),
ToolName: currentToolName,
ToolInput: json.RawMessage(currentToolArgs),
}
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input)
if err != nil {
// Log error but don't fail the tool execution
if debugMode {
fmt.Fprintf(os.Stderr, "Hook execution error: %v\n", err)
}
}
// Check if hook blocked the execution
if hookOutput != nil && hookOutput.Decision == "block" {
toolIsBlocked = true
blockReason = hookOutput.Reason
if blockReason == "" {
blockReason = "Tool execution blocked by security policy"
}
if !config.Quiet && cli != nil {
cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", blockReason))
}
}
}
if !config.Quiet && cli != nil {
// Start spinner for tool execution
currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName))
@@ -932,48 +854,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
},
// Tool result handler - called when a tool execution completes
func(toolName, toolArgs, result string, isError bool) {
// Check if this tool was blocked
if toolIsBlocked {
// Reset the flag for next tool
toolIsBlocked = false
// Display the blocked message
if !config.Quiet && cli != nil {
cli.DisplayToolMessage(toolName, toolArgs, fmt.Sprintf("Tool execution blocked: %s", blockReason), true)
}
// Reset block reason
blockReason = ""
return
}
// Execute PostToolUse hooks
var postToolHookOutput *hooks.HookOutput
if hookExecutor != nil && result != "" {
input := &hooks.PostToolUseInput{
CommonInput: hookExecutor.PopulateCommonFields(hooks.PostToolUse),
ToolName: currentToolName,
ToolInput: json.RawMessage(currentToolArgs),
ToolResponse: json.RawMessage(result),
}
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
if err != nil {
// Log error but don't fail
if debugMode {
fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err)
}
}
postToolHookOutput = hookOutput
}
// Check if hook wants to suppress output
if postToolHookOutput != nil && postToolHookOutput.SuppressOutput {
// Skip displaying tool result to user
// Note: Result still goes to LLM unless ModifyOutput is used
return
}
if !config.Quiet && cli != nil {
// Parse tool result content - it might be JSON-encoded MCP content
resultContent := result
@@ -1051,22 +931,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
},
// Add streaming callback handler
streamingCallback,
// Tool call approval handler - called before tool execution to get user approval
func(toolName, toolArgs string) (bool, error) {
if !config.IsInteractive || !config.ApproveToolRun {
return true, nil
}
if currentSpinner != nil {
currentSpinner.Stop()
currentSpinner = nil
}
// Tool approval via CLI is no longer supported; always approve in legacy path.
// Start spinner again for tool calls
currentSpinner = ui.NewSpinner("")
currentSpinner.Start()
return true, nil
},
)
// Make sure spinner is stopped if still running
@@ -1129,42 +993,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
cli.DisplayUsageAfterResponse()
}
// Execute Stop hook after agent has finished responding
executeStopHook(hookExecutor, response, "completed", config.ModelName)
// Return the final response and all conversation messages
return response, conversationMessages, nil
}
// executeStopHook executes the Stop hook if a hook executor is available
func executeStopHook(hookExecutor *hooks.Executor, response *fantasy.Response, stopReason string, modelName string) {
if hookExecutor != nil {
// Prepare metadata
var meta json.RawMessage
if response != nil {
metaData := map[string]any{
"model": modelName,
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
}
if metaBytes, err := json.Marshal(metaData); err == nil {
meta = json.RawMessage(metaBytes)
}
}
responseContent := ""
if response != nil {
responseContent = response.Content.Text()
}
input := &hooks.StopInput{
CommonInput: hookExecutor.PopulateCommonFields(hooks.Stop),
StopHookActive: true,
Response: responseContent,
StopReason: stopReason,
Meta: meta,
}
// Execute Stop hook (ignore errors as we're exiting anyway)
_, _ = hookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input)
}
}
+3 -17
View File
@@ -41,9 +41,6 @@ type StreamingResponseHandler func(content string)
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
type ToolCallContentHandler func(content string)
// ToolApprovalHandler is a function type for handling user approval of tool calls.
type ToolApprovalHandler func(toolName, toolArgs string) (bool, error)
// Agent represents an AI agent with MCP tool integration using the fantasy library.
// It manages the interaction between an LLM and various tools through the MCP protocol.
type Agent struct {
@@ -135,10 +132,10 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time.
func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, onToolApproval ToolApprovalHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, onToolApproval)
onResponse, onToolCallContent, nil)
}
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
@@ -147,7 +144,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler, onToolApproval ToolApprovalHandler,
onStreamingResponse StreamingResponseHandler,
) (*GenerateWithLoopResult, error) {
// Fantasy requires the current user input as Prompt, with prior messages as history.
@@ -177,17 +174,6 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
currentToolName = tc.ToolName
currentToolArgs = tc.Input
// Check approval if handler is set
if onToolApproval != nil {
approved, err := onToolApproval(tc.ToolName, tc.Input)
if err != nil {
return err
}
if !approved {
return fmt.Errorf("tool call %s rejected by user", tc.ToolName)
}
}
// Notify about the tool call
if onToolCall != nil {
onToolCall(tc.ToolName, tc.Input)
+3 -220
View File
@@ -2,17 +2,14 @@ package app
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"sync"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/hooks"
)
// App is the application-layer orchestrator. It owns the agentic loop,
@@ -93,17 +90,8 @@ func (a *App) SetProgram(p *tea.Program) {
// executed immediately in a background goroutine; otherwise it is appended
// to the queue and a QueueUpdatedEvent is sent to the program.
//
// Before queuing, the UserPromptSubmit hook is fired. If the hook blocks the
// prompt, a HookBlockedEvent is sent and the prompt is dropped.
//
// Satisfies ui.AppController.
func (a *App) Run(prompt string) {
// Fire UserPromptSubmit hook before accepting the prompt.
if blocked, reason := a.fireUserPromptSubmitHook(prompt); blocked {
a.sendEvent(HookBlockedEvent{Message: fmt.Sprintf("Prompt blocked by hook: %s", reason)})
return
}
a.mu.Lock()
if a.closed {
@@ -181,20 +169,12 @@ func (a *App) RunOnce(ctx context.Context, prompt string, w io.Writer) error {
result, err := a.executeStep(stepCtx, prompt, nil /* program */, nil /* writer */)
if err != nil {
stopReason := "error"
if stepCtx.Err() != nil {
stopReason = "cancelled"
}
a.fireStopHook(nil, stopReason)
return err
}
// Record token usage for the completed step.
a.updateUsage(result, prompt)
// Fire Stop hook on successful completion.
a.fireStopHook(result.FinalResponse, "completed")
responseText := ""
if result.FinalResponse != nil {
responseText = result.FinalResponse.Content.Text()
@@ -283,12 +263,6 @@ func (a *App) runPrompt(prompt string) {
result, err := a.executeStep(stepCtx, prompt, prog, nil)
if err != nil {
// Fire Stop hook on error/cancellation.
stopReason := "error"
if stepCtx.Err() != nil {
stopReason = "cancelled"
}
a.fireStopHook(nil, stopReason)
a.sendEvent(StepErrorEvent{Err: err})
return
}
@@ -296,9 +270,6 @@ func (a *App) runPrompt(prompt string) {
// Record token usage for the completed step.
a.updateUsage(result, prompt)
// Fire Stop hook on successful completion.
a.fireStopHook(result.FinalResponse, "completed")
a.sendEvent(StepCompleteEvent{
Response: result.FinalResponse,
Usage: result.TotalUsage,
@@ -332,58 +303,17 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program,
// Signal spinner start.
sendFn(SpinnerEvent{Show: true})
// Wire the approval callback.
onApproval := a.buildApprovalFunc(ctx, prog)
// Per-step state tracking for hook callbacks.
var (
currentToolName string
currentToolArgs string
toolIsBlocked bool
blockReason string
)
result, err := a.opts.Agent.GenerateWithLoopAndStreaming(ctx, msgs,
// onToolCall — store name/args for subsequent hook callbacks
// onToolCall
func(toolName, toolArgs string) {
currentToolName = toolName
currentToolArgs = toolArgs
sendFn(ToolCallStartedEvent{ToolName: toolName, ToolArgs: toolArgs})
},
// onToolExecution — fire PreToolUse on isStarting=true
// onToolExecution
func(toolName string, isStarting bool) {
if isStarting {
if blocked, reason := a.firePreToolUseHook(ctx, currentToolName, currentToolArgs); blocked {
toolIsBlocked = true
blockReason = reason
if blockReason == "" {
blockReason = "Tool execution blocked by security policy"
}
sendFn(HookBlockedEvent{Message: fmt.Sprintf("Tool execution blocked by hook: %s", blockReason)})
}
}
sendFn(ToolExecutionEvent{ToolName: toolName, IsStarting: isStarting})
},
// onToolResult — fire PostToolUse; honour block flag
// onToolResult
func(toolName, toolArgs, result string, isError bool) {
if toolIsBlocked {
// Reset flag; result event carries "blocked" info.
toolIsBlocked = false
blockMsg := fmt.Sprintf("Tool execution blocked: %s", blockReason)
blockReason = ""
sendFn(ToolResultEvent{
ToolName: toolName,
ToolArgs: toolArgs,
Result: blockMsg,
IsError: true,
})
return
}
// Fire PostToolUse hook.
if postOut := a.firePostToolUseHook(ctx, currentToolName, currentToolArgs, result); postOut != nil && postOut.SuppressOutput {
// Hook asked to suppress output; skip sending the result event.
return
}
sendFn(ToolResultEvent{
ToolName: toolName,
ToolArgs: toolArgs,
@@ -404,8 +334,6 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program,
sendFn(SpinnerEvent{Show: false})
sendFn(StreamChunkEvent{Content: chunk})
},
// onToolApproval
onApproval,
)
if err != nil {
@@ -419,43 +347,6 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program,
return result, nil
}
// buildApprovalFunc returns the ToolApprovalHandler to use for a step.
// In interactive mode (prog != nil) it sends a ToolApprovalNeededEvent and
// blocks until the TUI responds; otherwise it delegates to opts.ToolApprovalFunc.
func (a *App) buildApprovalFunc(ctx context.Context, prog *tea.Program) agent.ToolApprovalHandler {
if prog == nil {
// Non-interactive: use the configured approval func.
return func(toolName, toolArgs string) (bool, error) {
if a.opts.ToolApprovalFunc == nil {
return true, nil
}
return a.opts.ToolApprovalFunc(ctx, toolName, toolArgs)
}
}
return func(toolName, toolArgs string) (bool, error) {
// Parse toolArgs for display. If it's not JSON just use raw string.
displayArgs := toolArgs
if json.Valid([]byte(toolArgs)) {
displayArgs = toolArgs
}
responseCh := make(chan bool, 1)
prog.Send(ToolApprovalNeededEvent{
ToolName: toolName,
ToolArgs: displayArgs,
ResponseChan: responseCh,
})
select {
case <-ctx.Done():
return false, ctx.Err()
case approved := <-responseCh:
return approved, nil
}
}
}
// --------------------------------------------------------------------------
// Internal: event helpers
// --------------------------------------------------------------------------
@@ -471,79 +362,6 @@ func (a *App) sendEvent(msg tea.Msg) {
}
}
// --------------------------------------------------------------------------
// Internal: hook helpers
// --------------------------------------------------------------------------
// fireUserPromptSubmitHook fires the UserPromptSubmit hook.
// Returns (blocked bool, reason string).
func (a *App) fireUserPromptSubmitHook(prompt string) (bool, string) {
if a.opts.HookExecutor == nil {
return false, ""
}
input := &hooks.UserPromptSubmitInput{
CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.UserPromptSubmit),
Prompt: prompt,
}
output, err := a.opts.HookExecutor.ExecuteHooks(context.Background(), hooks.UserPromptSubmit, input)
if err != nil {
if a.opts.Debug {
fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err)
}
return false, ""
}
if output != nil && output.Decision == "block" {
return true, output.Reason
}
return false, ""
}
// firePreToolUseHook fires the PreToolUse hook before a tool executes.
// Returns (blocked bool, reason string).
func (a *App) firePreToolUseHook(ctx context.Context, toolName, toolArgs string) (bool, string) {
if a.opts.HookExecutor == nil {
return false, ""
}
input := &hooks.PreToolUseInput{
CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.PreToolUse),
ToolName: toolName,
ToolInput: json.RawMessage(toolArgs),
}
output, err := a.opts.HookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input)
if err != nil {
if a.opts.Debug {
fmt.Fprintf(os.Stderr, "PreToolUse hook execution error: %v\n", err)
}
return false, ""
}
if output != nil && output.Decision == "block" {
return true, output.Reason
}
return false, ""
}
// firePostToolUseHook fires the PostToolUse hook after a tool executes.
// Returns the hook output (may be nil if no hooks configured or on error).
func (a *App) firePostToolUseHook(ctx context.Context, toolName, toolArgs, result string) *hooks.HookOutput {
if a.opts.HookExecutor == nil || result == "" {
return nil
}
input := &hooks.PostToolUseInput{
CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.PostToolUse),
ToolName: toolName,
ToolInput: json.RawMessage(toolArgs),
ToolResponse: json.RawMessage(result),
}
output, err := a.opts.HookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
if err != nil {
if a.opts.Debug {
fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err)
}
return nil
}
return output
}
// updateUsage records token usage from a completed agent step into the configured
// UsageTracker (if any). It uses the actual token counts from the agent result's
// TotalUsage field when available; otherwise it falls back to text-based estimation.
@@ -568,38 +386,3 @@ func (a *App) updateUsage(result *agent.GenerateWithLoopResult, userPrompt strin
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, responseText)
}
}
// fireStopHook fires the Stop hook after a step completes, errors, or is cancelled.
// response may be nil for error/cancelled steps.
func (a *App) fireStopHook(response *fantasy.Response, stopReason string) {
if a.opts.HookExecutor == nil {
return
}
var meta json.RawMessage
if response != nil {
metaData := map[string]any{
"model": a.opts.ModelName,
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
}
if metaBytes, err := json.Marshal(metaData); err == nil {
meta = json.RawMessage(metaBytes)
}
}
responseContent := ""
if response != nil {
responseContent = response.Content.Text()
}
input := &hooks.StopInput{
CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.Stop),
StopHookActive: true,
Response: responseContent,
StopReason: stopReason,
Meta: meta,
}
// Execute Stop hook (ignore errors — we're completing regardless).
_, _ = a.opts.HookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input)
}
-100
View File
@@ -57,7 +57,6 @@ func (s *stubAgent) GenerateWithLoopAndStreaming(
_ agent.ResponseHandler,
_ agent.ToolCallContentHandler,
_ agent.StreamingResponseHandler,
_ agent.ToolApprovalHandler,
) (*agent.GenerateWithLoopResult, error) {
// Optional blocking: wait for a signal or ctx cancellation.
if s.blockCh != nil {
@@ -361,105 +360,6 @@ func TestCancelCurrentStep_safeWhenIdle(t *testing.T) {
app.CancelCurrentStep()
}
// --------------------------------------------------------------------------
// Cancel during tool approval (ToolApprovalFunc unblocks via ctx)
// --------------------------------------------------------------------------
// TestCancelDuringApproval verifies that cancelling the step while the
// ToolApprovalFunc is waiting unblocks the func via ctx.Done() and the step
// terminates cleanly.
func TestCancelDuringApproval(t *testing.T) {
approvalUnblocked := make(chan struct{}, 1)
// ToolApprovalFunc blocks until ctx is cancelled.
approvalFunc := func(ctx context.Context, _, _ string) (bool, error) {
<-ctx.Done()
approvalUnblocked <- struct{}{}
return false, ctx.Err()
}
// captureApprovalStub calls onToolApproval() which delegates to approvalFunc.
stub := newCaptureApprovalStub()
app := New(Options{
Agent: stub,
ToolApprovalFunc: approvalFunc,
}, nil)
defer app.Close()
app.Run("trigger approval")
// Wait for the stub to signal it's started (and therefore calling approval).
select {
case <-stub.started:
case <-time.After(2 * time.Second):
t.Fatal("step never started")
}
// Give the approval func a moment to block on ctx.Done().
time.Sleep(10 * time.Millisecond)
app.CancelCurrentStep()
// Approval func should unblock when ctx is cancelled.
select {
case <-approvalUnblocked:
case <-time.After(2 * time.Second):
t.Fatal("approval func did not unblock after cancel")
}
ok := waitForCondition(2*time.Second, func() bool {
app.mu.Lock()
defer app.mu.Unlock()
return !app.busy
})
if !ok {
t.Fatal("app did not become idle after cancel-during-approval")
}
}
// captureApprovalStub is a specialised stubAgent that calls the captured
// ToolApprovalHandler (which is the app-layer's approval wrapper that delegates
// to Options.ToolApprovalFunc) to simulate the agent needing tool approval.
type captureApprovalStub struct {
started chan struct{}
}
func newCaptureApprovalStub() *captureApprovalStub {
return &captureApprovalStub{started: make(chan struct{}, 1)}
}
func (s *captureApprovalStub) GenerateWithLoopAndStreaming(
ctx context.Context,
_ []fantasy.Message,
_ agent.ToolCallHandler,
_ agent.ToolExecutionHandler,
_ agent.ToolResultHandler,
_ agent.ResponseHandler,
_ agent.ToolCallContentHandler,
_ agent.StreamingResponseHandler,
onToolApproval agent.ToolApprovalHandler,
) (*agent.GenerateWithLoopResult, error) {
// Signal that we've started.
select {
case s.started <- struct{}{}:
default:
}
// Call onToolApproval, which will delegate to ToolApprovalFunc and block.
if onToolApproval != nil {
approved, err := onToolApproval("test_tool", "{}")
if err != nil {
return nil, err
}
if !approved {
return nil, errors.New("tool denied")
}
}
return makeResult("after approval"), nil
}
// --------------------------------------------------------------------------
// ClearQueue
// --------------------------------------------------------------------------
-21
View File
@@ -76,20 +76,6 @@ type QueueUpdatedEvent struct {
Length int
}
// ToolApprovalNeededEvent is sent when the agent requires user approval before
// executing a tool call. The TUI must display the approval dialog and send the
// user's decision on the provided ResponseChan.
type ToolApprovalNeededEvent struct {
// ToolName is the name of the tool awaiting approval.
ToolName string
// ToolArgs is the JSON-encoded arguments for the pending tool call.
ToolArgs string
// ResponseChan is the channel on which the TUI must send the approval decision.
// Sending true approves the tool call; sending false denies it.
// The app layer goroutine is blocking on this channel (select with ctx.Done).
ResponseChan chan<- bool
}
// SpinnerEvent is sent to show or hide the spinner animation in the stream component.
// The spinner is shown before the first streaming chunk arrives and hidden once
// content begins flowing or the step completes.
@@ -98,13 +84,6 @@ type SpinnerEvent struct {
Show bool
}
// HookBlockedEvent is sent when a hook returns a block decision, preventing
// the operation from proceeding. The TUI displays the block reason to the user.
type HookBlockedEvent struct {
// Message is the human-readable reason the hook blocked the action.
Message string
}
// MessageCreatedEvent is sent when a new message is added to the message store.
// This allows the TUI to stay in sync with the conversation history.
type MessageCreatedEvent struct {
-21
View File
@@ -7,7 +7,6 @@ import (
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
"github.com/mark3labs/mcphost/internal/session"
)
@@ -24,15 +23,9 @@ type AgentRunner interface {
onResponse agent.ResponseHandler,
onToolCallContent agent.ToolCallContentHandler,
onStreamingResponse agent.StreamingResponseHandler,
onToolApproval agent.ToolApprovalHandler,
) (*agent.GenerateWithLoopResult, error)
}
// ToolApprovalFunc is the callback invoked by the app layer when the agent needs
// user approval before executing a tool call. It must return true to approve or
// false to deny. The ctx is used to unblock the call if the app is shutting down.
type ToolApprovalFunc func(ctx context.Context, toolName, toolArgs string) (bool, error)
// UsageUpdater is the interface the app layer uses to record token usage after
// each agent step. It is satisfied by *ui.UsageTracker (which lives in
// internal/ui) without creating an import cycle — the concrete type is wired
@@ -45,12 +38,6 @@ type UsageUpdater interface {
EstimateAndUpdateUsage(inputText, outputText string)
}
// AutoApproveFunc is a ToolApprovalFunc that always approves tool calls.
// Used in non-interactive mode.
var AutoApproveFunc ToolApprovalFunc = func(_ context.Context, _, _ string) (bool, error) {
return true, nil
}
// Options configures an App instance. It mirrors the fields from AgenticLoopConfig
// in cmd/root.go but is owned by the app layer rather than the CLI.
type Options struct {
@@ -58,14 +45,6 @@ type Options struct {
// *agent.Agent satisfies this interface; tests may supply stubs.
Agent AgentRunner
// ToolApprovalFunc is called when the agent needs user confirmation before
// running a tool. Required; use AutoApproveFunc for non-interactive mode.
ToolApprovalFunc ToolApprovalFunc
// HookExecutor is the optional hooks executor for firing UserPromptSubmit,
// PreToolUse, PostToolUse, and Stop events around the agentic loop.
HookExecutor *hooks.Executor
// SessionManager is the optional session manager for persisting conversation
// history to disk. When non-nil, the MessageStore calls it on every mutation.
SessionManager *session.Manager
-2
View File
@@ -166,8 +166,6 @@ type Config struct {
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
Theme any `json:"theme" yaml:"theme"`
MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"`
ApproveToolRun bool `json:"approve-tool-run" yaml:"approve-tool-run"`
// Model generation parameters
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
-133
View File
@@ -1,133 +0,0 @@
package ui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ApprovalComponent is the tool approval dialog for the parent AppModel.
// It displays tool name and arguments, lets the user approve or deny the call,
// and returns an approvalResultMsg tea.Cmd instead of tea.Quit — lifecycle is
// entirely managed by the parent.
//
// Key bindings:
// - y / Y → approve immediately
// - n / N → deny immediately
// - left → select "yes"
// - right → select "no"
// - enter → confirm current selection
// - esc / ctrl+c → deny (same as "no")
type ApprovalComponent struct {
toolName string
toolArgs string
width int
selected bool // true = "yes" highlighted, false = "no" highlighted
}
// NewApprovalComponent creates a new ApprovalComponent for the given tool call.
// width is the terminal width passed down from the parent model.
// By default the "yes" option is highlighted.
func NewApprovalComponent(toolName, toolArgs string, width int) *ApprovalComponent {
return &ApprovalComponent{
toolName: toolName,
toolArgs: toolArgs,
width: width,
selected: true, // default to "yes"
}
}
// Init implements tea.Model. No startup commands needed.
func (a *ApprovalComponent) Init() tea.Cmd {
return nil
}
// Update implements tea.Model. Handles keyboard input and returns an
// approvalResultMsg tea.Cmd when the user makes a decision.
// It does NOT return tea.Quit — the parent owns the program lifecycle.
func (a *ApprovalComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "y", "Y":
return a, approvalResult(true)
case "n", "N":
return a, approvalResult(false)
case "left":
a.selected = true
return a, nil
case "right":
a.selected = false
return a, nil
case "enter":
return a, approvalResult(a.selected)
case "esc", "ctrl+c":
return a, approvalResult(false)
}
case tea.WindowSizeMsg:
a.width = msg.Width
}
return a, nil
}
// View implements tea.Model. Renders the approval dialog with tool info and
// yes/no selection.
func (a *ApprovalComponent) View() tea.View {
// Add left padding to entire component (2 spaces like other UI elements).
containerStyle := lipgloss.NewStyle().PaddingLeft(2)
// Title
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
MarginBottom(1)
// Input box with huh-like styling
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(lipgloss.Color("39")).
PaddingLeft(1).
Width(a.width - 2) // Account for container padding
// Style for the currently selected option
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("42")). // Bright green
Bold(true).
Underline(true)
// Style for the unselected option
unselectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) // Dark gray
var view strings.Builder
view.WriteString(titleStyle.Render("Allow tool execution"))
view.WriteString("\n")
view.WriteString(fmt.Sprintf("Tool: %s\nArguments: %s\n\n", a.toolName, a.toolArgs))
view.WriteString("Allow tool execution: ")
var yesText, noText string
if a.selected {
yesText = selectedStyle.Render("[y]es")
noText = unselectedStyle.Render("[n]o")
} else {
yesText = unselectedStyle.Render("[y]es")
noText = selectedStyle.Render("[n]o")
}
view.WriteString(yesText + "/" + noText + "\n")
return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String())))
}
// approvalResult returns a tea.Cmd that emits an approvalResultMsg with the
// given decision. The parent AppModel receives this and sends the result on
// the stored approvalChan.
func approvalResult(approved bool) tea.Cmd {
return func() tea.Msg {
return approvalResultMsg{Approved: approved}
}
}
-229
View File
@@ -528,232 +528,3 @@ func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinner(t *testing.T) {
t.Fatal("expected no tick reschedule when not in spinner phase")
}
}
// ==========================================================================
// ApprovalComponent tests
// ==========================================================================
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
// newTestApproval creates a new ApprovalComponent with canned tool info.
func newTestApproval() *ApprovalComponent {
return NewApprovalComponent("test_tool", `{"arg":"val"}`, 80)
}
// sendApprovalMsg calls component.Update and returns the updated component and cmd.
func sendApprovalMsg(a *ApprovalComponent, msg tea.Msg) (*ApprovalComponent, tea.Cmd) {
m, cmd := a.Update(msg)
return m.(*ApprovalComponent), cmd
}
// extractApprovalResult runs the cmd and returns the approvalResultMsg, or fails.
func extractApprovalResult(t *testing.T, cmd tea.Cmd) approvalResultMsg {
t.Helper()
if cmd == nil {
t.Fatal("expected non-nil cmd from approval key press")
}
msg := cmd()
result, ok := msg.(approvalResultMsg)
if !ok {
t.Fatalf("expected approvalResultMsg, got %T", msg)
}
return result
}
// --------------------------------------------------------------------------
// TestApprovalComponent_DefaultSelection is "yes".
// --------------------------------------------------------------------------
func TestApprovalComponent_DefaultSelection_IsYes(t *testing.T) {
a := newTestApproval()
if !a.selected {
t.Fatal("expected default selection to be 'yes' (selected=true)")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_Y_ApproveEmitsTrue verifies that pressing 'y' emits
// approvalResultMsg{Approved: true}.
// --------------------------------------------------------------------------
func TestApprovalComponent_Y_ApproveEmitsTrue(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'y'})
result := extractApprovalResult(t, cmd)
if !result.Approved {
t.Fatal("expected Approved=true for 'y' key press")
}
}
// TestApprovalComponent_YUpper_ApproveEmitsTrue verifies that pressing 'Y'
// also emits approvalResultMsg{Approved: true}.
func TestApprovalComponent_YUpper_ApproveEmitsTrue(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'Y'})
result := extractApprovalResult(t, cmd)
if !result.Approved {
t.Fatal("expected Approved=true for 'Y' key press")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_N_DenyEmitsFalse verifies that pressing 'n' emits
// approvalResultMsg{Approved: false}.
// --------------------------------------------------------------------------
func TestApprovalComponent_N_DenyEmitsFalse(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'n'})
result := extractApprovalResult(t, cmd)
if result.Approved {
t.Fatal("expected Approved=false for 'n' key press")
}
}
// TestApprovalComponent_NUpper_DenyEmitsFalse verifies that pressing 'N'
// also emits approvalResultMsg{Approved: false}.
func TestApprovalComponent_NUpper_DenyEmitsFalse(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'N'})
result := extractApprovalResult(t, cmd)
if result.Approved {
t.Fatal("expected Approved=false for 'N' key press")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_Enter_ConfirmsSelection verifies that pressing enter
// confirms the currently selected option.
// --------------------------------------------------------------------------
func TestApprovalComponent_Enter_ConfirmsYesSelection(t *testing.T) {
a := newTestApproval()
a.selected = true // "yes" selected
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEnter})
result := extractApprovalResult(t, cmd)
if !result.Approved {
t.Fatal("expected Approved=true when 'yes' is selected and enter pressed")
}
}
func TestApprovalComponent_Enter_ConfirmsNoSelection(t *testing.T) {
a := newTestApproval()
a.selected = false // "no" selected
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEnter})
result := extractApprovalResult(t, cmd)
if result.Approved {
t.Fatal("expected Approved=false when 'no' is selected and enter pressed")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_ESC_DeniesApproval verifies that pressing ESC emits
// approvalResultMsg{Approved: false} (same as "no").
// --------------------------------------------------------------------------
func TestApprovalComponent_ESC_DeniesApproval(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEscape})
result := extractApprovalResult(t, cmd)
if result.Approved {
t.Fatal("expected Approved=false for ESC")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_CtrlC_DeniesApproval verifies that ctrl+c emits
// approvalResultMsg{Approved: false}.
// --------------------------------------------------------------------------
func TestApprovalComponent_CtrlC_DeniesApproval(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
result := extractApprovalResult(t, cmd)
if result.Approved {
t.Fatal("expected Approved=false for ctrl+c")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_Left_SelectsYes verifies that pressing left navigates
// to "yes".
// --------------------------------------------------------------------------
func TestApprovalComponent_Left_SelectsYes(t *testing.T) {
a := newTestApproval()
a.selected = false // start on "no"
a, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyLeft})
if !a.selected {
t.Fatal("expected selected=true (yes) after pressing left")
}
if cmd != nil {
t.Fatal("expected nil cmd from left arrow (just navigation)")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_Right_SelectsNo verifies that pressing right navigates
// to "no".
// --------------------------------------------------------------------------
func TestApprovalComponent_Right_SelectsNo(t *testing.T) {
a := newTestApproval()
a.selected = true // start on "yes"
a, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyRight})
if a.selected {
t.Fatal("expected selected=false (no) after pressing right")
}
if cmd != nil {
t.Fatal("expected nil cmd from right arrow (just navigation)")
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_WindowSizeMsg updates the width field.
// --------------------------------------------------------------------------
func TestApprovalComponent_WindowSizeMsg_UpdatesWidth(t *testing.T) {
a := newTestApproval()
a, _ = sendApprovalMsg(a, tea.WindowSizeMsg{Width: 120, Height: 40})
if a.width != 120 {
t.Fatalf("expected width=120, got %d", a.width)
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_NoQuit verifies that the component never returns a
// tea.Quit cmd (parent manages lifecycle).
// --------------------------------------------------------------------------
func TestApprovalComponent_NoQuitFromYKey(t *testing.T) {
a := newTestApproval()
_, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'y'})
if cmd != nil {
msg := runCmd(cmd)
if _, ok := msg.(tea.QuitMsg); ok {
t.Fatal("ApprovalComponent must not return tea.Quit (parent manages lifecycle)")
}
}
}
// --------------------------------------------------------------------------
// TestApprovalComponent_Init_ReturnsNil verifies Init() returns nil.
// --------------------------------------------------------------------------
func TestApprovalComponent_Init_ReturnsNil(t *testing.T) {
a := newTestApproval()
if cmd := a.Init(); cmd != nil {
t.Fatal("expected Init() to return nil cmd")
}
}
-8
View File
@@ -7,14 +7,6 @@ type submitMsg struct {
Text string
}
// approvalResultMsg is sent by the ApprovalComponent when the user makes a decision
// on a tool approval dialog. The parent model receives this and sends the result on
// the approvalChan that was stored when ToolApprovalNeededEvent arrived.
type approvalResultMsg struct {
// Approved is true if the user approved the tool call, false if denied.
Approved bool
}
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
// presses ESC once during stateWorking. If this message arrives before the user
// presses ESC a second time, the canceling state is reset to false.
+2 -68
View File
@@ -20,10 +20,6 @@ const (
// stateWorking means the agent is running. The stream component is active.
// The input component remains visible and editable for queueing messages.
stateWorking
// stateApproval means a tool approval dialog is active. The user must
// approve or deny before the agent can continue.
stateApproval
)
// AppController is the interface the parent TUI model uses to interact with the
@@ -90,10 +86,6 @@ type AppModel struct {
// Placeholder until StreamComponent is implemented in TAS-16.
stream streamComponentIface
// approval is the child tool approval dialog component.
// Placeholder until ApprovalComponent is implemented in TAS-17.
approval approvalComponentIface
// renderer renders completed assistant messages for tea.Println output.
renderer *MessageRenderer
@@ -113,10 +105,6 @@ type AppModel struct {
// A second ESC within 2 seconds will cancel the current step.
canceling bool
// approvalChan is the response channel for the current tool approval.
// Set when a ToolApprovalNeededEvent arrives; cleared after sending the result.
approvalChan chan<- bool
// width and height track the terminal dimensions.
width int
height int
@@ -145,12 +133,6 @@ type streamComponentIface interface {
GetRenderedContent() string
}
// approvalComponentIface is the interface the parent requires from ApprovalComponent.
// It will be satisfied by the real ApprovalComponent created in TAS-17.
type approvalComponentIface interface {
tea.Model
}
// --------------------------------------------------------------------------
// Constructor
// --------------------------------------------------------------------------
@@ -162,8 +144,7 @@ type approvalComponentIface interface {
// satisfies AppController once the app layer is implemented (TAS-4).
//
// NewAppModel constructs all child components (InputComponent, StreamComponent)
// using the provided options. ApprovalComponent is created dynamically per-step
// in Update when a ToolApprovalNeededEvent arrives.
// using the provided options.
func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
width := opts.Width
if width == 0 {
@@ -262,11 +243,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Route key events to the focused child.
if m.state == stateApproval && m.approval != nil {
updated, cmd := m.approval.Update(msg)
m.approval, _ = updated.(approvalComponentIface)
cmds = append(cmds, cmd)
} else if m.input != nil {
if m.input != nil {
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
@@ -293,14 +270,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateWorking
}
// ── Approval result ──────────────────────────────────────────────────────
case approvalResultMsg:
if m.approvalChan != nil {
m.approvalChan <- msg.Approved
m.approvalChan = nil
}
m.state = stateWorking
// ── App layer events ─────────────────────────────────────────────────────
case app.SpinnerEvent:
@@ -361,25 +330,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stream.Reset() // stop spinner
}
case app.HookBlockedEvent:
// Print hook blocked message immediately to scrollback.
cmds = append(cmds, m.printHookBlocked(msg))
case app.MessageCreatedEvent:
// Informational — no action needed by parent.
case app.QueueUpdatedEvent:
m.queueCount = msg.Length
case app.ToolApprovalNeededEvent:
// Store the response channel and transition to approval state.
m.approvalChan = msg.ResponseChan
m.state = stateApproval
// Construct the ApprovalComponent and init it (returns nil cmd, but good practice).
approvalComp := NewApprovalComponent(msg.ToolName, msg.ToolArgs, m.width)
cmds = append(cmds, approvalComp.Init())
m.approval = approvalComp
case app.StepCompleteEvent:
// Flush any remaining streamed text to scrollback, then reset stream
// and return to input state.
@@ -412,10 +368,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
if m.state == stateApproval && m.approval != nil {
_, cmd := m.approval.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
@@ -447,14 +399,6 @@ func (m *AppModel) renderStream() string {
return ""
}
if m.state == stateApproval && m.approval != nil {
// Show both stream context and the approval dialog stacked.
return lipgloss.JoinVertical(lipgloss.Left,
m.stream.View().Content,
m.approval.View().Content,
)
}
// Show canceling warning if set.
if m.canceling {
warning := lipgloss.NewStyle().
@@ -559,16 +503,6 @@ func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd {
return tea.Println(rendered)
}
// printHookBlocked renders a hook-blocked notice and emits it above the BT region.
func (m *AppModel) printHookBlocked(evt app.HookBlockedEvent) tea.Cmd {
theme := GetTheme()
rendered := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(" ⛔ Hook blocked: " + evt.Message)
return tea.Println(rendered)
}
// printErrorResponse renders an error message and emits it above the BT region.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd {
if evt.Err == nil {
-57
View File
@@ -143,63 +143,6 @@ func TestStateTransition_InputToWorking(t *testing.T) {
}
}
// TestStateTransition_WorkingToApproval verifies that a ToolApprovalNeededEvent
// while in stateWorking transitions the model to stateApproval and creates the
// ApprovalComponent.
func TestStateTransition_WorkingToApproval(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
responseChan := make(chan bool, 1)
m = sendMsg(m, app.ToolApprovalNeededEvent{
ToolName: "test_tool",
ToolArgs: `{"arg":"val"}`,
ResponseChan: responseChan,
})
if m.state != stateApproval {
t.Fatalf("expected stateApproval after ToolApprovalNeededEvent, got %v", m.state)
}
if m.approval == nil {
t.Fatal("expected approval component to be set")
}
if m.approvalChan == nil {
t.Fatal("expected approvalChan to be set")
}
}
// TestStateTransition_ApprovalToWorking verifies that an approvalResultMsg
// while in stateApproval sends the result on approvalChan and transitions back
// to stateWorking.
func TestStateTransition_ApprovalToWorking(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateApproval
responseChan := make(chan bool, 1)
m.approvalChan = responseChan
m = sendMsg(m, approvalResultMsg{Approved: true})
if m.state != stateWorking {
t.Fatalf("expected stateWorking after approvalResultMsg, got %v", m.state)
}
if m.approvalChan != nil {
t.Fatal("expected approvalChan to be cleared")
}
// Verify the approval result was sent on the channel.
select {
case approved := <-responseChan:
if !approved {
t.Fatal("expected approved=true to be sent on responseChan")
}
default:
t.Fatal("expected a value on responseChan")
}
}
// TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent
// transitions from stateWorking back to stateInput and resets the stream component.
func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) {
-2
View File
@@ -142,7 +142,6 @@ func (m *MCPHost) Prompt(ctx context.Context, message string) (string, error) {
nil, // onToolResult
nil, // onResponse
nil, // onToolCallContent
nil, // onToolApproval
)
if err != nil {
return "", err
@@ -181,7 +180,6 @@ func (m *MCPHost) PromptWithCallbacks(
nil, // onResponse
nil, // onToolCallContent
onStreaming,
nil, // onToolApproval
)
if err != nil {
return "", err