Skip to content

feat: implement ExitPlanMode HITL for Tool Permission Model#1589

Open
quay-devel wants to merge 5 commits into
ambient-code:mainfrom
quay-devel:feat/exit-plan-mode-hitl
Open

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

Conversation

@quay-devel
Copy link
Copy Markdown
Contributor

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

Summary

Implements the Tool Permission Model spec from PR #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 via shared hitl-tools.ts; new ExitPlanModeMessage component with approve/reject/request-changes actions

Closes #1583
Spec: #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

    • Plan Review UI: users can approve, reject, or request changes to plans with optional feedback.
  • Improvements

    • Broader human-in-the-loop tool support: interactions now include an additional tool type.
    • More consistent detection and handling of tool-driven prompts so the UI reliably shows waiting-for-input states.

quay-devel and others added 2 commits May 14, 2026 18:41
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 <[email protected]>
- 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 <[email protected]>
@netlify
Copy link
Copy Markdown

netlify Bot commented May 14, 2026

Deploy Preview for cheerful-kitten-f556a0 canceled.

Name Link
🔨 Latest commit bf81358
🔍 Latest deploy log https://app.netlify.com/projects/cheerful-kitten-f556a0/deploys/6a0ce99122737e00083dd937

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d0f3eb00-f7da-47ef-a2c9-31a6009956b3

📥 Commits

Reviewing files that changed from the base of the PR and between 45eb7fc and bf81358.

📒 Files selected for processing (3)
  • components/frontend/src/components/session/exit-plan-mode.tsx
  • components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py
  • components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
  • components/frontend/src/components/session/exit-plan-mode.tsx
  • components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py

📝 Walkthrough

Walkthrough

Adds unified HITL (AskUserQuestion + ExitPlanMode) detection and handling across backend, frontend, and runner: backend generalizes HITL detection and preserves state, frontend centralizes helpers, updates status logic, and adds ExitPlanMode UI, and the runner enriches ExitPlanMode calls and expands allowed tools.

Changes

HITL Tool Support

