Skip to content

feat(daemon): a Slack elicitation offers a multi-select and a Confirm - #1825

Merged
spacedragon merged 2 commits into
mainfrom
dev/yulong/slack-multiselect
Sep 7, 2026
Merged

feat(daemon): a Slack elicitation offers a multi-select and a Confirm#1825
spacedragon merged 2 commits into
mainfrom
dev/yulong/slack-multiselect

Conversation

@spacedragon

Copy link
Copy Markdown
Contributor

Implements the Slack-column item on #1794: multi-select on Slack, with no modal.

Multi-select landed for webchat in #1801 and declined on Slack, because a row of buttons cannot express "pick several, then confirm". multi_static_select can, inside a message — which is why this is separate from the form work that does need a modal.

The interaction, and where the in-progress selection lives

A multi_static_select does not submit itself: Slack delivers an interaction on every selection change, and the reader needs a separate Confirm. So the card is select + Confirm + Dismiss.

Checked rather than assumed: Slack re-sends the whole selection on each change (actions[0].selected_options), so nothing accumulates — only the last one has to be remembered. It lives on the card's own pending record and is freed by every settle path, so there is no new cleanup hook. Nothing else could hold it: the relay persists no message content, and Confirm's button value is frozen at render time.

It is seeded from the schema's default, matching the select's initial_options, so an untouched Confirm submits exactly what the card was posted showing. It is relayed, never trusted — Confirm delegates to the same path as every other answer, so #1815's re-derivation against the card is still the only thing that makes it valid.

Message-level state.values was deliberately not used: it is documented for views, and I could not confirm Slack populates it for elements in a message's actions block. The selected_options route needs no such guarantee.

Per-kind option limits, because Slack's two caps differ

