fix(gateway): retire native clarify cards on timeout, reset and prose cancel

One adapter-facing seam replaces the Slack-only callback: an adapter whose
clarify prompt is a persistent card (Slack Block Kit) defines
`retire_clarify_card(clarify_id, notice)`, and the gateway calls it from
every path that ends a clarify without a button click:

- TurnRunner._clarify_callback_sync: when the bounded wait returns a
  sentinel (timeout, /new or run-end clear_session), schedule the retire
  with the expired notice on the gateway loop (#110821).
- run_inbound TEXT_REJECTED_PROSE: retire with the cancelled notice before
  the prose is routed as a follow-up (#111019). Lookup is on the adapter
  class so MagicMock doubles cannot fabricate the method; no platform ==
  SLACK special-case.

The Slack map is keyed by clarify_id and popped before the first await, so
a late timer cannot touch a newer prompt and the button handler's ts-keyed
guard makes a racing click a no-op. Gateway-restart-orphaned cards stay
out of scope: nothing is waiting on the new process, and the click path
already renders them expired.

Tests trimmed to invariants: the runner-level timeout probe (card adapter
vs no-card adapter), the inbound prose retire, and one Slack test covering
buttons-dropped + late-click-noop. Docs updated for the new in-place edit.
This commit is contained in:
teknium1
2026-09-14 18:27:59 -07:00
committed by Teknium
parent a41187e187
commit 71cac9426d
8 changed files with 138 additions and 44 deletions
+9 -10
View File
@@ -1048,8 +1048,8 @@ class SlackAdapter(BasePlatformAdapter):
# Bounded: never-clicked prompts would otherwise leak forever.
self._approval_resolved: Dict[Any, bool] = {}
self._clarify_resolved: Dict[Any, bool] = {}
# clarify_id → (channel_id, message_ts, rendered_question). This lets the inbound
# free-prose path retire a native card that will no longer accept a response.
# clarify_id → (channel_id, message_ts, rendered_question) so the gateway can retire a
# card whose clarify ended without a click (timeout, reset, superseding prose).
self._clarify_messages: Dict[str, Tuple[str, str, str]] = {}
# Model picker state keyed by workspace message marker (team_id, ts) →
# picker context (providers, session_key, on_model_selected, stage).
@@ -5416,12 +5416,13 @@ class SlackAdapter(BasePlatformAdapter):
channel_id, msg_ts, question_text, decision_text, "Clarification", "clarify", sanitize=False
)
async def cancel_clarify_message(self, clarify_id: str) -> None:
"""Retire a Block Kit clarify card released by unmatched free prose.
async def retire_clarify_card(self, clarify_id: str, notice: str) -> None:
"""Rewrite a still-live clarify card into a terminal, button-less state.
The generic inbound path intentionally lets that prose continue as a normal follow-up.
Slack alone needs to edit its already-posted interactive card so its buttons do not
advertise an answer path that the released clarify can no longer accept.
The gateway calls this whenever it ends a clarify without a button click — the wait
timed out, the session was reset, or unmatched free prose superseded the prompt — so
the card stops advertising an answer path the released clarify can no longer accept.
Keyed by clarify_id, so a late call cannot touch a newer prompt; no-op once resolved.
"""
target = self._clarify_messages.pop(clarify_id, None)
if target is None:
@@ -5429,9 +5430,7 @@ class SlackAdapter(BasePlatformAdapter):
channel_id, msg_ts, question_text = target
# A late action handler must be a no-op while the best-effort chat.update is in flight.
self._clarify_resolved[msg_ts] = True
await self._update_clarify_message(
channel_id, msg_ts, question_text,
"↩️ Clarification cancelled — your message will be handled as a follow-up.")
await self._update_clarify_message(channel_id, msg_ts, question_text, notice)
async def _handle_clarify_action(self, ack, body, action) -> None:
"""Handle a clarify button click (a choice or "Other") from Block Kit."""