From ffef7ac7fa0d5b8f93b1b14a8fc80cc2b1e7c4e4 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 14 May 2026 18:41:02 +0000 Subject: [PATCH 1/3] feat: implement Tool Permission Model with ExitPlanMode HITL support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ExitPlanMode as a human-in-the-loop tool alongside AskUserQuestion, enabling plan approval workflows in ACP sessions. This implements the spec from PR #1586 (closes #1583). Runner: - Add ExitPlanMode to BUILTIN_FRONTEND_TOOLS halt set - Enrich ExitPlanMode tool args with plan file content from .claude/plans/ - Complete Tier 1 tool allowlist (NotebookEdit, WebFetch, TodoWrite, etc.) Backend: - Generalize isAskUserQuestionToolCall → isHITLToolCall to detect both AskUserQuestion and ExitPlanMode for status derivation and compaction - Add ExitPlanMode test cases for waiting_input detection Frontend: - Generalize HITL detection in use-agent-status and stream-message - Add ExitPlanModeMessage component with approve/reject/request-changes Co-Authored-By: Claude Opus 4.6 --- components/backend/websocket/agui_proxy.go | 12 +- components/backend/websocket/agui_store.go | 6 +- .../backend/websocket/agui_store_test.go | 37 +++- .../src/components/session/exit-plan-mode.tsx | 207 ++++++++++++++++++ .../src/components/ui/stream-message.tsx | 19 ++ .../frontend/src/hooks/use-agent-status.ts | 8 +- .../ag_ui_claude_sdk/adapter.py | 39 +++- .../ambient_runner/bridges/claude/mcp.py | 18 +- 8 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 components/frontend/src/components/session/exit-plan-mode.tsx diff --git a/components/backend/websocket/agui_proxy.go b/components/backend/websocket/agui_proxy.go index 2289bea15..a20382fae 100644 --- a/components/backend/websocket/agui_proxy.go +++ b/components/backend/websocket/agui_proxy.go @@ -1128,17 +1128,19 @@ func updateLastActivityTime(projectName, sessionName string, immediate bool) { }() } -// isAskUserQuestionToolCall checks if a tool call name is the AskUserQuestion HITL tool. -// Uses case-insensitive comparison after stripping non-alpha characters, -// matching the frontend pattern in use-agent-status.ts. -func isAskUserQuestionToolCall(name string) bool { +// isHITLToolCall checks if a tool call name is a HITL (human-in-the-loop) tool. +// Matches AskUserQuestion and ExitPlanMode using case-insensitive comparison +// after stripping non-alpha characters, matching the frontend pattern in +// use-agent-status.ts. +func isHITLToolCall(name string) bool { var clean strings.Builder for _, r := range strings.ToLower(name) { if r >= 'a' && r <= 'z' { clean.WriteRune(r) } } - return clean.String() == "askuserquestion" + normalized := clean.String() + return normalized == "askuserquestion" || normalized == "exitplanmode" } // ─── Between-Run Listener ──────────────────────────────────────────── diff --git a/components/backend/websocket/agui_store.go b/components/backend/websocket/agui_store.go index 2b76550f4..77b89ccfd 100644 --- a/components/backend/websocket/agui_store.go +++ b/components/backend/websocket/agui_store.go @@ -432,7 +432,7 @@ func DeriveAgentStatus(sessionID string) string { return types.AgentStatusIdle } } - if toolName, _ := evt["toolCallName"].(string); isAskUserQuestionToolCall(toolName) { + if toolName, _ := evt["toolCallName"].(string); isHITLToolCall(toolName) { return types.AgentStatusWaitingInput } } @@ -527,9 +527,9 @@ func compactFinishedRun(sessionID string) { types.EventTypeStepStarted, types.EventTypeStepFinished: snapshots = append(snapshots, evt) case types.EventTypeToolCallStart: - // Preserve AskUserQuestion tool calls — DeriveAgentStatus() needs them + // Preserve HITL tool calls — DeriveAgentStatus() needs them // to detect waiting_input status after compaction. - if toolName, _ := evt["toolCallName"].(string); isAskUserQuestionToolCall(toolName) { + if toolName, _ := evt["toolCallName"].(string); isHITLToolCall(toolName) { snapshots = append(snapshots, evt) } case types.EventTypeRaw, types.EventTypeCustom, types.EventTypeMeta: diff --git a/components/backend/websocket/agui_store_test.go b/components/backend/websocket/agui_store_test.go index bc20cfda3..634ff1f42 100644 --- a/components/backend/websocket/agui_store_test.go +++ b/components/backend/websocket/agui_store_test.go @@ -186,15 +186,46 @@ func TestDeriveAgentStatus(t *testing.T) { } }) - t.Run("case-insensitive AskUserQuestion detection", func(t *testing.T) { + t.Run("RUN_FINISHED with same-run ExitPlanMode returns waiting_input", func(t *testing.T) { + sessionID := "test-session-exit-plan-mode" + sessionsDir := filepath.Join(tmpDir, "sessions", sessionID) + if err := os.MkdirAll(sessionsDir, 0755); err != nil { + t.Fatalf("Failed to create sessions dir: %v", err) + } + + events := []map[string]interface{}{ + {"type": types.EventTypeRunStarted, "runId": "run-123"}, + {"type": types.EventTypeToolCallStart, "runId": "run-123", "toolCallName": "ExitPlanMode"}, + {"type": types.EventTypeRunFinished, "runId": "run-123"}, + } + eventsFile := filepath.Join(sessionsDir, "agui-events.jsonl") + f, err := os.Create(eventsFile) + if err != nil { + t.Fatalf("Failed to create events file: %v", err) + } + for _, evt := range events { + data, _ := json.Marshal(evt) + f.Write(append(data, '\n')) + } + f.Close() + + status := DeriveAgentStatus(sessionID) + if status != types.AgentStatusWaitingInput { + t.Errorf("Expected %q for same-run ExitPlanMode, got %q", types.AgentStatusWaitingInput, status) + } + }) + + t.Run("case-insensitive HITL tool detection", func(t *testing.T) { sessionID := "test-session-case-insensitive" sessionsDir := filepath.Join(tmpDir, "sessions", sessionID) if err := os.MkdirAll(sessionsDir, 0755); err != nil { t.Fatalf("Failed to create sessions dir: %v", err) } - // Test various casings of AskUserQuestion - testCases := []string{"askuserquestion", "ASKUSERQUESTION", "AskUserQuestion", "AsKuSeRqUeStIoN"} + testCases := []string{ + "askuserquestion", "ASKUSERQUESTION", "AskUserQuestion", "AsKuSeRqUeStIoN", + "exitplanmode", "EXITPLANMODE", "ExitPlanMode", "eXiTpLaNmOdE", + } for _, toolName := range testCases { events := []map[string]interface{}{ {"type": types.EventTypeRunStarted, "runId": "run-123"}, diff --git a/components/frontend/src/components/session/exit-plan-mode.tsx b/components/frontend/src/components/session/exit-plan-mode.tsx new file mode 100644 index 000000000..1ea8b3e8b --- /dev/null +++ b/components/frontend/src/components/session/exit-plan-mode.tsx @@ -0,0 +1,207 @@ +"use client"; + +import React, { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ClipboardCheck, CheckCircle2, Send } from "lucide-react"; +import { formatTimestamp } from "@/lib/format-timestamp"; +import type { ToolUseBlock, ToolResultBlock } from "@/types/agentic-session"; + +export type ExitPlanModeMessageProps = { + toolUseBlock: ToolUseBlock; + resultBlock?: ToolResultBlock; + timestamp?: string; + onSubmitAnswer?: (formattedAnswer: string) => Promise; + isNewest?: boolean; +}; + +type AllowedPrompt = { + tool: string; + prompt: string; +}; + +function hasResult(resultBlock?: ToolResultBlock): boolean { + if (!resultBlock) return false; + const content = resultBlock.content; + if (!content) return false; + if (typeof content === "string" && content.trim() === "") return false; + return true; +} + +export const ExitPlanModeMessage: React.FC = ({ + toolUseBlock, + resultBlock, + timestamp, + onSubmitAnswer, + isNewest = false, +}) => { + const input = toolUseBlock.input; + const planContent = (input.planContent as string) || ""; + const allowedPrompts = (input.allowedPrompts as AllowedPrompt[]) || []; + const alreadyAnswered = hasResult(resultBlock); + const formattedTime = formatTimestamp(timestamp); + + const [submitted, setSubmitted] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showFeedback, setShowFeedback] = useState(false); + const [feedback, setFeedback] = useState(""); + const disabled = alreadyAnswered || submitted || isSubmitting || !isNewest; + + const handleDecision = async (decision: "approve" | "reject" | "request_changes", feedbackText?: string) => { + if (!onSubmitAnswer || disabled) return; + const response: Record = { decision }; + if (feedbackText) { + response.feedback = feedbackText; + } + try { + setIsSubmitting(true); + await onSubmitAnswer(JSON.stringify(response)); + setSubmitted(true); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* Avatar */} +
+
+ {disabled ? ( + + ) : ( + + )} +
+
+ + {/* Content */} +
+ {formattedTime && ( +
{formattedTime}
+ )} + +
+

