Files
slopus--happy/docs/plans/agent-sdk-upgrade-plan-mode-fix.md
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

7.8 KiB

Agent SDK Upgrade + Plan Mode Fix + Integration Tests

Context

Plan mode buttons (accept/reject) don't reliably appear in the Happy app UI. Root cause: the CLI's permissionHandler.handleToolCall auto-approves ExitPlanMode when permissionMode is stale (bypassPermissions from a prior session), and reset() never clears permissionMode. Additionally, isAborted always returns true for ExitPlanMode as part of a PLAN_FAKE_RESTART hack, which causes a dead-end when the tool is auto-approved without injecting a restart message.

The current custom SDK (src/claude/sdk/) reimplements what @anthropic-ai/claude-agent-sdk now provides natively, including toolUseID in canUseTool (eliminates our 1-second delay hack) and setPermissionMode() (eliminates the session-restart-on-mode-change hack).

Control flow with the bug

[CLI] handleToolCall(ExitPlanMode, ...)
│
├─ permissionMode === 'bypassPermissions'?   ← stale from prior session
│  └─ YES → return allow ✅                  ← 🐛 skips permission UI
│
│  handlePermissionResponse NEVER called
│  └─ PLAN_FAKE_RESTART never injected
│
└─ isAborted(ExitPlanMode) → always true
   └─ claudeRemote exits → queue empty → stuck

The fix (Part 1 — shipped)

ExitPlanMode now always goes through the permission request flow, before the bypassPermissions/acceptEdits shortcuts:

// permissionHandler.ts handleToolCall()
const descriptor = getToolDescriptor(toolName);

if (descriptor.exitPlan) {
    // Always ask — never auto-approve plan exit
    return this.handlePermissionRequest(toolCallId, toolName, input, options.signal);
}

if (this.permissionMode === 'bypassPermissions') { ... }  // other tools still bypass

Part 1: Immediate Bug Fix

File: packages/happy-cli/src/claude/utils/permissionHandler.ts

ExitPlanMode must never be auto-approved. In handleToolCall, check descriptor.exitPlan before the bypassPermissions/acceptEdits shortcuts and always route to handlePermissionRequest.


Part 2: Agent SDK Migration

2a. Install dependency

cd packages/happy-cli && yarn add @anthropic-ai/claude-agent-sdk@^0.2.96

2b. Migrate SDK types

File: src/claude/sdk/types.ts

Replace custom types with re-exports from the Agent SDK:

  • SDKMessage → from agent-sdk
  • CanCallToolCallbackCanUseTool from agent-sdk (now receives options.toolUseID)
  • QueryOptions → from agent-sdk Options
  • PermissionResult → from agent-sdk
  • Keep any Happy-specific extensions as wrappers

2c. Migrate query.ts

File: src/claude/sdk/query.ts

Replace our custom Query class + query() function with the Agent SDK's query():

  • The Agent SDK's query() returns a Query that is AsyncGenerator<SDKMessage> with control methods
  • It handles process spawning, stdin/stdout, control requests internally
  • Our handleControlRequest / processControlRequest / cleanupControllers all become unnecessary

Key mapping:

Current (custom) Agent SDK
canCallTool: (name, input, opts) => ... canUseTool: (name, input, opts) => ...
opts has { signal } opts has { signal, toolUseID, suggestions, title, ... }
Manual --permission-prompt-tool stdio arg Handled automatically when canUseTool provided

2d. Use toolUseID from canUseTool

File: src/claude/utils/permissionHandler.ts

The canUseTool callback now receives options.toolUseID. Remove:

  • resolveToolCallId() method and the 1-second delay retry hack
  • toolCalls[] tracking array
  • onMessage() method (no longer needed for tool ID resolution)

2e. Use setPermissionMode for mode changes

File: src/claude/claudeRemote.ts

Store the Query object so we can call query.setPermissionMode() when mode changes arrive, instead of killing and restarting the session.

File: src/claude/claudeRemoteLauncher.ts

When a permission mode change is detected in nextMessage():

  • Instead of returning null (triggering restart), call query.setPermissionMode(newMode)
  • Update permissionHandler.handleModeChange() as before
  • Continue processing — no restart needed

Caveat: Model changes still require restart (different --model flag). Keep hash-based detection for model changes only.


Part 3: Plan Mode Hack Cleanup

Depends on Part 2 being complete.

3a. Remove PLAN_FAKE_RESTART / PLAN_FAKE_REJECT

File: src/claude/sdk/prompts.ts — delete entirely

File: src/claude/utils/permissionHandler.ts — in handlePermissionResponse:

  • When plan approved: call setPermissionMode(approvedMode) on the query, then return { behavior: 'allow' }
  • When plan denied: return { behavior: 'deny', message: reason }
  • No more fake messages, no more queue injection

3b. Remove isAborted always-true for ExitPlanMode

File: src/claude/utils/permissionHandler.ts — in isAborted():

  • Remove the special case for exit_plan_mode
  • Only return true when responses.get(toolCallId)?.approved === false

3c. Remove tool result content transformation

File: src/claude/claudeRemoteLauncher.ts — remove the PLAN_FAKE_REJECT → "Plan approved" transformation in onMessage() (lines 196-224)

3d. Remove planModeToolCalls tracking

File: src/claude/claudeRemoteLauncher.ts — remove planModeToolCalls Set and detection logic


Part 4: Integration Tests

File: src/claude/planMode.integration.test.ts

Three tests against an isolated /tmp/happy-testing-ground-<random>/ fixture with hello-world.js in a git repo:

  1. Plan approval → ExitPlanMode received by canCallTool, plan input has content, Claude edits the file
  2. Plan denial → file untouched after deny
  3. Regression → ExitPlanMode always reaches canCallTool (the bug we found)

File: src/testing/planModeTestFixture.ts — creates the isolated fixture

File: vitest.config.ts — new integration-plan-mode project with 180s timeout


Hacks inventory (to remove in Parts 2-3)

Hack Location Why it exists SDK replacement
PLAN_FAKE_RESTART / PLAN_FAKE_REJECT sdk/prompts.ts, permissionHandler.ts, claudeRemoteLauncher.ts SDK doesn't support plan mode exit natively setPermissionMode() + allow/deny
resolveToolCallId + 1s delay permissionHandler.ts:156-163 Race between permission request and message processing toolUseID in canUseTool options
isAborted always-true for ExitPlanMode permissionHandler.ts:342-346 Prevents SDK from executing the fake-rejected tool Not needed when approval is real
Tool result content transformation claudeRemoteLauncher.ts:196-224 Converts PLAN_FAKE_REJECT to "Plan approved" for logs Not needed when flow is clean
Hash-based mode change → kill session → restart claudeRemoteLauncher.ts:372-378 SDK doesn't restart on mode change setPermissionMode() mid-session

Files modified (summary)

File Change
package.json Add @anthropic-ai/claude-agent-sdk dependency
src/claude/sdk/types.ts Re-export Agent SDK types
src/claude/sdk/query.ts Replace with Agent SDK wrapper
src/claude/sdk/prompts.ts Delete (PLAN_FAKE constants)
src/claude/sdk/index.ts Update exports
src/claude/utils/permissionHandler.ts Fix bug, use toolUseID, remove hacks
src/claude/utils/getToolDescriptor.ts No change (already correct)
src/claude/claudeRemote.ts Use setPermissionMode, store query ref
src/claude/claudeRemoteLauncher.ts Remove plan mode hacks, simplify mode changes
src/claude/planMode.integration.test.ts New — plan mode tests
src/testing/planModeTestFixture.ts New — test fixture helper
vitest.config.ts Add plan mode test suite