fix(cli): block Windows Tab approval-mode toggle when input has a Tab consumer#4308
Conversation
… consumer Closes #4171. On Windows, Shift+Tab is indistinguishable from a bare Tab in many terminals, so useAutoAcceptIndicator accepts a bare Tab as the approval-mode cycle shortcut. To avoid double-firing with the input area, AppContainer passes a `shouldBlockTab` callback that suppresses the cycle when the input has its own Tab handler. Until now that callback only tracked the autocomplete dropdown (`shouldShowSuggestions`). When the buffer was empty and the followup prompt-suggestion ("input prediction") was visible, pressing Tab on Windows accepted the suggestion *and* cycled approval mode at the same time — the exact behaviour reported in #4171. The mid-input ghost-text and reverse/command-search paths had the same gap. Broaden the signal: compute `hasTabConsumer` from every Tab consumer inside InputPrompt — autocomplete dropdown, followup suggestion, mid-input ghost text, reverse-search, command-search — and feed that into `shouldBlockTab`. A single Tab keystroke now triggers exactly one action on Windows; macOS and Linux behaviour is unchanged. Tests cover the four states (followup visible, ghost text visible, autocomplete visible, idle).
📋 Review SummaryThis PR addresses issue #4171 by fixing a Windows-specific bug where pressing Tab could double-fire—both accepting a suggestion AND cycling approval mode simultaneously. The fix broadens the 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
|
Triage of the automated review (re: comment 4484041170). No code changes pushed — none of the six findings clear the filters for a behavioural fix. Per-finding rationale below. 🟡 High — add a comment about React same-value bail-out at 🟢 Medium — wrap 🟢 Medium — move "See #4171" earlier in the 🔵 Low — expand 🔵 Low — add a comment naming the source file of 🔵 Low — rename Happy to revisit any of these with a concrete reproduction or design argument. |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
There was a problem hiding this comment.
Pull request overview
This PR addresses a Windows-specific keyboard conflict where pressing Tab could both (a) accept an input-side suggestion and (b) trigger the Windows fallback that cycles approval mode (treating bare Tab like Shift+Tab), as reported in #4171.
Changes:
- Expand the signal passed from
InputPromptto the parent so it reports whether any input-area feature is expected to consume a Tab keypress (not just the autocomplete dropdown). - Rename the parent state from
hasSuggestionsVisible→hasTabConsumer, and feed it intouseAutoAcceptIndicator’sshouldBlockTab. - Add regression tests to validate
InputPromptreportshasTabConsumercorrectly across followup suggestions, ghost text, and autocomplete.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/cli/src/ui/components/InputPrompt.tsx | Computes hasTabConsumer and reports it to the parent so Windows Tab approval-mode cycling can be suppressed when input-side Tab behavior is active. |
| packages/cli/src/ui/components/InputPrompt.test.tsx | Adds regression tests ensuring the parent callback receives true/false appropriately for key Tab-consuming UI states. |
| packages/cli/src/ui/AppContainer.tsx | Renames state to hasTabConsumer and wires it into useAutoAcceptIndicator.shouldBlockTab. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
wenshao
left a comment
There was a problem hiding this comment.
Reviewed via lightweight cross-repo mode. No new findings beyond existing inline comments. The change is well-scoped, correctly addresses the Windows Tab double-fire issue (#4171), and the 4 new regression tests cover the primary code paths. Known tech-debt items (prop rename, reverse/command search test coverage) are tracked in existing comments. — qwen-latest-series-invite-beta-v30 via Qwen Code /review
…review) Three review findings on PR #4308 addressed together — all touch the same `hasTabConsumer` signal surface exposed from InputPrompt to AppContainer. 1. **Tighten signal semantics (Copilot)**: drop the standalone `reverseSearchActive || commandSearchActive` terms. When those overlays have matches, their `showSuggestions` flag already flows into `shouldShowSuggestions` and Tab is consumed via `ACCEPT_SUGGESTION_REVERSE_SEARCH`. When they're active without matches, Tab is NOT consumed — including the bare flags misrepresented the signal as "Tab consumer present" when it really meant "modal overlay open". `hasTabConsumer` now strictly matches its name. 2. **useEffect cleanup on unmount (wenshao)**: previously, if any Tab consumer was active when InputPrompt unmounted (e.g. streaming begins while autocomplete is open), AppContainer's `hasTabConsumer` state retained the stale `true` value and kept blocking Windows Tab approval-mode cycling for the entire unmount window. Effect now resets to `false` on cleanup. The pre-existing code had the same gap with one trigger; expanding to 3 triggers materially raised the likelihood. 3. **JSDoc on prop name (wenshao)**: `onSuggestionsVisibilityChange` now carries broader "Tab consumer" semantics than the name suggests. Cross-file rename across UIActionsContext + Composer + AppContainer is too much churn for #4308's scope; add JSDoc on the prop declaration documenting the broader signal and that the name is retained for backward compatibility. 4. **Test coverage (wenshao)**: add two tests — autocomplete dismissal reports `false` (true→false transition); unmount-while-active reports `false` (cleanup regression guard).
wenshao
left a comment
There was a problem hiding this comment.
[Critical] Composer.tsx Footer/KeyboardShortcuts hiding regression
Composer.tsx:80-84 receives the broadened onSuggestionsVisibilityChange signal (now hasTabConsumer) into its local showSuggestions state, which gates Footer/KeyboardShortcuts rendering at Composer.tsx:165:
{uiState.isInputActive && !showSuggestions && (showShortcuts ? <KeyboardShortcuts /> : ...<Footer />)}Before this PR, only the autocomplete dropdown triggered Footer hiding. After this PR, followup prompt suggestions and mid-input ghost text also hide the Footer — neither occupies vertical space that conflicts with the Footer. This is a UI regression on all platforms.
Suggested fix: Split the callback into two signals — keep onSuggestionsVisibilityChange narrow (shouldShowSuggestions only) for Composer's Footer control, and add a separate onTabConsumerChange for AppContainer's shouldBlockTab:
// InputPrompt.tsx — restore narrow semantics
useEffect(() => {
onSuggestionsVisibilityChange?.(shouldShowSuggestions);
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
// New callback for Tab blocking
useEffect(() => {
onTabConsumerChange?.(hasTabConsumer);
return () => onTabConsumerChange?.(false);
}, [hasTabConsumer, onTabConsumerChange]);— qwen-latest-series-invite-beta-v28 via Qwen Code /review
…review) Self-inflicted regression caught by wenshao: the previous round broadened `onSuggestionsVisibilityChange` from "autocomplete dropdown visible" to "any Tab consumer present", but Composer.tsx was using that same callback for a different purpose — hiding the Footer / KeyboardShortcuts when the dropdown would overlap their vertical space. As a result, followup prompt suggestions and mid-input ghost text (both inline within the input box, neither competing for vertical space) were also hiding the Footer on every platform. Split into two signals: - `onSuggestionsVisibilityChange` — narrow, autocomplete dropdown only. Kept local to Composer for Footer hiding. Restored to pre-PR semantics; no cleanup-on-unmount needed (the entire conditional in Composer.tsx is already gated by `uiState.isInputActive`, which goes false when InputPrompt unmounts). - `onTabConsumerChange` — broad, any input-side Tab consumer (autocomplete + followup + ghost text). Plumbed through UIActionsContext to AppContainer's `hasTabConsumer` state → useAutoAcceptIndicator's `shouldBlockTab`. Retains the cleanup-on-unmount wenshao added last round (the broad signal IS read while InputPrompt is unmounted). Tests: - All 6 broad-signal regression tests renamed to assert `onTabConsumerChange`. - 3 new narrow-signal regression tests pin that `onSuggestionsVisibilityChange` does NOT fire `true` for followup or ghost text. Catches the exact shape of my regression.
|
Fixed in f66392b — accepted the split. The previous round broadened a callback that Composer was already using for a different purpose (Footer hiding); I missed that consumer in self-review. Mea culpa, this is the textbook self-inflicted regression shape. What's in the commit:
Tests:
Local validation: 150 / 77 / 14 / Composer tests all pass, lint clean, no TS errors in changed files. |
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen-latest-series-invite-beta-v28 via Qwen Code /review
…consolidate audit) Decisive dry-run evidence: a heavy PR (#4308, run 26101338786) ran solo (no endpoint contention) yet still hit the 50m timeout — the full bundled /review (9 agents incl. Agent 7 = npm ci + whole-monorepo build/test, redundant with the repo's own CI) cannot finish heavy PRs in CI. Steer the skill via the dry-run prompt to skip Agent 7 / npm ci / build / linter and consolidate the 6a/6b/6c personas into one audit. Prompt-level only; bundled SKILL.md unchanged.
…4469) * fix(core): decouple auto-memory recall from main-agent request path (#4172) * docs: add async memory recall design spec and implementation plan * refactor(core): introduce MemoryPrefetchHandle, replace pendingRecallAbortController field * refactor(core): fire memory recall as non-blocking prefetch with settledAt flag * refactor(core): replace blocking await with zero-wait settledAt poll at UserQuery consume point Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(core): inject recalled memory on first ToolResult when UserQuery consume point misses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(core): replace pendingRecallAbortController with pendingMemoryPrefetch in all cleanup paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(memory): remove 1s AbortSignal.timeout from relevanceSelector — caller controls lifetime Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): update auto-memory tests for async prefetch pattern — drop fake timers and deadline references Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): add ToolResult inject test — memory injected on first ToolResult when recall settles after UserQuery Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): address codex review findings on async memory recall Three findings fixed: 1. Abort previous prefetch before installing a new one (line 1059): A new UserQuery/Cron used to overwrite pendingMemoryPrefetch without aborting the old controller, leaking an unbounded background recall now that the 1s side-query timeout is gone. 2. Move the UserQuery consume poll AFTER the async reminder setup: ensureTool + listSubagents are awaited between the old poll location and the final assembly, so recalls that settled during those awaits used to be missed (and a tool-less turn never got a ToolResult retry). The poll now runs immediately before requestToSend assembly, and unshifts memory to the front of systemReminders to preserve ordering. 3. Append memory after functionResponse on ToolResult turns: The Qwen API requires the functionResponse part to immediately follow the model's functionCall (see lines 1209-1213). Prepending memory text risked breaking that pairing on the native Gemini path. Appending keeps the pair intact on Gemini and produces the same OpenAI output (text becomes a separate user message after the tool messages). Tests: - Updated ToolResult inject test to assert memory index > functionResponse - Added abort-previous-prefetch test (mid-flight UserQuery aborts old handle) 224/224 tests pass; tsc clean on changed files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(core): add JSDoc + clarifying comments per review feedback Annotations only, no behavior change: - MemoryPrefetchHandle: full JSDoc covering lifecycle (create → consume → discard) - UserQuery consume site: explain why we unshift (front of systemReminders) - ToolResult inject site: reference hasPendingToolCall pattern instead of brittle line numbers when citing the Qwen functionCall/Response constraint - relevanceSelector.ts: explain why the side-query has no inline timeout (caller controls lifetime via MemoryPrefetchHandle.controller) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): bridge caller abort signal into memory prefetch + doc accuracy fixes Behavior fix (addresses copilot review on client.ts:1071): - When the parent sendMessageStream signal aborts (user Ctrl-C / Esc), the prefetch controller now aborts too. Previously the recall side-query would keep running until a later cleanup (next UserQuery / /clear / etc), wasting fast-model tokens on work whose result no one would consume. - Listener uses { once: true } and is also removed in the promise's finally() so a long-lived parent signal doesn't accumulate listeners across many turns under normal completion. - Edge case: if signal is already aborted when fire runs, abort the controller synchronously instead of attaching a listener. Test: - New regression guard: "should abort the pending prefetch when the caller signal aborts" — verifies the abort handler installed on the recall side fires once the parent signal aborts. Doc accuracy (addresses copilot review on the design spec): - ToolResult inject: was documented as "prepend", actual implementation appends to preserve functionCall/functionResponse pairing. Updated both the prose summary and the code sample. - Cleanup section: was documented as 6 abort-locations including the "post-consume clear"; the consume sites don't actually abort (the promise has already settled). Reorganized as 5 abort-and-clear sites + 2 clear-only sites with the distinction made explicit. - Fire path snippet: added the abort-previous-prefetch line and the caller-signal bridge so the spec matches the current implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(core): consolidate memory-prefetch lifecycle + safety nets per round-3 review Architectural (root-cause fix for cleanup-path sibling drift): - New private cancelPendingMemoryPrefetch() consolidates the abort+clear idiom (was duplicated across 6 sites). Logs at debug when discarding a settled-but-unconsumed handle so missing-memory scenarios are diagnosable. - New private tryConsumeMemoryPrefetch() consolidates the consume-and-mark-consumed dance (was duplicated UserQuery + ToolResult). - All existing cleanup sites + the two newly-flagged early-return sites (LoopDetected, Error) now use the helper; future early-returns can rely on the finally-block safety net. - sendMessageStream try-finally now uses a `normalCompletion` flag: only the bottom-of-try return path preserves the prefetch (intentional — next ToolResult turn may consume it); every other exit (uncaught exception, abnormal early-return) goes through cancelPendingMemoryPrefetch in finally. Diagnostics: - Restored AbortError debug log in fire-path catch (was silent after removing the deadline mechanism; aborts now come from 4+ sources so a trace is valuable). - Updated stale "deadline" log in recall.ts to reflect current abort sources (caller signal / new UserQuery / cleanup / 30 s safety timeout). Safety net: - Added 30 s ceiling in relevanceSelector via AbortSignal.any(...). Generous enough that normal ~1 s recalls don't trip it; bounds zombie side-queries if the model API hangs and the caller never aborts. Replaces the uncancellable `new AbortController().signal` fallback that would have left callerless invocations running indefinitely. Doc sync: - Design doc updated: UserQuery consume code sample now shows `unshift` (matches implementation) with an inline note on the prepend-vs-append contrast. Tests: - New regression guard: resetChat aborts pending prefetch and clears the handle. - New regression guard: LoopDetected mid-stream aborts pending prefetch and clears the handle (catches the sibling-drift bug this round caught). 227/227 tests pass; tsc clean on changed files. Declined from this round: - `await Promise.resolve()` after fire path: defensive — current code has multiple natural microtask drains before consume point. Added comment documenting the dependency instead. - Renaming `settledAt: number | null` to `settled: boolean`: timestamp has diagnostic value for future instrumentation; current consumers' null-check usage is documented in the JSDoc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): correct getLastLoopType mock return type — null, not undefined CI tsc --build (stricter than --noEmit) caught: src/core/client.test.ts(2996,65): error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'LoopType | null'. getLastLoopType()'s contract returns LoopType | null; the test mock was returning undefined. Switched to null to match the type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): preserve memory prefetch across hook/next-speaker continuations + accurate recall abort log Round-4 review findings (self-inflicted regression from round-3): 1. Preserve pending prefetch on `return hookTurn` (Stop-hook continuation) and `return continueTurn` (next-speaker continuation). The round-3 `normalCompletion = true` was only set at the bottom-of-try `return turn`, leaving these two recursive-yield paths to trip the finally cleanup. When the inner Hook turn produced tool calls, the subsequent ToolResult turn found `pendingMemoryPrefetch === undefined` and memory was silently dropped. 2. recall.ts catch log distinguishes caller-driven aborts (heuristic genuinely skipped below) from the 30s safety-net timeout in relevanceSelector (the caller's signal is NOT aborted by that path, so the heuristic fallback actually runs). Regression guard added: - "should PRESERVE the pending prefetch when next-speaker continueTurn returns" — was red before this commit, green after. 258/258 tests pass; tsc --build clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(worktree): Phase C — session persistence, hooksPath, Footer + WorktreeExitDialog, three-mode --resume restore (#4174) * docs(worktree): update design doc — split Phase C/D, add Future section - Phase C: session persistence + hooksPath + StatusLine + WorktreeExitDialog - Phase D: --worktree CLI flag + symlinkDirectories - Future: sparse checkout, .worktreeinclude, tmux, PR reference parsing - Feature comparison table updated with Phase A/B completion status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(worktree): add Phase C implementation plan 8 tasks: WorktreeSession sidecar storage, hooksPath setup, EnterWorktree/ExitWorktree session wiring, useWorktreeSession hook, Footer display, --resume context injection, WorktreeExitDialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(worktree): update Phase C plan after claude-code comparison - WorktreeSession: add originalHeadCommit field - hooksPath: add .husky/ detection + skip-if-already-set logic - StatusLine payload: expand worktree field to match claude-code schema - WorktreeExitDialog: load dirty state on mount, display counts in dialog - UIState.activeWorktree: add originalCwd, originalBranch, originalHeadCommit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(worktree): add WorktreeSession sidecar storage New worktreeSessionService.ts exposes read/write/clear functions for the sidecar JSON file at <chatsDir>/<sessionId>.worktree.json. SessionService gains getWorktreeSessionPath() so callers don't need to know the layout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): configure core.hooksPath after worktree creation createUserWorktree() now sets `core.hooksPath` inside the new worktree to the main repo's hooks directory (.husky preferred, .git/hooks fallback) so commits inside the worktree run the same pre-commit checks as the main repo. Mirrors claude-code's performPostCreationSetup logic — skips the subprocess when the value already matches to avoid ~14ms spawn overhead. Failures are non-fatal: the worktree is still usable without hooks. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): persist WorktreeSession sidecar in EnterWorktreeTool After creating a worktree, EnterWorktreeTool now writes a sidecar JSON file at <chatsDir>/<sessionId>.worktree.json with the full session state (slug, paths, branches, original HEAD SHA). --resume reads this in Phase C task 7 to restore worktree context. Best-effort: write failures don't abort the creation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): clear WorktreeSession sidecar in ExitWorktreeTool After successful keep or remove, ExitWorktreeTool now clears the sidecar JSON file iff its slug matches the worktree being exited. The slug check prevents wiping the sidecar when the user exits a worktree that isn't currently tracked (multiple worktrees on disk, sidecar tracks one). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): expose active worktree via useWorktreeSession + UIState New useWorktreeSession hook watches the sidecar JSON file (created by EnterWorktreeTool, deleted by ExitWorktreeTool) and returns the current WorktreeSession or null. AppContainer wires it into a new UIState.activeWorktree field consumed by Footer (Task 6) and WorktreeExitDialog (Task 8). A showWorktreeExitDialog state placeholder is added too, hardcoded false until Task 8 wires the dialog trigger. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): show active worktree in Footer + StatusLine payload Footer renders `⎇ <branch> (<slug>)` when activeWorktree != null, but only when the user has no custom statusline (their script likely handles it from the stdin payload itself). useStatusLine's StatusLineCommandInput gains a `worktree` field with {name, path, branch, original_cwd, original_branch} — matches claude-code's schema so statusline scripts can be shared across both CLIs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): inject context hint on --resume when worktree is active On --resume, if the session has a WorktreeSession sidecar, append an INFO history item pointing the model at the worktree path so it continues using it for file operations. Stale sidecars (worktree dir deleted out-of-band) are cleaned up so the Footer indicator doesn't go stale. qwen-code can't process.chdir() the way claude-code does because Config.targetDir is immutable; the context hint is the equivalent behavioral cue. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): add WorktreeExitDialog with dirty-state inspection WorktreeExitDialog renders when the user double-presses Ctrl+C inside a worktree. On mount it runs `git status --porcelain` and `git rev-list --count <originalHeadCommit>..HEAD` to show how many uncommitted files and new commits the user would discard by choosing "Remove". The dialog never auto-removes — every exit goes through explicit user confirmation per requirements. handleExit in AppContainer intercepts the second-press quit when activeWorktree is set and shows the dialog instead. A new UIAction handleWorktreeExit(choice) routes the user's choice through removal (via GitWorktreeService.removeUserWorktree) + sidecar cleanup + /quit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(worktree): add Phase C E2E test plan Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(worktree): fix E2E test plan sidecar path + jq selector - sidecar lives at ~/.qwen/projects/<sanitized-cwd>/chats/, not ~/.qwen/tmp/<hash>/ - qwen --output-format json emits a JSON array, not NDJSON — jq needs .[] Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): add showWorktreeExitDialog to dialogsVisible Phase C task 8 introduced showWorktreeExitDialog state and the dialog render in DialogManager, but missed adding the flag to the dialogsVisible OR expression. DefaultAppLayout only renders DialogManager when dialogsVisible is true, so the dialog was never shown — second Ctrl+C in a worktree silently absorbed instead of triggering the prompt. Caught by Group E E2E tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(worktree): extend --resume context restore to headless + ACP modes Phase C task 7 originally placed the worktree-restore logic in AppContainer.tsx (TUI only). E2E Group C exposed that headless and ACP modes never run AppContainer, so stale sidecars accumulate and the model loses worktree context after --resume. Refactor to a shared `restoreWorktreeContext` helper in core, then wire the three entry points: - TUI (AppContainer): keep historyManager.addItem(INFO) UX, route via the helper. - Headless (nonInteractiveCli): prepend the notice as a system-reminder block on the user prompt; emit a `worktree_restored` system message to the JSON adapter so SDK consumers can react. - ACP (Session.pendingWorktreeNotice): set by acpAgent.loadSession on resume, consumed and cleared exactly once on the next #executePrompt. All three modes call the same helper, so stale-sidecar cleanup is consistent. Helper covers: missing sidecar, live worktree dir, deleted worktree dir, regular file at worktreePath, malformed JSON. 5 new unit tests for restoreWorktreeContext (13/13 pass total). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(worktree): add ACP-mode integration tests for --resume context Covers: - acpAgent.worktree.test.ts (3 tests): loadSession sets pendingWorktreeNotice only when worktree dir is live, clears stale sidecar otherwise, swallows restoreWorktreeContext errors. - Session.worktree.test.ts (4 tests): #executePrompt prepends the system-reminder block exactly once on first prompt, clears the pending notice, second prompt sees no leakage, no-op when nothing was set. E2E via real ACP protocol is impractical without a Zed client; these tests cover the integration boundaries directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(worktree): clarify hooksPath comment + pendingWorktreeNotice one-shot rationale Two doc-only fixes from PR #4174 review: - gitWorktreeService.ts: previous hooksPath comment overstated the optimization (claimed claude-code's ~14ms saving but we still do a read subprocess). Rewrite to be explicit: write-skip only, read retained, parseGitConfigValue's full optimization deliberately not ported because the read happens once per worktree creation. - Session.ts: pendingWorktreeNotice doc now explains why it's one-shot (after the first prompt the worktree path is already in conversation context; re-injecting would clutter history without adding signal). No behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): add getResumedSessionData to nonInteractiveCli mock Config CI surfaced TypeError: config.getResumedSessionData is not a function across 12 tests in nonInteractiveCli.test.ts. The Phase C ada0837e2 commit added a worktree-restore call in the headless path that probes config.getResumedSessionData(); the mock Config never had that method. Return undefined to short-circuit the restore block — these tests don't exercise --resume. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address PR #4174 reviewer findings Bundled response to the two review rounds. Per-thread replies follow. CORE — worktree sidecar robustness (Findings 3252368644, 3252368651, 3255171690): - atomicWriteJSON instead of fs.writeFile (no more half-written sidecar after a crash) - readWorktreeSession now schema-validates the parsed object and returns null on missing/wrong-type fields instead of propagating undefined into consumers - restoreWorktreeContext clears the sidecar on JSON parse failure / read I/O error so a corrupted file doesn't block every subsequent --resume CORE — hooksPath setup (Finding 3252368645): - configureHooksPath distinguishes ENOENT (benign "candidate not present") from real stat errors (EACCES/EIO/ENOTDIR); the latter are warn-logged so a silently-degraded hooksPath is visible to operators CLI — handleWorktreeExit Remove path (Findings 3252368637, 3252368640 a+b): - Anchor GitWorktreeService at activeWorktree.originalCwd (the captured repo root), not config.getTargetDir() — fixes monorepo-subdirectory launches where the worktree lives under the repo root but getTargetDir points at a subpackage - Check removeUserWorktree return value; on failure, leave the sidecar intact so --resume can recover (previous code cleared it regardless) - Pass forceDeleteBranch:true to honour the dialog's "discards N commits" label — without it `git branch -d` refused unmerged commits and the branch was silently preserved CLI — useWorktreeSession watcher (Finding 3252368648): - Normalize fs.watch filename via toString() so the Linux-Buffer code path triggers reloads (previous comparison silently never matched) - Treat null filename as "unknown, reload to be safe" (recursive watchers on some platforms emit events without a payload) CLI — WorktreeExitDialog (Findings 3252368650, 3255171694): - execGit now correctly reads numeric exit codes from .code/.status (NodeJS.ErrnoException.code is a string for spawn errors, number for subprocess exits); previous typeof === 'number' check always missed - Dialog body shows an "⚠ Could not measure worktree state (...)" banner when git status / rev-list failed, so the user doesn't see a misleading "0 files, 0 commits" before choosing Remove CLI — closeAnyOpenDialog (Round 2 review body): - Wire WorktreeExitDialog into the standard dialog-dismissal path so Ctrl+C dismisses it the same way it dismisses every other dialog TEST FIXES — vitest timeouts: - Real git invocations + user-global hooks (e.g. trustup post-commit webhooks) can take 10–20s per setUp on CI. Bump testTimeout + hookTimeout to 30s for the three integ test suites that spawn git (Phase B/C worktree integ tests) so the suite isn't flaky. NEW TESTS: - worktreeSessionService.test: 3 new cases covering malformed JSON, missing required fields, wrong-type fields, malformed sidecar cleanup, partial sidecar cleanup (16 total, up from 13). - useWorktreeSession.test.tsx: 4 new cases — null when no sidecar, parsed sidecar at mount, reacts to delete, reacts to creation. - WorktreeExitDialog.test.tsx: 1 new case — loading frame renders before git probes resolve. (Async dialog states tested via E2E — vi.mock of execFile in ink-testing-library doesn't fire mock impl reliably.) - nonInteractiveCli.test: 3 new "Phase C --resume" cases — system-reminder injection on live worktree, no injection when sidecar absent, stale sidecar cleanup when worktree dir is gone. DECLINED FINDINGS (replied on threads): - 3252368642 (Dialog Keep clears sidecar) — declined-design. Dialog Keep = "exit app, keep worktree for next --resume"; tool Keep = "I'm done with this worktree". Intentionally different semantics. - 3252368643 (originalHeadCommit base branch) — false-positive. There is no base_branch parameter; getCurrentCommitHash() returns HEAD which equals the tip of the current branch (== baseBranch in createUserWorktree). - 3252368640 part c (bypass safety guards) — declined-design. The dialog IS the safety affordance for this path — it shows dirty-state counts and asks for explicit user confirmation before removal. - 3255171696 (DialogManager async fire-and-forget) — false-positive. handleSlashCommand('/quit') is inside the await chain in handleWorktreeExit, so the described race ("process.exit before remove completes") cannot occur. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): correct linter-mangled imports in useWorktreeSession.test Pre-commit hook auto-fixed imports collapsed value imports (writeWorktreeSession, clearWorktreeSession) into an `import type` block, breaking runtime resolution. Split back into value + type imports. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): normalize path separators for Windows in worktree session integ Windows CI failure: `repoRoot` from Node's `fs.mkdtemp` returns backslash-separated paths (`C:\Users\runneradmin\…`), but `originalCwd` in the sidecar comes from `getRepoTopLevel()` which delegates to `git rev-parse --show-toplevel` — git on Windows returns forward slashes (`C:/Users/runneradmin/…`). The Windows-only assertion `expect(originalCwd).toBe(repoRoot)` was comparing two different representations of the same canonical path and rightly failed on `Object.is` equality. Compare via path.normalize on both sides so the assertion holds across platforms without changing the runtime path (originalCwd still records git's output verbatim, which is what consumers expect since other places in the codebase that read `getRepoTopLevel()` also work with that shape). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address PR #4174 round 4 findings Finding #3256237933 (Critical, follow-up to #3252368640 part 1): handleWorktreeExit silently /quit'd when removeUserWorktree returned {success:false}, contradicting the user's intent after they clicked "Remove worktree and branch (discards N commits, M files)". Now surfaces an ERROR history item with the underlying error message and STAYS in the session so the user can decide what to do (retry via exit_worktree, fix the lock/permission/corruption issue, or quit anyway). Same treatment applied to the hard-failure catch block — previously it caught the throw and proceeded to /quit with no log; now it emits the error and stays alive. Finding #3256236050 (Nit): originalCwd field name implies "user's launch cwd" but actually stores `getRepoTopLevel()` (different in monorepo subdir launches — the gap closed by #3252368637). Renaming the field would force on-disk migration of every existing sidecar (every active --resume breaks until users wipe the old file). Doc-only fix: WorktreeSession.originalCwd now carries an explicit JSDoc explaining the semantics and warning consumers expecting process.cwd() to NOT use this field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address PR #4174 round 5 findings Finding #3256241831 (Nit, but awareness UX): the built-in `⎇` indicator used to disappear whenever `statusLineLines.length > 0`, on the assumption that the user's custom statusline rendered worktree itself. That assumption is unsafe — scripts written before Phase C don't know about `payload.worktree`, scripts can deliberately ignore the field, and partial scripts may render some fields but not worktree. In any of those cases the user sees no worktree UI while having an active worktree, risking destructive operations in the wrong cwd. New behavior: indicator shows by default regardless of statusline. Added an opt-out setting `ui.hideBuiltinWorktreeIndicator` (default false) for users whose custom statusline already renders worktree and want to avoid duplication. Finding #3256239608 (Nit): `fs.watch` in useWorktreeSession holds an inode handle to `chatsDir` at mount time. If the directory is deleted out-of-band (manual cleanup, antivirus quarantine, reset scripts) and recreated, the watcher does NOT re-attach to the new inode and the Footer indicator stops reacting to sidecar changes. Reviewer explicitly accepted this as a documented limitation rather than adding polling-fallback or error-event-handler complexity for an edge case that doesn't arise in normal use. Added a JSDoc block on the hook explaining the limitation and pointing to the future fix shapes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(worktree): regenerate settings.schema.json for hideBuiltinWorktreeIndicator CI Lint step caught that the JSON schema mirror in packages/vscode-ide-companion was out of date after adding the new ui.hideBuiltinWorktreeIndicator setting in 80f9cb495. Regenerated via `npm run generate:settings-schema`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(worktree): address PR #4174 round 6 findings Critical fixes: - #3259975247: TUI dialog Remove now reads the in-worktree session marker and refuses to delete a worktree owned by a different session — same ownership guard ExitWorktreeTool already applies. Stale/copied sidecars can no longer destroy another session's work. - #3259975249: TUI --resume queues a one-shot pendingWorktreeNotice ref consumed by handleFinalSubmit; the user's first prompt is prefixed with the same <system-reminder> block headless/ACP use. Previously only the INFO history item showed in the transcript (UI-only), so resumed models could silently edit the parent checkout. - #3259975245: exit_worktree action='keep' no longer clears the sidecar. `keep` means "preserve the worktree for later"; clearing the persisted binding broke --resume / Footer / WorktreeExitDialog for kept worktrees. Now matches the Dialog keep semantics. Test updated to assert preservation instead of clearing. - ACP unstable_resumeSession parity: factored the worktree restore block into #restoreWorktreeOnResume() and called from both loadSession() and unstable_resumeSession(). ACP clients using resume no longer miss the worktree context. Suggestion-level fixes: - #3259975237: configureHooksPath now resolves the canonical hooks dir via `git rev-parse --git-common-dir` instead of constructing `<sourceRepoPath>/.git/hooks`. The construction assumed .git is a directory, but when Qwen runs from a linked worktree it's a file pointing at the real gitdir → ENOTDIR → silent no-hooks worktree. - #3259975242: only writes core.hooksPath when the key is unset. A non-empty inherited or user-configured value is preserved instead of being silently replaced. - #3256839787: restoreWorktreeContext adds a structural invariant check — worktreePath must live under <originalCwd>/.qwen/worktrees/. A tampered/copied sidecar pointing at an arbitrary existing dir is rejected and cleared so the model can't be redirected. Tests: - worktreeSessionService.test: 17/17 (added prefix-escape rejection case + restructured the existing live-worktree case to satisfy the new structural invariant). - exit-worktree.session.integ.test: rewrote keep test to assert preservation (matches new behavior). - nonInteractiveCli.test: updated fixture worktreeDir to live under <originalCwd>/.qwen/worktrees/ for the prefix invariant. - All other suites pass without modification. Test coverage gap acknowledgement (no comment_id reply): per-handler unit tests for handleWorktreeExit + dialog post-load states remain covered by the E2E Group E suite in docs/e2e-tests/worktree-phase-c.md. The execFile mock path in ink-testing-library still doesn't deliver async useEffect state transitions reliably, so unit testing those states adds more harness than signal; deferring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): apply defaultModalities() on env-var-only model config (#4219) (#4262) * fix(core): apply defaultModalities() on env-var-only model config (#4219) When qwen-code is configured only via env vars (OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL) with no modelProviders entry, resolveGenerationConfig() never invoked defaultModalities(), so generationConfig.modalities stayed undefined for image-capable models. The two other config paths (modelRegistry.resolveModelConfig and modelsConfig.applyResolvedModelDefaults) already call it. This aligns the env-var-only path with both so multimodal models like qwen3.6-35b-a3b correctly accept @image attachments. Fixes #4219 * test(core): lock modalities fallback invariants on env-var-only path Address review feedback on PR #4262: - Strengthen the positive regression test to also assert video:true and source kind ('computed'), matching the source-tracking convention used elsewhere in this file and catching regex regressions in modalityDefaults. - Add negative case: unknown model → modalities resolves to {} (text-only), never undefined — the key invariant introduced by the fix. - Add negative case: explicit settings.generationConfig.modalities is not clobbered by the fallback (lock the `=== undefined` guard). - Extend the fallback's comment to document the undefined → {} semantic so future maintainers don't reintroduce `modalities === undefined` branches. No behavior change. * test(core): pin Qwen OAuth modalities auto-detect for coder-model Round-2 review feedback on #4262: `resolveGenerationConfig` is shared by both the OpenAI/env-var-only path and `resolveQwenOAuthConfig`, which passes `resolvedModel` (defaults to 'coder-model') as modelId. So the new modalities fallback also activates for Qwen OAuth — a real behavior change (was undefined, now { image: true, video: true }). The change is desired (coder-model supports vision per the existing warning text in resolveQwenOAuthConfig), but no test pinned it down. Add a regression test so future MODALITY_PATTERNS edits can't silently shift Qwen OAuth behavior. * fix(cli): block Windows Tab approval-mode toggle when input has a Tab consumer (#4308) * fix(cli): block Windows Tab approval-mode toggle when input has a Tab consumer Closes #4171. On Windows, Shift+Tab is indistinguishable from a bare Tab in many terminals, so useAutoAcceptIndicator accepts a bare Tab as the approval-mode cycle shortcut. To avoid double-firing with the input area, AppContainer passes a `shouldBlockTab` callback that suppresses the cycle when the input has its own Tab handler. Until now that callback only tracked the autocomplete dropdown (`shouldShowSuggestions`). When the buffer was empty and the followup prompt-suggestion ("input prediction") was visible, pressing Tab on Windows accepted the suggestion *and* cycled approval mode at the same time — the exact behaviour reported in #4171. The mid-input ghost-text and reverse/command-search paths had the same gap. Broaden the signal: compute `hasTabConsumer` from every Tab consumer inside InputPrompt — autocomplete dropdown, followup suggestion, mid-input ghost text, reverse-search, command-search — and feed that into `shouldBlockTab`. A single Tab keystroke now triggers exactly one action on Windows; macOS and Linux behaviour is unchanged. Tests cover the four states (followup visible, ghost text visible, autocomplete visible, idle). * fix(cli): tighten hasTabConsumer, add unmount cleanup + tests (#4308 review) Three review findings on PR #4308 addressed together — all touch the same `hasTabConsumer` signal surface exposed from InputPrompt to AppContainer. 1. **Tighten signal semantics (Copilot)**: drop the standalone `reverseSearchActive || commandSearchActive` terms. When those overlays have matches, their `showSuggestions` flag already flows into `shouldShowSuggestions` and Tab is consumed via `ACCEPT_SUGGESTION_REVERSE_SEARCH`. When they're active without matches, Tab is NOT consumed — including the bare flags misrepresented the signal as "Tab consumer present" when it really meant "modal overlay open". `hasTabConsumer` now strictly matches its name. 2. **useEffect cleanup on unmount (wenshao)**: previously, if any Tab consumer was active when InputPrompt unmounted (e.g. streaming begins while autocomplete is open), AppContainer's `hasTabConsumer` state retained the stale `true` value and kept blocking Windows Tab approval-mode cycling for the entire unmount window. Effect now resets to `false` on cleanup. The pre-existing code had the same gap with one trigger; expanding to 3 triggers materially raised the likelihood. 3. **JSDoc on prop name (wenshao)**: `onSuggestionsVisibilityChange` now carries broader "Tab consumer" semantics than the name suggests. Cross-file rename across UIActionsContext + Composer + AppContainer is too much churn for #4308's scope; add JSDoc on the prop declaration documenting the broader signal and that the name is retained for backward compatibility. 4. **Test coverage (wenshao)**: add two tests — autocomplete dismissal reports `false` (true→false transition); unmount-while-active reports `false` (cleanup regression guard). * fix(cli): split Tab-consumer signal so it doesn't hide Footer (#4308 review) Self-inflicted regression caught by wenshao: the previous round broadened `onSuggestionsVisibilityChange` from "autocomplete dropdown visible" to "any Tab consumer present", but Composer.tsx was using that same callback for a different purpose — hiding the Footer / KeyboardShortcuts when the dropdown would overlap their vertical space. As a result, followup prompt suggestions and mid-input ghost text (both inline within the input box, neither competing for vertical space) were also hiding the Footer on every platform. Split into two signals: - `onSuggestionsVisibilityChange` — narrow, autocomplete dropdown only. Kept local to Composer for Footer hiding. Restored to pre-PR semantics; no cleanup-on-unmount needed (the entire conditional in Composer.tsx is already gated by `uiState.isInputActive`, which goes false when InputPrompt unmounts). - `onTabConsumerChange` — broad, any input-side Tab consumer (autocomplete + followup + ghost text). Plumbed through UIActionsContext to AppContainer's `hasTabConsumer` state → useAutoAcceptIndicator's `shouldBlockTab`. Retains the cleanup-on-unmount wenshao added last round (the broad signal IS read while InputPrompt is unmounted). Tests: - All 6 broad-signal regression tests renamed to assert `onTabConsumerChange`. - 3 new narrow-signal regression tests pin that `onSuggestionsVisibilityChange` does NOT fire `true` for followup or ghost text. Catches the exact shape of my regression. * fix(core): mirror Qwen3 reasoning on outbound history (#4294) * feat(core): extend cross-auth fast models to agents (#4153) * feat(core): extend cross-auth fast models to agents * fix(core): tighten cross-auth model resolution fallbacks When a forked-agent caller passes a selector that cannot resolve (e.g. `fast` with no fast model configured), fall back to the parent session model instead of forwarding the raw selector string to the provider. Matches the subagent path, where unresolvable selectors mean "inherit parent". In BaseLlmClient.createContentGeneratorForModel, do not cache the unregistered-model fallback. getCurrentContentGenerator() reads the runtime view from AsyncLocalStorage, which can differ between calls; caching would pin the first call's view-bound generator under the selector key and reuse it on later calls after that view has unwound. * docs(core): drop stale getFastModelForSideQuery from sideQuery JSDoc The function was removed when fast-model resolution collapsed onto getFastModel(); the JSDoc fallback chain still mentioned it. * feat(cli,core): add Auto approval mode with LLM classifier (#4151) * feat(cli,core): add Auto approval mode with LLM classifier (#auto-mode) Add a fifth approval mode positioned between Auto-Edit and YOLO that uses an LLM classifier to evaluate each tool call and auto-approve safe ones while blocking risky ones — letting agents work autonomously on long sessions without forcing users to confirm every shell/network call. Three-layer filter when L4 returns 'ask'/'default': L5.1 acceptEdits fast-path: Edit/Write inside workspace -> allow L5.2 safe-tool allowlist: Read/Grep/LS/TodoWrite/... -> allow L5.3 LLM classifier: two-stage (fast/thinking) via sideQuery Anti-injection: assistant text and tool results are stripped from the classifier transcript; each tool projects its args through a new `toAutoClassifierInput` method to redact sensitive/voluminous fields. Pending action is rendered as a user-role text turn so it survives the OpenAI Chat Completions converter (which drops orphan tool_calls). Safety: fail-closed on classifier failure; denial-tracking caps 3 consecutive blocks / 2 consecutive unavailable before falling back to manual confirmation; dangerous allow rules (Bash interpreter wildcards, any Agent/Skill allow) are temporarily stripped while in AUTO and restored on exit — settings.json is never modified. Config: --approval-mode auto # CLI flag tools.approvalMode: "auto" # settings.json permissions.autoMode.hints.{allow,deny}: string[] # natural-lang permissions.autoMode.environment: string[] * chore(schema): regenerate settings.schema.json after adding tools.approvalMode 'auto' The autogenerated VS Code settings schema was out of sync with the runtime SETTINGS_SCHEMA after the AUTO mode addition; CI's Lint job caught the drift. No behavior change — this is purely the regenerated output of `npm run generate:settings-schema`. * test(cli): update expected error message after adding 'auto' to approval-mode choices Two tests in `loadCliConfig`'s error-path coverage hard-coded the list of valid approval modes in the expected error string. Add `auto` to match the runtime message produced by the new five-mode enum. * test(core): fix autoMode test fixture on Windows The fixture's mock isPathWithinWorkspace used path.sep to join the root prefix, but the hard-coded test paths use forward slashes regardless of OS. On Windows path.sep is '\\', so prefix matching failed and L5.1 fast-path tests returned false (and the L5.1-gating test then fell into the classifier branch, hitting an undefined getToolRegistry mock). Hard-code '/' in the fixture — it controls only intra-file consistency between mock roots and mock paths, not real workspace behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,core): three asymmetries surfaced by self-review of PR #4151 ACP path (Session.ts) had two asymmetries with the CLI scheduler that silently degraded AUTO behavior, and the classifier transcript builder left historical tool_use calls vulnerable to the OpenAI converter's orphan-tool_call filter on the default Qwen / DashScope backend. 1) ACP runs the classifier even when finalPermission === 'allow' The CLI scheduler short-circuits when L4 returned 'allow' (user- explicit rule matched) so the classifier never sees the call. The ACP duplicate only short-circuits on 'deny'. Mirror the scheduler: set autoModeAllowed = (finalPermission === 'allow') before the AUTO L5 block. Without this, a user-written `Bash(git push *)` allow rule in an ACP session could reach the classifier and be blocked by a conservative Stage-1 verdict. 2) ACP never records a successful fallback approval When the denialTracking streak forced fallback, ACP correctly dropped into requestPermission — but after the user approved, the streak was never reset. consecutiveBlock stayed at 3, so every subsequent call re-fell into fallback. The session was permanently downgraded to manual approval until the mode toggled. Add the post-outcome recordFallbackApprove call paralleling coreToolScheduler.ts:1705- 1717 (approve outcomes only; cancel/abort preserve the streak). 3) Classifier transcript: historical functionCalls become orphans on OpenAI-compatible backends buildClassifierContents kept model.functionCall parts but stripped tool results entirely (anti-injection). On Anthropic-native APIs that's fine, but the OpenAI Chat Completions converter (converter.ts:1422-1455) filters out tool_calls without a matching tool response, and since the assistant message has no text content either, the entire turn gets dropped. The classifier on Qwen / DashScope ended up seeing only user prompts plus the pending action — zero record of prior tool actions in the chain. Match ClaudeCode's `buildTranscriptEntries` (yoloClassifier.ts): render every historical model.functionCall as a user-role text turn ("Prior action: tool(args)") projected through toAutoClassifierInput. The result contains only user-role text — no functionCall parts, no assistant tool_calls — so it is converter-agnostic by construction. Tests updated to assert the new shape and added a regression guard verifying no functionCall part survives anywhere in the output. ACP fixes have no new unit tests: their logic is mechanically symmetric with the CLI scheduler branch, the underlying recordFallbackApprove state machine is covered by denialTracking.test.ts, and adding ACP integration tests for these two-to-four-line branches would dwarf the fix itself. The fix correctness is verifiable from the diff against the existing scheduler comparison. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): recordFallbackApprove resets BOTH consecutive counters Asymmetry caught by copilot[bot] on PR #4151: the original implementation only cleared consecutiveBlock when the user approved a fallback prompt, leaving consecutiveUnavailable at its threshold. A transient classifier API blip (2 consecutive unavailable verdicts) therefore permanently downgraded the rest of the session to manual approval — even after the user explicitly approved the prompt — because every subsequent shouldFallback() call kept seeing the {reason: 'consecutive_unavailable'} branch. The fix mirrors recordAllow: a manual approval signals the user accepted the action and the next call should re-engage the classifier. If the API is still degraded, the next call simply re- arms the counter (one unavailable / one block), same recovery curve as initial onset. No permanent lock-out, and the documented "Counter resets on user approve or mode switch" behavior from the PR body now actually holds for both reasons. Existing test 'does not reset consecutiveUnavailable' was codifying the bug — replaced with three positive cases (unavailable recovery, total-counter preservation as telemetry, and the no-op guard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,core): address PR #4151 review findings (defense-in-depth + sibling-drift) 20 findings from reviewers wenshao (gpt-5.5 / deepseek-v4-pro / mimo-v2.5-pro) on PR #4151. Triaged through the five-filter framework, accepted findings clustered into four root-cause groups + a misc group. A) Sibling drift: AUTO mode missing in entry-point allowlists - packages/core/src/agents/background-agent-resume.ts — `normalizeApprovalMode` now accepts `'auto'`; `reconcileResumedApprovalMode` now treats `'auto'` as privileged (downgrade in untrusted folder). - packages/cli/src/nonInteractive/control/controllers/permissionController.ts — `validModes` for `set_permission_mode` includes `'auto'`; the non-interactive tool-permission switch handles AUTO (delegates to the scheduler's classifier). - packages/cli/src/config/config.ts — non-interactive deny-list switch adds an AUTO arm that mirrors PLAN/DEFAULT (no fallback UI available). - packages/sdk-typescript/{types/protocol,types/queryOptionsSchema}.ts — `PermissionMode` and the SDK `permissionMode` zod enum accept `'auto'`. - packages/vscode-ide-companion/* — `ApprovalModeValue`, `ApprovalMode` enum, `APPROVAL_MODE_MAP`, `APPROVAL_MODE_INFO`, `APPROVAL_MODE_VALUES`, and all ACP-session mode unions now include AUTO. B) Sub-agent AUTO path (architectural) - agent.ts: untrusted-folder guard in `resolveSubagentApprovalMode` now blocks the `AUTO` privileged mode the same way it blocks YOLO / AUTO_EDIT. - agent.ts: `createApprovalModeOverride(_, AUTO)` now triggers `PermissionManager.stripDangerousRulesForAutoMode()` on the shared manager, so the override path matches the top-level entry path. - agent.ts: `AgentTool.toAutoClassifierInput` forwards the full prompt (was truncated to 200 chars, which hid attack payloads past character 200 from the classifier while the sub-agent received the full text). C) Sibling drift: dangerous-rule surface - dangerousRules.ts: interpreter list expanded with php / lua / julia / R / rscript / groovy / awk / pwsh / cargo / npm / pnpm / yarn / make / gradle / mvn / rake / just / eval / exec / source. Token-based detection now catches multi-word interpreter subcommands (`bun run *`, `npm run *`), absolute-path forms (`/usr/bin/python3 *`), and Monitor-tool allow rules with the same logic. Literal concrete commands (`Bash(npm test)`, `Bash(python script.py)`) are NOT flagged. - permission-manager.ts: `addSessionAllowRule` / `addPersistentRule` now stash newly added dangerous allow rules into `strippedAllowRules` while in AUTO mode, instead of letting an "Always allow" choice on a fallback prompt persist a broad rule that bypasses the classifier. - tools/tools.ts: default `toAutoClassifierInput` returns `''` (the no-security-relevance sentinel) instead of `undefined` (which fell through to raw args). Third-party MCP tools no longer leak raw parameters — potentially API keys, tokens, file contents — into the classifier LLM prompt by default. Internal tools that need their args inspected for safety override the method explicitly. D) Classifier defense-in-depth (architectural) - autoMode.ts: `send_message` removed from SAFE_TOOL_ALLOWLIST so the classifier sees destination + body and can judge inter-agent steering. - autoMode.ts: when `pmForcedAsk=true` (user wrote an explicit ask rule), the function now returns `{ via: 'fallback' }` instead of falling through to the classifier — honoring the documented "ask rules force manual confirmation" guarantee. - classifier.ts: new `sanitizeClassifierReason` strips angle-bracket pseudo-tags, collapses whitespace, and clamps length to 200 chars; applied at the stage-2 boundary so `decision.reason` cannot smuggle a `<system>...` payload into the main model's tool-error message. - classifier.ts: `buildClassifierContents` / `buildClassifierSystemPrompt` are now wrapped in a try/catch that funnels to the existing `failClosed` handler, so any pathological input (circular projected args, registry lookup error, …) becomes an `unavailable=true` block result instead of crashing the tool-execution loop. - classifier-transcript.ts: transcript now truncates to the most recent 40 messages so long autonomous sessions don't overflow the fast classifier's context window — which would otherwise tip the session into the `consecutive_unavailable` fallback after two overflow-induced failures. E) Misc - coreToolScheduler.ts + Session.ts: `finalPermission === 'allow'` path now calls `recordAllow` in AUTO mode so an explicit allow-rule match resets the denialTracking streak (otherwise a 3-block streak would silently force the next classifier-eligible call into manual approval right after an allow-ruled call just worked). - useAutoAcceptIndicator.ts: mount-time effect emits the first-time AUTO information notice + stripped-rules notice when the session starts already in AUTO (`--approval-mode auto` flag or `tools.approvalMode: "auto"` in settings). Previously the notices only fired on Shift+Tab / `/approval-mode` switches. Test updates: - permissions/autoMode.test.ts: SAFE_TOOL_ALLOWLIST snapshot updated (no longer contains send_message). pmForcedAsk regression test now asserts the new `via: 'fallback'` semantics. - permissions/dangerousRules.test.ts: 25 new cases covering extended interpreter list, multi-word subcommands, absolute paths, and Monitor tool. - tools/toAutoClassifierInput.test.ts: AgentTool now asserts full- prompt passthrough rather than 200-char truncation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vscode-ide-companion): include 'auto' in NEXT_APPROVAL_MODE cycle The cycle map in `acpTypes.ts` is typed as `{ [k in ApprovalModeValue]: ApprovalModeValue }`. After adding `'auto'` to `ApprovalModeValue` in the previous commit, this map became missing the `auto` arm — caught by CI's tsc check (`error TS2741: Property 'auto' is missing`). Add it between `auto-edit` and `yolo` so the cycle order remains plan → default → auto-edit → auto → yolo → plan, matching the core APPROVAL_MODES ordering. Local lint/typecheck only — not introduced or surfaced by review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): silence two CodeQL findings on PR #4151 CodeQL 223 — Incomplete multi-character sanitization (packages/core/src/permissions/classifier.ts:258) A single `/<[^>]*>/g` pass can leave residual angle-brackets when the input is crafted to overlap (e.g. `<scr<script>ipt>`). In our actual use case the sanitized string is a prompt fragment, not HTML output, so a "reconstituted script tag" doesn't matter — but iterating the strip until the string stabilises is cheap defense-in-depth and removes the warning. Bounded by 8 iterations so the loop is always O(n) regardless of how the attacker structures the input. CodeQL 222 — Polynomial regex on uncontrolled data (packages/core/src/permissions/dangerousRules.ts:93) The regex `/[*]+$/` is actually linear (single-character class + `$` anchor, no backtracking), but CodeQL flags any `replace(<regex>, ...)` applied to user-controlled input. Replace the regex with a manual trailing-`*` strip via `slice` + a counted loop — same semantics, no regex engine involved, warning cleared. Existing tests cover both branches (classifier transcript sanitizer test suite, dangerousRules interpreter coverage). No regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,core,docs): address 4 non-blocker findings from PR #4151 review Top-level review on c5cf60ee8 declared "可以合并" (good to merge) but flagged 5 non-blocker items. Four are mechanical / low-cost; the fifth (thresholds → config) is intentionally deferred — see review reply. 1. docs/users/features/auto-mode.md:223 The "agent classifier sees first 200 chars of prompt" line was a stale leftover from before the truncation was removed (the AgentTool.toAutoClassifierInput regression guard now asserts full- prompt passthrough). Updated to describe the actual behavior plus the safety rationale (same shape as run_shell_command forwarding the full command). Also expanded the projection table with a note that MCP tools default to argument-stripped projection — pairing with the Limitations addendum below. 2. coreToolScheduler.ts:1425 + Session.ts:1945 The unavailable error message was overwriting `failClosed`'s classified reason ('Conversation transcript exceeds classifier context window' / 'Classifier prompt construction failed' / etc.) with a generic "blocked for safety" line. Operators lose the diagnostic distinction. Both sites now append the original reason in parentheses when present: 'Auto mode classifier unavailable; action blocked for safety (Classifier stage 1 unavailable - …)'. 3. permission-manager.ts:771 The session branch of the dangerous-rule stash didn't dedupe by raw string, while the persistent branch did. A user repeatedly clicking "Always allow" on the same fallback prompt would have piled duplicate stash entries that all activate on AUTO exit. Mirror the persistent-branch dedup. 4. docs/users/features/auto-mode.md (Limitations) Added a bullet making MCP-tool conservative-blocking explicit: third-party tools that haven't overridden toAutoClassifierInput show only their name to the classifier, so most calls will be blocked unless the user has written an explicit allow rule. This was a deliberate fail-closed choice from the previous round, but users wouldn't predict it without documentation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli,core): inline classifier reason inside unavailable message Minor nit from review on a3138cf5d: the previous wording put the specific failClosed reason at the tail — "unavailable; action blocked for safety (Conversation transcript exceeds classifier context window)" — which separates the reason from the "unavailable" context. wenshao's suggested wording inlines the reason right after the noun it qualifies: "Auto mode classifier unavailable (Conversation transcript exceeds classifier context window); action blocked for safety". Both forms preserve the diagnostic content. The inlined version reads more naturally for operators scanning a tool-error trace. Mirror the change in the ACP Session.ts path so CLI and ACP keep parallel diagnostic shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli,core): address 10 review findings from PR #4151 round 4 Two reviewers (DeepSeek/deepseek-v4-pro + qwen-latest-series-invite- beta-v28, both via wenshao /review) flagged 12 inline + 2 out-of-scope findings. 11 accepted and fixed; 1 partially declined (L5 integration tests — see classified reply). Grouped by root-cause class: # Class A — missing tool projections (sibling-drift sweep) `SendMessageTool`, `MonitorTool`, `CronCreateTool` all reach the classifier in AUTO (not on the allowlist, L3 default 'ask') but had no `toAutoClassifierInput` override. The base default returns `''` → `projectFunctionArgs` maps to `{}` → classifier sees just the tool name. For `send_message` this was particularly bad: it was intentionally REMOVED from the safe allowlist in an earlier round so the classifier could inspect message content, but the classifier ended up seeing zero arguments anyway. - send-message: + getDefaultPermission='ask' (was inheriting 'allow' from BaseToolInvocation, so the scheduler auto-approved at L4 before L5 ran) + toAutoClassifierInput forwarding task_id+message. - monitor: toAutoClassifierInput forwards command+directory (same shape as ShellTool — classifier needs the actual command). - cron-create: toAutoClassifierInput forwards cron+prompt+recurring (the scheduled prompt runs against the agent at fire-time, so the classifier must see what the agent will be asked to do). # Class B — client.toPermissionMode missing AUTO arm SessionStart hooks in AUTO mode were silently receiving `permission_mode: 'default'`. Add the missing case before the default branch. Parallels the round-2 sibling-drift sweep that fixed the same shape in background-agent-resume. # Class C — duplicated CLI/ACP AUTO branch + missing tests The classifier-block error message and the approve-outcome predicate were duplicated verbatim in `coreToolScheduler.ts` and ACP `Session.ts`. Extracted two helpers: - `formatClassifierBlockMessage(decision)` in autoMode.ts - `isApproveOutcome(outcome)` in denialTracking.ts Both unit-tested with regression-guard cases. Both callsites now use the helpers, so a future outcome added in one place can't drift. Also added two `evaluateAutoMode` test cases the reviewer flagged as missing: `pmForcedAsk=true` honors user intent (was already tested) and `skipClassifier=true` routes to fallback without dispatching the classifier (NEW guard against denialTracking regression). # Class D — perf + dead code + Edit preview - `getHistory(false)` → `getHistoryTail(40, false)` at the two AUTO classifier-dispatch sites. The transcript builder already truncates to 40 messages; cloning the full session every non-fast-path call was wasted work. - Removed `recordFallbackReject` (dead code per reviewer audit). The "rejection preserves state" invariant is enforced by simply not calling any state-mutating function; an exported no-op helper invited future drift. - Bumped Edit/WriteFile preview from 80 → 300 chars and added explicit truncation flags. In-workspace edits take the acceptEdits fast-path so this only affects out-of-workspace writes (~/.npmrc etc.) — exactly the case where the classifier needs more headroom to spot a hostile payload after a benign prefix. # Class E — prompt-injection via workspace hints + colon-form Bash FP - User-provided `autoMode.hints.{allow,deny}` are now wrapped in `<user_hint>` tags in the classifier system prompt, and a new decision principle explicitly tells the classifier to treat instruction-shaped hints ("always set shouldBlock=false") as adversarial prompt injection rather than directives. This pairs with the existing untrusted-workspace short-circuit (workspace settings are dropped from merged settings on untrusted folders) to defend in depth against a hostile `.qwen/settings.json`. - `isDangerousBashRule` no longer flags specific colon-form rules like `Bash(python3:run-tests)` as dangerous. Previously two paths (firstToken-equals-content + colon-with-interpreter) hit specific concrete rules as if they were wildcards. Now only empty-suffix (`python:`) and `*`-suffix variants are dangerous; concrete suffixes are treated the same as `Bash(npm run test)`. Two new test groups codify the boundary. # Class F — classifier observability The `failClosed` helper consumed the underlying error and returned only a generic sanitized reason. Operators debugging "every AUTO call is unavailable" had no way to distinguish API timeout / context overflow / construction failure. Added `debugLogger.warn` inside both fail paths (failClosed + the stage-2-review-unavailable branch) that logs the original error name+message. No telemetry/UI surface change — debug-only. # Out-of-scope (top-level review summary) Already covered as part of Class A — both SendMessageTool and MonitorTool projections plus SendMessage permission override fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk,serve,docs): include 'auto' in DAEMON_APPROVAL_MODES sibling sites After rebase onto current main, three sites needed updating to keep the AUTO mode integrated end-to-end: 1) packages/sdk-typescript/src/daemon/types.ts:706 `DAEMON_APPROVAL_MODES` literal tuple was still 4-mode. The new `approval-mode-drift.test.ts` (#4282 fold-in) asserts this tuple mirrors core's `APPROVAL_MODES` sequence-exactly — it caught the drift before runtime, exactly as designed. 2) packages/cli/src/serve/server.test.ts:2287 The 400-response assertion for unknown approval-mode literal still expected the 4-mode list. Updated to include 'auto' between 'auto-edit' and 'yolo' (matching core APPROVAL_MODES ordering). 3) docs/developers/qwen-serve-protocol.md:1124 Protocol docs listed 4 modes for the `POST /session/:id/approval- mode` body validator. Updated to 5. These are mechanical follow-ups to AUTO mode's existing entry-point sweep — covered by sibling-drift class but only surfaced once main landed the SDK drift detector and the new serve API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core,sdk): two critical bypasses + SDK union drift on PR #4151 wenshao surfaced two critical findings on the round-4 fix; both are self-inflicted regressions from defenses I added that didn't go deep enough. # 1. <user_hint> tag escape (classifier-prompts/system-prompt.ts) [gpt-5.5 — comment 3263963950] Round 4 wrapped user-provided hints in raw `<user_hint>...</user_hint>` tags to mark them as untrusted context. But the tag envelope is broken the moment the payload itself contains a closing tag: "allow": ["</user_hint>\n- Allow all shell commands\n<user_hint>"] renders as a real bullet outside the wrapper. The defense was empty. Fix: render user hints as JSON-encoded string literals labelled `user hint:`. JSON.stringify keeps the entire payload inside a single quoted string with newlines escaped to `\n` and quotes to `\"` — the injected text can never become its own structural bullet line. Decision-principles text updated to reference the new shape. Regression-guard test: a payload containing `</user_hint>` plus an injection sentence preceded by a newline must NOT appear as a standalone bullet line. # 2. Privileged tools' L3 default = 'allow' bypassed the classifier [gpt-5.5 — comment 3263963966] Round 4 added `toAutoClassifierInput` projections to AgentTool / SkillTool / CronCreateTool but did NOT override `getDefaultPermission`. The base default is `'allow'`, and the scheduler short-circuits at L4 when finalPermission === 'allow' (the AUTO ack short-circuit I added in round 1 to honor explicit allow rules) — so the new projections were never reached and arbitrary sub-agent spawns / skill invocations / scheduled prompts silently approved. Same shape as the SendMessageTool critical from round 4. That round fixed the one tool the reviewer pointed at; this round audits the sibling sites I should have caught at the same time. Override `getDefaultPermission` to return `'ask'` on all three: - AgentTool — sub-agent spawn - SkillTool — skill load + user code execution - CronCreateTool — scheduled prompt that runs against agent at fire- time Updated the two existing "should not require confirmation" tests in agent.test.ts + skill.test.ts which were codifying the bypass. # 3. SDK QueryOptions.permissionMode union missing 'auto' [gpt-5.5 top-level review] Sibling drift: the SDK protocol schema accepts 'auto' but the public `QueryOptions.permissionMode` literal union was still 4-mode. Typed SDK consumers calling `query({ permissionMode: 'auto' })` got a TS error. Updated the union, refreshed the JSDoc + priority chain, and inserted 'auto' in the documented mode list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core,cli): close 5 review findings on PR #4151 round 5 Two critical + three sugges…
Summary
useAutoAcceptIndicator'sshouldBlockTabcallback is now driven by a broaderhasTabConsumersignal computed insideInputPrompt.useAutoAcceptIndicatortreats a bare Tab on Windows as Shift+Tab (terminals there often can't distinguish the two). PreviouslyshouldBlockTabonly saw the autocomplete dropdown viashouldShowSuggestions, so when the buffer was empty and a followup prompt-suggestion was visible, pressing Tab on Windows accepted the suggestion and cycled approval mode at the same time — exactly what Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171 reports. The mid-input ghost-text and reverse/command-search paths had the same latent gap.packages/cli/src/ui/components/InputPrompt.tsx— confirmhasTabConsumercovers every Tab branch in the same component (autocomplete, followup, ghost text, two searches). TheuseEffectdeps are correct becausehasTabConsumeris a primitive boolean, so React's same-value bail-out keeps the parent setter idle when nothing flipped.packages/cli/src/ui/AppContainer.tsx— pure renamehasSuggestionsVisible→hasTabConsumerto make the new semantics legible; cross-component prop nameonSuggestionsVisibilityChangeis unchanged to keep the patch focused.AgentComposer.tsx) is deliberately untouched: it has no competing Tab consumer (no autocomplete, no followup, no ghost text —BaseTextInputswallows Tab), so addingshouldBlockTabthere would be a no-op.Validation
```bash
npx vitest run src/ui/components/InputPrompt.test.tsx # 146 pass (4 new for Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171)
npx vitest run src/ui/hooks/useAutoAcceptIndicator.test.ts # 14 pass (existing Windows cases still green)
npx vitest run src/ui/AppContainer.test.tsx # 77 pass
npx tsc --noEmit # clean
npx eslint src/ui/components/InputPrompt.tsx \
src/ui/AppContainer.tsx \
src/ui/components/InputPrompt.test.tsx # clean
```
```
✓ InputPrompt > hasTabConsumer reporting (issue Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171) > reports true while the followup prompt suggestion is visible
✓ InputPrompt > hasTabConsumer reporting (issue Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171) > reports true while mid-input ghost text offers an accept
✓ InputPrompt > hasTabConsumer reporting (issue Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171) > reports true while the autocomplete dropdown is visible
✓ InputPrompt > hasTabConsumer reporting (issue Tab 键在windows上存在冲突,当输入框为空时,Tab会同时触发输入预测和权限模式变更,应当修改为一次键盘事件只对应一个行为 #4171) > reports false when the input area is idle (no dropdown, no followup, no ghost)
```
Scope / Risk
Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Closes #4171