Skip to content

fix(daemon): tell the channel when an agent's question cannot be rendered there - #1819

Merged
spacedragon merged 3 commits into
mainfrom
dev/yulong/elicit-decline-notice
Sep 7, 2026
Merged

fix(daemon): tell the channel when an agent's question cannot be rendered there#1819
spacedragon merged 3 commits into
mainfrom
dev/yulong/elicit-decline-notice

Conversation

@spacedragon

Copy link
Copy Markdown
Contributor

Implements the Slack-column item on #1794: say something in-channel when a form cannot be rendered, instead of declining in silence.

The problem

When an elicitation could not be rendered, onAcpElicit returned undefined, AcpHost answered decline, and nothing appeared in the conversation at all. The agent asked the reader a question and the reader never learned it had been asked — from the channel it is indistinguishable from the agent ignoring them.

This is what made every other gap worse. A multi-field form, a pattern we refuse to run, a required field with no control, an option list past the surface's cap: all of them vanished without trace.

Not Slack-only

A notice is text, not a card, so Telegram, Discord and Feishu were silent in exactly the same way and can all carry one. All four are covered.

The notice reads:

💬 The agent asked something this chat can't collect an answer for, so it was declined:
   <the agent's question>
   You can answer it in the session console: <link>

No schema vocabulary — the reader did not write the schema, so requestedSchema / minLength / multi-enum appear nowhere. If the console link cannot be computed the line is dropped and the question still goes out; a link we cannot build must not cost the reader the question.

The quoted question is agent-authored, so it is defused per surface

#1810 fixed a live phishing shape: agent text in a Slack mrkdwn block could render a tappable link with attacker-chosen wording. Quoting the question in a notice would have reintroduced exactly that, so it goes through the same defusing — but the defusing is not one dialect:

Surface Reads markup?
Slack yes — autolinks bare URLs, honours <url|label>
Discord yes — honours [label](url); the repo already relies on this, discord/render.ts:535 emits [details](…) inside a notice
Telegram no parse_mode is set, so no label syntax to spoof with
Feishu plain text

elicitCardMessage's escaping is Slack-specific (HTML entities), so pasting it into a Telegram notice would print literal &amp;, and doing nothing on Discord leaves [label](evil) live. CLAUDE.md forbids core reading a platform name, so the fact is declared where the other post-dispatch surface facts live:

noticeMarkup?: 'slack-mrkdwn' | 'markdown'   // absent ⇒ verbatim, no markup

Three implementers on arrival (Slack, Discord, and the absent arm), so the member is not an interface guessed from a single caller. Core reads the flag.

The notice is built from the maskAgentSecrets-masked params, and goes out through the existing notice path so the chrome marking that makes a peer daemon's backfill skip it applies here too.

One notice per distinct question per turn

Not one per elicitation, and not one per turn. The flood risk is a runtime re-raising the same unrenderable ask in a retry loop, and deduping on the question text removes exactly that. But a turn that asks three genuinely different unanswerable questions has three things the reader has not been told, and staying silent about two of them is the bug being fixed. The set lives on Pending, so it is scoped to the turn and freed with it.

Not covered, and the plan was wrong about why

The original plan said webchat needed nothing because it renders every shape. That is not true. awaitWebchatElicitation also returns undefined — declining with nothing shown — when elicitForm is null (a required property no control can answer) and when the sink throws. Webchat is silent in the same way; its fix is a stream event rather than a notice, and it is now tracked on #1794 rather than left implicit.

A webchat continuation does get a notice: it falls through to the platform path and its origin thread was silent, which is the case this fixes.

Verification

  • protocol 488, relay 644, web 2400 — all green.
  • pnpm typecheck: error TS count 0.
  • eslint + prettier clean on the six changed files.

The full daemon suite was not observed green, and this PR does not claim it was. This host's root filesystem is full (~120 MB free) and the suite fails wholesale with SQLITE_FULL: database or disk is full. Running it under a tmpfs TMPDIR got it to 10 failing files, of which one was real and mineturn-chrome.test.ts asserts Slack's chrome set exhaustively and the new noticeMarkup broke it. That is fixed here, and it is precisely the failure a hand-picked subset would have missed. The rest were the documented pre-existing evaluation-runner case, acp-matrix dialling live runtimes (402 Insufficient Balance), and files whose failing test names changed run to run under disk pressure.

Run alone and green: elicit-decline-notice (10), turn-chrome (4), daemon-permission-autoallow, approval-dm, daemon-commands — 204 passing together on re-verification.

Every new test was confirmed red with only the source reverted: the notice not posted, the question not defused, the raw rather than masked question quoted, and the repeat not collapsed.

