Skip to content

fix(panel): don't let a dismissed approval silence later requests (#309) - #2

Open
halindrome wants to merge 5 commits into
mainfrom
fix/dismiss-silences-requests
Open

fix(panel): don't let a dismissed approval silence later requests (#309)#2
halindrome wants to merge 5 commits into
mainfrom
fix/dismiss-silences-requests

Conversation

@halindrome

Copy link
Copy Markdown
Owner

Fixes wxtsky#309.

Cause

Dismissing an approval hides it but deliberately leaves it queued, so the CLI stays blocked and the prompt stays recoverable. The card-and-sound trigger in handlePermissionRequest was gated on the queue having been empty:

permissionQueue.append(request)
if permissionQueue.count == 1 { ...show card...; SoundManager.shared.handleEvent("PermissionRequest") }

A dismissed-but-queued entry keeps that count at ≥ 1 forever, so every later request — from any session — landed on count >= 2 and showed nothing, silently, until the dismissed one was resolved some other way.

Queue size was standing in for "is a card already showing". Dismissal is exactly the state where those two stop agreeing.

Change

Gate on the same predicate the display path already uses — nextVisiblePermissionIndex(), which skips dismissed sessions — evaluated before the append.

Showing the card through showNextPending() rather than pointing the surface at this session by hand matters: the approval card renders the head of the queue, so with a dismissed entry still leading it, a hand-set surface would show the dismissed request's content under the new session's name. showNextPending() promotes the first visible request and applies the session-list and Smart Suppress rules that were previously duplicated here.

Deliberately unchanged

The dismissed session's own next request stays hidden; dismissal is per-session and clears when that session's request resolves. That is existing behaviour, and it is now pinned by a test so this fix is not read as altering it. If it should instead re-show, that is a separate call worth its own change.

Tests

Two added to AppStatePermissionFlowTests: a later session's request still raises a card while a dismissed one sits in the queue, and the same-session case stays hidden. The regression test was confirmed to fail against the old gate, and it stops on that failure rather than continuing into an await that would hang.

swift test: 689 tests, 2 skipped, 0 failures.

Branches from main @ 9e3a1eb and is independent of wxtsky#310.

shanemccarron-maker and others added 2 commits August 12, 2026 10:17
Dismissing an approval hides it but deliberately leaves it queued, so the CLI
stays blocked and the prompt stays recoverable. The card-and-sound trigger in
handlePermissionRequest was gated on `permissionQueue.count == 1`, which a
dismissed entry keeps false forever — so every later permission request, from
every session, arrived with no card and no sound until the dismissed one was
resolved some other way.

Queue size was standing in for "is a card already showing", and dismissal is
exactly the state where those two stop agreeing. The gate now asks the same
predicate the display path uses, nextVisiblePermissionIndex(), which already
skips dismissed sessions.

Showing the card via showNextPending() rather than pointing it at this session
by hand matters: with a dismissed entry still leading the queue, the approval
card renders the head, so a hand-set surface would show the dismissed request's
content under the new session's name.

The dismissed session's own next request stays hidden — dismissal is
per-session and clears when that session's request resolves. That behaviour is
unchanged and now pinned by a test so this fix isn't read as altering it.

Fixes wxtsky#309. 689 tests, 0 failures; the new regression test was confirmed to
fail against the old gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
QA found the first attempt incomplete. `nextVisiblePermissionIndex() != nil`
is "a non-dismissed request is queued", which is still not "a card is on
screen": handlePermissionRequest un-dismisses the session on entry ("session
needs user decision again"), so a dismissed session's NEXT request makes its
own still-queued earlier request count as visible while nothing is displayed.
The silencing then resumed exactly as wxtsky#309 described, one step later.

The gate now asks the surface directly. That also settles what the previous
commit's pinning test got backwards: a dismissal is cleared by the session's
next request arriving, not by the dismissed request resolving, so that
session's next request must bring its card back. The old test asserted the
broken state was correct and its doc comment stated an invariant the code does
not have; both are corrected.

Also strengthened, per QA:
- the cross-session regression test's anti-hang guard checked only the surface;
  an implementation that points the card at the arriving session by hand passes
  that while the dismissed request still leads the queue, so approve resolves
  the wrong one and the await hangs. It now checks the queue head too.
