Skip to content

[feat] Channels: Slack, bridge, and Agenta channel (takeover of #6051) - #6644

Draft
mmabrouk wants to merge 484 commits into
release/v0.115.4from
feat/channels
Draft

[feat] Channels: Slack, bridge, and Agenta channel (takeover of #6051)#6644
mmabrouk wants to merge 484 commits into
release/v0.115.4from
feat/channels

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 8, 2026

Copy link
Copy Markdown
Member

Context

JP's channels branch (#6051) had no owner after he left. This PR carries that work forward on the current release branch. It is a draft while the live QA and the fix stack land on top of it.

What this branch contains today:

What comes next

Stacked PRs on top of this one, each small and reviewed: the decisions from the findings triage, the feature flag, and every defect the live Slack QA finds.

The takeover page and the decision record live under docs/design/channels-research/v2/.

https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT

mmabrouk and others added 30 commits August 7, 2026 12:18
…-mount

fix(runner): steered sessions keep their workspace mount (pre-existing)
…migration

feat(api): agent behavior leaves the public API: handler-mode ops, one error envelope, routes deleted
chore(clients): regenerate both API clients from the branch spec
…e benchmark's run identity is now derived from non-secret values only (base+project context hash replaces the API-key fingerprint; the record only needs to say which deployment context produced a run); the runner test asserts the fence shape with startsWith/endsWith instead of a regex CodeQL reads as HTML filtering; the web test parses rendered HTML with the test environment's DOM instead of regex tag-stripping, which is also more correct. No product code changes.
fix(ci): clear the CodeQL highs (test and benchmark code only)
The tab icon on agenta.ai did not match the docs (and every other Agenta
surface). Browsers prefer the SVG favicon over the .ico when both are
declared, so favicon.svg — not favicon.ico — is what actually renders,
and it was the only asset in the set drawn from different measurements.

The two .ico files are byte-identical; favicon.svg was hand-authored
separately in the on-page-SEO commit and its transform was eyeballed:
the symbol came out at 53.1% x 43.8% of the tile against ~71.5% x 58.6%
everywhere else, on a #0A0A0B tile instead of #1E1C1D.

Rescale the symbol to scale(2.14) at translate(73 105) and correct the
tile fill and radius, which reproduces the bounding box of
android-chrome-512x512.png to within a pixel:

  favicon.svg  x[73..437] y[105..403]
  canonical    x[73..438] y[106..404]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the last C0 gap. The design stated "one function composes it, no
exceptions" but never said how, and the function name in circulation was
invented rather than specified.

external_key becomes a UUID — uuid5 over the adapter's declared key fields
for that grain — rather than a joined string. A join needs an escaping rule
for platforms whose ids carry the delimiter (Teams), and an untested
escaping rule is a latent collision that merges two conversations.

Adapters declare identity.key_fields per grain and never compose; core
composes. The field set is the fragile part either way: change it and every
row re-keys, and a hash makes that invisible rather than merely quiet. A
declaration is checkable, so WP2's contract suite holds adapters to it —
including the distinctness case, since a too-small field set silently
merges threads.

Renames the wire field data.space.external_key to data.space.locator: a
bridge sends its platform's fields, never a key, and the old name would
have invited a raw string into a UUID column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review pass. Each of these was the same concept said twice, or a platform
word in a channel-neutral place.

- ChannelDeliveryState.PENDING -> CREATED. Past tense like the trigger
  states, and it records what we did rather than asserting a queue we do
  not have.
- Inbox `content`/`sender` move under `processed`, which is what the table
  below them already called that pair. `raw` stays commented on both sides.
- Agent DTOs inherit the house `Slug` mixin instead of declaring `slug: str`.
- Dropped `name` from the agent and space queries.
- ChannelSpaceCandidate: no external_key (it would key an unconfigured
  place), `name` -> `display_name`, and a note on why a view holds its
  locator flat.
- Catalog routes lose the repeated domain: /channels/catalog/, not
  /channels/catalog/channels/.
- text.format is markdown|html|plain, never a platform's dialect name;
  identity.scope and conversation.default_unit documented.
- addressing.sigils.{agent,command} and protocol.versions grouped.
- Protocol starts at 0.1.0. CloudEvents' specversion keeps its own name and
  its own 1.0 — three version fields at three granularities, now
  distinguished, with parsers dispatching on event type not protocol.
- adapter port gains discover_spaces (seven methods); verify_signature
  returns the installation id, not bool — verification and identification
  are one act.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Researched against the platforms' own docs and written into channels.md,
each with the values that are uncertain or tier-dependent called out rather
than rounded, and every non-obvious number cited.

The research broke the schema in four places, all of them one scalar being
asked to hold two real values, so the schema changed:

- files splits by direction. Telegram is 50 MB up / 20 MB down and Discord's
  bot cap is not its user cap, so one max_bytes was wrong for two of three
  first-class channels.
- Limits are now stated as "what the adapter renders against", not the
  largest the platform documents. Slack publishes 4000 as guidance while
  Block Kit enforces 3000; Telegram is 4096 plain but 1024 for a caption;
  Discord 2000 plain but 4096 in an embed. Under-declaring degrades,
  over-declaring fails at send.
- identity.scope can depend on the install, not just the platform: Slack
  under Enterprise Grid issues one global user id. Declare the narrower
  workspace value and let the wider install be a superset — the reverse
  cannot distinguish two people sharing an id across workspaces.
- buttons.max: Slack has two live APIs with two real caps (legacy 5,
  Block Kit 25). Declare the one the adapter builds against.

Grouping pass over the declaration, same theme as the last commit:
rendering.controls.{update,ephemeral}, addressing.commands.{native,
in_conversation}, conversation.default, identity.keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gate

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C0 of the channels workstreams: the frozen interface surface every parallel
package codes against. Declarations only — no behaviour, no persistence, no
routes mounted.

- core/channels/dtos.py: entities, flags, policy documents, capability
  declaration, requests and responses
- core/channels/types.py: the domain exception hierarchy
- core/channels/interfaces.py: ChannelsDAOInterface
- core/channels/adapters/interface.py: ChannelAdapterInterface, seven methods
- core/channels/utils.py: compose_external_key and the key derivations;
  resolve_policy left NotImplementedError for WP1
- entrypoints/routers.py: the Channels tag and the mount scaffold

compose_external_key is uuid5 over the adapter's declared identity.keys for a
grain, so no platform field set is hardcoded in core. The seed tests assert the
properties WP2's contract suite will hold every adapter to: grain distinctness,
canonicalisation, the no-threads None, and a raise on an incomplete locator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds ChannelsIngressRouter with the two literal public routes
(/slack/events/, /bridge/events/) per entities.md §9: signature
verification through WP2's adapter port, dedup-tolerant write through
WP1's service, bounded-timeout enqueue onto WP4's dispatch_task, and
nothing else -- no routing or resolution, which stays WP4's.

WP1's ChannelsService and WP2's ChannelAdapterRegistry don't exist in
this worktree yet, so ingress.py types against them under TYPE_CHECKING
and the test suite fakes both to their spec'd shape.

Adds the four _PUBLIC_ENDPOINTS entries per route (bare, /api/,
/preview/, /api/preview/) to middlewares/auth.py -- WP3's sole owned
edit to that file -- plus a guard test proving a bare "/channels/"
prefix is never registered and that WP8's config routes under
/channels/ still require auth.

api/entrypoints/routers.py wiring is left for the C1 checkpoint merge
per the collision rule in workstreams/README.md.
…t suite

Adds ChannelAdapterRegistry (mirrors TriggersGatewayRegistry) and
normalise_capabilities (zero-clamping, absurd-value clamping, unknown-key
drop via pydantic's default extra=ignore, identity.keys passthrough).

Ships the reusable adapter contract suite as run_contract_suite(adapter),
a plain async function any package can call against its own adapter —
gated assertions for controls.update, buttons.max, backfill, signature
verification, and four identity.keys assertions (distinctness,
canonicalisation, incompleteness, no-threads). Proven against a
well-behaved fake and three deliberately lying fakes (edit-is-a-new-post,
ungated buttons, too-small identity.keys), each producing an
AssertionError naming the violated capability.

Fixes a C0 shape bug found while writing the bridge.hello test:
ChannelConversation.units/default were typed as ChannelSessionScope
(thread|message, the policy-level enum) instead of ChannelKeyGrain
(thread|space, capabilities.md's actual declared vocabulary) — a platform
with no threads degenerates to "space", never "message". Corrected in
dtos.py and the matching seed test fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ce, resolve_policy, migration oss000000021

Builds the channels domain's persistence stack per entities.md §2-3, §7-8 on
top of the frozen C0 seed (dtos.py, types.py, interfaces.py, adapters/interface.py):

- dbs/postgres/channels/{dbas,dbes,dao,mappings}.py: the seven tables, every
  constraint/index from §3, and a ChannelsDAO implementing ChannelsDAOInterface
  following TriggersDAO's session-per-call, ON CONFLICT DO NOTHING/DO UPDATE
  shapes (record_inbox_event dedups to None, record_outbox_event falls back to
  a fetch of the existing row, transition_* update in place by id).
- core/channels/utils.py: resolve_policy, the pure five-level intersection
  (capability -> channel default -> agent/space/grant), with decided_by
  tracking which level produced each field per D25.
- core/channels/service.py: ChannelsService's configuration and policy thirds
  (agents/spaces/grants/threads, fetch_capabilities, resolve_effective_policy,
  discover_spaces); routing/delivery methods declared per §8 and left raising
  NotImplementedError for WP3/WP4/WP5.
- oss000000021: the one channels migration; applies and downgrades
  symmetrically (verified via alembic's offline SQL renderer). Carries a
  coordination note for WP7's identity-link tables, added once WP7's own
  worktree defines their shape.
- Flagged gap: entities.md §8 assigns close_thread a real WP1 implementation,
  but threads are append-only with no edit_thread in the frozen §7 list. Added
  ChannelsDAOInterface.close_thread (update flags.is_active in place by id,
  mirroring transition_inbox_trigger) since nothing else lets the service do
  what its own spec requires — flagged for the WP1/WP7 checkpoint.
- Tests: DTO/exception/compose_external_key coverage plus new DAO-level
  integration tests (inbox dedup+ordering, trigger dedup+transition, outbox
  conflict-returns-row+one-row-for-its-life, the two partial-unique-index
  rejections, entity round-trips, migration round-trip), all under
  pytest.mark.integration with a reachability skip mirroring sessions'
  pattern. resolve_policy tests cover every case in specs-wp1.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation

Verified all 115 items against the actual code (dbas/dbes/dtos/types/
interfaces/utils/service/dao/mappings/migration) and the unit test suite
rather than trusting the commit message. 107 checked, 8 unchecked.

Ran the offline suite (26 passed) and confirmed the 23 DAO/migration
integration tests skip on the Postgres-reachability probe in this worktree
(unreachable here) rather than having actually proven anything — those stay
unchecked with a "Deferred to C1" annotation instead of being marked done.

Flagged in place, without touching code: the documented close_thread gap
(DAO method added beyond entities.md §7's frozen list, with its own
docstring explaining why), a count_grants SELECT-id+len() vs SELECT-count(*)
shape difference, and the known resolve_policy/capabilities.md conversation-
units vocabulary mismatch (constructing units=["space"] today raises a
Pydantic ValidationError one layer earlier than expected, since
ChannelSessionScope has no SPACE member -- confirmed by reproduction).

Added an "Open at checkpoint C1" section grouping the 8 unchecked lines into
needs-a-deployment (6, covering 23 test functions), blocked-on-WP7 (1), and
genuinely-unfinished (1: no per-exception .message unit test).
Restores f4748b9772, which a concurrent task-list agent discarded with a reset.

The test layer is the folder: run-tests.py resolves oss/tests/pytest/<layer>/,
so DAO and migration tests under unit/ were only reachable via an explicit path
and self-skipped on a Postgres probe when run.

Moves the six DB-dependent files to integration/channels/ and drops both skip
probes. An integration run with no database now fails instead of reporting
green; the unit layer is env-free: 26 pass, 0 skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the one genuinely-unfinished item the reconciliation found: all ten
ChannelsError subclasses now assert .message, str(e) == e.message, and
catchability as the domain base, plus that ChannelSignatureInvalid carries
nothing but the channel.

Also repoints the ledger's deferred annotations at integration/channels/ and
drops the skip-probe language, which no longer describes the tests.

Unit layer: 49 pass, 0 skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The serialised collision-file edits no package could make, plus the one
cross-package defect the merge surfaced.

- entrypoints/routers.py: ChannelsDAO + ChannelAdapterRegistry (empty until
  WP6) + ChannelsService, and the ingress router mounted at /channels and
  /preview/channels. dispatch_task stays None until WP4.
- resolve_policy: conversation.units is a grain vocabulary (thread|space), not
  a scope one (thread|message). MESSAGE is always available and never declared,
  so the capability ceiling can only take THREAD away. Indexing a grain against
  SESSION_SCOPE_ORDER raised ValueError for any platform without threads --
  including contract.md's own bridge example.

Unit layer: 2202 pass, 0 channels skips. Both ingress routes appear in the
OpenAPI schema and all eight path variants resolve as public; configuration
routes under /channels/ stay authenticated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It spawns `pnpm exec tsx` against services/runner and executes that
component's TypeScript -- its own section header already called it an
integration test while it sat in unit/. run-tests.py resolves the layer by
folder, so `--unit` was running it.

Also fixes the guard: it skipped on missing pnpm, but the real dependency is
tsx, which only exists after `pnpm install` in services/runner. In a fresh
worktree pnpm resolves and tsx does not, so instead of skipping the CLI
printed 'undefined' and the test failed on a JSON decode error that named
nothing useful.

SDK unit layer: 1974 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SQLAlchemy's Enum() persists the member NAME, and every other enum type in
the database is uppercase for that reason (folder_kind_enum APPLICATIONS,
secretkind_enum PROVIDER_KEY) even though those DTOs also carry lowercase
values. The channels migration created lowercase labels instead, so every
insert failed: invalid input value for enum channelspacekind: "TOPIC".

Fixes the migration rather than the column: keeping Column(Enum(X)) bare
means channels reads like the rest of the codebase, where a values_callable
on five columns would not.

Also drops the migration round-trip test. A round-trip downgrades, which
against a shared dev database drops the channels tables from under whatever
else is using them; the task ledger records it as verify-by-hand.

Channels integration: 22 pass against a real database. Unit: 2202 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
env.py hardcoded @postgres:5432, the compose service name, so anything run
outside the network -- every integration test -- could not resolve it. The
.env.*.dev files cannot carry localhost either: run.sh feeds the same file to
compose via env_file, so an uncommented POSTGRES_HOST=localhost would break
every container.

Adds POSTGRES_HOST defaulting to "postgres", so with the variable unset all
three composed URIs are byte-identical to before -- verified for both
editions. An explicit POSTGRES_URI_* still wins, and POSTGRES_PORT still does
not feed the in-network URIs, as its comment requires.

py-run-tests exports POSTGRES_HOST=localhost (only when unset), so load-env
alone is now enough to run the integration layer: 22 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… is unreachable

Attachment teardown reads the object store directly to prove the object is
really gone. The bundled SeaweedFS publishes no host port at all -- compose
exposes 8333 only on its own network -- so unlike Postgres this cannot be
reached by pointing at localhost, and the test failed on name resolution.

Follows the Postgres guard already in this conftest: a probe plus an autouse
skip, keyed on a new store_required marker so only the file that reads the
store directly is affected. Its sibling attachment tests go through the API
and are untouched.

These two only surfaced now because making POSTGRES_HOST configurable let the
existing Postgres guard pass, so the tests got one dependency further.

API acceptance: 802 pass, 15 skip, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jp-agenta and others added 19 commits August 24, 2026 14:50
One conflict, in the secrets DTO tests: both sides added their own payload
helper and their own cases to the same file. Both kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both this branch's add_channels migration and the release's
add_session_streams_references claimed revision oss000000021 (the collision
arrived when the branch merged v0.112.1). Alembic stops with 'multiple head
revisions', the API never starts, and no deployment of this branch can
migrate any database that includes the release.

Renumber add_channels to oss000000022 on top of the release migration.

Caveat for existing dev databases that already applied the old id: bump the
alembic_version_oss row from oss000000021 (channels) to oss000000022 by hand,
or the runner will try to re-apply the channels DDL.
…-channels

# Conflicts:
#	api/oss/tests/pytest/unit/secrets/test_dtos.py
The channels work continues on this branch. This merge takes JP's
feat/add-channels branch onto the current release branch and resolves the
18 conflicts:

- The pi_agenta harness is gone on the release, so its adapter, template,
  and tests are dropped. The mock harness stays, because the channels
  integration and acceptance suites drive it. It now carries the platform
  instructions like every other harness.
- The secrets DTO keeps the release's shared validator and adds the
  channel secret branch to it. The channels service builds the vault
  update payload with the release's update DTO.
- The web preferences page keeps the release flags and adds the Agenta
  channel probe flag. The voice input flag no longer exists.
- The channels migration is renumbered to oss000000029 on top of the
  release chain.
- The streaming SDK test takes the branch version, whose CLI boundary
  test moved to the integration tier.

Claude-Session: https://claude.ai/code/session_01R3RytXiaiv24fGoxd8KkXN
Slack puts thread_ts only on messages inside a thread; a top-level message
has none. The THREAD key composition declares thread_ts required, so every
plain channel message raised ChannelLocatorIncomplete in the dispatch worker.
Slack showed nothing, with no error anywhere the operator can see.

parse_event now falls back to the message's own ts, which is Slack's own
threading model: a top-level message roots the thread its replies live in
(a reply's thread_ts IS the parent's ts). This also makes the agent's answer
land as a thread reply under the mention it answers.

The one existing test that pinned the old shape asserted a locator the rest
of the system could not consume; it now pins the new semantics: a parent and
the replies threaded under it resolve to one unit.

(cherry picked from commit f26abb5)
The delivery worker's first post for an item has no receipt yet, and that
branch passed an empty locator to the adapter. The Slack adapter needs
locator['channel'], so it raised KeyError and the first answer on any thread
was silently never delivered — the playground showed the answer, Slack showed
'Working…' forever.

The thread row already holds the full target (team, channel, thread_ts);
_send now takes the thread and uses its locator for the no-receipt post.
Same defect class as the bridge's F63: a read with no writer, surviving
because fixtures seeded the receipt directly.

(cherry picked from commit 60a54b1)
…ry path

Three defects found and hotfixed during a full day of live QA on Slack, then
run for the rest of the day against the live deployment.

The bot's own indicator edit came back as a `message_changed` event whose
author sits in the nested message, so the authorship check read it as human
input: the adapter rooted a phantom thread off the edit's synthetic ts, ran a
paid turn, posted, edited that post, and went round again. Edit and delete
subtypes now drop at parse, and authorship is read one level down too.

A turn-ended event can outrun the final record commit, so the fold read an
empty answer and rendered an empty Slack section, which Slack rejects
wholesale -- the indicator then sat on "Working..." forever. The read is now
retried twice before rendering; an empty section block is never emitted; a
still-empty fold leaves the indicator alone and logs loudly rather than
delivering a blank bubble; and a blocks rejection falls back to text-only with
the rejected payload logged.

(cherry picked from commit 0b62e15)
…ploy

Thirteen entries, F69-F81, from deploying the branch on bighetzner against a
real Slack workspace on 2026-08-14: the migration id collision, the connection
create that could never succeed, the missing url_verification handshake, the
top-level thread_ts crash, the unresolvable 'main' reference key, the
empty-locator first post, the approval-surface trio, the pending-choice
replay, dropped deliveries, lost turn-ended events, the space_kind
pass-through gap, backfill concatenation, and the first-ever run of the
written suites.

Three carry fixes in review already: PR #6070 (F69), PR #6071 (F72),
PR #6072 (F74). Three more run as local hotfixes on the test stack and need a
design decision each (F70, F71, F73).

(cherry picked from commit 0cafeaa)
Thirty findings from a full day driving the branch on a real Slack workspace,
the agenta bench as a no-Slack control, and a real Telegram bridge.

Two are fixed in PR #6079 (the bot-echo cascade, F82; the empty-answer
delivery path, F85). One is a product decision rather than a defect (F86, the
durable agent folder spanning every space and user). Eight came from building
a bridge against the contract, F97 and F105-F109, and F97 corrects F63's
premise: the create path does write delivery_url and delivery round-trips
cleanly once capabilities exist, so the missing piece is the hello route
alone.

The rest span the trigger policy nobody reads, forwardfill's flood-or-amnesia
key mismatch, two edit models that treat omitted fields as "reset" (one of
which permanently bricks a connection), the ungated `ask` permission mode, and
the approval surface that cannot work under per-message session scope.

(cherry picked from commit 595ce53)
Two unit tests from the release did not know the channels additions:

- The commit-time harness validator now lists mock among the allowed
  harness kinds, since the mock harness stays on this branch.
- The redaction map that names each secret kind's primary credential
  gets an entry for channel secrets, so the probe route and the value
  preview treat the bot token as the credential.

Claude-Session: https://claude.ai/code/session_01HXt8WBUK9MrWKNTYbUKpaT
@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 8, 2026 11:43am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

3 participants