mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+1
-53
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user