Skip to content

fix(desktop): keep the clarify card until it is answered, skipped, or expired - #4

Merged
SlowGreek merged 1 commit into
costas-codefrom
fix/clarify-card-persistence
Jul 29, 2026
Merged

fix(desktop): keep the clarify card until it is answered, skipped, or expired#4
SlowGreek merged 1 commit into
costas-codefrom
fix/clarify-card-persistence

Conversation

@SlowGreek

Copy link
Copy Markdown
Owner

Clicking into a chat that had an unanswered clarify flashed the question card and then lost it — the question became unrecoverable and the agent stayed blocked until agent.clarify_timeout (3600s).

Three independent defects, all fixed here.

1. The card's lifetime was gated on the wrong thing

ClarifyToolLive gated the interactive panel on messageRunning (thread.isRunning && message.status === 'running'). thread.isRunning is fed from busy, and resumeSession sets busyRef true on entry then back to the resumed running in its finally — which the deferred resume path reports as false. So the panel mounted from the persisted tool-call args, then unmounted a beat later.

It now lives and dies with its own request, correlated by request id (a row hydrated from clarify.request) or question text (a tool.start row, whose id is the model's tool_call_id and never matches).

Correlating per row rather than per session is load-bearing: a transcript can hold several clarify rows, and a session-wide match let an older un-resulted row claim a newer request, then strand itself on a spinner when the pending panel rejected the mismatch. That regression was caught in review and is covered by a test verified to fail against the un-correlated version.

2. clarify.request is one-shot and was never replayable

The gateway tracked the wait in _pending / _pending_prompt_payloads but never handed it back, so a reopened window, an app restart, or a reconnect that dropped the frame could never re-arm — the card rendered the stored question with no request_id to answer with.

_session_pending_prompts() now replays still-blocked prompts through _live_session_payload as pending_prompts, and the renderer re-arms on both session.activate and session.resume. Only a live session can hold a blocked prompt, so that is the payload that can carry one.

3. clarify.expire had no desktop handler at all

The gateway emitted it; nothing listened. It now drops the request and the sidebar's needs-input flag — but only when the clear actually happened, so a stale expire cannot silence a session still blocked on a newer question (clearClarifyRequest() returns that verdict).

Also clears parked requests on a gateway switch: they key off runtime session ids the next backend re-mints, and the card no longer self-limits via the running flag.

Verification

  • desktop vitest: 2918 passed, 0 failed
  • scripts/run_tests.sh tests/tui_gateway/: 487 passed, 0 failed
  • npm run typecheck and eslint clean
  • The Python test drives the real _block bridge on a worker thread and asserts the replayed request_id actually releases the blocked tool — not a mock.

Reviewed adversarially by a second model, which cleared lock discipline on _prompt_lock (no deadlock), payload safety (sudo -S -p '' payload is {}; secret carries the request, never the value), and backward compat in both directions.

… expired

Clicking into a chat with an unanswered clarify flashed the question card
and then lost it, leaving the question unrecoverable and the agent blocked
until agent.clarify_timeout (3600s). Three independent defects:

1. The card's lifetime was gated on `messageRunning`
   (thread.isRunning && message.status === 'running'). thread.isRunning is
   fed from `busy`, and resumeSession sets busyRef true on entry then back
   to the resumed `running` in its finally block — which the deferred
   resume path reports as false. So the panel mounted from the persisted
   tool-call args, then unmounted a beat later. It now lives and dies with
   its own request, correlated by request id (a row hydrated from
   clarify.request) or question text (a tool.start row, whose id is the
   model's tool_call_id and never matches). Correlating per ROW, not per
   session, keeps an older un-resulted clarify from claiming a newer
   request and stranding itself on a spinner.

2. clarify.request is a one-shot event. The gateway tracked the wait in
   _pending/_pending_prompt_payloads but never handed it back, so a
   reopened window, an app restart, or a reconnect that dropped the frame
   could never re-arm and the card had no request_id to answer with.
   _session_pending_prompts() now replays still-blocked prompts through
   _live_session_payload as `pending_prompts`, and the renderer re-arms on
   both session.activate and session.resume. Only a live session can hold
   a blocked prompt, so this is the payload that can carry one.

3. clarify.expire was emitted by the gateway and had no desktop handler at
   all. It now drops the request and the sidebar's needs-input flag — but
   only when the clear actually happened, so a stale expire cannot silence
   a session still blocked on a newer question. clearClarifyRequest()
   returns that verdict.

Also clears parked requests on a gateway switch: they key off runtime
session ids the next backend mints fresh, and the card no longer
self-limits via the running flag.

Verified: desktop vitest 2918 passed, tests/tui_gateway 487 passed,
typecheck and eslint clean. The multi-clarify test was confirmed to fail
against the un-correlated version of the fix.
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 89d9d0d

all good!

@SlowGreek
SlowGreek merged commit 55cfa46 into costas-code Jul 29, 2026
46 checks passed
@SlowGreek
SlowGreek deleted the fix/clarify-card-persistence branch July 29, 2026 19:13
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.

2 participants