Plan Review

+ + {/* Plan content */} + {planContent && ( +
+ + {planContent} + +
+ )} + + {/* Allowed prompts */} + {allowedPrompts.length > 0 && ( +
+

Requested permissions:

+
    + {allowedPrompts.map((p, i) => ( +
  • + + {p.tool}: {p.prompt} +
  • + ))} +
+
+ )} + + {/* Request changes feedback input */} + {showFeedback && !disabled && ( +
+ setFeedback(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && feedback.trim()) { + e.preventDefault(); + handleDecision("request_changes", feedback.trim()); + } + }} + disabled={disabled} + className="h-8 text-sm" + /> +
+ )} + + {/* Action buttons */} + {!disabled && ( +
+ {!showFeedback ? ( + <> + + + + + ) : ( + <> + + + + )} +
+ )} +
+
+
+
+ ); +}; + +ExitPlanModeMessage.displayName = "ExitPlanModeMessage"; diff --git a/components/frontend/src/components/ui/stream-message.tsx b/components/frontend/src/components/ui/stream-message.tsx index 4c83e76d2..a56e07f0f 100644 --- a/components/frontend/src/components/ui/stream-message.tsx +++ b/components/frontend/src/components/ui/stream-message.tsx @@ -5,6 +5,7 @@ import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types import { LoadingDots, Message } from "@/components/ui/message"; import { ToolMessage } from "@/components/ui/tool-message"; import { AskUserQuestionMessage } from "@/components/session/ask-user-question"; +import { ExitPlanModeMessage } from "@/components/session/exit-plan-mode"; import { ThinkingMessage } from "@/components/ui/thinking-message"; import { SystemMessage } from "@/components/ui/system-message"; import { Button } from "@/components/ui/button"; @@ -25,6 +26,11 @@ function isAskUserQuestionTool(name: string): boolean { return normalized === "askuserquestion"; } +function isExitPlanModeTool(name: string): boolean { + const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); + return normalized === "exitplanmode"; +} + const getRandomAgentMessage = () => { const messages = [ "The agents are working together on your request...", @@ -59,6 +65,19 @@ export const StreamMessage: React.FC = ({ message, onGoToRes ); } + // Render ExitPlanMode with plan approval component + if (isExitPlanModeTool(message.toolUseBlock.name)) { + return ( + + ); + } + // Check if this is a hierarchical message with children const hierarchical = message as HierarchicalToolMessage; return ( diff --git a/components/frontend/src/hooks/use-agent-status.ts b/components/frontend/src/hooks/use-agent-status.ts index b6fff8912..522d3c781 100644 --- a/components/frontend/src/hooks/use-agent-status.ts +++ b/components/frontend/src/hooks/use-agent-status.ts @@ -5,9 +5,9 @@ import type { } from "@/types/agentic-session"; import type { PlatformMessage } from "@/types/agui"; -function isAskUserQuestionTool(name: string): boolean { +function isHITLTool(name: string): boolean { const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion"; + return normalized === "askuserquestion" || normalized === "exitplanmode"; } /** @@ -30,7 +30,7 @@ export function useAgentStatus( // Non-running phases if (phase !== "Running") return "idle"; - // Scan backwards for the last tool call to check for unanswered AskUserQuestion. + // Scan backwards for the last tool call to check for unanswered HITL tools. // Raw AG-UI messages store tool calls in msg.toolCalls[] (PlatformToolCall[]). for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -38,7 +38,7 @@ export function useAgentStatus( // Check the last tool call on this message const lastTc = msg.toolCalls[msg.toolCalls.length - 1]; - if (lastTc.function?.name && isAskUserQuestionTool(lastTc.function.name)) { + if (lastTc.function?.name && isHITLTool(lastTc.function.name)) { const hasResult = lastTc.result !== undefined && lastTc.result !== null && diff --git a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index 6eb633abd..5e8971277 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -11,6 +11,7 @@ import json import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Any, AsyncIterator, TYPE_CHECKING # AG-UI Protocol Events @@ -77,7 +78,31 @@ # These are HITL (human-in-the-loop) tools that require user input before # the agent can continue. The adapter treats them identically to frontend # tools registered via ``input_data.tools``. -BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion"} +BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion", "ExitPlanMode"} + + +def _read_plan_file(options: Any) -> str | None: + """Read the most recent plan file from .claude/plans/ in the cwd.""" + cwd = None + if isinstance(options, dict): + cwd = options.get("cwd") + elif hasattr(options, "cwd"): + cwd = options.cwd + if not cwd: + return None + plans_dir = Path(cwd) / ".claude" / "plans" + if not plans_dir.is_dir(): + return None + plan_files = sorted( + plans_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True + ) + if not plan_files: + return None + try: + return plan_files[0].read_text(encoding="utf-8") + except OSError: + return None + logger = logging.getLogger(__name__) @@ -1048,6 +1073,18 @@ def flush_pending_msg(): ) if is_frontend_tool: + # Enrich ExitPlanMode with plan file content + if current_tool_display_name == "ExitPlanMode": + plan_content = _read_plan_file(self._options) + if plan_content and pending_msg and pending_msg.get("tool_calls"): + last_tc = pending_msg["tool_calls"][-1] + try: + args = json.loads(last_tc.function.arguments) if last_tc.function.arguments else {} + args["planContent"] = plan_content + last_tc.function.arguments = json.dumps(args) + except (json.JSONDecodeError, AttributeError): + pass + # Flush before halt (message_stop won't fire after interrupt) flush_pending_msg() diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index a3674bc1d..55da7b73c 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -23,14 +23,26 @@ DEFAULT_ALLOWED_TOOLS = [ "Read", "Write", - "Bash", - "Glob", - "Grep", "Edit", "MultiEdit", + "NotebookEdit", + "Glob", + "Grep", + "Bash", + "WebFetch", "WebSearch", + "TodoWrite", "Skill", "Agent", + "TaskOutput", + "TaskStop", + "EnterPlanMode", + "EnterWorktree", + "ExitWorktree", + "CronCreate", + "CronDelete", + "CronList", + "ScheduleWakeup", ] From af4a825d4d72f2ad4cfb65883e4074e6b561ded0 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 14 May 2026 18:51:16 +0000 Subject: [PATCH 2/3] refactor: address review feedback for ExitPlanMode HITL - Extract shared hitl-tools.ts with normalizeToolName, isHITLTool, isAskUserQuestionTool, isExitPlanModeTool, and hasToolResult helpers - Remove duplicated hasResult and tool detection functions from ask-user-question.tsx, exit-plan-mode.tsx, stream-message.tsx, and use-agent-status.ts - Add 100KB size guard to _read_plan_file to prevent oversized events - Log JSON errors during ExitPlanMode plan enrichment instead of silently swallowing them - Use stable composite key for allowedPrompts list rendering Co-Authored-By: Claude Opus 4.6 --- .../components/session/ask-user-question.tsx | 12 ++------- .../src/components/session/exit-plan-mode.tsx | 15 +++-------- .../src/components/ui/stream-message.tsx | 11 +------- .../frontend/src/hooks/use-agent-status.ts | 6 +---- components/frontend/src/lib/hitl-tools.ts | 26 +++++++++++++++++++ .../ag_ui_claude_sdk/adapter.py | 12 ++++++--- 6 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 components/frontend/src/lib/hitl-tools.ts diff --git a/components/frontend/src/components/session/ask-user-question.tsx b/components/frontend/src/components/session/ask-user-question.tsx index 1065acdbe..8e0711e19 100644 --- a/components/frontend/src/components/session/ask-user-question.tsx +++ b/components/frontend/src/components/session/ask-user-question.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { HelpCircle, CheckCircle2, Send, ChevronRight } from "lucide-react"; import { formatTimestamp } from "@/lib/format-timestamp"; +import { hasToolResult } from "@/lib/hitl-tools"; import type { ToolUseBlock, ToolResultBlock, @@ -31,7 +32,6 @@ function parseQuestions(input: Record): AskUserQuestionItem[] { if (isAskUserQuestionInput(input)) { return input.questions; } - // Handle simple { question: "..." } format (e.g. from Claude Code AskUserQuestion tool) if (typeof input.question === 'string' && input.question.trim()) { return [{ question: input.question, @@ -41,14 +41,6 @@ function parseQuestions(input: Record): AskUserQuestionItem[] { return []; } -function hasResult(resultBlock?: ToolResultBlock): boolean { - if (!resultBlock) return false; - const content = resultBlock.content; - if (!content) return false; - if (typeof content === "string" && content.trim() === "") return false; - return true; -} - export const AskUserQuestionMessage: React.FC = ({ toolUseBlock, resultBlock, @@ -57,7 +49,7 @@ export const AskUserQuestionMessage: React.FC = ({ isNewest = false, }) => { const questions = parseQuestions(toolUseBlock.input); - const alreadyAnswered = hasResult(resultBlock); + const alreadyAnswered = hasToolResult(resultBlock); const formattedTime = formatTimestamp(timestamp); const isMultiQuestion = questions.length > 1; diff --git a/components/frontend/src/components/session/exit-plan-mode.tsx b/components/frontend/src/components/session/exit-plan-mode.tsx index 1ea8b3e8b..18262d95b 100644 --- a/components/frontend/src/components/session/exit-plan-mode.tsx +++ b/components/frontend/src/components/session/exit-plan-mode.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ClipboardCheck, CheckCircle2, Send } from "lucide-react"; import { formatTimestamp } from "@/lib/format-timestamp"; +import { hasToolResult } from "@/lib/hitl-tools"; import type { ToolUseBlock, ToolResultBlock } from "@/types/agentic-session"; export type ExitPlanModeMessageProps = { @@ -23,14 +24,6 @@ type AllowedPrompt = { prompt: string; }; -function hasResult(resultBlock?: ToolResultBlock): boolean { - if (!resultBlock) return false; - const content = resultBlock.content; - if (!content) return false; - if (typeof content === "string" && content.trim() === "") return false; - return true; -} - export const ExitPlanModeMessage: React.FC = ({ toolUseBlock, resultBlock, @@ -41,7 +34,7 @@ export const ExitPlanModeMessage: React.FC = ({ const input = toolUseBlock.input; const planContent = (input.planContent as string) || ""; const allowedPrompts = (input.allowedPrompts as AllowedPrompt[]) || []; - const alreadyAnswered = hasResult(resultBlock); + const alreadyAnswered = hasToolResult(resultBlock); const formattedTime = formatTimestamp(timestamp); const [submitted, setSubmitted] = useState(false); @@ -114,8 +107,8 @@ export const ExitPlanModeMessage: React.FC = ({

Requested permissions:

    - {allowedPrompts.map((p, i) => ( -
  • + {allowedPrompts.map((p) => ( +
  • {p.tool}: {p.prompt}
  • diff --git a/components/frontend/src/components/ui/stream-message.tsx b/components/frontend/src/components/ui/stream-message.tsx index a56e07f0f..fa5afd4bc 100644 --- a/components/frontend/src/components/ui/stream-message.tsx +++ b/components/frontend/src/components/ui/stream-message.tsx @@ -10,6 +10,7 @@ import { ThinkingMessage } from "@/components/ui/thinking-message"; import { SystemMessage } from "@/components/ui/system-message"; import { Button } from "@/components/ui/button"; import { FeedbackButtons } from "@/components/feedback"; +import { isAskUserQuestionTool, isExitPlanModeTool } from "@/lib/hitl-tools"; export type StreamMessageProps = { message: (MessageObject | ToolUseMessages | HierarchicalToolMessage) & { streaming?: boolean }; @@ -21,16 +22,6 @@ export type StreamMessageProps = { currentUserId?: string; }; -function isAskUserQuestionTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion"; -} - -function isExitPlanModeTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "exitplanmode"; -} - const getRandomAgentMessage = () => { const messages = [ "The agents are working together on your request...", diff --git a/components/frontend/src/hooks/use-agent-status.ts b/components/frontend/src/hooks/use-agent-status.ts index 522d3c781..379819dcd 100644 --- a/components/frontend/src/hooks/use-agent-status.ts +++ b/components/frontend/src/hooks/use-agent-status.ts @@ -4,11 +4,7 @@ import type { AgentStatus, } from "@/types/agentic-session"; import type { PlatformMessage } from "@/types/agui"; - -function isHITLTool(name: string): boolean { - const normalized = name.toLowerCase().replace(/[^a-z]/g, ""); - return normalized === "askuserquestion" || normalized === "exitplanmode"; -} +import { isHITLTool } from "@/lib/hitl-tools"; /** * Derive agent status from session data and the raw AG-UI message stream. diff --git a/components/frontend/src/lib/hitl-tools.ts b/components/frontend/src/lib/hitl-tools.ts new file mode 100644 index 000000000..6a630f607 --- /dev/null +++ b/components/frontend/src/lib/hitl-tools.ts @@ -0,0 +1,26 @@ +import type { ToolResultBlock } from "@/types/agentic-session"; + +export function normalizeToolName(name: string): string { + return name.toLowerCase().replace(/[^a-z]/g, ""); +} + +export function isAskUserQuestionTool(name: string): boolean { + return normalizeToolName(name) === "askuserquestion"; +} + +export function isExitPlanModeTool(name: string): boolean { + return normalizeToolName(name) === "exitplanmode"; +} + +export function isHITLTool(name: string): boolean { + const normalized = normalizeToolName(name); + return normalized === "askuserquestion" || normalized === "exitplanmode"; +} + +export function hasToolResult(resultBlock?: ToolResultBlock): boolean { + if (!resultBlock) return false; + const content = resultBlock.content; + if (!content) return false; + if (typeof content === "string" && content.trim() === "") return false; + return true; +} diff --git a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index 5e8971277..6582e99c1 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -81,6 +81,9 @@ BUILTIN_FRONTEND_TOOLS: set[str] = {"AskUserQuestion", "ExitPlanMode"} +_PLAN_FILE_MAX_BYTES = 100 * 1024 # 100 KB + + def _read_plan_file(options: Any) -> str | None: """Read the most recent plan file from .claude/plans/ in the cwd.""" cwd = None @@ -99,7 +102,10 @@ def _read_plan_file(options: Any) -> str | None: if not plan_files: return None try: - return plan_files[0].read_text(encoding="utf-8") + content = plan_files[0].read_text(encoding="utf-8") + if len(content.encode("utf-8")) > _PLAN_FILE_MAX_BYTES: + content = content[: _PLAN_FILE_MAX_BYTES] + "\n\n[truncated]" + return content except OSError: return None @@ -1082,8 +1088,8 @@ def flush_pending_msg(): args = json.loads(last_tc.function.arguments) if last_tc.function.arguments else {} args["planContent"] = plan_content last_tc.function.arguments = json.dumps(args) - except (json.JSONDecodeError, AttributeError): - pass + except (json.JSONDecodeError, AttributeError) as e: + logger.debug("Failed to enrich ExitPlanMode with plan content: %s", e) # Flush before halt (message_stop won't fire after interrupt) flush_pending_msg() From d7352bbdfb430bdb01e20c7b67cda0a5f143c0d6 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 14 May 2026 19:11:32 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit review findings - Add runtime type guards for planContent (typeof string) and allowedPrompts (Array.isArray) in ExitPlanModeMessage to prevent runtime errors from unexpected backend data - Fix byte-accurate truncation in _read_plan_file: slice encoded bytes instead of character count to respect the 100KB limit for multi-byte UTF-8 content Co-Authored-By: Claude Opus 4.6 --- .../frontend/src/components/session/exit-plan-mode.tsx | 4 ++-- .../runners/ambient-runner/ag_ui_claude_sdk/adapter.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/frontend/src/components/session/exit-plan-mode.tsx b/components/frontend/src/components/session/exit-plan-mode.tsx index 18262d95b..2e0b489d4 100644 --- a/components/frontend/src/components/session/exit-plan-mode.tsx +++ b/components/frontend/src/components/session/exit-plan-mode.tsx @@ -32,8 +32,8 @@ export const ExitPlanModeMessage: React.FC = ({ isNewest = false, }) => { const input = toolUseBlock.input; - const planContent = (input.planContent as string) || ""; - const allowedPrompts = (input.allowedPrompts as AllowedPrompt[]) || []; + const planContent = typeof input.planContent === "string" ? input.planContent : ""; + const allowedPrompts = Array.isArray(input.allowedPrompts) ? input.allowedPrompts as AllowedPrompt[] : []; const alreadyAnswered = hasToolResult(resultBlock); const formattedTime = formatTimestamp(timestamp); diff --git a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py index 6582e99c1..292c8c8ad 100644 --- a/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py +++ b/components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py @@ -103,8 +103,9 @@ def _read_plan_file(options: Any) -> str | None: return None try: content = plan_files[0].read_text(encoding="utf-8") - if len(content.encode("utf-8")) > _PLAN_FILE_MAX_BYTES: - content = content[: _PLAN_FILE_MAX_BYTES] + "\n\n[truncated]" + content_bytes = content.encode("utf-8") + if len(content_bytes) > _PLAN_FILE_MAX_BYTES: + content = content_bytes[:_PLAN_FILE_MAX_BYTES].decode("utf-8", errors="ignore") + "\n\n[truncated]" return content except OSError: return None