Skip to content

feat(approval-prompt): unified PauseRequest→UI data model for ACP, CLI, Desktop (#1322 Phase 3)#1443

Merged
esengine merged 9 commits into
esengine:mainfrom
paradoxSCH:feat/approval-prompt-unification
May 21, 2026
Merged

feat(approval-prompt): unified PauseRequest→UI data model for ACP, CLI, Desktop (#1322 Phase 3)#1443
esengine merged 9 commits into
esengine:mainfrom
paradoxSCH:feat/approval-prompt-unification

Conversation

@paradoxSCH

@paradoxSCH paradoxSCH commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1322 Phase 3.

Note to reviewer: This is a single PR with 4 sequentially-dependent commits. Each commit can be reviewed independently in the GitHub "Commits" tab, but they are not independently mergeable (CLI TUI depends on the core model; Desktop depends on both). This structure was chosen over stacked PRs because the three surfaces must migrate together to avoid an intermediate state where old inline assembly and new ApprovalPrompt code coexist.

Introduces a single source of truth in @reasonix/core-utils that converts any PauseGate PauseRequest into a platform-agnostic ApprovalPrompt data structure. All four surfaces (ACP, CLI TUI, Desktop, Dashboard wire protocol) now consume this structure instead of independently assembling titles, subtitles, options, and tones.

What changed

Surface Before After
ACP permissionOptionsFor(), permissionTitleFor(), verdictFor() inline in src/acp/gates.ts toApprovalPrompt() + resolveApprovalPrompt() from core-utils
CLI TUI ShellConfirm.tsx / PathConfirm.tsx inline title/sub/option assembly + local derivePrefix Consume ApprovalPrompt; drive SingleSelect from prompt.actions
Desktop ConfirmApprovalCard / PathAccessApprovalCard inline assembly + local derivePrefix Consume ApprovalPrompt; map actions to primary/secondary/tertiary buttons
RPC wire Flat fields only (command, kind) Backward-compatible flat fields + new prompt: ApprovalPrompt field

Why we standardized all three surfaces in one PR instead of prototyping one card first

Owner esengine originally suggested: "prototype one card first (ConfirmApprovalCard) before standardizing the rest."

After prototyping the core model, we found that the value of ApprovalPrompt is as a cross-surface contract, not as a local refactor:

  1. The model only proves itself when multiple consumers agree on it. Prototyping ConfirmApprovalCard alone would still leave derivePrefix, title assembly, and option lists duplicated in CLI TUI and ACP. The bug that motivated Phase 3 (桌面版询问用户是否允许运行命令时,选了始终允许后,根本不生效,后续还是继续问用户 #1180 — Desktop derivePrefix diverged from CLI) would remain.

  2. The three commits are strictly layered and reviewable independently.

    • Commit 1 (core model + ACP) is pure logic, zero UI changes, fully covered by existing tests/acp-yolo.test.ts.
    • Commit 2 (CLI TUI) only touches ShellConfirm.tsx, PathConfirm.tsx, and their render sites in App.tsx.
    • Commit 3 (Desktop) only touches thread.tsx, App.tsx, protocol.ts, and the Desktop RPC emitter.
      Each layer validates that the previous abstraction is sufficient.
  3. Incremental rollout would create temporary duplication. If we landed Desktop first while CLI still used inline assembly, any title/option change would require editing both the old inline code and the new ApprovalPrompt code until CLI migrated. A single branch eliminates this overlap period.

Benefits

  • Single source of truth: derivePrefix, tone selection, title/subtitle assembly, and action lists all live in packages/core-utils/src/approval-prompt.ts.
  • One-file changes: Adding a new option or changing tone now requires editing only approval-prompt.ts instead of 3–4 files.
  • Zero user-visible behavior changes: All existing tests pass; labels and options remain identical.
  • Two-phase deny flow described in data: ApprovalAction.secondaryInput tells any surface when an action requires extra input (e.g., deny reason), replacing hardcoded \"deny\" checks.
  • Test coverage: 78 new tests across 5 files, all green:
    • packages/core-utils/tests/approval-prompt.test.ts (37)
    • tests/acp.test.ts (28)
    • tests/shell-confirm-render.test.tsx (2)
    • desktop/src/App.test.ts (5)
    • desktop/src/ui/thread.test.tsx (6)

Risk mitigations

  • ACP protocol: existing tests/acp-yolo.test.ts validates verdict mapping.
  • Desktop backward compatibility: RPC events keep all flat fields; old Desktop builds ignore the new prompt field.
  • i18n: English fallback labels match current behavior; surfaces can map action.id to their own translation keys.
  • Desktop CI tests: desktop/src/App.test.ts and desktop/src/ui/thread.test.tsx import from desktop/src/ source files, which transitively depend on Desktop-only packages (lucide-react, react-markdown, @tauri-apps/api, etc.) that are not installed in the root node_modules used by CI. We mock these local module boundaries (./CommandPalette, ./Markdown, ./cards) and external deps via tests/mocks/ + vitest.config.ts aliases so the tests can run without a full desktop/node_modules. This is a known testing-infrastructure debt that should be addressed in a future "Desktop testing infrastructure" PR (either by extracting pure-logic modules or by making CI install Desktop dependencies).

Commits

  1. 0dffa32 — feat(core-utils): introduce ApprovalPrompt unified data model
  2. c6edbd8 — feat(cli): migrate ShellConfirm and PathConfirm to ApprovalPrompt model
  3. ff90c22 — feat(desktop): migrate ConfirmApprovalCard and PathAccessApprovalCard to ApprovalPrompt
  4. 761cb6d — test(desktop): add ApprovalPrompt reducer and component tests
  5. d5ac04c — fix(desktop-tests): mock local desktop modules and add vitest aliases for CI

Test plan

  • npm run verify passes (255 test files, 3505 tests)
  • ACP gate tests: npx vitest run tests/acp.test.ts
  • Shell confirm render tests: npx vitest run tests/shell-confirm-render.test.tsx
  • Desktop reducer tests: npx vitest run desktop/src/App.test.ts
  • Desktop component tests: npx vitest run desktop/src/ui/thread.test.tsx
  • Manual smoke: boot Desktop, trigger shell command that needs approval, verify card renders correctly

🤖 Generated with Claude Code

paradoxSCH and others added 9 commits May 20, 2026 15:37
Problem: Desktop ContextPanel showed stale token counts after /compact
because it relies on per-turn API usage events (model.final) to update
cacheHitTokens/cacheMissTokens. /compact is a local operation with no
API call, so no usage event fires and the meter never refreshes.

Fix (high-cohesion):
1. ContextManager.getLogTokens() — real-time token count of current log.
2. Loop.getCurrentLogTokens() — forwards to ContextManager.
3. Extend \$ctx_breakdown event with optional logTokens field.
4. emitCtxBreakdown() now computes + pushes logTokens alongside
   reservedTokens, becoming the unified context-refresh entrypoint.
5. compact_history handler calls emitCtxBreakdown(tab) on success so
   the frontend immediately receives the compressed token count.
6. App.tsx \$ctx_breakdown handler resets cacheHitTokens=0 and
   cacheMissTokens=logTokens when logTokens is present, calibrating
   the meter to the actual current context size.

This keeps all computation in ContextManager, all emitting in
emitCtxBreakdown, and all consumption in App.tsx — no new event types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ne#1322 Phase 3 PR 1)

- Add `toApprovalPrompt()` and `resolveApprovalPrompt()` in `@reasonix/core-utils`
  to serve as the single source of truth for PauseRequest → UI metadata mapping.
- Define `ApprovalPrompt`, `ApprovalAction`, `ApprovalTone`, `ApprovalPromptKind`
  types with English fallback labels.
- Move `derivePrefix` computation into `toApprovalPrompt` so all surfaces share
  one implementation.
- Add `secondaryInput` to `ApprovalAction` to describe two-phase deny flows.
- Replace ACP `gates.ts` inline mapping with calls to the new unified helpers,
  deleting duplicated `permissionOptionsFor`, `permissionTitleFor`,
  `permissionKindFor`, `verdictFor`, and all `ID_*` constants.
- Update `acp.test.ts` to use new semantic action IDs (`always_allow`, etc.).
- Add `packages/core-utils/tests/**/*.test.ts` to vitest include pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…el (esengine#1322 Phase 3 PR 2)

- Update `ShellConfirm.tsx` and `PathConfirm.tsx` to accept `prompt: ApprovalPrompt`
  instead of individual raw fields.
- Drive `SingleSelect` items from `prompt.actions` instead of hardcoded options.
- Trigger deny phase via `action.secondaryInput` detection instead of hardcoded
  "deny" value check.
- Remove `clampCommand`, `tildeify`, and `derivePrefix` from ShellConfirm/PathConfirm
  (computed centrally in `toApprovalPrompt`).
- In `App.tsx`, call `toApprovalPrompt()` at render time for both shell and path
  confirmations, removing inline `derivePrefix` at the component boundary.
- Update `shell-confirm-render.test.tsx` to use `ApprovalPrompt` fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… to ApprovalPrompt (esengine#1322 Phase 3 PR 3)

- Add `@reasonix/core-utils` workspace dependency to `desktop/package.json`.
- Extend `ConfirmRequiredEvent` and `PathAccessRequiredEvent` with optional
  `prompt: ApprovalPrompt` for backward-compatible RPC.
- In `src/cli/commands/desktop.ts`, call `toApprovalPrompt()` when emitting
  `$confirm_required` and `$path_access_required` events.
- Update `ConfirmApprovalCard` and `PathAccessApprovalCard` in
  `desktop/src/ui/thread.tsx` to consume `prompt` instead of raw fields.
  - Drive primary/secondary/tertiary buttons from `prompt.actions` mapped by
    `kind` (`allow_once`, `allow_always`, `reject`).
  - Map `ApprovalPrompt.tone` to Desktop `ApprovalTone` (warn/danger/info/brand).
  - Remove inline `derivePrefix` from card components.
- Update `PendingConfirm` and `PendingPathAccess` types in `desktop/src/App.tsx`
  to store `prompt`, and pass it to the card components at render time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add desktop/src/App.test.ts (5 reducer integration tests)
- Add desktop/src/ui/thread.test.tsx (6 component rendering tests)
- Install @testing-library/react and jsdom for Desktop component testing
- Configure vitest with React aliases to prevent duplicate React copies
- Fix tildeify double-slash normalization bug on Windows
- Fix ACP gates comment length to pass policy check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolve package-lock.json conflict by regenerating from main.
CI installs only root dependencies; desktop-specific deps like
@tauri-apps/api and lucide-react are not available in test runners.
Add vi.mock() calls to App.test.ts and thread.test.tsx so vitest
can resolve these imports without the desktop node_modules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CI installs only root dependencies; desktop-specific packages like
lucide-react and @tauri-apps/api are not available. Add mock modules
in tests/mocks/ and wire them via resolve.alias in vitest.config.ts
so desktop tests can resolve these imports without desktop/node_modules.

Also remove redundant vi.mock() calls from App.test.ts and thread.test.tsx
since aliases now handle module resolution globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…p loading

App.test.ts imports reduce from App.tsx, which transitively loads
CommandPalette, Markdown, and theme modules with desktop-only deps.
thread.test.tsx imports from thread.tsx, which loads cards.tsx,
which loads Markdown.tsx with react-markdown and other deps.

Add vi.mock() for these local module boundaries so tests resolve
without needing the full desktop/node_modules dependency tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@esengine esengine merged commit 3a09a2b into esengine:main May 21, 2026
4 checks passed
esengine pushed a commit that referenced this pull request May 21, 2026
…t a workspace member

`npm ci` inside desktop/ failed with EUNSUPPORTEDPROTOCOL because desktop/
is not listed under root workspaces, so the `workspace:*` spec added in
#1443 had no resolver. Replace with `file:../packages/core-utils`,
which is what the desktop install actually wants (source-only package,
type-only import in desktop sources).
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.

RFC: 建立 packages/core-utils 共享包,根治 CLI / Desktop / Dashboard / ACP 四端重复实现

2 participants