feat(daemon): a Slack elicitation offers a multi-select and a Confirm - #1825
Conversation
…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>
There was a problem hiding this comment.
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
| 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 ?? [], |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 inrelay-cp.ts, besidedecodePermValuefor the same reason: both Slack ingress paths must read one card identically. It searchesstate.valuesby 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 noblock_idwhen there is no session target.- Both edges forward the snapshot on Confirm, and it goes through the same
multiSelectAcceptsgate as before. - The shared state is gone, not supplemented:
PendingElicit.selectedandnoteElicitSelectionare deleted. A self-contained Confirm was the point, so leaving the copy beside a snapshot would have kept the bug reachable. elicitation-selectis 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 keepsapp.action(/^ac_elicit_select:/)as a bareack()only because Bolt logs an unhandled-request warning otherwise. The select'saction_idsurvives 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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
… 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&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>
…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>
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_selectcan, 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_selectdoes 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 buttonvalueis frozen at render time.It is seeded from the schema's
default, matching the select'sinitial_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.valueswas deliberately not used: it is documented for views, and I could not confirm Slack populates it for elements in a message'sactionsblock. Theselected_optionsroute 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) — unchangedmulti-enum→ 100 options (the select's own cap) and 75 characters per optionvalueThat 75-char value cap is exactly why #1813 rejected
static_selectfor 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-choicepath could not be reused: a select payload has novaluefield 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 newRdSlackActionmembers rather than by wideningvalue.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
defaultrather 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
sendApprovalDmreusesbuildElicitationCard, so addingmulti-enumto 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
minItemsConfirm tells the reader nothing. Mitigated by putting the bounds on the card as a context line; the proper fix is an ephemeralresponse_urlreply.minItems: 0with 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
pnpm typecheck:error TScount 0. eslint + prettier clean on the 15 changed files.render,daemon-permission-autoallow,elicit-decline-notice,approval-dm,daemon-commands,slack-status-actor,turn-chrome): 388 passing, independently re-run.acp-matrixdialling live runtimes, the five skills-CLI files (skills CLI exited with status 1), bwrapsandbox, the memoedevaluation-runnerbaseline, anddaemon-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.tsused 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) andCannot read properties of undefined (reading 'kinds')(no DM surface).