- the new "asking again doesn't silence others" test passed under the broken
  gate as originally written (resolving A's requests surfaces B either way).
  It now asserts a card is on screen at the moment B arrives, which is the
  state that actually differs.

Known limitation, unchanged from main and noted in the code: a card suppressed
by Smart Suppress also leaves a visible request undisplayed, so a second
session's request waits behind it. Closing that needs showNextPending to skip
un-openable entries; tracked separately.

690 tests, 0 failures; both new guards verified red against the incomplete gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 1

3-lens panel (contract-security, regression-edges, test-quality) on
fix/dismiss-silences-requestsmain, range origin/main..HEAD.

The round's headline question — the new showNextPending() call during enqueue —
came back clean.
All three lenses independently traced it and found no ordering or
reentrancy defect. The one substantive finding is that the fix stops short of the
symptom it targets.

Contract Verification

Synthesized contract — no ticket linked; upstream defect report is wxtsky#309.

Criterion Status Evidence
While a dismissed request sits in the queue, a different session's request still raises its card and sound ⚠ partially satisfied Gate rewritten at AppState.swift:1318-1329; works when the dismissed request is the only queued entry. Two reachable states still silence it — Finding 1
The card shows the arriving request, not the dismissed one leading the queue ✅ pass showNextPending() (AppState.swift:1976-1987) promotes the first visible entry and derives surface/activeSessionId from that entry, not from the caller's sessionId. Asserted by identity at AppStatePermissionFlowTests.swift:562-566, and reinforced at :578-582 (approve resolves laterTask, dismissedTask stays blocked)
The dismissed session's own next request stays hidden ✅ pass AppStatePermissionFlowTests.swift:590-620 asserts surface == .collapsed; the test would fail if the code re-showed. Mechanism is mislabelled though — Finding 3
A request must not steal a card that is legitimately on screen ✅ pass if !wasShowingPermission (AppState.swift:1322) is strictly narrower than the old count == 1 gate in the steal direction. Asserted, though not pinned by mutation — see Observations
Dismissal leaves the request queued and the CLI blocked; nothing resolves or drops a request ✅ pass dismissPermissionPrompt (AppState.swift:1552-1568) only inserts into dismissedPermissionSessionIds. The guard at :1322 forces showNextPending() into its first branch (:1978-1987), which only reorders and sets surface — the continuation-resuming branches are unreachable from this call site. assertTaskNotResolved at test :581
showNextPending() mid-enqueue must not corrupt mergeDuplicatePermissionRequest or the tool-use cache ✅ pass See below
No regression in permission / question / dismissal / Buddy flows ✅ pass swift test: 689 tests, 2 skipped, 0 failures on this HEAD
New behaviour covered by tests that fail if it regresses ✅ pass AppStatePermissionFlowTests.swift:539-585; confirmed failing against the old gate

The ordering / reentrancy question — no defect

The specific concern (enqueue now reorders permissionQueue, which it never did before)
was traced by two lenses against every reader of the queue. It holds:

  • mergeDuplicatePermissionRequest is called at AppState.swift:1308, before the
    append at :1319 and before showNextPending() at :1327, and returns early. It can never
    observe the reorder. Internally it is content-addressed anyway
    (firstIndex(where: { $0.toolUseId == toolUseId }), AppState+ToolUseCache.swift:150,168).
  • Tool-use cache drains (resolveToolUseIfCompleted:45, resolveOrphanPermissionsOnActivity:94)
    re-derive head position at call time (wasHead = staleIndex == 0, headWasOrphan from
    permissionQueue.first before mutating), so a promoted head is handled correctly.
  • UI consumers (NotchPanelView.swift:2155,2261-2264, ESP32StatePublisher.swift:421)
    match by sessionId, not by arrival order. The one position-sensitive path — session-list
    inline approve — is safe because its buttons gate on approvalQueueIndex == 0 while
    approvePermission/denyPermission act on removeFirst(); head and displayed entry stay
    the same object after promotion.
  • Reentrancy: AppState is @MainActor and the whole path is synchronous, so nothing
    observes a partially-updated state and no notification fires twice. showNextPending()'s
    question/completion/collapse branches are unreachable from this call site because the guard
    pre-checks nextVisiblePermissionIndex() != nil and nothing mutates in between.
  • Sound parity: the old gate fired on count == 1 after append (queue was empty); the new
    predicate goes false→true in exactly that case, so no sound is lost.

Finding 1 — wasShowingPermission still is not "a card is showing"

  • Severity: major · Status: confirmed · Category: logic-error / edge-case
  • Concurring lenses: [claude:contract-security] | [claude:regression-edges] | [claude:test-quality]
  • Where: Sources/CodeIsland/AppState.swift:1291-1329

What was tested: whether the new gate eliminates the wxtsky#309 symptom (a dismissed request
silencing every later request) in all reachable states.

Expected: a request from another session raises its card and sound whenever no card is
actually on screen.

Actual risk: the MR's own comment diagnoses the bug correctly — "queue size is not
'is a card showing'" — and then substitutes a second predicate that is also not
"is a card showing". wasShowingPermission = nextVisiblePermissionIndex() != nil means
"a non-dismissed request exists in the queue", which is strictly weaker. Two reachable states
leave a visible request leading the queue with surface == .collapsed, and from that point
every later request from every session is swallowed with no card and no sound:

  1. Same-session second request. dismissedPermissionSessionIds.remove(sessionId) at
    AppState.swift:1291 runs 27 lines before wasShowingPermission is computed at :1318.
    So a second request from the already-dismissed session un-dismisses its own still-hidden
    first request, making it "visible" while nothing is on screen. The MR's own second test
    constructs exactly this state
    (AppStatePermissionFlowTests.swift:590-620 ends with
    permissionQueue.count == 2, dismissal set empty, surface == .collapsed). A third
    session arriving there gets nothing. Reachable in normal operation — the codebase's own
    comments at AppState+ToolUseCache.swift:154-159 exist to handle parallel same-session
    tool calls, each with its own PermissionRequest.
  2. Smart Suppress. showNextPending() sets activeSessionId but only assigns surface
    when shouldAutoOpenPendingSurface(for:) (AppState.swift:930-938) returns true. With
    session A's terminal frontmost, A is promoted and "visible" but no card appears — and
    session B, which does deserve a card, is then silenced.

The state is durable: refreshDerivedState (AppState.swift:1056-1077) only recomputes
summary/mascot state and raises no surface.

Not a regressionmain swallowed these states too via count == 1 — so the MR is not
worse than its base. It is an incomplete satisfaction of the first acceptance criterion. The
failure mode is fail-closed (a request goes unannounced and stays blocked), never fail-open.

Cost note: the panel split on the fix. test-quality reads it as cheap
(if case .approvalCard = surface is the predicate the comment describes). Both other lenses
note that criteria 1 and 3 are in genuine tension: a pure surface check satisfies criterion 1
but would re-raise the request the user explicitly dismissed, breaking criterion 3. Whatever
is done needs both new tests kept green, so this is a real design decision, not a one-line
reorder.

