-
Notifications
You must be signed in to change notification settings - Fork 117
feat: implement ExitPlanMode HITL for Tool Permission Model - #1589
feat: implement ExitPlanMode HITL for Tool Permission Model #1589quay-devel wants to merge 10 commits into
Conversation
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>
- 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>
✅ Deploy Preview for cheerful-kitten-f556a0 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
No actionable comments were generated in the recent review. 🎉 i️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughGeneralizes HITL tool detection (AskUserQuestion + ExitPlanMode), preserves HITL tool-start snapshots for status inference, centralizes frontend helpers, adds ExitPlanMode UI and plan-content enrichment in the runner, and updates tests and runner allowlist. ChangesHITL Tool Support
🚥 Pre-merge checks | ✅ 7 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (7 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify 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. Comment |
There was a problem hiding this 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
i️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 767b3627-8743-45b6-a69e-396e31eb5da9
📒 Files selected for processing (10)
components/backend/websocket/agui_proxy.gocomponents/backend/websocket/agui_store.gocomponents/backend/websocket/agui_store_test.gocomponents/frontend/src/components/session/ask-user-question.tsxcomponents/frontend/src/components/session/exit-plan-mode.tsxcomponents/frontend/src/components/ui/stream-message.tsxcomponents/frontend/src/hooks/use-agent-status.tscomponents/frontend/src/lib/hitl-tools.tscomponents/runners/ambient-runner/ag_ui_claude_sdk/adapter.pycomponents/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
- 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 <noreply@anthropic.com>
markturansky
commented
May 21, 2026
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[]— theArray.isArrayguard 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.tsconsolidates 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_inputtest 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_BYTESis 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 || !isNewestcorrectly covers every state where the user should not be able to interact. Thefinallyblock inhandleDecisionensuresisSubmittingalways resets even on throw. - Zero
anytypes, nopanic(), Shadcn UI used throughout, React Query not applicable (purely prop-driven component). Conventions are clean across the stack.
Recommendations
- (Minor) Normalize
current_tool_display_namebefore the ExitPlanMode enrichment guard — a one-line helper keeps it consistent with the rest of the system. - (Minor) Add unit tests for
_read_plan_filecovering the truncation and empty-directory cases. - (Nit) Reinstate the single-line comment on the bare
{ question }branch inask-user-question.tsx.
Amber · standards loaded from CLAUDE.md, specs/standards/backend/, specs/standards/frontend/, specs/standards/security/
markturansky
commented
May 23, 2026
Amber Code Review — PR#1589: feat: implement ExitPlanMode HITL for Tool Permission Model
Summary
Solid, well-structured implementation of ExitPlanMode as a second HITL tool. The abstraction into hitl-tools.ts is exactly the right move — it eliminates three duplicate detection functions and gives a clean single source of truth. The backend status-derivation and compaction logic correctly handles the ExitPlanMode lifecycle. One critical behavioral uncertainty (tool result format), one major finding (unguarded Tier-1 tool expansion), and four minor findings.
Findings
Blocker
None.
Critical
1. ExitPlanMode tool result format not verified against Claude Code CLI expectations
exit-plan-mode.tsx:63–68 — The component sends JSON.stringify({decision: "approve" | "reject" | "request_changes", feedback?: "..."}) as the tool result. Whether Claude Code CLI's ExitPlanMode actually expects this structured JSON — or expects natural-language text (e.g. "Yes, proceed") — is unspecified in the diff. If the format is wrong, Claude receives a malformed tool result and may behave unexpectedly after user approval.
The test plan has [ ] (unchecked) for "verify approve/reject/request-changes sends correct tool result." This is the exact gap. The result contract with Claude Code CLI must be established before merge.
Fix: Document the expected tool result schema (from the Claude Code SDK docs or spec PR#1586). Add a test that mocks onSubmitAnswer and asserts the correct JSON structure is produced for each button path (approve, reject, request_changes).
Major
2. Tier-1 allowlist expansion in mcp.py without feature flags
components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py:29–51 — Fourteen new tools added to DEFAULT_ALLOWED_TOOLS, several with meaningful blast radius:
CronCreate— allows the runner to schedule persistent jobs that fire independent of session lifecycle.EnterWorktree/ExitWorktree— creates isolated git worktrees; side effects persist after session ends if cleanup fails.WebFetch— opens server-side SSRF risk if not already mitigated elsewhere.
Per CLAUDE.md: "Feature flags strongly recommended — gate new features behind Unleash flags." No flag here means a bad behavior from any of these tools has no kill switch.
If these tools were specified by the Tier-1 allowlist in PR#1586 and their addition is intentional/non-new, add a code comment pointing at the spec. Consider a single exitplanmode-hitl flag gating the expansion. At minimum, CronCreate and EnterWorktree/ExitWorktree warrant individual flags or a clear rationale for unflagged rollout.
Minor
3. border-l-3 is not a standard Tailwind CSS class
exit-plan-mode.tsx:88, 92 — Tailwind's built-in border-left width scale is 0, 1, 2, 4, 8 — no border-l-3. In standard Tailwind v3 JIT, this class compiles to nothing (the accent border won't render). If the existing AskUserQuestionMessage uses the same class and it works, there's a custom config in play — worth confirming. If not, replace with border-l-2 or border-l-4.
4. handleDecision silently drops submission errors
exit-plan-mode.tsx:55–68 — The try/finally block resets isSubmitting after an onSubmitAnswer failure, but no error is surfaced to the user. They can retry (since submitted stays false), but get no indication anything went wrong. Add an error state and display an inline message or toast on failure.
5. debug log level for ExitPlanMode plan injection failure
adapter.py:1092 — logger.debug("Failed to enrich ExitPlanMode with plan content: %s", e) — if this fires, the user sees the plan approval card with no plan content. That's confusing enough to warrant logger.warning(...) so it appears in production logs without requiring debug verbosity. Optionally add a logger.debug inside _read_plan_file when the plans directory doesn't exist, to aid onboarding debugging.
6. Removed non-obvious compatibility comment in ask-user-question.tsx
ask-user-question.tsx:34 — The deleted comment # Handle simple { question: "..." } format (e.g. from Claude Code AskUserQuestion tool) was the only explanation for why this second format path exists. A future maintainer won't know this is a backward-compat branch without it. Please restore.
Positive Highlights
hitl-tools.tsconsolidation is exactly right. Three files had duplicate detection functions; one shared library with named exports is clean and extensible.- Backend compaction and status derivation is logically sound.
compactFinishedRuncorrectly preserves onlyToolCallStart(notToolCallFinished) for HITL tools, andDeriveAgentStatuscorrectly scopes to the most recent run so compacted events from answered prompts don't produce falsewaiting_inputstatuses. _read_plan_filehas sensible guardrails: 100KB cap,errors="ignore"on truncated UTF-8,OSErrorcatch, gracefulNonereturn when no plans dir exists.isNewestguard on all HITL components correctly prevents users from re-submitting answers to stale prompts from prior runs.- Test coverage for ExitPlanMode mirrors the existing AskUserQuestion cases exactly in
agui_store_test.go— right pattern. - Go naming consistency:
isHITLToolCallin Go mirrorsisHITLToolin TypeScript — good cross-layer coherence.
Recommendations (prioritized)
- (Critical — before merge) Confirm tool result schema against Claude Code CLI's ExitPlanMode expectations and add an automated test for each decision path.
- (Major) Add an Unleash feature flag gating at minimum
CronCreate,EnterWorktree,ExitWorktreeadditions toDEFAULT_ALLOWED_TOOLS. - (Minor) Replace
border-l-3withborder-l-4(or confirm it resolves correctly in the project's Tailwind config). - (Minor) Add error state display to
handleDecisionforonSubmitAnswerfailures. - (Minor) Raise plan injection log level to
warning. - (Minor) Restore the
{question: "..."}backward-compat comment inparseQuestions.
🤖 Amber — code review agent | ambient-code/platform
@markturansky
markturansky
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Amber Review — PR #1589
Summary
Well-structured implementation. The hitl-tools.ts shared module is the right pattern — eliminates three copies of the same normalization function. Backend rename from isAskUserQuestionToolCall → isHITLToolCall is clean. Plan file enrichment in the adapter handles truncation and options formats correctly. Both CodeRabbit findings (runtime type guards, byte-correct truncation) are addressed in commit d7352bb.
Findings
Minor — border-l-3 is not a valid Tailwind CSS class
exit-plan-mode.tsx uses border-l-3 in two places, but Tailwind's border-left width scale only includes border-l (1px), border-l-2 (2px), border-l-4 (4px), border-l-8 (8px). The class is silently ignored — the colored left accent border that visually distinguishes the plan card won't render.
-"rounded-lg border-l-3 pl-3 pr-3 py-2.5", +"rounded-lg border-l-4 pl-3 pr-3 py-2.5",
(Check what ask-user-question.tsx uses for consistency — if it uses border-l-2, match that.)
Score
7/8 checks clean. LGTM with nit — the visual defect is minor and doesn't affect function.
— Amber (code review agent)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py (1)
26-49:⚠️ Potential issue | 🔴 Critical | ⚡ Quick win
ExitPlanModemissing fromDEFAULT_ALLOWED_TOOLS.The allowlist includes
EnterPlanMode(line 42) but notExitPlanMode. The PR objectives state ExitPlanMode was missing from the runner allowlist, causing indefinite hangs (issue#1583). Backend (isHITLToolCall) and frontend (isExitPlanModeTool) both expect this tool to pass through.🐛 Proposed fix
"EnterPlanMode", + "ExitPlanMode", "EnterWorktree",🤖 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/ambient_runner/bridges/claude/mcp.py` around lines 26 - 49, DEFAULT_ALLOWED_TOOLS is missing "ExitPlanMode", which causes HITL/ExitPlanMode tool calls to be blocked; update the DEFAULT_ALLOWED_TOOLS list in ambient_runner/bridges/claude/mcp.py to include "ExitPlanMode" alongside "EnterPlanMode" so that isHITLToolCall and isExitPlanModeTool pass through correctly.
🤖 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.
Outside diff comments:
In `@components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py`:
- Around line 26-49: DEFAULT_ALLOWED_TOOLS is missing "ExitPlanMode", which
causes HITL/ExitPlanMode tool calls to be blocked; update the
DEFAULT_ALLOWED_TOOLS list in ambient_runner/bridges/claude/mcp.py to include
"ExitPlanMode" alongside "EnterPlanMode" so that isHITLToolCall and
isExitPlanModeTool pass through correctly.
i️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: b108ded0-3d88-49a5-aef8-0c63016dd4c3
📒 Files selected for processing (1)
components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
markturansky
commented
May 27, 2026
🤖 Amber Review — PR #1589: feat: implement ExitPlanMode HITL for Tool Permission Model
Summary
Clean implementation of ExitPlanMode as a second HITL tool alongside AskUserQuestion. The change is well-scoped: the runner halts on ExitPlanMode, the backend generalizes its HITL detection, and the frontend gains a plan approval component with approve/reject/request-changes actions. Code quality is high and the PR follows established patterns throughout.
Findings
Blocker
None.
Critical
None.
Major
None.
Minor
-
hitl-tools.ts:14-17—isHITLToolduplicates normalized string comparisons// Current export function isHITLTool(name: string): boolean { const normalized = normalizeToolName(name); return normalized === "askuserquestion" || normalized === "exitplanmode"; } // Simpler — delegates to already-defined functions, can't drift out of sync export function isHITLTool(name: string): boolean { return isAskUserQuestionTool(name) || isExitPlanModeTool(name); }
Not a functional bug, but if a third HITL tool is added later, this is a two-place update instead of one. Style nit.
-
adapter.py_read_plan_file— picks most-recent.mdby mtime without session scoping
If a session workspace is ever reused or if multiple plan files accumulate, this could inject a stale plan. Low risk given current session isolation, but worth naming. A note in the function docstring ("assumes CWD is an isolated session workspace") would make the assumption explicit. -
ask-user-question.tsx:40— removed inline comment explaining fallback format
The deleted comment// Handle simple { question: "..." } format (e.g. from Claude Code AskUserQuestion tool)explained why the fallback branch exists. The code still works; the why is now gone. Minor documentation loss. -
exit-plan-mode.tsx— stale feedback input on cancel
When a user opens the Request Changes input, types something, then clicks Cancel,feedbackstate is not cleared. Re-clicking Request Changes shows the stale text. This matches the UX of the surrounding component (AskUserQuestion has a similar gap) so it's a nit, not a regression.
Positive Highlights
hitl-tools.tsis a clean extraction — eliminates three independent copies of the normalization logic that had drifted intoask-user-question.tsx,stream-message.tsx, anduse-agent-status.ts.- Correct UTF-8 byte truncation in
_read_plan_file: slicing encoded bytes rather than characters handles multi-byte sequences properly. The prior revision that fixed this was the right call. - Backend test coverage is solid — the new
ExitPlanModetest case mirrors the existingAskUserQuestionpattern exactly, and case-insensitive variants are exercised for both tools. - Runtime type guards in
ExitPlanModeMessage(typeof input.planContent === "string",Array.isArray(input.allowedPrompts)) prevent crashes from unexpected backend payloads — consistent with defensive frontend patterns. - JSON error logging in the adapter's plan-enrichment path (instead of a silent swallow) is an improvement surfaced by the review iteration.
Recommendations
- Address the
isHITLToolDRY nit if convenient — purely optional before merge. - Add a docstring note to
_read_plan_fileabout the isolated-workspace assumption.
Confidence: High — backend and frontend changes follow established patterns; runner change is isolated to non-auth path.
CI: Mergify summary passing. Manual UI verification items remain open (plan approval flow end-to-end).
Uh oh!
There was an error while loading. Please reload this page.
Summary
Implements the Tool Permission Model spec from PR #1586, adding
ExitPlanModeas a HITL (human-in-the-loop) tool that halts the event stream and waits for user approval — the same mechanism already used byAskUserQuestion.ExitPlanModeadded toBUILTIN_FRONTEND_TOOLShalt set; plan file content injected into tool args; Tier 1 allowlist completed with all missing toolsisAskUserQuestionToolCall→isHITLToolCallto detect both tools for status derivation and snapshot compaction; new test cases for ExitPlanModehitl-tools.ts; newExitPlanModeMessagecomponent with approve/reject/request-changes actionsCloses #1583
Spec: #1586
Test plan
go test ./websocket/...)npm run build)npx vitest run)panic()in Go code, noanytypes in TypeScript🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
Other