…instead of declining in silence

An elicitation no chat surface can render was answered `decline` with nothing at
all posted: the agent asked the reader a question and, from the channel, it was
indistinguishable from the agent ignoring them. A multi-field form, a required
field with no control, an option list past Slack's cap — all vanished.

The decline stands; the channel now hears the agent's own question, that this
chat cannot collect that answer, and the console link where it can. It is plain
text, so every chat platform with a text transport gets it — not Slack alone.
Silent by design where nothing was lost: a webchat turn (it renders every
shape), an MCP approval (the editor queue has its own notice), and a turn whose
surface was deliberately suppressed (which cancels, not declines).

The question is agent-authored, so it is defused before it becomes a tappable
link, the way each surface reads a notice: `noticeMarkup` is the turn-chrome
declaration for that, `slack-mrkdwn` for Slack's `<url|label>` and autolink,
`markdown` for Discord's `[label](url)`; Telegram and Feishu read neither and
take the text verbatim. It is built from the `maskAgentSecrets`-masked params.

One notice per distinct question per turn: a runtime re-raising the same
unrenderable ask floods nothing, and a genuinely different question is a second
thing the reader has not been told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One targeted fix needed: Slack notices use standard Markdown blocks, but the new declaration selects mrkdwn escaping. That leaves a way for the quoted question to contain an active link with agent-chosen wording, bypassing this PR’s intended link defusing. See the inline reproduction and Slack’s Markdown block documentation.

Validation: all 204 tests across the five focused daemon suites passed; the PR diff passes whitespace checks. No other blocking findings.

sent by review-bot (Codex · gpt-6-astra) · open in session

chatInputCards: true,
chromeMarkedNotices: true
chromeMarkedNotices: true,
noticeMarkup: 'slack-mrkdwn'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Match the Markdown format used by Slack notices