Layer / File(s) Summary
Backend HITL detection & tests
components/backend/websocket/agui_proxy.go, components/backend/websocket/agui_store.go, components/backend/websocket/agui_store_test.go
Replaces AskUserQuestion-only checks with isHITLToolCall (normalizes names and matches AskUserQuestion/ExitPlanMode). DeriveAgentStatus and compactFinishedRun now preserve/infer waiting_input for HITL tools. Tests updated to include ExitPlanMode and casing variants.
Frontend: hitl-tools helpers & status wiring
components/frontend/src/lib/hitl-tools.ts, components/frontend/src/hooks/use-agent-status.ts, components/frontend/src/components/session/ask-user-question.tsx
New tool-name normalization and predicates (normalizeToolName, isAskUserQuestionTool, isExitPlanModeTool, isHITLTool) and hasToolResult. useAgentStatus now scans for HITL tools; AskUserQuestion component uses hasToolResult.
Frontend: stream rendering & ExitPlanMode UI
components/frontend/src/components/ui/stream-message.tsx, components/frontend/src/components/session/exit-plan-mode.tsx
stream-message imports HITL predicates and renders ExitPlanModeMessage for ExitPlanMode tool uses. New ExitPlanModeMessage component renders plan-review UI, handles approve/reject/request-changes with optional feedback and submission state.
Runner: ExitPlanMode enrichment & allowlist
components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py, components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
Adds ExitPlanMode to builtin frontend tools; _read_plan_file() reads latest .claude/plans/*.md (UTF-8, truncated to 100KB) and injects planContent into tool call args. Expands DEFAULT_ALLOWED_TOOLS to include ExitPlanMode and other tools to avoid unhandled permission prompts.

Sequence Diagram

sequenceDiagram
    participant Claude as Claude (AI)
    participant Adapter as Adapter
    participant Backend as Backend (WebSocket)
    participant Frontend as Frontend
    participant User as User

    Claude->>Adapter: Invoke ExitPlanMode (tool call)
    Adapter->>Adapter: Read latest .claude/plans/*.md
    Adapter->>Adapter: Inject planContent into arguments
    Adapter->>Backend: Emit TOOL_CALL_START (ExitPlanMode)
    Backend->>Backend: isHITLToolCall() matches ExitPlanMode
    Backend->>Frontend: Stream event: TOOL_CALL_START / waiting_input
    Frontend->>Frontend: useAgentStatus() detects HITL tool
    Frontend->>Frontend: stream-message checks isExitPlanModeTool()
    Frontend->>Frontend: Render ExitPlanModeMessage with plan content
    Frontend->>User: Display "Plan Review" UI (approve/reject/changes)
    User->>Frontend: Click approve/reject + optional feedback
    Frontend->>Backend: Submit tool result via onSubmitAnswer
    Backend->>Backend: Snapshot TOOL_CALL_START to preserve waiting_input
    Backend->>Claude: Resume execution with user result
Loading
🚥 Pre-merge checks | ✅ 7 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (7 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 clearly describes adding ExitPlanMode HITL support.
Linked Issues check ✅ Passed PR comprehensively addresses issue #1583: implements ExitPlanMode as HITL tool, updates runner allowlist, adds plan content injection, generalizes backend detection, and provides frontend UI component.
Out of Scope Changes check ✅ Passed All changes directly support ExitPlanMode HITL implementation: runner registration, plan file handling, backend detection generalization, frontend components, and related tool allowlist expansion.
Performance And Algorithmic Complexity ✅ Passed No blocking perf issues. All algorithms bounded: string normalization O(n) on short names, message scan with early exit, plan file read 100KB capped, event log 20MB tail-bounded.
Security And Secret Handling ✅ Passed No hardcoded secrets or tokens. Auth/authz properly enforced. No injection vulnerabilities. Plan file reading safely bounded. No sensitive data leaks. All security controls intact.
Kubernetes Resource Safety ✅ Passed PR modifies only application source code (Go, TypeScript, Python); no Kubernetes manifest files or resource definitions are changed. Check not applicable.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@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 35-36: The variables planContent and allowedPrompts currently use
TypeScript assertions only; add runtime guards so planContent is set to
input.planContent if typeof input.planContent === "string" otherwise "" and set
allowedPrompts to Array.isArray(input.allowedPrompts) ? input.allowedPrompts as
AllowedPrompt[] : [] (or validate each element) before using ReactMarkdown and
.map; update the initialization of planContent and allowedPrompts in
exit-plan-mode.tsx to perform these checks so ReactMarkdown always gets a string
and .map runs on a real array.

In `@components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py`:
- Around line 105-108: The truncation checks byte length but slices by character
count, which can exceed _PLAN_FILE_MAX_BYTES for multi-byte UTF-8 chars; fix by
performing the truncation in bytes: read the file text into content, encode to
bytes (e.g., content_bytes = content.encode("utf-8")), if len(content_bytes) >
_PLAN_FILE_MAX_BYTES then slice the bytes to _PLAN_FILE_MAX_BYTES, decode back
to a string with a safe error handler (e.g., errors="ignore" or "replace") and
append the "\n\n[truncated]" marker before returning; update the logic around
plan_files, content, and _PLAN_FILE_MAX_BYTES accordingly.
🪄 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: Enterprise

Run ID: 767b3627-8743-45b6-a69e-396e31eb5da9

📥 Commits

Reviewing files that changed from the base of the PR and between 63545c7 and 45eb7fc.

📒 Files selected for processing (10)
  • components/backend/websocket/agui_proxy.go
  • components/backend/websocket/agui_store.go
  • components/backend/websocket/agui_store_test.go
  • components/frontend/src/components/session/ask-user-question.tsx
  • 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/frontend/src/lib/hitl-tools.ts
  • components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py
  • components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py

Comment thread components/frontend/src/components/session/exit-plan-mode.tsx Outdated
Comment thread components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py
quay-devel and others added 2 commits May 14, 2026 19:11
- 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 <[email protected]>
@markturansky
Copy link
Copy Markdown
Contributor

Claude Code Review

Summary

Clean, well-scoped implementation that correctly generalizes AskUserQuestion's HITL machinery to cover ExitPlanMode. The DRY refactor — extracting hitl-tools.ts to replace three independent copies of the same normalization logic — is the highlight. Backend Go tests adequately cover the new ExitPlanMode status detection. No blockers or criticals.

Findings

Blocker

None.

Critical

None.

Major

None.

Minor

1. Exact string match in adapter.py inconsistent with normalized approach everywhere else
components/runners/ambient-runner/ag_ui_claude_sdk/adapter.py — the enrichment guard:

if current_tool_display_name == "ExitPlanMode":

…uses an exact case-sensitive comparison while the backend (isHITLToolCall) and frontend (normalizeToolName) both strip non-alpha chars and lowercase before matching. If the SDK ever emits exitPlanMode or exit_plan_mode (unlikely, but it has happened with tool renames), the enrichment silently skips and the frontend renders a plan-review card with no plan content. The detection still fires because BUILTIN_FRONTEND_TOOLS uses exact match too, so the inconsistency is contained — but a helper that mirrors the normalization pattern would make this robust:

def _normalize_tool_name(name: str) -> str:
    return "".join(c for c in name.lower() if c.isalpha())

if _normalize_tool_name(current_tool_display_name) == "exitplanmode":

2. _read_plan_file is untested
The function is pure (no side effects beyond I/O) and has non-trivial behavior: mtime sort, 100 KB truncation at a byte boundary, UTF-8 decode with errors="ignore". The runner test suite covers other functions — adding a few table-driven tests for this one would prevent silent regressions from filesystem-layout assumptions. At minimum: empty plans dir, single file under limit, single file over limit, non-UTF-8 edge (though errors="ignore" handles it).

3. Removed comment in ask-user-question.tsx reduced signal
Line 35 previously had: // Handle simple { question: "..." } format (e.g. from Claude Code AskUserQuestion tool). This documented a non-obvious branch — the two-shape input protocol (AskUserQuestionInput[] vs bare { question: string }). The logic is preserved but the explanation is gone. Worth reinstating as a single-line comment since the shape switch is a subtlety a future reader would wonder about.

4. exit-plan-mode.tsx is exactly 200 lines (at the convention boundary)
Frontend conventions say "Components under 200 lines." The file counts 200 lines including the displayName footer. No refactor needed now, but note that any future additions push it over the stated limit.

Nit

  • input.allowedPrompts as AllowedPrompt[] — the Array.isArray guard validates it's an array but doesn't validate element shapes. Fine for now given the source is Claude tool args, but a comment noting the trust boundary would help a reader.
  • Buttons in the action row (Approve / Reject / Request Changes) don't show a spinner on isSubmitting. They disable correctly, so there's no functional bug, but user feedback on the submitting state is absent for Reject and Request Changes. Could be a follow-up.

Positive Highlights

  • Excellent DRY refactoring. hitl-tools.ts consolidates tool-name normalization from three independent copies (stream-message, use-agent-status, ask-user-question) into one source of truth with five well-named exports. This is exactly the right move.
  • Go test quality. The new RUN_FINISHED with same-run ExitPlanMode returns waiting_input test covers the most important status-derivation path, and the extended case-insensitive table now covers both tools symmetrically.
  • Proper 100 KB plan file cap. _PLAN_FILE_MAX_BYTES is defined as a named constant and enforced before sending content over the wire — prevents runaway payload sizes without silently dropping the content.
  • UI state machine is correct. disabled = alreadyAnswered || submitted || isSubmitting || !isNewest correctly covers every state where the user should not be able to interact. The finally block in handleDecision ensures isSubmitting always resets even on throw.
  • Zero any types, no panic(), Shadcn UI used throughout, React Query not applicable (purely prop-driven component). Conventions are clean across the stack.

Recommendations

  1. (Minor) Normalize current_tool_display_name before the ExitPlanMode enrichment guard — a one-line helper keeps it consistent with the rest of the system.
  2. (Minor) Add unit tests for _read_plan_file covering the truncation and empty-directory cases.
  3. (Nit) Reinstate the single-line comment on the bare { question } branch in ask-user-question.tsx.

Amber · standards loaded from CLAUDE.md, specs/standards/backend/, specs/standards/frontend/, specs/standards/security/

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

2 participants