Finding 2 — the regression test's anti-hang guard misses the most likely wrong implementation

  • Severity: minor · Status: confirmed · Category: test-gap
  • Lens: [claude:test-quality]
  • Where: Tests/CodeIslandTests/AppStatePermissionFlowTests.swift:567-580

What was tested: the comment at :567-569 says the guard exists so a failure reports by
name rather than as a timeout. Mutation: replace showNextPending() with the naive
activeSessionId = sessionId; surface = .approvalCard(sessionId: sessionId).

Expected: a named assertion failure.

Actual risk: under that mutation surface is .approvalCard("s-later"), so both the
assertion and the guard pass — but the queue head is still the dismissed request, so
approvePermission() resolves dismissedTask and await laterTask.value at :579 hangs
forever with no timeout. The whole XCTest suite hangs instead of failing, which is precisely
what the guard was written to prevent. Asserting the queue head's session id before :578
closes it in one line.

Finding 3 — the pinning test's doc comment states a false invariant

  • Severity: minor · Status: confirmed · Category: test-gap
  • Concurring lenses: [claude:contract-security] | [claude:test-quality]
  • Where: Tests/CodeIslandTests/AppStatePermissionFlowTests.swift:587-589

What was tested: the comment "dismissal is per-session and is only cleared when that
session's request resolves."

Actual risk: false as written. handlePermissionRequest clears the dismissal at
AppState.swift:1291 on the arrival of any further request from that session, before any
resolution. By the time the assertions run, s-same is no longer dismissed at all — the
request stays hidden because the !wasShowingPermission guard suppresses it, not because
dismissal persisted. The test pins the right behaviour but names the wrong mechanism, so
a maintainer who later deletes the guard will read this test's green status as proof that
dismissal still works. The same error appears in the synthesized contract's third criterion,
and the false premise is what makes Finding 1's second state easy to miss.

Direct answer to the review question: yes, the pinning test genuinely pins the behaviour —
it would fail if the code re-showed the dismissed session's request. The behaviour itself is
defensible; the state it leaves behind (two queued requests, an empty island, every other
session muted per Finding 1) is the part worth reconsidering.

Summary

Severity Confirmed Blocking
critical 0 0
major 1 1
minor 2 2
Total 3 3

Observations (non-blocking): 3.

Observations

  • Pre-existing — hotkey collapse leaves the same false state.
    executeShortcut(.togglePanel) (AppDelegate.swift:222-224) sets surface = .collapsed
    for any expanded surface including .approvalCard, without registering a dismissal —
    leaving a non-dismissed request queued with nothing on screen, after which the new gate
    suppresses every later card and sound. PanelWindowController.swift:269-272 explicitly
    guards the click-outside path; the hotkey path is not guarded. Not a regression (the
    old gate was equally silent here) and the call sites are untouched by this MR. A
    surface-based gate would close this and Finding 1 together, so fixing it separately is
    probably wasted effort.
  • "Don't steal the card" is asserted but not pinned (mutation gap). Deleting
    !wasShowingPermission, from AppState.swift:1322 still passes the assertion at
    AppStatePermissionFlowTests.swift:70-71, because showNextPending() promotes the first
    visible entry, which in that scenario is already at index 0 — an unguarded call is a no-op
    on surface. Closing it means adding a sound-emission seam to AppState, i.e. more new
    unreviewed code than the MR contains. Recorded so the guard is not mistaken for tested.
  • Sound emission and the reorder/dedup interaction are unasserted. Deleting
    SoundManager.shared.handleEvent("PermissionRequest") (AppState.swift:1328) fails no
    test — the "and play the sound" half of criterion 1 is verified by neither new test — and
    no test enqueues a duplicate toolUseId or drives a cache drain against a reordered queue.
    Both are safe by construction (see the ordering section) and both would need an injected
    seam around the SoundManager.shared singleton to assert. Cost exceeds the risk; recorded
    so the criteria are not silently treated as tested.

SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.

No security-scan claim is made for this HEAD (e984ad6); none was run.

Security posture of the change itself: nothing here can auto-approve, resolve, or drop a
permission request. The only new call is showNextPending(), and the guard immediately
preceding it forces its reorder-only branch; the continuation-resuming branches are
unreachable from this call site. Dismissal still leaves the CLI blocked.


⚠ QA agent token unavailable for this round.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel


Round 1 disposition (main loop)

All three findings fixed in 29e1456064204251fe0f4b9bc57793845d4124fb.

F1 was right and the fix was incomplete. Verified at AppState.swift:1291: dismissedPermissionSessionIds.remove(sessionId) runs on entry, before the visibility probe, so a dismissed session's next request un-dismisses it and its still-queued earlier request counts as visible while nothing is displayed — resuming the exact silencing wxtsky#309 reports. The gate now reads the surface directly.

F3 was right in a way that mattered more than a comment. Dismissal is cleared by the next request arriving, not by resolution — so the previous commit's pinning test asserted the broken state as correct. It is inverted, not just re-worded. The synthesized contract's third criterion carried the same error and should be read as corrected.

F2 fixed, and while re-checking I found the same weakness in my own new test: it passed under the broken gate because resolving A's requests surfaces B either way. It now asserts a card is on screen at the moment B arrives.

The round's headline question — whether the mid-enqueue showNextPending() reorder breaks the dedup path or the tool-use cache — came back clean from all three lenses, which is what the round was run for.

Known limitation, unchanged from main and now marked in code: a Smart-Suppress-suppressed card also leaves a visible request undisplayed, so a second session waits behind it. Needs showNextPending to skip un-openable entries; separate change.

Verification: swift test — 690 tests, 2 skipped, 0 failures. Both new guards confirmed red against the incomplete gate.