The notice applier calls SlackConnection.postMessage(), whose markdownBlock() emits { type: 'markdown' }, rather than the mrkdwn section used by elicitation cards. For example, the question Sign in at [your account](https\://example.invalid/login) passes through this branch unchanged: the escaped colon avoids BARE_URL_RE, and Markdown parses it as a link labeled “your account” to https://example.invalid/login. I reproduced that with the actual helper followed by the CommonMark parser. Slack documents both Markdown links and backslash escaping. Declare the actual notice dialect and escape its link syntax, or send the question through an explicitly plain-text surface, so the newly quoted question cannot restore the labeled-link behavior this PR intends to prevent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f53c233 — you're right, and it was the wrong dialect on the one surface where it mattered most.

Verified your claim in the code before changing anything: SlackConnection.postMessage builds its body with markdownBlock (connection.ts:734), which emits { type: 'markdown' }. So a Slack notice is standard Markdown, while an elicitation card posts an mrkdwn section — two different dialects on one platform, and I declared the card's for the notice. That routed the quoted question through HTML-entity escaping, which leaves [ and ] intact, and your https\\:// example additionally slips BARE_URL_RE while the parser still resolves the destination. The labeled link survived.

Slack now declares markdown alongside Discord. I also removed the slack-mrkdwn arm rather than leaving it: with no surface selecting it, it was a second way to make this exact mistake, and the card path already has its own defusing in elicitCardMessage — which is where that dialect belongs.

Worth stating explicitly since it shapes the fix: escaping the brackets is the part that matters. A bare URL left autolinking still shows the reader the destination they are going to; a label can say anything, and that is the property #1810 protects.

Tests: the dialect assertion in elicit-decline-notice.test.ts was asserting the disproven premise (it expected &lt;/&gt;), so it is inverted to assert the Markdown behaviour, and your exact construction is now a case of its own — Sign in at [your account](https\\://evil.example/login) must not leave [your account]( intact. turn-chrome.test.ts updated for the declaration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: f53c233 fixes this dialect finding and the original reproduction. All 204 focused tests pass.

The remaining backslash-escaping case is covered in my separate review comment.

sent by review-bot (Codex · gpt-6-astra) · open in session

…ard uses

`postMessage` sends `{ type: 'markdown' }` via `markdownBlock`, while an
elicitation card posts an `mrkdwn` section. Declaring the notice surface as
`slack-mrkdwn` therefore selected HTML-entity escaping for text the parser reads
as Markdown: brackets survived, and `[your account](https\://evil/login)` slipped
the bare-URL scan too, since the escaped scheme does not match it while the
parser still resolves the destination. The labeled link this change exists to
defuse stayed live on the one surface it mattered most.

Slack declares `markdown` alongside Discord, and the now-unreachable mrkdwn arm
is removed rather than left as a second way to be wrong — Slack's CARD text is
defused by `elicitCardMessage`, which is where that dialect belongs.

Escaping the brackets is the part that matters: a bare URL left autolinking
still shows the reader where they are going, whereas a label can say anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Slack dialect correction fixes the previous reproduction. One related P2 remains: the Markdown helper does not escape existing backslashes, so an already escaped question can become an active labeled link after processing. The inline comment includes the reproduced input and a targeted correction.

All 204 focused daemon tests passed at f53c233, and the PR diff passes whitespace checks. No other blocking findings.

sent by review-bot (Codex · gpt-6-astra) · open in session

* URL left autolinking still shows the reader the destination they are going to, whereas a
* label can say anything. A surface declaring no markup shows the text verbatim. Pure. */
export function defuseNoticeText(raw: string, markup?: NoticeMarkup): string {
if (markup === 'markdown') return raw.replace(/[[\]]/g, (c) => `\\${c}`).replace(BARE_URL_RE, (m) => `\`${m}\``)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Escape existing backslashes along with the brackets

Prefixing only [ and ] with a backslash can undo escaping already present in the question. For example, Sign in at \[your account\](https\://example.invalid/login) contains no Markdown link before this helper runs. The helper produces two backslashes before each bracket; Markdown consumes each pair as a literal backslash, leaving active link delimiters, and resolves the escaped-colon destination to HTTPS. I reproduced this using the actual helper followed by the CommonMark parser: zero links before processing, one labeled link afterward. This leaves the quoted-question link protection bypassable on the corrected Slack path. Escape existing backslashes too, before or together with brackets; that removes the link in the same reproduction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cf8c038. This one is nastier than the first: escaping only the brackets doesn't just fail to kill the link, it creates one that wasn't there.

Reproduced with the actual helper before changing anything:

input      : Sign in at \[your account\](https\://evil.example/login)
OLD helper : Sign in at \\[your account\\](https\://evil.example/login)
NEW helper : Sign in at \\\[your account\\\](https\\://evil.example/login)

The old output is a literal backslash followed by an active [ — the parser eats the pair we added and hands the delimiter back — so a question that held no link on arrival came out holding one. The new output is literal-backslash + literal-bracket, and the destination's escaped colon is escaped too.

Backslash, [ and ] are now escaped in a single pass, which is what makes it idempotent-safe: a sequential pass would have to re-scan its own output.

Test asserts your exact construction, byte for byte, rather than just "contains no link" — the interesting property here is what the escaping produces, not merely that a naive check passes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed fixed in cf8c038. I verified both reproductions with the Markdown parser, including backslash counts 0–6. All 204 focused tests passed, and I’ve approved the revision.

sent by review-bot (Codex · gpt-6-astra) · open in session

… revives

Prefixing only the brackets can CREATE the link it means to kill. A question
already containing an escaped label — `\[label\](https\://evil/login)` — holds no
Markdown link when it arrives. Escaping just the brackets makes each one a pair,
the parser consumes the pair as one literal backslash, and hands back an active
delimiter; the escaped-colon destination then resolves and the labeled link is
live on exactly the surface the previous commit corrected.

Backslash, `[` and `]` are now escaped in one pass, so an already-escaped bracket
stays literal instead of being handed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at cf8c038. Both previously reported link-escaping issues are resolved. I verified that the actual helper produces no Markdown links for either reproduction or bracket escapes with 0–6 preceding backslashes, while plain-text surfaces retain the original text.

All 204 focused daemon tests passed, and the PR diff passes whitespace checks. No remaining blocking findings.

sent by review-bot (Codex · gpt-6-astra) · open in session

@spacedragon
spacedragon merged commit 5dcded1 into main Sep 7, 2026
14 checks passed
@spacedragon
spacedragon deleted the dev/yulong/elicit-decline-notice branch September 7, 2026 04:03
spacedragon added a commit that referenced this pull request Sep 7, 2026
* feat(daemon): a URL elicitation posts a consent card on Slack

URL mode is the seam the spec reserves for credentials, API keys, OAuth and
payment. Webchat got its consent card in #1810; on Slack the same ask still
declined — latterly with a notice (#1819) the reader could not act on without
leaving the channel. This is a second SURFACE for that machinery, not a second
implementation of it: `elicitUrl` still reduces the ask, `handleUrlElicitConsent`
still settles it, and `onAcpElicitComplete` still re-labels it.

The URL is shown but deliberately not followable as text. Slack autolinks a bare
URL, and a reader who left through that autolink delivers no interaction at all,
so the ACP request would hang until the turn ended — consent has to be
observable. So the whole URL sits in a code span, which Slack mrkdwn does not
autolink, and an "Open link" button carries Slack's `url` field, which both opens
the page and still sends the interaction the card is settled on. `accept` means
the reader consented to open it, never that the flow behind it finished; Dismiss
is `decline` and the turn ending is `cancel`.

The card names the REAL host on its own line, so neither a Punycode lookalike nor
a userinfo prefix can pass itself off as the destination, and it calls out a
non-ASCII host and a missing `https`. Slack documents no scheme validation on
`url`, so the card re-checks `http`/`https` itself rather than trust it, and
refuses a URL too long for a button `value` (2000) or carrying a backtick that
would escape the code span. The agent's own `message` stays defused through
`elicitCardMessage`, so nothing in it can become a second followable link.

Note what this cannot promise: Slack mobile opens a `url` button in its own
in-app webview unless the reader turns "Open web pages in app" off, so the page
is out of the agent's and the daemon's reach but not necessarily in the reader's
own browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(console): name a consent URL's real host, so userinfo cannot impersonate it

The webchat consent card splits the original URL and weights the authority slice
as the host, so `https://login.example.com@evil.test/authorize` renders with
`login.example.com@evil.test` emphasised as the destination. The bytes are all
there, but the part the card presents AS the host is attacker-chosen text and
the link goes to evil.test.

The card now names where the link actually resolves whenever the authority
carries userinfo, and still shows the URL verbatim — a homograph or a userinfo
prefix is only a lookalike on screen, so the warning has to be about what is on
screen. The Slack consent card added alongside this shows `parsed.hostname`
directly and never had the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
spacedragon added a commit that referenced this pull request Sep 7, 2026
…up is decoded

Two P2s from review, both reproduced.

**The DM exception was dead code.** Slack normalization sets `thread` to
`thread_ts ?? ts`, so it is never absent and a root message carries its OWN ts
there — while the match tested `msg.thread !== undefined` to tell a root from a
reply. Every DM answer therefore took the threaded branch, compared its own ts
against the card's thread, and missed: a card at 100.001 with a top-level `42`
at 200.001 was not an answer, and the DM card was exactly as unanswerable as it
would have been with no exception at all.

The fact was never lost in normalization, only mis-read, so it is derived rather
than preserved: a message whose `thread` equals the ts inside its own `msgId` is
a root. No new normalized field, because `thread` has some forty readers across
daemon, relay and control-plane whose meaning must not shift under them, and a
wire field would need a skew window on both ingress paths for something already
in hand. The different-thread exclusion is unchanged and still tested: a reply
under another root of the same conversation — DM included — answers nothing.

**Slack's link markup reached validation undecoded.** Retrieved message text
keeps `<https://example.com/>` and `<mailto:a@b|a@b>`, so a `uri` or `email`
field refused the reader forever for following the card's own instruction.
`decodeSlackReplyValue` recovers what the reader typed, beside
`stripLeadingSelfMention` as the second pure step before any schema check: the
destination is the part BEFORE the pipe (a label is display text and is never
the value) and `mailto:` comes off, so an `email` field gets `a@b`.

DECODING, not extracting: only a reply that is one link and nothing else is
unwrapped. Prose with a link inside is returned whole, because choosing which
PART of a sentence is the answer is the guess this route refuses to make. It
also undoes the three entities Slack escapes in message text, which is lossless
and is where a wrong value would otherwise have passed `uri` silently — a query
string arrives as `?a=1&amp;b=2`. Inbound only: nothing here touches the
outbound defusing of agent-authored text (#1810/#1819).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spacedragon added a commit that referenced this pull request Sep 7, 2026
… reply (#1828)

* feat(daemon): a Slack text/number elicitation is answered by a thread reply

`text` and `number` join `SLACK_ELICIT_SURFACE.kinds`. Slack's own way to type
something is to send a message, so the card carries the question, what is
expected in plain words, whom it awaits and Dismiss — and nothing to type into.
No modal (a `trigger_id` expires in ~3s and the relay holds no message content)
and no `input` block with `dispatch_action`, which costs an interaction round
trip per change and buys nothing here.

The hard part is that such a reply would otherwise START A NEW TURN: while the
card is live the ACP prompt is still blocked, so ingress has to read the reply
as the answer rather than dispatch or queue it. `answerElicitReply` sits in both
Slack ingress paths — the relay's `rd/msg(im)` and the direct socket ladder — at
the same point, right after control commands (`!stop` still stops) and before
routing, and returns whether it consumed the delivery.

The interception IS the pending card: its `reply` target holds the conversation
(platform + transcript channel, so a second Slack app in that channel is a
different conversation), the turn's own thread (so another thread of the same
channel is not the answer) and the requester. Nothing else is remembered, so it
is released the instant the card settles, is dismissed, or the turn ends
through `releaseElicits` — no state can outlive its card.

The turn's requester answers; a card whose turn identified none waits for anyone
in the thread, and says so. The answer is re-derived against the card exactly as
a tapped option is (#1815) through `textAccepts` / `numberAccepts`, and a
numeric field accepts a real JS number, never the string that spelled it. An
invalid reply posts the reason and leaves the card live — Dismiss stays the only
explicit refusal. A DM session also takes a top-level message, where its reader
actually talks.

What is NOT an answer stays an ordinary message: a bot's post, an automation, a
reply carrying an attachment (consuming it would drop the file), a top-level
channel post, and anyone other than the requester. A human edit never even
reaches here — Slack ingress drops edit wrappers.

The approval DM keeps declining both kinds: a DM has no session thread whose
replies are intercepted, so its card would ask for a reply nothing reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(daemon): a reply answers one open question, and an address is not part of it

Two corrections to the thread-reply route, both about attributing an answer to
the card it was actually given to.

**Several open questions in one thread answer none of them.** The first cut took
the oldest matching card, which is the same lie a shared selection told on
#1825: the reader answers B's question and A's runtime is told they answered A.
Every value in it is one the card really offered, so no re-derivation can catch
it. Now a reply matching more than one live card settles nothing, consumes
nothing, and the ambiguity is said in the thread — how many questions are open
and that dismissing all but one (or letting one settle) makes the next reply
answer that one. So the reader has a way through instead of a silent
misattribution, and the property every other surface now has holds here too.

**A leading mention of the asking bot comes off before validation.** `@bot 42`
is 42: addressing the asker is chrome, not value, and refusing it as malformed
was a default that would bite. Only that one token, only at the front, and only
this bot's OWN user id — a mention of anyone else, a second one, or one anywhere
but the start stays part of the answer, because guessing which other PART of a
message is the value is how a typed answer comes back wrong. The id is read from
the card's own connection (`SlackConnection.botUserId`, resolved at `auth.test`
for send-only connections too), so both ingresses see the same identity and an
unresolved one strips nothing rather than guessing at the token. The match is a
pure rule beside the other Slack text rules, comparing the captured id, so no
user id is ever compiled into a pattern.

The two notices now share one `noticeInTurn`, so a refusal and an ambiguity land
the same way and neither can throw into the answer path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(daemon): a DM root really answers its card, and Slack's link markup is decoded

Two P2s from review, both reproduced.

**The DM exception was dead code.** Slack normalization sets `thread` to
`thread_ts ?? ts`, so it is never absent and a root message carries its OWN ts
there — while the match tested `msg.thread !== undefined` to tell a root from a
reply. Every DM answer therefore took the threaded branch, compared its own ts
against the card's thread, and missed: a card at 100.001 with a top-level `42`
at 200.001 was not an answer, and the DM card was exactly as unanswerable as it
would have been with no exception at all.

The fact was never lost in normalization, only mis-read, so it is derived rather
than preserved: a message whose `thread` equals the ts inside its own `msgId` is
a root. No new normalized field, because `thread` has some forty readers across
daemon, relay and control-plane whose meaning must not shift under them, and a
wire field would need a skew window on both ingress paths for something already
in hand. The different-thread exclusion is unchanged and still tested: a reply
under another root of the same conversation — DM included — answers nothing.

**Slack's link markup reached validation undecoded.** Retrieved message text
keeps `<https://example.com/>` and `<mailto:a@b|a@b>`, so a `uri` or `email`
field refused the reader forever for following the card's own instruction.
`decodeSlackReplyValue` recovers what the reader typed, beside
`stripLeadingSelfMention` as the second pure step before any schema check: the
destination is the part BEFORE the pipe (a label is display text and is never
the value) and `mailto:` comes off, so an `email` field gets `a@b`.

DECODING, not extracting: only a reply that is one link and nothing else is
unwrapped. Prose with a link inside is returned whole, because choosing which
PART of a sentence is the answer is the guess this route refuses to make. It
also undoes the three entities Slack escapes in message text, which is lossless
and is where a wrong value would otherwise have passed `uri` silently — a query
string arrives as `?a=1&amp;b=2`. Inbound only: nothing here touches the
outbound defusing of agent-authored text (#1810/#1819).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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