Skip to content

feat: implement ExitPlanMode HITL for Tool Permission Model#1

Closed
quay-devel wants to merge 2 commits into
mainfrom
feat/exit-plan-mode-hitl
Closed

feat: implement ExitPlanMode HITL for Tool Permission Model#1
quay-devel wants to merge 2 commits into
mainfrom
feat/exit-plan-mode-hitl

Conversation

@quay-devel
Copy link
Copy Markdown
Owner

@quay-devel quay-devel commented May 14, 2026

Summary

Implements the Tool Permission Model spec from PR ambient-code#1586, adding ExitPlanMode as a HITL (human-in-the-loop) tool that halts the event stream and waits for user approval — the same mechanism already used by AskUserQuestion.

  • Runner: ExitPlanMode added to BUILTIN_FRONTEND_TOOLS halt set; plan file content injected into tool args; Tier 1 allowlist completed with all missing tools
  • Backend: isAskUserQuestionToolCallisHITLToolCall to detect both tools for status derivation and snapshot compaction; new test cases for ExitPlanMode
  • Frontend: HITL detection generalized in use-agent-status.ts and stream-message.tsx; new ExitPlanModeMessage component with approve/reject/request-changes actions

Closes ambient-code#1583
Spec: ambient-code#1586

Test plan

  • Backend Go tests pass (go test ./websocket/...)
  • Frontend build passes with 0 errors, 0 warnings (npm run build)
  • Frontend tests pass (668 passed, 12 skipped via npx vitest run)
  • Runner adapter syntax verified
  • No panic() in Go code, no any types in TypeScript
  • Manual: verify ExitPlanMode halts stream and shows plan approval UI
  • Manual: verify approve/reject/request-changes sends correct tool result

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added "Exit Plan Mode" plan-review UI with approve/reject/request-changes, feedback entry, and inline plan rendering.
    • Frontend now recognizes and displays plan-review tool-use/result pairs.
    • Backend runner now enriches pending plan-review requests with plan content when available.
    • Introduced shared HITL helpers for broader tool detection and result checks.
  • Bug Fixes

    • Preserved HITL tool calls so sessions correctly show "waiting for input" after run completion.
  • Tests

    • Added coverage for plan-review tool detection and waiting-for-input status.

Review Change Stack

Add ExitPlanMode as a human-in-the-loop tool alongside AskUserQuestion,
enabling plan approval workflows in ACP sessions. This implements the
spec from PR ambient-code#1586 (closes ambient-code#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 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR adds ExitPlanMode HITL support: generalized HITL name normalization/predicates, backend status/compaction updates, a new ExitPlanModeMessage React component and stream wiring, Claude adapter plan-file injection, and an expanded default tool allowlist.

Changes

ExitPlanMode HITL Tool Feature