QA-Fix-Commit: 29e1456

Round 1's surface-only gate had its own hole, found by two lenses
independently: drainPermissions (process exit, or a question arriving for the
session) empties the queue without clearing `surface`, and the card renders
nothing when there is no head request. A bare `.approvalCard` check therefore
blocked on a phantom card and swallowed the next request from every session —
the same wxtsky#309 defect, third variant. The gate now requires both an
.approvalCard surface and a non-empty queue.

Card and sound also stopped sharing a gate. They answer different questions:
the card asks "is one already on screen", the sound asks "does this request
start a new burst" — which is what `count == 1` used to approximate. Sharing
the surface-derived gate made the sound fire per request instead of per burst
whenever no card was open, e.g. under Smart Suppress. The sound now keys off
whether a visible request was already queued, which keeps burst behaviour and
still fixes the dismissed-request silence.

Tests, per QA:
- new: a request arriving under a stale .approvalCard surface must still raise
  a card (verified red against round 1's gate).
- the cross-session test asserted only "not collapsed", the weakest thing
  available; it now names the card it expects.
- the re-raise test could not tell showNextPending's promotion from a
  hand-pointed surface, both requests being from one session; it now asserts
  the card shows the earlier queued request, not the arriving one.

691 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 2

Panel: 3 lenses (contract-security, regression-edges, test-quality), all completed.
Round 1's three findings are fixed in 29e1456. This round re-reviewed the rewritten
gate. All four findings below sit on lines 29e1456 wrote — i.e. on QA round 1's own
fix, not on the author's original change.

Test suite was run by the orchestrator on this HEAD: 690 tests, 2 skipped, 0 failures,
with both new guards verified red against the pre-fix gate. Not re-run by the panel.

Contract Verification

Source: synthesized (no ticket linked; upstream defect report is wxtsky#309).
Criterion 3 was corrected before verification — the synthesized wording ("the
dismissed session's own next request stays hidden") was wrong, derived from the same
misreading round 1 caught. Verified against the corrected behaviour instead.

# Criterion Status Evidence
1 A different session's request must still raise its card and play the sound while a dismissed request sits in the queue partially-satisfied Holds for the reported flow (AppState.swift:1328-1340; test at AppStatePermissionFlowTests.swift:538-595). Fails in the stale-surface state — see Finding 1.
2 The card raised must show the arriving request, not the dismissed one leading the queue satisfied showNextPending() promotes the chosen request to index 0 (AppState.swift:1989-1997); the test asserts the queue head, not just the surface (:574-578).
3 CORRECTED: a dismissed session's next request re-raises that session's card satisfied dismissedPermissionSessionIds.remove(sessionId) at AppState.swift:1291; surface is .collapsed after dismissal so the gate opens. testDismissedSessionsNextRequestReRaisesItsCard (:605-636). Both lenses that traced it agree the round-1 inversion was made in the right direction.
4 A request arriving while a card is legitimately on screen must not steal it satisfied if case .approvalCard = surface is captured before the permissionQueue.append (:1328-1330). .questionCard can still be replaced, but that matches main.
5 Dismissal leaves the request queued and the CLI blocked; nothing resolves or drops a request satisfied dismissPermissionPrompt() unchanged (:1563-1580); test asserts permissionQueue.count == 1 after dismiss and assertTaskNotResolved(dismissedTask). No new continuation.resume in the diff.
6 showNextPending() from inside handlePermissionRequest must not corrupt the replay-dedup path or the tool-use cache satisfied Round 1's clearance still holds; 29e1456 changed only the gate predicate, not the call site or its position. mergeDuplicatePermissionRequest returns above the gate (:1308-1311) and looks up by toolUseId; AppState+ToolUseCache.swift is untouched and position-independent.
7 No regression in permission, question, dismissal, or Buddy/companion flows partially-satisfied Findings 1 and 2. Buddy/companion, dismissal and ESP32 paths were traced clean.
8 New behaviour covered by tests that fail if it regresses satisfied Three tests added; orchestrator verified both new guards go red against the incomplete gate. Findings 3 and 4 are strengthening notes, not coverage failures.

Findings

Finding 1 — Surface-derived gate blocks on a stale .approvalCard that no queued request backs ↩ on code QA round 1 introduced

  • Severity: major | Status: confirmed | Relevance: regression | Category: logic-error
  • Area: Sources/CodeIsland/AppState.swift:1328-1340
  • Concurring: [claude:contract-security] | [claude:regression-edges] (found independently by both)

What was tested: whether surface == .approvalCard(sid) is a sound proxy for "an
actionable approval card is on screen". Every writer of permissionQueue and surface
was traced, along with every path that empties the queue.

Expected: the gate suppresses auto-open only while a real, queue-backed approval card
is displayed.

Actual risk: the implication does not hold. NotchPanelView.swift:205-223 renders
nothing for .approvalCard when pendingPermission is nil, and there is no else.
Two paths leave surface == .approvalCard(A) with A's request gone from the queue:

  1. handleProcessExit (AppState.swift:546) calls drainPermissions(forSession:) at
    :570 and never touches surface; drainPermissions (:1937-1946) and
    refreshDerivedState (:1056) do not either. The 5-second follow-up only repairs
    this via removeSession (:620-634); the early returns at :588 and :604 skip
    that, leaving the stale surface indefinitely.
  2. handleQuestion (:1581) drains the session's permissions at :1602, then opens the
    question card only if shouldAutoOpenPendingSurface (:930-938) is true. Smart
    Suppress defaults to on (Settings.swift:139, registered :223), so with the
    session's terminal frontmost no question card opens and .approvalCard(A) survives.

In that state the next PermissionRequest from any session is appended and neither
displayed nor sounded — no card, no sound, CLI blocked. That is the exact wxtsky#309 defect
class this MR exists to fix, re-entered through a different door. main was immune here
precisely because its gate was queue-derived: an empty queue made permissionQueue.count == 1 true and the request was shown.

Secondary effect: the panel then renders a mis-attributed card — NotchPanelView.swift:207
takes the tool from pendingPermission (now B's request) while sessions[sid] and
sessionId: sid at :208/:215 come from the stale surface's A.

Proportionality: the fix is cheap relative to the defect — conjoin the surface check
with a check that a visible request actually backs it, evaluated before the append. That
is a one-condition change at :1329, smaller than what the MR already ships. No new
mechanism required.

Finding 2 — nextVisiblePermissionIndex() != nil conjunct is dead; the sound now fires per request ↩ on code QA round 1 introduced

  • Severity: minor | Status: confirmed | Relevance: regression | Category: behaviour-change
  • Area: Sources/CodeIsland/AppState.swift:1333-1340
  • Concurring: [claude:regression-edges] | [claude:contract-security] | [claude:test-quality] (all three)

What was tested: whether the gate's second clause can ever be false, and what the
effective gate therefore is.

Expected: a guard that meaningfully narrows the branch.

Actual risk: it cannot be false. Line :1291 removes the arriving session from
dismissedPermissionSessionIds before the append, so the just-appended request is always
visible to nextVisiblePermissionIndex() (:205-210). Nothing between :1291 and :1333
re-inserts into the set. The effective gate is !approvalCardOnScreen alone, which is
broader than the adjacent comment implies, and no test can distinguish the clause's
presence.

Behavioural consequence: with Smart Suppress on and the terminal frontmost — or with the
session list open — surface never becomes .approvalCard, so every subsequent request
re-enters the branch and re-plays PermissionRequest. main sounded once per burst
(permissionQueue.count == 1); HEAD sounds once per request. The card itself is correctly
not stolen (showNextPending keeps surface != .sessionList at :1995).

Proportionality: the sound change is arguably an improvement and is the MR's own
premise, so this is flagged so it is a decision rather than an accident. If deliberate,
the right action is a comment, not a code change. The dead conjunct is a one-line
deletion. .sessionList has no test touching it at all — the highest-ranked of the
uncovered states the new gate reaches.

Finding 3 — Cross-session test leans on its weakest available assertion ↩ on code QA round 1 introduced

  • Severity: minor | Status: confirmed | Relevance: regression | Category: test-gap
  • Area: Tests/CodeIslandTests/AppStatePermissionFlowTests.swift:662-688

What was tested: whether XCTAssertNotEqual(appState.surface, .collapsed) constrains
anything or holds trivially from fixture setup.

Expected: an assertion pinning which card is up when B's request lands.

Actual risk: it is not vacuous — it goes red against the round-1 queue-derived
proxy, so it earns its place. But it is the weakest form available: it also passes for
.sessionList, .questionCard, or an .approvalCard pointed at the wrong session.
XCTAssertEqual(appState.surface, .approvalCard(sessionId: "s-a")) is the same number of
lines and strictly stronger — and the test's own comment concedes the tail assertions
"pass either way", which makes this the only line carrying the test. Minor per the
proportionality cap: not asserting enough, rather than asserting something false.

Finding 4 — Re-raise test cannot distinguish showNextPending() from a hand-pointed surface ↩ on code QA round 1 introduced

  • Severity: minor | Status: confirmed | Relevance: regression | Category: test-gap
  • Area: Tests/CodeIslandTests/AppStatePermissionFlowTests.swift:605-636

What was tested: the round-2 headline worry — is round 1's inverted assertion true
against shipped code, or was it inverted the wrong way?

Expected: surface == .approvalCard(sessionId: "s-same") after the same session's
second request.

Actual risk: the assertion is correct. Traced line by line through
dismissPermissionPrompt (:1563) → handlePermissionRequest (:1291) →
showNextPending (:1988-1998); both assertions hold. Round 1's inversion was made in
the right direction. The residual gap is narrow: because both requests carry the same
sessionId, the assertion cannot distinguish showNextPending() from a mutation writing
surface = .approvalCard(sessionId: sessionId) by hand — the very mutation the production
comment at :1334-1337 defends against. Asserting permissionQueue.first?.event.toolName == "Bash" closes it in one line. Test 1 (:574) already pins the head for the
cross-session case, so the suite as a whole catches this mutation; the gap is per-test.

Observations

Non-blocking. Recorded, not proposed as work for this MR.

  • The ponytail: Smart-Suppress limitation is genuinely pre-existing and this MR makes
    it slightly better — it should not block.
    Verified against main's behaviour, not
    just the comment's claim: on main, A's suppressed request enqueued with no card and
    B's later request hit count == 2 and produced nothing at all. On HEAD, B's arrival
    passes the gate so the sound now plays, though the card still isn't opened because
    shouldAutoOpenPendingSurface(for: A) is still false. Identical card behaviour, better
    sound behaviour. The real fix — teaching showNextPending to skip un-openable entries —
    changes the promotion contract for all six of its call sites and would add more new,
    unreviewed behaviour than this MR contains. Recommendation: track separately. The
    comment names the ceiling and the upgrade path, which is the right disposition. Two
    lenses reached this conclusion independently.
  • showNextPending() can leave surface pointed at A while the queue head is B
    (AppState.swift:1988-2021), so the card renders B's command but click-to-jump goes to
    A's terminal (NotchPanelView.swift:206-216). The approve/deny decision is not
    misrouted — both act on the head, which is the request displayed. Reachable identically
    on main; this MR's new call site cannot create the mismatch (it only runs when
    surface is not .approvalCard). Not worth fixing inside this MR.
  • The sound side of the gate is unassertable in both directions.
    SoundManager.shared is a global singleton with no injection point, so no test can
    notice the handleEvent("PermissionRequest") call being deleted or fired N times. Half
    of Dismissing an approval card silences every later permission request, from all sessions wxtsky/CodeIsland#309's contract ("no card and no sound") is therefore untestable as written.
    Explicitly not worth fixing here: a protocol seam plus a fake would add more new
    code than the 32-line production change.
  • Smart Suppress is unreachable in the test fixtures, but the tests do not pin the
    limited behaviour.
    makePermissionRequestEvent (:690-711) emits no terminal
    metadata, so shouldAutoOpenPendingSurface (:930) always short-circuits true. A
    future fix to the ceiling will not force edits to these three tests — no churn risk.
  • Test ordering is clean. Each new test constructs its own AppState(); neither the
    setenv("CODEX_HOME", …) at :521 nor the smartSuppress default reaches them.
  • Tooling gap for the operator: all three lenses loaded the code-navigation tools
    successfully, but the CMM project Users-ahby-Sources-CodeIsland is not indexed and
    no indexed project is an ancestor of this repo. Every lens fell back to ctx_* plus
    opening each cited definition site with Read, so every symbol claim above is
    definition-site verified — but the graph queries (trace_path, search_graph) were
    unavailable this round. Worth indexing the repo before the next round.

Summary

Severity Contract Regression Total
critical 0 0 0
major 0 1 1
minor 0 3 3
Blocking (critical + major) 1
Relevance Count
contract 0
regression 4
observation 6

Lens navigation: contract-security ctx, regression-edges ctx, test-quality ctx.
No failed lenses. Working tree verified unchanged across the panel (HEAD, branch and
porcelain status snapshotted before and after).


SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.

No security-scan claim is made for 29e1456 — none was computed.


⚠ Posted with dev credentials — QA agent token unavailable.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel


Round 2 disposition (main loop)

All four findings fixed in f3642dda20d57463f20344fa6c16e3c34a8df1c9.

The major was real and mine: round 1 replaced a queue-derived gate with a surface-derived one, and traded one hole for another. The gate now requires both halves — an .approvalCard surface and a non-empty queue — and the phantom-card case is pinned by a test verified red against round 1's gate.

On the dead-conjunct finding: the right fix was not to delete it but to notice that card and sound were answering different questions. They no longer share a gate — the sound keys off whether a burst was already in progress, which restores count == 1's burst semantics that round 1 had silently changed to per-request under Smart Suppress. That behaviour change was not in any finding; it surfaced from the finding's reasoning.

qa_introduced_blocking: 1 and all four findings blamed to 29e1456 — this cycle is now reviewing its own fixes. The panel was right to raise diminishing returns while also saying the major should still be fixed; both were correct.

Verification: swift test — 691 tests, 2 skipped, 0 failures.

QA-Fix-Commit: f3642dd

Round 3 finding. The assertion checks that the card renders the queue head, so
the user is asked about the earlier request — it pins queue order, not which
mechanism set the surface. With both requests from one session no assertion at
that level can tell showNextPending's promotion from a hand-pointed surface;
the cross-session test's head check is what covers that.

Comment only. 691 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 3

3 of 3 blocking findings are on code an earlier round of this QA cycle introduced. This cycle may be fixing its own work rather than the MR's.

Panel: 3 lenses (contract-security, regression-edges, test-quality), run concurrently against origin/main..HEAD @ f3642dd.
Contract source: synthesized from upstream issue wxtsky#309. Criterion 3 was corrected before this round: the originally synthesized text ("the dismissed session's own next request stays hidden") was wrong; the verified criterion is that a dismissed session's next request must re-raise its card — arrival clears the dismissal.

Verification note: swift test was run by the orchestrator on this HEAD — 691 tests, 2 skipped, 0 failures — and the new phantom-card guard was separately confirmed to go red against round 2's pre-fix gate. The panel did not re-run the suite.

Navigation note: all three lenses loaded the code-navigation tools successfully, but this repo has no CMM index (list_projects contains no entry rooted at or above /Users/ahby/Sources/CodeIsland). All three fell back to opened definition sites with cited line numbers, per the fallback evidence rule. Navigation regime: ctx for all three. Worth indexing before any further round.

Contract Verification

# Criterion Status Evidence
1 A different session's request still raises its card and plays the sound while a dismissed request sits in the queue satisfied AppState.swift:1333-1358; dismissPermissionPrompt (:1581) collapses the surface, so approvalCardOnScreen is false and showNextPending() (:2006) runs. nextVisiblePermissionIndex() (:205) is nil pre-append, so the sound fires. Test testDismissedPermissionDoesNotSilenceALaterSessionsRequest.
2 The card shows the arriving request, not the dismissed queue leader satisfied showNextPending promotes the first visible entry to index 0 (:2007-2009); the view renders pendingPermission = permissionQueue.first (:125, NotchPanelView.swift:207-211). Asserted on the queue head, not merely the surface (tests :574-578).
3 (corrected) A dismissed session's next request MUST re-raise its card — arrival clears the dismissal satisfied Un-dismiss at AppState.swift:1291, before the gate. Test testDismissedSessionsNextRequestReRaisesItsCard asserts the card returns and that the head is the earlier Bash request.
4 A request arriving while a card is legitimately on screen must not steal it satisfied Two-part gate at :1333-1338 evaluated before the append at :1346, so it describes pre-arrival state. Verified across .approvalCard (+empty and +non-empty queue), .sessionList, .questionCard. See Finding 2 for the three-session .questionCard case.
5 Dismissal leaves the request queued and the CLI blocked; nothing resolves or drops a request satisfied The added block :1313-1358 contains no continuation.resume and no removal; showNextPending only reorders. Asserted by queue-count plus assertTaskNotResolved(dismissedTask).
6 The reentrant showNextPending() must not corrupt mergeDuplicatePermissionRequest or the tool-use cache satisfied mergeDuplicatePermissionRequest (AppState+ToolUseCache.swift:148) runs before the append (AppState.swift:1308) and recomputes firstIndex itself. resolveToolUseIfCompleted (:62-78) and resolveOrphanPermissionsOnActivity (:94-133) recompute per call. The session-list row index is a computed property re-evaluated on redraw. No caller holds a queue index across the reentrant call. Independently, the reorder is an identity operation whenever the head is visible.
7 No regression in existing permission / question / dismissal / Buddy flows satisfied The only behavioural deltas vs. main are (a) the card raises in states where main showed nothing, and (b) the sound predicate is a strict superset of main's count == 1. approvePermission, denyPermission, drainPermissions, handlePeerDisconnect and the Buddy path are untouched.
8 New behaviour covered by tests that fail if it regresses partially satisfied Card behaviour: 4 tests, all asserting the queue head rather than only the surface — which specifically defeats a "point the surface at the arrival by hand" false pass. Sound behaviour (burstAlreadyInProgress, :1345): zero assertions. See Finding 1.

Finding 1 — Sound predicate burstAlreadyInProgress has no test coverage ↩ on code QA round 2 introduced

  • Severity: minor · Relevance: contract (criterion 8) · Category: test-gap · Status: confirmed
  • Reported by: contract-security | regression-edges | test-quality (all three, independently)
  • Where: Sources/CodeIsland/AppState.swift:1345, consumed at :1356-1358
  • Expected: the sound clause named in criterion 1 is regression-protected.
  • Actual risk: reverting :1345 to a queue-derived proxy such as !permissionQueue.isEmpty — the exact shape of the round-1 major — leaves all 691 tests green while re-introducing the silent panel. SoundManager is a bare singleton with no injection point (SoundManager.swift:5-6, handleEvent at :32); the only existing coverage is the pure static isInQuietHours.
  • Verified by reading: the predicate is correct. Pre-append it is false exactly when the queue is empty or holds only dismissed-session entries — main's count == 1 behaviour plus the intended dismissed case — and it cannot double-fire, since handlePermissionRequest un-dismisses the arriving session at :1291 before evaluating it and takes one branch per call.
  • Cost note (all three lenses concur): closing this requires a SoundManager DI seam threaded through AppState — more new, unreviewed production code than the MR itself contains, arriving after three review passes. Recommend a follow-up ticket, not a round-3 fix.

Finding 2 — An open question card can be stolen by an older permission when a third session's request arrives ↩ on code QA round 1 introduced

  • Severity: minor · Relevance: regression · Category: edge-case · Status: confirmed
  • Reported by: regression-edges
  • Where: Sources/CodeIsland/AppState.swift:1333-1355; showNextPending at :2006-2016
  • Expected: an open question card is not replaced by an approval card showing an unrelated, older request.
  • Actual risk: showNextPending() now runs on every arrival that fails the card gate, not only when the queue was empty. showNextPending guards only surface != .sessionList (:2013), so a .questionCard surface is overwritten at :2014. Reachable with three sessions: A's permission queued → B's question raises .questionCard(B) → C's permission arrives → the card is replaced by A's request mid-read.
  • Mitigating: nothing is lost. The question stays in questionQueue and showNextPending's question branch (:2017-2023) restores it once permissions clear; the CLI stays blocked throughout. The two-session variant of this state is improved by the MR (the old gate left a phantom question card with an empty questionQueue).
  • Cost note: the obvious fix is another condition on the card gate — precisely the change shape that produced the major finding in each of the previous two rounds. Given the transient, self-healing impact, the fix plausibly costs more than the defect.

Finding 3 — A comment added by the round-2 fix overstates its assertion's discriminating power ↩ on code QA round 2 introduced

  • Severity: minor · Relevance: regression · Category: test-gap · Status: confirmed
  • Reported by: test-quality
  • Where: Tests/CodeIslandTests/AppStatePermissionFlowTests.swift:632-640
  • Expected: the comment describes what the assertion actually rules out.
  • Actual risk: the comment claims the assertion distinguishes "showNextPending promoted the queue head" from "the surface was pointed at the arriving request by hand". Both requests in that test belong to s-same, so a hand-pointed implementation leaves the head as the Bash request and the assertion still passes. The genuine hand-pointing guard is the other test's head check at :574-578, where the two sessions differ.
  • Impact: no user-visible impact, and the assertion is not false — it does catch an implementation that inserts the arrival at the head. The cost is to a future reader who trusts the comment. One-line comment edit; does not justify a further round on its own.

Summary

Severity Contract Regression Total
critical 0 0 0
major 0 0 0
minor 1 2 3

No blocking (critical/major) findings. All three minor findings carry an explicit cost note arguing against acting on them in this round, and all three sit on code a previous round of this QA cycle wrote.

State-space sweep (checked, no finding):

  • (.approvalCard, queue empty) — the phantom surface after drainPermissions (:1955, which never touches surface). Gate correctly returns false. Covered by testRequestArrivingUnderAStaleApprovalSurfaceStillRaisesACard, and the guarded state is genuinely reachable: handleProcessExit (:570) drains without calling showNextPending().
  • (.approvalCard, queue non-empty, all entries dismissed)unreachable. dismissPermissionPrompt (:1587-1594) collapses the surface whenever nextVisiblePermissionIndex() is nil.
  • (.approvalCard for session X, queue holds only other sessions' entries) — gate true and a card genuinely is on screen; the arrival queues behind it and surfaces on resolve. Asserted by testDismissedSessionAskingAgainDoesNotSilenceOtherSessions.
  • (.sessionList, any queue) — gate false, showNextPending runs, its :2013 guard keeps the list open, matching the old code.
  • Queue reordering is a no-op whenever the head is visibleremove(at:0)/insert(at:0) is identity. The head moves only when it is dismissed.
  • ConcurrencyAppState is @MainActor; there is no suspension point between the gate at :1333 and showNextPending() at :1354, so no resolve or dismissal can interleave.
  • Same-session silent re-raise — a dismissed session asking again re-raises the card with no sound. Deliberate: main is silent and card-less here, and the contract requires only the card.

Security posture: clean. The change adds no path that can auto-approve, silently drop, or misattribute a request. The added block contains no continuation.resume and no queue removal. Every resume site (approvePermission:1405, denyPermission:1563, drainPermissions:1961, AppState+ToolUseCache.swift:167, :118) is untouched and still requires explicit user action or an explicit drain trigger. No tokens or secrets are handled on these paths.

Round-2 assessment upheld: the ponytail: note at AppState.swift:1324-1327 — a Smart-Suppress-suppressed card leaving a visible request undisplayed so a second session waits behind it — is pre-existing on main and is not worsened by this MR. Round 3 does not contest it.

Observations

Non-blocking; not defects this MR introduces.

  • Approval card can show one session's command under another session's identity chrome (severity major as a standalone defect, but pre-existing and identical on main; reported independently by contract-security and regression-edges). The card takes its body from the queue head but its identity chrome (project name, session label, terminal badge) from the surface's associated session id. showNextPending (AppState.swift:2007-2015) promotes a new head and sets activeSessionId, but assigns surface only when shouldAutoOpenPendingSurface(for: sid) (:930-939) is true — with Smart Suppress on and the session's terminal frontmost, surface keeps the stale sid. NotchPanelView.swift:206-221 then renders appState.sessions[sid] above pendingPermission's tool and input, and onAllow resolves the head. Reached identically on main via the pre-existing showNextPending callers (:1577, :1981, AppState+ToolUseCache.swift:70-77). Distinct from the ponytail: note above (that one is a hidden card; this is a wrong-identity card). Warrants its own ticket. Fixing it inside this round would mean touching showNextPending's surface contract — exactly what produced the round-1 and round-2 majors.
  • No CMM index exists for this repo. All three lenses loaded the navigation tools successfully but found no indexed project at or above /Users/ahby/Sources/CodeIsland. Not a tooling failure and not a silent degradation — every symbol claim in this note is backed by an opened definition site — but worth fixing before any further round.

SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.


⚠ QA agent token unavailable — this note was not posted by the QA agent.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel


Round 3 disposition (main loop)

No blocking findings — the round-2 gate holds under every state the lenses probed, including the ones this round was aimed at.

Fixed: the comment overstating what its assertion pins (c86fe891864254270664e169f5b22a2804ffb2ca, comment only).

Deferred, with the panel's own cost reasoning accepted:

  • Sound predicate has no test coverage. Closing it needs a SoundManager dependency-injection seam — more new, unreviewed production code than this PR contains. Contract criterion 8 is therefore only partially satisfied and this PR should not be read as claiming the burst-sound behaviour is pinned.
  • An open question card can be stolen by an older permission when a third session's request arrives. Self-healing and non-lossy. The fix is another card-gate condition — the exact change shape that produced the round-1 and round-2 majors. Deferring on those grounds specifically, not because it is unimportant.

Cycle ends here on the panel's diminishing_returns: 3 of 3 findings sit on code earlier rounds of this cycle wrote, and each carries a lens cost note arguing against fixing it now. This is the operator's call and it has been put to them.

Standing caveats: approval_eligible=false, so no QA approval was emitted regardless of the clean verdict; and no codebase-memory index exists for this repo, so all three rounds enumerated callers by definition-site reads rather than graph queries.

Verification: swift test — 691 tests, 2 skipped, 0 failures.

QA-Fix-Commit: c86fe89

Two defects found by reviewing this change merged with the wxtsky#308 answer-routing
fix. Both are present on this branch alone, so they belong here rather than in
the integration.

1. The gate asked `!permissionQueue.isEmpty` — a whole-queue question — while
   staleness is per-session. `drainPermissions` empties one SESSION's requests
   without clearing `surface` (a question arriving for that session does this),
   so a card can be left pointing at a session with nothing queued while others
   still wait. The whole-queue test reads that as "a card is up", and every
   later request queues silently behind a card for a session that has no
   pending request. Now asked per session.

2. `dismissedPermissionSessionIds.remove(sessionId)` ran before the replay-dedup
   early return, so a replayed tool_use_id for a dismissed session resurrected
   the request the user had hidden — taking the card the arriving session should
   have received, and counting as a burst already in progress so that session
   lost its sound too. A replay is the same decision arriving twice, not a new
   one. A same-id request with *differing* tool inputs is a distinct request
   (wxtsky#169) and still clears the dismissal; that path is pinned too.

This one is latent on main as well: a resurrected request leads
nextVisiblePermissionIndex(), so any later showNextPending() can raise the card
the user dismissed.

New AppStatePermissionGateTests covers all three cases. Both fixes verified by
mutating the production line each names and confirming the test fails — and the
replay test stops on that failure instead of hanging on the await that follows.

694 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
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.

Dismissing an approval card silences every later permission request, from all sessions

2 participants