67f9db8bc8
* feat(web): switch claude-native permission mode mid-session Claude Code's permission mode could only be chosen when starting a session: `--permission-mode` is a launch flag, so a running session was stuck in whatever mode it booted with. Switching to auto mode meant either attaching to the tmux pane and pressing shift+tab, or ending the session and starting a new one. Claude Code exposes no non-interactive mode command (`/permissions` opens an interactive rules dialog, and settings files are read only at startup), so the switch drives the TUI's own shift+tab cycle: - `claude_native_bridge.set_permission_mode` presses `BTab` and reads the mode footer Claude renders below its input box after each press, until the target mode appears. The cycle is walked rather than computed from a press count because its width varies — `auto` is only in it for accounts that have the mode, and `bypassPermissions` only when the session launched into it. `dontAsk`/`bypassPermissions` are rejected up front as unreachable. - The runner dispatches a `permission_mode_change` event and echoes back the mode the pane actually landed on. - PATCH /v1/sessions accepts `permission_mode` and persists it as a label only after the runner confirms the switch, so the UI can never claim auto mode while Claude is still prompting on every edit. The label (not `terminal_launch_args`, which records only launch flags) is what the UI reads back after a reload. - The composer grows a mode picker for claude-native sessions, mirroring the existing Codex plan-mode toggle. The prompting mode is relabelled "Manual" to match what Claude Code calls it in its own UI and renders in the pane footer; the wire value stays `default`. Test Plan - New bridge tests cover the walk, the no-op when already in the target mode, an unreachable mode, non-cycleable modes, a pane with no footer, and a stale footer mid-repaint (this last case was a real bug found against a live TUI: reading the pre-keystroke mode made the cycler lag a mode behind and report `auto` unreachable). - New runner tests cover the dispatch, the 503 on a failed switch, and the non-native no-op; new server tests cover the forward, the label-only-after-confirmation rule, and both rejection paths. - Verified end-to-end against a real `claude` 2.1.220 TUI in tmux: every mode reached and confirmed in the pane, including a repeated request for the mode already active. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * feat(web): mirror in-terminal permission-mode switches to the UI A shift+tab pressed inside the Claude Code TUI never reached the web UI: only UI-driven switches stamped the mode label, so the composer's picker showed a stale mode until the next switch from the web side. Claude Code emits no event on a mode change and hook payloads only arrive on tool use, so the rendered mode footer is the only available signal. The forwarder polls it (throttled to 2s — each read spawns a tmux capture-pane subprocess) and POSTs `external_permission_mode_change` when it differs from the last reported mode. The server persists the label and publishes `session.permission_mode` so the live picker follows the pane and a reloading client restores the same state. Anchor the footer scan on the input box's closing rule instead of a fixed tail offset: the footer's height scales with concurrent subagents, and the anchor also excludes transcript text structurally, so a mode name Claude echoed while discussing modes can't be misread as live. The first observation after spawn is the launch mode, not a switch, so it seeds the baseline without posting — otherwise a passive spawn default could clobber a mode the web UI just set. An unreadable pane reads as "unknown" rather than a guess, and the picker hides instead of displaying a mode the session may not be in (a `permissions.defaultMode` in a settings file never reaches the launch args). Also move the picker into the session-config gear modal alongside model and effort, keeping the composer row uncluttered. Fix: `external_permission_mode_change` was missing from the events route's payload-validation passthrough, so every forwarder POST was rejected with a 400 before reaching its handler — and the forwarder swallows HTTP errors at debug level, so the mirror failed silently. Added an end-to-end test that drives the real forwarder against a live server and asserts the event crosses the actual SSE wire, since each layer's own mocked tests passed while the seam between them was broken. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * test: drop redundant permission-mode cases and tighten comments The `sse.test.ts` block duplicated `sessionEvents.test.ts`, which pins the same envelope through `parseEventLines` and is the file whose stated job is catching silent lift bugs. The composer case asserting the absence of the old standalone picker guarded a selector that no longer exists anywhere. Rewrite the comments that described the change instead of the code: the e2e docstring recounted how the 400 was found, and a composer comment narrated what the gear "used to" do. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * fix(server): don't 500 on a silent permission-mode PATCH `silent` suppresses the live runner forward, so no mode is confirmed and no label is written — but the publish read `labels_to_set[...]` unconditionally and raised KeyError, turning a request that changed nothing into a 500. Gate the publish on the label actually being set. That also keeps an unconfirmed mode off the picker, matching the rule the forward path already follows: the label is written only once the pane really moved. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * fix(ci): address all 4 failing CI checks on PR #4018 - Regenerate openapi.json to include the new SessionPermissionModeEvent type and permission_mode field on UpdateSessionRequest; fixes Pytest (server-rest) openapi drift check. - Update E2E landing-page permission-mode test: the default mode label was renamed from "Default" to "Manual" in claudePermissionMode.ts; fixes E2E UI Tests (shard 2/3). - Add test_claude_native_permission_mode_switch_persists to cover the new in-chat permission-mode picker in the gear modal; extends _patch_session_as_claude_native with a permission_mode param so the label stub is set; resolves the E2E UI Required gate. - Raise the asyncio yield deadline in the quiescence-recheck test from 10 000 to 50 000 to eliminate flaky timing failures on slower CI runners; fixes Pytest (misc). Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * fix(claude-native): show the permission picker for manual-mode sessions A session launched in manual mode had no way to leave it. Manual is the default and writes no `--permission-mode` arg, and the forwarder discarded its first pane observation as "just the launch mode", so nothing ever recorded the session's mode. With no mode to render the web picker hid itself, and the switch could never be started — not because cycling failed, but because there was no control to press. Post the first observation instead of seeding silently, so every live claude-native session publishes its mode within one poll whatever it launched in, CLI `omnigent claude` included. The overwrite this guarded against cannot happen: the server ignores a mode equal to the stored label, and the PATCH path only persists after the runner confirms the pane repainted, so a stored label already matches the pane. Verified against a real Claude Code 2.1.237 pane: manual renders `⏸ manual mode on`, and shift+tab cycles manual → accept edits → plan → auto → manual, matching the bridge's footer table exactly. Three tests had encoded seed-silently as intended behavior and now assert the launch mode reaching the wire. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> * test(e2e): locate permission-mode options by data attribute The picker's options render their label and description in nested spans, so each option's accessible name is "Auto Auto-runs; a classifier blocks risky actions" — never the bare label. `get_by_role("option", name="Auto", exact=True)` could therefore never match, and the click timed out after 30s. Tag each option with `data-permission-mode` and select on that, matching how the sibling model and effort rows already expose `data-model-id` and `data-effort-level` for the same reason. Co-authored-by: Isaac Signed-off-by: harry-yao_data <harry.yao@databricks.com> --------- Signed-off-by: harry-yao_data <harry.yao@databricks.com> Co-authored-by: harry-yao_data <harry.yao@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
482 KiB
482 KiB