Layer / File(s) Summary
HITL Tool Detection Abstraction
components/backend/websocket/agui_proxy.go, components/frontend/src/hooks/use-agent-status.ts, components/frontend/src/lib/hitl-tools.ts
Generalize tool-name detection to isHITLToolCall/isHITLTool and add normalizeToolName plus specific predicates for AskUserQuestion and ExitPlanMode, and hasToolResult for result checks.
Backend Agent Status with HITL Signals
components/backend/websocket/agui_store.go, components/backend/websocket/agui_store_test.go
DeriveAgentStatus and compactFinishedRun now use HITL detection; compaction preserves HITL tool calls and tests cover ExitPlanMode and case-insensitive detection.
ExitPlanModeMessage React Component
components/frontend/src/components/session/exit-plan-mode.tsx
New client component rendering plan-review UI, deriving answered/disabled state from hasToolResult, managing feedback/submission, rendering plan markdown and requested-permissions, and exposing an onSubmitAnswer callback.
Stream Message & AskUserQuestion Integration
components/frontend/src/components/ui/stream-message.tsx, components/frontend/src/components/session/ask-user-question.tsx
Wire ExitPlanModeMessage into stream rendering for exitplanmode tool uses; replace local answered-result logic with shared hasToolResult and import shared HITL predicates.
Claude Adapter: ExitPlanMode Support
components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py
Add ExitPlanMode to BUILTIN_FRONTEND_TOOLS; implement _read_plan_file to read the most recent .claude/plans/*.md (UTF-8, truncated), and inject planContent into pending ExitPlanMode tool call arguments during streaming with errors logged at debug level.
Default Tool Permissions Expansion
components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
Expand DEFAULT_ALLOWED_TOOLS list to include a broader set of tool names (e.g., NotebookEdit, WebFetch, TodoWrite, TaskOutput, TaskStop, EnterPlanMode, ExitPlanMode, EnterWorktree, ExitWorktree, CronCreate, CronDelete, CronList, ScheduleWakeup, etc.).

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Performance And Algorithmic Complexity ❌ Error Byte vs character truncation bug in adapter.py lines 106-107: checks byte length but truncates by character count, allowing multibyte UTF-8 to exceed 100KB size cap, defeating event-size guard. Encode truncation point to bytes first, then decode UTF-8: keep = max(_PLAN_FILE_MAX_BYTES - len(suffix_bytes), 0); content = content_bytes[:keep].decode("utf-8", errors="ignore") + suffix
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title follows Conventional Commits format (feat: scope description) and accurately summarizes the main change: implementing ExitPlanMode HITL tool.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security And Secret Handling ✅ Passed No hardcoded secrets/tokens, injection vulns, or auth bypasses detected. GetK8sClientsForRequest checks intact. Safe JSON/input handling applied.
Kubernetes Resource Safety ✅ Passed No Kubernetes manifests were modified in this PR. The changes are exclusively in application source code (Go, TypeScript, Python) implementing ExitPlanMode HITL functionality. Check is not applicable.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/exit-plan-mode-hitl
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/exit-plan-mode-hitl

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/frontend/src/components/session/exit-plan-mode.tsx`:
- Around line 42-43: Validate and coerce the incoming payload before rendering:
ensure planContent is a string (e.g., check typeof input.planContent ===
"string" and fallback to "" for ReactMarkdown) and ensure allowedPrompts is an
array (use Array.isArray(input.allowedPrompts) ? input.allowedPrompts as
AllowedPrompt[] : []), then use that sanitized values for rendering and mapping;
update usages in this component (references: planContent, allowedPrompts,
ReactMarkdown and the map over allowedPrompts) so malformed payloads don’t cause
runtime errors.

In `@components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py`:
- Around line 84-104: The _read_plan_file function can crash or return unsafe
content because plan_files sorting uses p.stat() which can raise on
broken/unreadable files and symlinked *.md can escape .claude/plans; modify
_read_plan_file to: 1) iterate over plans_dir.glob("*.md") and build a safe list
by catching OSError when calling p.stat() (skip files that error), 2) for each
candidate ensure p.is_file() and that
p.resolve().is_relative_to(plans_dir.resolve()) or compare commonpath to prevent
symlink escape, 3) sort the filtered safe list by mtime (use try/except when
reading mtime), and 4) catch exceptions around read_text (OSError) and return
None on failure—refer to symbols _read_plan_file, plans_dir, plan_files,
p.stat(), p.resolve(), and read_text to locate changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 47a7b2ed-0ad3-4ad3-a9fb-8dc4143b2125

📥 Commits

Reviewing files that changed from the base of the PR and between d1645a9 and ffef7ac.

📒 Files selected for processing (8)
  • components/backend/websocket/agui_proxy.go
  • components/backend/websocket/agui_store.go
  • components/backend/websocket/agui_store_test.go
  • components/frontend/src/components/session/exit-plan-mode.tsx
  • components/frontend/src/components/ui/stream-message.tsx
  • components/frontend/src/hooks/use-agent-status.ts
  • components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py
  • components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py

Comment on lines +42 to +43
const planContent = (input.planContent as string) || "";
const allowedPrompts = (input.allowedPrompts as AllowedPrompt[]) || [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate tool payload shapes before rendering

Lines 42-43 assume planContent is string and allowedPrompts is array. If payload shape is malformed, this can crash at Line 117 (map on non-array) or pass invalid input into ReactMarkdown at Line 106.

Proposed fix
-  const planContent = (input.planContent as string) || "";
-  const allowedPrompts = (input.allowedPrompts as AllowedPrompt[]) || [];
+  const planContent =
+    typeof input.planContent === "string" ? input.planContent : "";
+  const allowedPrompts: AllowedPrompt[] = Array.isArray(input.allowedPrompts)
+    ? input.allowedPrompts.filter(
+        (p): p is AllowedPrompt =>
+          !!p &&
+          typeof p === "object" &&
+          typeof (p as { tool?: unknown }).tool === "string" &&
+          typeof (p as { prompt?: unknown }).prompt === "string",
+      )
+    : [];

Also applies to: 104-109, 117-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/frontend/src/components/session/exit-plan-mode.tsx` around lines
42 - 43, Validate and coerce the incoming payload before rendering: ensure
planContent is a string (e.g., check typeof input.planContent === "string" and
fallback to "" for ReactMarkdown) and ensure allowedPrompts is an array (use
Array.isArray(input.allowedPrompts) ? input.allowedPrompts as AllowedPrompt[] :
[]), then use that sanitized values for rendering and mapping; update usages in
this component (references: planContent, allowedPrompts, ReactMarkdown and the
map over allowedPrompts) so malformed payloads don’t cause runtime errors.

Comment on lines +84 to +104
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden plan-file discovery to prevent run failures and file exfiltration

Line 97 can raise on broken/unreadable files (p.stat() in sort key), which can fail the stream. Also, symlinked *.md entries can escape .claude/plans and inject arbitrary file contents into planContent (Line 1083).

Proposed fix
 def _read_plan_file(options: Any) -> str | None:
     """Read the most recent plan file from .claude/plans/ in the cwd."""
@@
-    plan_files = sorted(
-        plans_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True
-    )
-    if not plan_files:
+    root = plans_dir.resolve()
+    candidates: list[tuple[float, Path]] = []
+    for p in plans_dir.glob("*.md"):
+        try:
+            # Do not follow symlinks outside plans dir
+            resolved = p.resolve()
+            if root not in resolved.parents:
+                continue
+            if not p.is_file() or p.is_symlink():
+                continue
+            candidates.append((p.stat().st_mtime, p))
+        except OSError:
+            continue
+
+    if not candidates:
         return None
+    candidates.sort(key=lambda t: t[0], reverse=True)
+    latest = candidates[0][1]
     try:
-        return plan_files[0].read_text(encoding="utf-8")
+        return latest.read_text(encoding="utf-8")
     except OSError:
         return None

Also applies to: 1076-1086

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py` around lines
84 - 104, The _read_plan_file function can crash or return unsafe content
because plan_files sorting uses p.stat() which can raise on broken/unreadable
files and symlinked *.md can escape .claude/plans; modify _read_plan_file to: 1)
iterate over plans_dir.glob("*.md") and build a safe list by catching OSError
when calling p.stat() (skip files that error), 2) for each candidate ensure
p.is_file() and that p.resolve().is_relative_to(plans_dir.resolve()) or compare
commonpath to prevent symlink escape, 3) sort the filtered safe list by mtime
(use try/except when reading mtime), and 4) catch exceptions around read_text
(OSError) and return None on failure—refer to symbols _read_plan_file,
plans_dir, plan_files, p.stat(), p.resolve(), and read_text to locate changes.

- 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 <noreply@anthropic.com>
@quay-devel quay-devel closed this May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: ExitPlanMode hangs indefinitely in ACP sessions — runner lacks plan approval handler

1 participant