Skip to content

fix(inbox): a mirrored prompt must be answerable where it arrives - #625

Open
djpentz wants to merge 2 commits into
andrewyng:mainfrom
djpentz:fix/telegram-approval-replies
Open

fix(inbox): a mirrored prompt must be answerable where it arrives#625
djpentz wants to merge 2 commits into
andrewyng:mainfrom
djpentz:fix/telegram-approval-replies

Conversation

@djpentz

@djpentz djpentz commented Sep 4, 2026

Copy link
Copy Markdown

The bug

mirror_inbox_item decides how to send a prompt by asking whether the item has
buttons. But having buttons and being able to draw them are different questions:
BasePlatformAdapter.send_interactive falls back to plain send(), and only
SlackAdapter overrides it. On Telegram — and on any adapter that hasn't implemented
it — the interactive branch therefore sends bare text, dropping two things the other
branch includes:

  • the [ow:<id>] tag, which is the only thing _resolve_inbox_reply can correlate a
    reply against, and
  • any instruction about how to answer.

buttons_for returns buttons for approvals and option questions, which are exactly the
prompts that suspend an agent. So on those platforms the reader is shown a question they
cannot answer from the surface it arrived on, and the agent waits indefinitely. Free-text
questions are unaffected — they take the text branch and work fine, which is why this can
go unnoticed in testing.

How we found it

A scheduled task on a headless deployment parked a web_fetch approval at 07:00. Its
Telegram card had no buttons, no tag and no instructions. The run stayed suspended for
36 hours, and because skip-on-overlap won't start a run while the previous one is
still going, the next day's run never happened either. The task's run_count was still
0 two days after it was armed, while the UI showed it enabled and healthy.

Answering by hand with approve [ow:<id>] — after reading the id out of the database —
released it immediately, which confirmed the parser was fine and the message was the
problem.

The change

  • BasePlatformAdapter.supports_interactive = False, SlackAdapter.supports_interactive = True.
  • Gateway.supports_interactive(target) answers the question per target.
  • The mirror picks its branch on capability, not on the presence of buttons.
  • The text fallback names the words the reply parser accepts — approve/deny, or the
    option labels — and keeps the tag.

Slack behaviour is unchanged. Any future adapter is safe by default: it gets an
answerable text prompt until it implements buttons.

Tests

tests/test_inbox_mirror_answerable.py covers all three paths — approval without
buttons, option question without buttons, and buttons where the platform draws them.
The first two fail against main.

Ran locally on 3.12: the new file plus the 196 tests matching
inbox|connector|mirror|telegram|slack|interaction all pass.

Follow-up, not in this PR

Telegram inline keyboards would be the nicer answer for these two item kinds, mirroring
what SlackAdapter already does. That's a bigger change and this one stands on its own —
it makes every non-Slack platform answerable today, and keeps the fallback honest for
whatever adapter comes next.

Screenshots of the before/after cards follow in a comment.

`buttons_for` returns buttons for approvals and option questions, and
`mirror_inbox_item` branched on that alone. But only an adapter implementing
`send_interactive` can draw them — the base class quietly falls back to plain
text, and Telegram (like any adapter that hasn't implemented it) takes that path.

The result on those platforms: the reader gets a question with no buttons, no
`[ow:<id>]` tag for `_resolve_inbox_reply` to correlate a reply against, and no
instructions — while the text branch, used for free-text questions, carries both.
Approvals and option questions are exactly the prompts that suspend an agent, so
the effect is an agent waiting forever on a question nobody could answer.

Found in production: a scheduled task parked a `web_fetch` approval at 07:00, its
Telegram card could not be answered, and the task stayed suspended for 36 hours —
taking the next day's run with it, since skip-on-overlap declines to start a run
while the previous one is still going.

Adapters now declare `supports_interactive` (True on Slack, False by default), the
gateway answers that question per target, and the mirror picks the branch on
capability rather than on the presence of buttons. The text fallback names the
words the reply parser accepts — "approve"/"deny", or the option labels — and
keeps the tag. Slack is unchanged.

Tests cover all three paths and fail against the previous behaviour.
@djpentz

djpentz commented Sep 4, 2026

Copy link
Copy Markdown
Author

Screenshots from the deployment where this was found — same bot, same chat, a Telegram binding on a headless engine.

Before. A scheduled run parks a web_fetch approval. buttons_for returns Approve/Deny, so the mirror takes the interactive branch, TelegramAdapter inherits the base send_interactive and sends plain text — and the message arrives with no buttons, no [ow:<id>] tag and no instructions. There is nothing the reader can do with it. Note the message directly above it: a plan prompt, which has no buttons, so it took the text branch and does carry the tag and a hint. Same bot, minutes apart.

before: an approval with nothing to reply to

After. The same engine with this patch applied. The 20:29 message is the old build; the 20:48 one is the new: the same approval now carries the instruction line and the tag, so it can be answered from the phone.

IMG_8716

Replying deny [ow:ad711097…] resolved the item and released the suspended run — the parser was always fine; the message simply never carried what it needs.

Two follow-ups this suggests, deliberately not in this PR: honouring reply_to_message so a swipe-reply needs no tag, and resolving a bare approve/deny when exactly one item is pending on that binding. Both make the natural gesture work; this PR is about the prompt not being a dead end in the first place.

The card is answerable now, but only with approve or deny — and for a scheduled run
those are both one-offs. The answer that makes a routine stop asking, "always", was
reachable solely as a button in an app, so a prompt arriving on a phone at 07:00
could be cleared but never silenced, and the same question came back the next
morning. That is the whole complaint about unattended work: it is not unattended if
it needs a laptop.

The reply parser learns a third intent, mapping to the same `always_task` resolution
the in-app card sends. It is gated where it means nothing: on a question it is taken
as a plain yes rather than stored as the answer text, and the card only advertises it
when the item belongs to a routine — in a plain session it would resolve as a
one-off and read as a lie.
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.

1 participant