#1813 made the option limit a property of the surface. It has to become a property of the surface and the kind, because Slack's caps are not the same thing:

  • enum → 24 options (the 25-element actions block minus Dismiss) — unchanged
  • multi-enum → 100 options (the select's own cap) and 75 characters per option value

That 75-char value cap is exactly why #1813 rejected static_select for single-select. A multi-select has no button fallback, so an over-long option value declines instead. Keeping the limits per kind is what stops the select's 100 quietly widening the button row's 24 — pinned by a test where 25 buttons still decline while 25 select options do not.

One design consequence worth naming: the request id rides the select's own action_id, not its option values, because a card identified only by its selected options cannot report an empty selection. Option values therefore stay bare, so the 75-char cap applies exactly.

Both interaction edges needed changing, and that was not avoidable

The existing ac_elicit:* / elicitation-choice path could not be reused: a select payload has no value field at all, and Confirm is a third verb with no room in one nullable string. Both edges — the relay's HTTP ingest and the daemon's Bolt handlers — decode two new verbs, carried as two new RdSlackAction members rather than by widening value.

Skew: forward (new relay → old daemon) fails closed — zod rejects the action, the forward is refused, the card stays live. Backward (old relay → new daemon) simply never sends them, so Confirm submits what the card was posted showing, which is why seeding from default rather than leaving it empty matters.

The relay also folds the selection into its dedup id, so two changes on one card are two interactions rather than one swallowed.

The approval DM would have been widened silently

sendApprovalDm reuses buildElicitationCard, so adding multi-enum to the Slack surface widened the DM card too — and a DM tap settles through the editor path, which holds no per-card selection. That would have been a Confirm that could never work.

SLACK_DM_ELICIT_SURFACE (enum + boolean) now bounds it, and the DM's own re-derivation sites use it. This is the same shape as the ?? [] empty-DM-card hole already tracked on #1794.

Known gaps, recorded on #1794 rather than left in this body

  • A refused Confirm is silent. The re-derivation convention is "drop it, leave the card live", so a below-minItems Confirm tells the reader nothing. Mitigated by putting the bounds on the card as a context line; the proper fix is an ephemeral response_url reply.
  • The 75-char option-value cap bites in practice. Enum values that are paths, URLs or ids are common, and such a multi-select declines on Slack while rendering on webchat. Same fix as feat(daemon): a URL elicitation posts a consent card on Slack #1821's long-URL gap: a per-request nonce with a server-side lookup.
  • minItems: 0 with a seeded default makes an untouched Confirm accept [], indistinguishable from a deliberate empty choice. Webchat reads it the same way (feat(console): webchat answers multi-select elicitations #1801), so consistency was kept rather than inventing a second rule.

Verification

  • protocol 488, relay 646, web 2404 — green.
  • pnpm typecheck: error TS count 0. eslint + prettier clean on the 15 changed files.
  • Affected daemon suites (render, daemon-permission-autoallow, elicit-decline-notice, approval-dm, daemon-commands, slack-status-actor, turn-chrome): 388 passing, independently re-run.
  • Full daemon suite: 11 files failing, all confirmed as the known-unrelated set — acp-matrix dialling live runtimes, the five skills-CLI files (skills CLI exited with status 1), bwrap sandbox, the memoed evaluation-runner baseline, and daemon-smoke's gitcred/workspace pair, which reproduced exactly at HEAD with the source reverted. Two more (daemon-duty-drain, daemon-hook) pass alone and were a different pair in an earlier run — full-suite load flakes.

One real failure this caused, fixed here: elicit-decline-notice.test.ts used a multi-select as its "Slack cannot render this" exemplar. It now uses a free-text field, plus a case asserting a multi-select gets a card and no notice.

Every new test was confirmed red with only the source reverted — 12 daemon cases across four files and 2 relay cases, with failures like Target cannot be null or undefined (no card) and Cannot read properties of undefined (reading 'kinds') (no DM surface).

…on Slack

`multi-enum` joins `SLACK_ELICIT_SURFACE.kinds`: a `multi_static_select` can
express "pick several" where a row of buttons cannot, and since a select never
submits on its own the card carries its own Confirm beside Dismiss. The option
limits become per KIND, because one surface's controls do not hold the same
list — a select takes 100 options against an actions row's 24, and caps an
option `value` at 75 chars against a button's 2000 — so neither kind's cap
widens the other, and a list past either still declines instead of being sliced.

The in-progress selection lives on the card's own pending record: Slack
re-sends the whole current selection on every change, so nothing accumulates,
but something must hold the last one — the relay persists no message content
and a Confirm button's `value` is fixed when the card is rendered. It is seeded
from the schema's `default`, so an untouched Confirm submits exactly what the
card was posted showing, and it is freed with the card. It is relayed, never
trusted: Confirm goes through the same answer path a button tap takes, where
#1815's `multiSelectAccepts` re-derives it against the card, so a selection
outside `minItems`/`maxItems` is refused and the card stays live.

The two interactions are two new `RdSlackAction` verbs. `elicitation-choice`
could not carry them: a select's payload has no `action.value` at all, and
Confirm is a third verb with no room in one nullable field. The request id
rides the select's own `action_id` rather than its option values, so
deselecting everything still names its card. Skew fails closed forward — a
daemon predating the verbs rejects the whole action and the card stays live.

Accept content is the array under the property name, Dismiss declines, turn end
cancels, the agent's message stays defused, and the settled card names the
chosen option LABELS. The approval DM gets its own surface: its taps settle
through the editor path, which holds no per-card selection, so a multi-select
is withheld there rather than posted with a Confirm that could never work.

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 correctness issue needs fixing: the new Confirm flow submits the last selection cached for the card, which can differ from the clicking user's current selection. I reproduced both a delayed selection handler causing an older list to be accepted and one reader confirming another reader's draft.

Use the Confirm interaction's own state.values for this select, then apply the existing validation. Slack explicitly documents full state for message block_actions in its full-state changelog.

Validation: 388 focused daemon tests and 43 relay tests passed; the reproductions above exercised the changed code without adding repository tests.

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

Comment on lines +1955 to +1960
async confirmElicitSelection(a: { requestId: string; actor?: InteractionActor }): Promise<void> {
const rec = this.pendingElicits.get(a.requestId)
if (!rec || rec.surface !== 'slack' || rec.kind !== 'multi-enum' || rec.url) return
await this.handleElicitChoice({
requestId: a.requestId,
value: rec.selected ?? [],

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] Confirm must submit the clicking user's current selection

rec.selected is shared by every reader of this card and reflects processing order, not the state being confirmed. Reproduced: A selects lint, B selects test, then A confirms and the runtime receives ['test']. Even with one reader, delaying the selection handler's store.getSession() lets a later Confirm overtake it and accept the previous list; both interactions are acknowledged successfully. The whitelist cannot detect this because the stale values are valid options.

Read and forward this select's state.values[block_id][action_id].selected_options from the Confirm payload on both Slack ingress paths, and validate that snapshot. Slack explicitly includes message state in block_actions, so confirmation need not depend on earlier change events.

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 d708c35, and thank you for the two-reader reproduction — that is what makes it a correctness bug rather than a race. rec.selected was a second copy of the truth, shared by every reader of one card and ordered by processing, and the re-derivation structurally cannot catch it: a stale selection is made of options the card really did offer, so the accept asserts a reader chose something they did not.

You were also right about the changelog; my reason for avoiding state.values was that I could not confirm Slack populates it for elements in a message's actions block, and it has since 2020-09-01.

What changed:

  • selectedOptionsFromState(state, actionId) — a pure codec in relay-cp.ts, beside decodePermValue for the same reason: both Slack ingress paths must read one card identically. It searches state.values by action id across every block, so it does not depend on how Slack grouped the card's elements, nor on the card naming that block — the direct socket path posts no block_id when there is no session target.
  • Both edges forward the snapshot on Confirm, and it goes through the same multiSelectAccepts gate as before.
  • The shared state is gone, not supplemented: PendingElicit.selected and noteElicitSelection are deleted. A self-contained Confirm was the point, so leaving the copy beside a snapshot would have kept the bug reachable.
  • elicitation-select is dropped too — it existed only to feed that record. The relay now returns null for it (Slack still gets its 200 via the fall-through), and the daemon keeps app.action(/^ac_elicit_select:/) as a bare ack() only because Bolt logs an unhandled-request warning otherwise. The select's action_id survives as the key the snapshot is found under.

Two things the fix turned up: null vs [] is now load-bearing — no state for that select forwards nothing and leaves the card live, rather than confirming a selection it cannot see, while an emptied select is still a real answer. And the dedup id now hashes the values, or two Confirms carrying different selections would have collapsed into one redelivery.

Tests: your literal sequence at the relay edge (A selects, B selects — both forward nothing now — A confirms with ['lint'], B with ['test']), plus a coordinator test asserting each reader's Confirm resolves the runtime with their own list. Against the old sources that test times out, which is the bug's own signature: the submitted list is not the one the reader tapped, so the re-derivation refuses it and the request is never answered.

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 in d708c35b: both Slack paths submit Confirm’s own selection, the shared cache is gone, and missing state stays distinct from an empty selection. Existing validation still applies.

This resolves my P2 finding.

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

…shared record

The card kept the in-progress selection on its pending record, which is shared
by every reader of that card and ordered by processing, not by what any one of
them was looking at. A selects `lint`, B selects `test`, A confirms — and the
runtime is told A answered `['test']`. Single-reader it is the same bug with
one actor: a Confirm that overtakes a slower select handler accepts the
previous list. Both interactions ack successfully, and the re-derivation cannot
see it, because a stale selection is made of options the card really offered —
so an `accept` asserts a reader chose something they did not.

Slack has carried a message's FULL state on `block_actions`, not just on view
submissions, since 2020-09-01, so a Confirm tap already carries the selection
its own card was showing. `selectedOptionsFromState` reads it — by ACTION id
across every block, so it does not depend on how Slack grouped the card's
elements — and both Slack ingress paths now forward that snapshot on the
confirm action: the relay's HTTP edge and the daemon's own Bolt handler. It is
validated exactly as the relayed selection was, through `multiSelectAccepts` on
the shared answer path, so bounds and unoffered values are still refused with
the card left live.

`rec.selected` is gone: a self-contained Confirm is the point, and a second
copy of the truth is what created the bug. The `elicitation-select` wire verb
goes with it — its only purpose was feeding that record. A selection change is
still ACKED at both edges (Slack requires it, and Bolt warns about an
unhandled action), but the relay forwards nothing and the daemon records
nothing. `elicitation-confirm` carries `values`; a payload with no state for
the select forwards nothing at all rather than confirm a selection it cannot
see, which is not the same as an empty selection — an emptied select is still a
real answer.

The `default` seeding survives only on the card, as `initial_options`, which is
what Slack then reports as that select's state for a reader who never touched
it. The daemon holds no copy of it to go stale.

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 d708c35b. The previous P2 finding is resolved: both Slack paths submit the Confirm interaction's own selection, the shared selection cache is removed, and missing state remains distinct from an empty selection. Existing option and bounds validation still applies.

No remaining blocking findings. Validation: 390 focused daemon tests and 44 relay tests passed; daemon and relay typechecks passed.

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

@spacedragon
spacedragon merged commit 11ab6b0 into main Sep 7, 2026
14 checks passed
@spacedragon
spacedragon deleted the dev/yulong/slack-multiselect branch September 7, 2026 08:09
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>
spacedragon added a commit that referenced this pull request Sep 7, 2026
…1830)

* feat(daemon): a multi-field Slack elicitation is answered in a modal

Issue #1794's last Slack-column item, and the one shape on that surface that
genuinely needs a modal: an `actions` block holds no `input`, so a multi-field
form's fields cannot be in the channel at all. Every other shape already answers
without one — buttons, a `multi_static_select` plus Confirm (#1825), a thread
reply (#1828), a consent button (#1821).

`elicitForm(_, SLACK_ELICIT_SURFACE)` now feeds a card that is the defused
question, a line naming what will be asked, an Answer button and Dismiss.
`elicitTarget` keeps feeding every single-field card, byte for byte; a select
question plus its own "Other" box is still ONE question and stays on the button
row. Answer opens a modal carrying one `input` block per field, in schema order:
`static_select` for an enum and for a boolean, `multi_static_select` with
`max_selected_items`, `plain_text_input` with `min_length`/`max_length`,
`number_input` with `is_decimal_allowed` and its bounds, and
`initial_value`/`initial_option` from `default`.

On submit the whole record is re-derived against the form THAT CARD rendered —
webchat's rule (#1807) and every tapped option's convention (#1815): exactly the
rendered property set, every `required` name present, an omitted optional field
allowed, each value valid for its own field, and the accepted content in the
schema's own types. One bad field refuses the submission with that field's own
`response_action: errors` instead of being dropped, and the card stays live;
`pattern`, which has no Slack attribute, is checked exactly there. A form no
modal can hold — an enum option value past Slack's 75-char select cap, a minimum
length past an input's 3000 — is declined with the existing notice BEFORE the
card is posted, so Answer is never offered with no modal behind it.

**Where the view lives.** The issue framed opening the modal as a choice between
a relay→daemon round trip inside the trigger id's ~3s window and the relay
holding a pre-rendered view. Neither is needed: in relay-fronted mode the daemon
already holds the bot's own token — that is how it posts this card and rewrites
it on settle — so the relay forwards only the `trigger_id`
(`elicitation-open`, one-way, never awaited) and the daemon opens the view
itself, exactly as `open-config` has opened the status modal all along. Nothing
is pre-rendered anywhere: the view is built on demand from the card's own pending
`params` by ONE function on both ingress paths, so the two modals are identical
by construction, and it dies with the card. What round-trips through Slack is the
opaque session target the card's `block_id` already carried, echoed in the
modal's `private_metadata` beside the request id.

The submission is the one interaction on this seam that Slack itself waits for,
since per-field errors exist only as a `view_submission` response. It rides
`RdAck`'s existing opaque `response` slot, which the relay surfaces verbatim on
its 200 and decodes not at all; the direct Socket Mode path answers it on
`ack()` with no relay involved. A `trigger_id` that expires leaves the card live
with its Answer button — the reader taps again. Two readers may each open the
modal, since Slack keeps a view's state per view rather than on the message: the
first submission settles the ACP request and the second is answered with the
closed view rather than silence, which is what #1825's shared selection could
not do. A tap on an already-settled card opens that same closed view.

`SLACK_DM_ELICIT_SURFACE` does not gain multi-field: an approval DM has no turn
whose card could be rewritten, and its path reads the single-field reduction
only. An MCP approval never takes the form path either — allow/deny is one enum,
and chat approval's bookkeeping lives on the single-field path.

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

* fix(daemon): a blank optional multi-select is an omission, and openView is arena-exempt

Slack sends `[]` for a multi-select nobody touched, and the submission read that
as an empty ANSWER — so an optional field carrying `minItems: 1` failed its own
bounds and the reader could not submit past their own blank at all. A blank
selection is now an omission when the field is optional, which is what the
schema already allows. A required one keeps its bounds: there an empty selection
is a real answer and `minItems` decides, which is #1801's reading unchanged.

The evaluation arena's connection-surface gate also caught `openView`. It is
exempt for the same reason `openStatusModal` is: it is reachable only from a
Slack interaction, and the arena has no interactivity to raise one.

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