feat(slack): partner follow-ups — per-agent bots, per-channel consent, completion report-back (ent#222/#223/#224) - #1763
Conversation
3610227 to
0995adb
Compare
|
Resolve by running |
/review report —
|
/review — re-run after fixesBoth criticals are now verified present on the branch (
Tests: 14 green, including two new regressions — a static guard that the toggle keeps its human-only check, and one asserting a bot token in failure text never reaches the posted message. Three things the re-review caught that the first pass did not1. My first "fixed and pushed" was false. The review worktree was in detached HEAD, so 2. My own C2 fix had a latent flaw. The first version decided truncation with 3. Push protection rejected the regression test — it hardcoded a realistic Unchanged from the first passInformational: the Critical: 0 outstanding · CI re-running on |
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr (paired with trinity-enterprise#232). Approving. Since dev's required checks run no unit tests, I ran all four touched test files locally on this branch: 41 passed (test_222_per_agent_bot_seam, test_223_slack_channel_proactive_consent, test_224_channel_completion_report, updated test_1649). Load-bearing integration points verified against the real code, not just the diff:
effect_guardusage matches the real #1084 API exactly (async CM,g.replay/g.snapshot,EffectInProgressErrorbefore yield);db.get_executionreturns theScheduleExecutionmodel (attribute access safe,source_channel*fields present);send_message_detailedkwargs match.- Dual-track migration complete and correctly chained:
schema.py+tables.py+ SQLitemigrations.py+ Alembic0030revising0029_product_events(the real dev head) — the schema-parity blind spot covered. - Consent posture is the right one and is proven: new-binding-denies + existing-backfilled-to-allow tested against a real legacy table, including idempotency (an operator's explicit OFF survives re-runs).
- The human-only consent toggle (
reject_agent_principalbefore the owner check — access-first, self-uniform per Invariant #8) plus its guard test and the learnings entry: exactly the right response to the self-grant class. - Sanitize-over-2x-window-before-truncate in the completion reporter matches the #1578 chokepoint discipline, with the phantom-ellipsis edge tested. The seam is inert-by-default and fail-open (failure-injection tested); router early-return conforms to
_resolve_agent_and_token'sOptional[Tuple[str, str]]contract. Three MCP surfaces updated in sync (Invariant #13).
Non-blocking items:
- learnings.md has an accidental duplicate line — the ent#183 tar Lesson is repeated verbatim (line ~132). Trivial; fix on the branch or in a follow-up.
- Manual status bump needed: all three issues are cross-tracker (ent#222/#223/#224), so no auto-promotion — set
status-in-devon them after merge, manual close at release. - Known residual to name somewhere visible: with a dedicated bot AND the workspace bot in one channel, an un-@mentioned thread-reply
messageevent is delivered to both apps and can produce two turns (the design's Phase-3 "shared-channel disambiguation tests" are deferred, but Phase 2 + wiring ship now, so the window is live for entitled installs). - Behavioral caveat worth documenting: the completion report requires the executing agent (B) to be bound to the originating channel with consent — a non-channel-bound worker's completion still won't report. Defensible consent posture, but it means the classic router-agent topology stays silent.
architecture.md's adapters catalog doesn't list the newadapters/per_agent_bot.pyseam (one line), and the requirements area file wasn't touched for the new OSS-visible capability. Docs-only; fine as a follow-up given the enterprise-docs privacy split.
Sequencing: either order vs ent#232 is safe (both sides guard); the submodule pointer bump at the next release must carry both.
Enterprise submodule pointer bumped — ent#222 is now active in this PRtrinity-enterprise#232 merged to enterprise Why it had to be in this PR: without it the OSS half here ships inert — Verified
It is a direct child of Merge plan is now two steps, not three: merge this PR → then #1752 (its Alembic |
Proactive consent was modeled per RECIPIENT (agent_sharing.allow_proactive, keyed by verified email). In an open Slack workspace users never authenticate, so no recipient record exists to opt in and a Slack-facing agent had no sanctioned path to post unprompted — channel posts had no consent gate at all, only the owner check + #1609 rate caps. For Slack the consent unit is the CHANNEL BINDING. - slack_channel_agents gains allow_proactive; dual-track migration (SQLite db/migrations.py + Alembic 0029) plus schema.py DDL and tables.py MetaData. - Default posture, deliberately split so nothing silently flips: NEW bindings -> 0 (deny). Binding an agent is not itself consent. EXISTING bindings -> backfilled to 1 (allow), because channel posts worked with no gate before this change. The backfill runs only when this caller added the column (_safe_add_column returns True), so an operator's later explicit OFF survives re-runs. - Enforcement in send_agent_slack_channel_message sits between the 404 (not bound) and the 429 (rate capped) so the three refusal reasons stay distinguishable, and returns a NAMED, actionable code (proactive_not_allowed) the agent can relay instead of a generic failure. Rate caps still apply on top. - New owner-gated PUT /api/agents/{name}/slack/channels/{channel_id}/proactive so the flag is toggleable rather than API-only. 8 unit tests: default-deny for new bindings, flag present on both read paths, toggle on/off + scoping, unknown-binding miss, and the migration's no-silent-flip backfill + post-migration default + idempotency. Related to ent#223 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ason, docs (ent#223)
Completes the remaining ent#223 acceptance criteria on top of the backend gate:
- GET /api/agents/{name}/slack/channel now returns allow_proactive so the panel
can render the current state (it was write-only before).
- SlackChannelPanel.vue gains an "Allow proactive messages" switch that calls
PUT .../slack/channels/{channel_id}/proactive and re-reads the binding, so the
control always reflects what the backend stored. This is what makes the flag
toggleable rather than API-only.
- MCP send_group_message now reports WHY a send was refused: consent_required
(with an actionable hint), rate_limited, or not_bound — the three causes the
backend already keeps distinguishable (403 / 429 / 404). Previously a consent
refusal surfaced as a generic error the agent could not act on.
- docs/memory/feature-flows/proactive-messaging.md records the two consent units:
DM = per-recipient verified email, Slack channel = per-channel binding, plus
the split default posture and the refusal semantics.
Related to ent#223
…t#223) 0029_slack_channel_allow_proactive declared down_revision = 0028_agent_reminders, but dev ALREADY carries 0029_product_events off that same parent. Two revisions sharing a parent are two Alembic heads, and `upgrade head` then fails with "Multiple head revisions are present" — which aborts init_database and CRASH-LOOPS THE BACKEND on boot. Caught by deploying the branch to a real PostgreSQL stack; no unit test would have seen it. Renamed to 0030_slack_channel_allow_proactive chained off 0029_product_events, so the line is linear again. Verified on a live stack: backend boots, alembic head = 0030_slack_channel_allow_proactive, and the allow_proactive column is present on slack_channel_agents. Related to ent#223
…hread (ent#224) The reported failure: a user asks in Slack, the agent delegates a ~10-minute job to another agent, the job succeeds — and nobody tells the user. The trigger side worked; the completion side died silently. Two things were actually missing, and both are fixed here: 1. Channel context did not survive delegation. The A->B path never forwarded source_channel*, so B's row had no destination and B's terminal had nowhere to report. ParallelTaskRequest gains parent_execution_id; chat_execution_service copies the parent's channel/chat/thread down to the child (fail-open — a miss changes nothing). MCP chat_with_agent gains an optional execution_id so a delegating agent can pass its own execution, mirroring the #1084 effect tools. 2. Nothing reported at the terminal. channel_completion_report posts the outcome to the originating channel/thread, hooked at both CAS-won terminal chokepoints beside spawn_task_terminal_event (#1578) — success AND failure, because a silent failure is the bug being closed. The no-double-post rule is the load-bearing part: a direct channel turn is synchronous and the adapter already replies inline, and those rows carry triggered_by == the channel name. Reporting them would duplicate every normal Slack reply in a customer workspace, so we report ONLY when the context was INHERITED (triggered_by not in {slack, telegram, whatsapp}). Gated by ent#223 per-channel consent, at-most-once via effect_guard (#1084) keyed on the resolved destination (never the generated body), Slack-only in v1, and fail-soft throughout — a reporting failure never disturbs an execution that already completed and was billed. 12 unit tests: the double-post guard across all three channel triggers, delegated reporting into the right thread, consent + unbound gates, no-context/non-Slack/ missing-row no-ops, failure terminals reporting, replay not re-posting, and the never-raises guarantee. Related to ent#224
The edition-agnostic seam that lets the enterprise slack_per_agent_bots module (trinity-enterprise#222) route an inbound Slack event to the agent whose DEDICATED bot received it, and reply through that bot's own token — coexisting with the workspace-level single bot. - adapters/per_agent_bot.py — inert-by-default hook registry (set_resolver / set_token_provider). With no resolver registered (OSS-only, or before the enterprise module loads) every path is a no-op and channel routing is byte-for-byte unchanged. Both hooks FAIL OPEN — an error falls back to normal routing, so a bug can never take Slack offline. - message_router._resolve_agent_and_token — consult the per-agent resolver first; a match wins over the channel binding and uses the per-agent bot token. Falls through to channel routing when no binding / no token. - slack_adapter.parse_message — stamp the RECEIVING bot identity (authorizations[0].user_id + api_app_id + team_id) into message.metadata so the resolver can key on it. Additive; inert for every non-Slack channel. Mirrors the connector seam (#118): OSS owns the seam, the enterprise module owns the policy (it registers the resolver + token provider at startup). 5 unit tests: inert-by-default, resolver reads recipient bot, non-Slack ignored, both hooks fail-open, token provider. The end-to-end path (real Slack event -> per-agent routing) needs a live per-agent Slack app to verify — the recipient-bot extraction from authorizations is the live-verification point. Related to trinity-enterprise#222 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SlackAgentBotPanel: configure an agent's dedicated Slack bot from the Sharing tab, mounted beside the existing workspace SlackChannelPanel. - Entitlement-gated on `slack_per_agent_bots` via the enterprise store, so it is hidden entirely in OSS / unentitled builds — never a blank or broken section. - Paste bot (xoxb-) + app-level (xapp-) tokens; on save the backend validates them against Slack auth.test and the panel then shows the RESOLVED identity (bot name, bot_user_id, team) rather than echoing anything secret. Tokens are write-only — a read never returns them. - Enable/disable without re-entering credentials, replace tokens, and remove. - Refusals surface the backend's NAMED code (wrong token type, bot already bound to another agent, Slack unreachable) instead of a generic failure, so the operator knows what to fix. Verified the component compiles under Vite (HMR, no build errors); the flows themselves still need a live per-agent Slack app to exercise end to end. Related to trinity-enterprise#222
…nt#224)
The e2e job failed the mcp-server image build:
src/tools/chat.ts(296,9): error TS2353: Object literal may only specify known
properties, and 'parent_execution_id' does not exist in type '{...}'
chat.ts passed parent_execution_id into apiClient.task(), but the client's
options type never declared it, so tsc rejected the call and the Docker build
exited 2.
Two changes, because the type fix alone is not enough: the request body in
client.ts is assembled field-by-field rather than spread, so the property would
have been silently DROPPED at runtime even once it compiled — the delegated task
would never have inherited the channel context and ent#224 would have been a
no-op in production. Declared it on the options type AND forwarded it into the
body.
Verified with the same toolchain CI uses: tsc --noEmit clean, npm run build
exit 0, 87/87 mcp-server tests pass.
Related to ent#224
The base-vs-head regression diff flagged two new failures:
test_1649_group_message_history.TestRoutersPersist
::test_slack_router_persists_at_the_posted_ts
::test_slack_router_uses_the_parent_thread_when_replying
Both stub get_slack_channels_for_agent with a binding dict that has no
allow_proactive, so the ent#223 consent gate refuses the post with 403 and the
send — the thing these tests actually assert on — never happens. The tests cover
#1649 persistence, not consent, so the fixture should represent a CONSENTED
channel.
Also fixed the third stub in the same class, which the diff did NOT flag because
it was passing for the wrong reason: test_slack_router_does_not_persist_a_failed
_send asserts pytest.raises(HTTPException), and the consent 403 is an
HTTPException — so it short-circuited before ever reaching the 502 send-failure
path it exists to cover. A false green is worse than a red.
Related to ent#223
…#224) Both findings in this PR were classes, not one-offs, and /autoplan reads this file before planning: * a capability-GRANTING endpoint must reject agent principals — ownership and role checks all pass for an agent's own key, because it resolves to the owner; * a new egress sink inherits the payload's credential hazard but not the sanitisation the original sink applied. Related to ent#223, ent#224
…re Slack (ent#223/#224) Two critical findings from /review, both invisible to the existing tests, which covered the db/service layer but neither the PRINCIPAL nor the EGRESS. C1 — an agent could grant ITSELF proactive consent. set_slack_channel_proactive gated only on can_user_share_agent, but an agent-scoped key resolves to the OWNER on REST (dependencies.py:423), so that check passes for the agent's own key. The agent could flip on the exact control ent#223 adds, making consent self-serve. Added reject_agent_principal: granting consent is a human decision; SENDING under it stays agent-callable. Same class as trinity-ops-agent#232. C2 — unsanitised failure text was posted into a customer Slack channel. The #1578 emit chokepoint credential-sanitises summary_or_error before egress precisely because a failure terminal can carry secrets (event_dispatch_service.py:311). This sink reused that payload, truncated it, and never sanitised — so a token in an error message would be posted verbatim to a persistent, externally hosted, human-visible surface. Now sanitises over a 2x window BEFORE truncating, the same order #1578 uses, because a bare slice can cut a secret so the pattern no longer matches and it survives. Truncation is decided on what was actually cut rather than by comparing against the raw input: redaction changes length, so the naive comparison appends a phantom ellipsis to text that was never truncated. Covered by its own test. Related to ent#223, ent#224
…agent Slack bots (ent#222) trinity-enterprise#232 merged to enterprise main (6f073c7), so dev's pointer at 594b0afd predates the private slack_per_agent_bots module. Without this bump the OSS half in this PR ships INERT: adapters/per_agent_bot.py has no resolver registered, so per-agent routing silently does not exist and ent#222 would look like it simply did not work — the ent#185 class. Pointer moves 594b0afd -> 6f073c73, which carries backend/slack_per_agent_bots/ (module + manager + cascade wiring) and the enterprise Alembic 0012 slack_agent_bindings revision. Also picks up nothing else: 6f073c7 is a direct child of 594b0afd, which dev already had. Related to ent#222
9106830 to
70ce676
Compare
…t#224 debt (ent#265) - requirements/public-access.md: NEW §15.1h "Channel Completion Report-Back (CHANNEL-REPORT — ent#224 Slack, ent#265 Telegram)" — generic mechanism (inherited-context-only, D0 row-creation persistence + provenance guard, binding-agent resolution, chokepoints incl. D3, effect-guard at-most-once, sanitize-before-truncate), per-channel consent units, the two-DM-consent- regimes rationale (F6), known v1 limits, the deliberate ungated proactive-send scope cut. (§15.1f was already taken by WHATSAPP-001 — plan's placement kept, id shifted to h.) - feature-flows/channel-completion-report.md: NEW flow doc — entry points, D0/D3 fixes, chokepoint coverage table with the v1 boundaries (lease-reaper, bulk sweeps, pull sink, operator-terminate, restart mid-inline-turn, FAILED→SUCCESS resurrection, fan-out per-child, pre-migration NULL rows), destination/consent resolution per channel, D1 identity design with every rejected alternative, failure modes, Testing. Pays the #1763 flow-doc debt (ent#224 shipped undocumented). - feature-flows.md: Recent Updates row + Collaboration-table entry. - telegram-integration.md: ent#265 section (column + toggle + pointer) + revision row. - task-completion-events.md: sibling-spawn paragraph (report rides beside the #1578 emit at the same CAS-won chokepoints incl. the failure applier). - architecture.md: services-catalog entry for channel_completion_report.py, schedule_executions DDL line for source_channel_agent, telegram DB-module line mentions allow_proactive. - learnings.md: the D0 class — a threaded parameter only one callee branch consumes is a severed wire; mock-row suites are blind to it AND to its facade-passthrough sibling (caught pre-merge by the row-read-back tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OSS half of the three Slack issues from the 2026-07-23 design-partner call. Consolidated into one PR because they are one body of work: they share a migration chain, ent#224 is gated by ent#223's consent flag, and all three touch the same Slack path.
ent#223 — per-channel proactive consent
Consent was per-recipient (
agent_sharing.allow_proactive, keyed by verified email). In an open Slack workspace nobody authenticates, so there was no recipient to opt in — and channel posts had no consent gate at all.slack_channel_agents.allow_proactive, dual-track (SQLite + Alembic0030)proactive_not_allowedSlackChannelPanel.vuesend_group_messagenow reports why:consent_required/rate_limited/not_boundent#224 — completion reported back to the originating channel/thread
The partner's bug: agent delegates a ~10-min job, it succeeds, nobody tells the user. Two things were missing (the issue called it "a join" — it wasn't):
source_channel*, so B had no destination. Addedparent_execution_id+ inheritance, and an optionalexecution_idon MCPchat_with_agent.channel_completion_report, hooked at both CAS-won chokepoints besidespawn_task_terminal_event(feat: system-emitted agent.task.completed/failed events at execution terminal (async caller report-back) #1578). Success and failure.triggered_by== the channel name). Reporting those would duplicate every normal Slack reply in a customer workspace, so reporting is restricted to executions that inherited context. Consent-gated, at-most-once viaeffect_guardkeyed on the resolved destination.ent#222 — per-agent bot identities (OSS half)
adapters/per_agent_bot.py— inert-by-default hook registry; with no resolver registered every path is a no-op and routing is byte-for-byte unchanged. Both hooks fail open.message_routerresolves a per-agent bot over the channel binding;slack_adapterstamps the receiving bot identitySlackAgentBotPanel.vue— entitlement-gated config UI (resolved identity shown; tokens write-only)Verified on a live PostgreSQL stack (not just unit tests)
25 unit tests, plus a real deployment:
0030→0029_product_events)effect_guard→ no second send; inline turn → no sendauth.testreached Slack and mapped to namedinvalid_bot_token; cascade proven by a real rename (binding followed the agent)🔴 A boot-breaking bug this deployment caught
0029_slack_channel_allow_proactivecollided with0029_product_events(already on dev) — two Alembic heads →upgrade headfails → backend crash-loops. Reproduced (HTTP 000), then fixed by rebasing to0030. No unit test could have found this. My eval PR #1752 had the identical defect; it's fixed there too (0031, chained off0030) — merge #1752 after this.Honest limits
🤖 Generated with Claude Code