feat(acp): map finish reasons to ACP stop reasons

Amolith created

- Permission denial returns refusal (not an error)
- Context cancellation returns cancelled
- Max tokens from model returns max_tokens
- Other errors still return the error with end_turn

Assisted-by: Claude Sonnet 4 via Crush

Change summary

internal/acp/agent.go | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)

Detailed changes

internal/acp/agent.go 🔗

@@ -2,14 +2,17 @@ package acp
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"log/slog"
 	"strings"
 
+	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/coder/acp-go-sdk"
 )
 
@@ -185,12 +188,23 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
 	}
 
 	// Run the agent.
-	_, err := a.app.AgentCoordinator.Run(ctx, string(params.SessionId), prompt)
+	result, err := a.app.AgentCoordinator.Run(ctx, string(params.SessionId), prompt)
 	if err != nil {
-		if ctx.Err() != nil {
+		// Permission denial is a normal user choice, not an error.
+		if errors.Is(err, permission.ErrorPermissionDenied) {
+			return acp.PromptResponse{StopReason: acp.StopReasonRefusal}, nil
+		}
+		// Context cancellation means the user cancelled the request.
+		if errors.Is(err, context.Canceled) {
 			return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil
 		}
-		return acp.PromptResponse{}, err
+		// Other errors are actual errors.
+		return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, err
+	}
+
+	// Map the agent's finish reason to an ACP stop reason.
+	if result != nil && result.Response.FinishReason == fantasy.FinishReasonLength {
+		return acp.PromptResponse{StopReason: acp.StopReasonMaxTokens}, nil
 	}
 
 	return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil