Skip to content

Rich session activity in the messaging apps, Slack first (CHOO-2621) - #394

Open
simonflk-sb wants to merge 130 commits into
codex/sdk-server-splitfrom
work/rich-session-activity-slack
Open

simonflk-sb wants to merge 130 commits into
codex/sdk-server-splitfrom
work/rich-session-activity-slack

Conversation

@simonflk-sb

@simonflk-sb simonflk-sb commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Someone watching an agent work from Slack cannot tell what it is doing, and
cannot answer it when it asks for something — the only signal today is a
plain-text "working…" indicator. This builds the layer between the session
interaction contract and the messaging platforms that turns a session's
requests and turn activity into a real, answerable presence in a channel,
Slack first.

Key Changes

  • A platform-neutral session projection. The contract is mirrored into
    Python (a byte-identical lift of session-v1 from the upstream SDK branch)
    and held as a per-session replica: coalescing, dedup by (itemId, revision)
    / (requestId, revision), and deciding channel root versus a secondary
    surface.
  • Request cards on Slack. An approval or a set of questions renders as
    buttons plus a text fallback that is answerable by typing (R1 2), backed
    by a handle reserved per request so an answer always resolves to the right
    card and revision.
  • Turn activity as one message per turn, edited in place rather than
    reposted, ending on the turn's own state — streamed into a thread as a
    plan block where Slack allows it, posted and rewritten at the channel
    root otherwise.
  • A verified inbound path. A press or a typed answer becomes a Command
    from a verified Switch identity, never from the payload; a refusal (stale
    card, unresolvable actor) is told back to the person, threaded under the
    card, on every collaboration platform — not just Slack.
  • post_rich / update_rich on the adapter port, so which renderer draws
    a turn or a card is each adapter's own choice. The base falls back to a
    short neutral turn summary for a platform with no renderer of its own yet;
    a request card has no neutral form yet and is stubbed pending the first
    off-Slack renderer.
  • !session-demo, behind a feature flag, replays a recorded fixture
    through the whole path into a real bridged channel — the way this has been
    verified against actual Slack behaviour with no backend in place yet.

Design Decisions

  • The existing runtime-state indicator is untouched and stays as the fallback
    for connectors that cannot speak the contract.
  • Room interaction (posting, inviting, attachments) stays on the MCP server,
    out of the session replica — session activity and room interaction are
    different things.
  • Out of scope here: the server half (routes, storage, leases, reservations —
    PR CHOO-2667 — the server side of the session interaction contract #411). This branch is driven from fixtures until that lands, so nothing
    yet consumes the Commands it builds.

Testing

Full test suite passes locally (2,900+ tests, including the Postgres-backed
ones), with no change to Slack's rendered output guarded by the existing
Slack-specific suite passing unmodified throughout. Manually exercised
against a real Slack workspace via !session-demo.

Not for merge yet. Targets a temporary base branch so only this branch's
code is under review; lands once the upstream contract is accepted and the
server half (#411) is in place.

raullopez-sandbox and others added 17 commits September 4, 2026 13:32
…Codex adapters

Introduce @switch-console/agent-providers: one ProviderAdapter SPI and a
normalized ProviderRuntimeEvent stream that drive coding agents over their
native protocols instead of typing into a TUI in tmux.

- OpenCode: one `opencode serve` per session driven with @opencode-ai/sdk v2
  over HTTP + SSE; config isolated via XDG_CONFIG_HOME so only the session's
  MCP servers are registered.
- Claude: one streaming-input `query()` per session on the Agent SDK; approvals
  via canUseTool, AskUserQuestion via a PreToolUse hook, subagents from
  Task tool use.
- Codex: `codex app-server` JSON-RPC over stdio with a minimal typed protocol
  subset; turn/steer joins a running turn, multi_agent_v2 enables subagents.

A shared conformance suite runs every adapter against the real provider:
simple turn, file write, approval accept/decline, interrupt, mid-turn steer,
resume, user input, subagent and MCP registration. 28 of 30 pass; the two
skips are verified vendor limits recorded in the README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
…t-providers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
…apter

An OpenCode agent whose provider config sets runtime: 'provider' runs its
sessions through @switch-console/agent-providers instead of a PTY in tmux.

Main: ProviderAgentRuntime owns the adapter session, keeps a bounded
transcript, maps provider events to agent status, and exposes a
sessionTranscript RPC. Room messages are delivered as turns through a
ProviderInjectionSink; approvals and questions are posted into the room by
provider-room-relay and the next addressed reply answers them. Auto-started
sessions deliver their trigger as a turn. The session's Switch skill is
written into the adapter's isolated config so the room workflow still loads.

Renderer: a transcript panel replaces the terminal for provider sessions,
with streaming assistant text, collapsible activity rows, approval and
question cards, a composer and a Stop control; an agent-level toggle opts
an OpenCode agent in.

Provider sessions are local-only until the sidecar hosts the adapter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
Found by driving a provider-backed OpenCode agent from Mattermost:

- allow the MCP servers a session registered so the switch tools never ask
  for permission and stall the first turn
- emit transcript updates on the session topic the renderer subscribes to
- always start a restored provider session instead of leaving it Starting
- hold room messages while a turn runs and deliver them as the next turn,
  without gating control commands such as interrupt
- give each user entry its own id so a steered message keeps the earlier one
- title MCP approvals by permission name instead of the wildcard pattern

The e2e harness gains a seed step and ignores Switch's own status posts so
an interrupt cannot pass on a "working on it" line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
Room-originated transcript entries carry who said what and where, so the UI
shows "user · #room" and the message body while the model still receives the
Switch envelope. Approval and question cards drop their controls once
answered and say where the answer came from. A room message held behind a
running turn is delivered into that turn after five minutes with a notice.

Also fixes the room id being unknown for a spawned session's first message,
and threads the origin through the auto-spawn trigger, which bypasses the
room connection. The e2e question scenario now names the ask-the-user tool
and answers by number so it exercises the relay path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
The OpenCode adapter reads the delegated agent from the task tool input and
the activity row shows the task description, so a delegation reads
"Subagent · explore  Read README.md first line" instead of "task running".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
The provider runtime now supports Claude alongside OpenCode. The agent's
named definition is launched through the SDK's agent option, model and
effort come from the launch profile, and registering the session's switch
MCP server puts the CLI in strict MCP config so the installed connector
plugin's copy is dropped while its skills still load: one set of switch
tools per session.

Two adapter fixes found against the real room: tools of MCP servers the
session registered are allowed instead of asked about, and a PreToolUse
hook reclaims the permission decision for shell and file tools, because the
connector plugin's own hook otherwise allows every mediated call before
canUseTool runs. The opt-in toggle is shared by both providers and gated by
supportsProviderRuntime in shared code. The e2e harness can seed a
claude-code agent and accepts a prose clarifying question for providers
that offer no native ask-the-user tool to SDK sessions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
…ry mode

Claude Code only offers AskUserQuestion to an SDK session while a canUseTool
callback is registered, in default and bypassPermissions alike. The adapter
dropped the callback under bypassPermissions to avoid the SDK's shadowed
warning, so the tool never appeared and the conformance scenario was skipped
as a vendor limit. It now registers the callback in every mode, answers
AskUserQuestion from it, and filters that one warning. Claude conformance is
10/10 and the room-level question scenario passes on the numbered relay path,
which is now the harness default for every provider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8CWxXQ9653KNWAj3Mj5jf
The session interaction contract, taken byte-identical from
`origin/worktree-sdk-providers` at 2469a18 so that branch rebases
cleanly onto this one later. Do not edit these files here.

Everything alongside it is the minimum needed to build and test the
lifted copy on main: the `./session-v1` subpath export, the tsdown
entry, and zod — already resolved at 4.4.3 elsewhere in the workspace,
so the lockfile gains only the specifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first slice of the session projection: the platform-neutral layer
between the session interaction contract and the messaging bridges. A
recorded session goes in through a transport seam, the projection decides
whether the room may see its open approval, and the Slack renderer turns
it into buttons.

`audience` is access, not placement. A `session-members` request never
reaches a room, because no messaging platform has a surface that means
"session members only" — Slack's expandable detail on an assistant message
is visible to the whole channel. Placement is a separate decision this
layer makes later, from `Item.kind`.

The card carries the text form of the same question as well as buttons, so
it is already answerable in words once the parser lands, and its callback
payload carries nothing but an opaque reference the caller minted.

Nothing is wired into a live bridge yet: the contract's server routes do
not exist, so the only `SessionEventSource` is a fixture replay, and the
existing runtime-state indicator is untouched.

`test_session_contract_parity.py` reads the same `examples.json` as
`session-v1.test.ts` and mirrors its assertions, following the pattern
`test_claude_connector_hook.py` and `bin.handshake.test.ts` already set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@simonflk-sb simonflk-sb changed the title CHOO-2621 slice 1: a session approval becomes a Slack card CHOO-2621: rich session activity in the messaging apps (Slack first) Sep 8, 2026
@simonflk-sb
simonflk-sb changed the base branch from main to simonflk/choo-2621-temp-base September 8, 2026 10:44
Comment thread core/switch_core/bridges/collaboration/session/renderers/slack.py Outdated
simonflk-sb and others added 2 commits September 8, 2026 11:17
Slack reads a message's `text` as mrkdwn whether or not blocks accompany
it, so the fallback was a way for an approval's title, detail or option
label to write a `<!channel>` broadcast that the card itself already
refused.

Button labels stay as their author wrote them: `plain_text` is not parsed,
so an entity there would reach the reader as an entity, and it would be
truncated mid-escape at Slack's 75-character limit.

The three replacements move to `slack/mrkdwn.py`, shared with
`escape_label_for_body`, which had the only other copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@simonflk-sb

Copy link
Copy Markdown
Collaborator Author

I have read the CLA Document and I hereby sign the CLA

switch-cla-assistant Bot added a commit that referenced this pull request Sep 8, 2026
simonflk-sb and others added 2 commits September 8, 2026 12:13
`block_actions` arrived over Socket Mode and was acked and dropped. It now
resolves to a `request.answer` command.

The callback is trusted for two things: which control was operated, and an
opaque token. Everything else in the command comes from a new
`session_request_posts` row that the token resolves to — session, epoch, room,
thread and the revision the answer stands against — or from the Switch identity
the bridge verifies for the actor itself. A token is always looked up scoped to
its bridge, so one replayed over another workspace's connection resolves to
nothing.

The command id is derived with uuid5 over session, epoch, request, revision,
option and actor, so one person pressing twice is one command while two people
choosing differently stay two.

Nothing consumes session commands yet — that is the server half. The command is
built and then dropped with a warning that says so.

CHOO-2621

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A card was rendered once, open, with a button per option. It now renders
whatever state the request is in, and is edited in place as the request moves:
buttons while it is open, who is answering while an answer is in flight, and
what became of it once it has settled.

Buttons appear only while the request is open. Offering one on a card whose
request is already answered invites a press that cannot land.

A settled card names the option chosen, the scope it takes, and the actor and
surface it was chosen from. A request that closed without being answered never
reads as answered, whichever way it closed. Where the host says less than that —
an answered outcome with no result, an option the request never offered — the
card says so rather than filling the gap.

The text form follows the same state, because it is what a notification carries
and a notification that still asks a settled question asks for an answer that
cannot land.

`update_blocks` raises where `update_message` logs: a card left showing buttons
for a settled request is the failure that must not be silent, and
`SessionRequestCards.refresh` posts the outcome into the thread when the edit
fails.

CHOO-2621

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread core/switch_core/bridges/collaboration/session/inbound.py Outdated
Comment thread core/switch_core/bridges/collaboration/session/renderers/slack.py Outdated
Slack caps a section at 3000 characters and rejects the whole post when one
exceeds it, so an over-long title took the card with it rather than just the
value that caused it. Title, detail and option label now have budgets set so no
combination of them can reach a limit.

The cut is made on the source and the result escaped whole, never the other way
round: slicing an escaped string can leave half an entity behind and put a
literal `&am` in front of the reader. Escaping is monotone in prefix length, so
the longest prefix that still fits is found by binary search on the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
default `json.dumps` pads every separator and would read a compliant event
as oversized.
"""
return len(json.dumps(event, separators=(",", ":")).encode("utf-8"))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

json.dumps defaults to ensure_ascii=True, so an emoji counts as 12 bytes here and 4 on the host

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sent by 🤖 @simonflk.remote-coder from Switch:

Good catch, and worse than it looks: it is not only a mismatch, it is a mismatch in the rejecting direction. An event the host sent well inside 64 KiB gets refused on arrival here, and the less ASCII it contains the sooner. A message that is mostly emoji or CJK hits the cap at roughly a third of the size the host allows.

Fixed in 870990censure_ascii=False, so what is counted is UTF-8 of the same string JSON.stringify produces, which is what TextEncoder().encode(...).byteLength counts in validation.ts.

The test has no TypeScript counterpart, because the divergence is in Python's defaults rather than in the contract: it asserts event_bytes({"a": "😀"}) == len('{"a":"😀"}'.encode()) and that a notice of 10,000 emoji parses instead of raising PAYLOAD_TOO_LARGE. Both fail before the change.

Fixes review of a003517:

- SlackAdapter.reaction_target and mark_activity now normalise the
  "channel:ts" composite SessionTurnActivity passes (Origin.thread_id /
  message_id) to a bare ts before using it, matching what _track_turn
  already does. Previously the composite never matched _thread_trigger's
  bare-ts keys, so reaction_target was a no-op and the reaction call itself
  was handed a non-timestamp string.
- SessionTurnActivity._release_thread now calls _mark_thread(working=False)
  whenever nothing else is holding the reaction ref, not only when this
  turn is the one _thread_turns recorded as having claimed it — so a
  reaction this process's own bookkeeping lost track of, but Slack is still
  showing, gets cleared rather than stranded, while a claim still held by
  another live turn is still protected.
- Documented the remaining shared-state risk with the old runtime-state
  indicator (both go through mark_activity/_mark_being_read's _eyes) on
  SlackAdapter.mark_activity: not reachable today (session-v1 connectors
  never call the runtime-state endpoint, and runtime-state's own callers
  don't speak session-v1), but not structurally enforced either.

Added 3 regression tests using a realistic "channel:ts" composite ref
(the previous tests all used plain strings, which passed even with the
composite left unstripped), each verified via git-stash to fail on
a003517 and pass now.
Fixes review of 860a0c7:

- SessionTurnActivity.publish/_begin now take asked_on explicitly rather
  than resolving it from the thread via SlackAdapter.reaction_target.
  refresh_activity computes it once from origin.message_id (falling back to
  thread_root_id when there is no separate asking message) at the moment
  the command is read, and carries it alongside thread_root_id. The old
  _thread_trigger-based resolution read whichever message had most recently
  been seen in the thread at the time a turn was first drawn, which is not
  necessarily the one that asked this turn, and could not be right at all
  for an answer given by button press since no message event fires to
  update it.
- Deleted SlackAdapter.reaction_target, now unused.
- Documented that sharing _eyes with the runtime-state indicator's
  _track_turn can still collide, since _eyes is keyed on the message alone
  with nothing about which session or agent put it there — two different
  agents with a turn each in the same Slack thread are enough, regardless
  of whether any single session does both. No connector does both today,
  but that only rules out one agent colliding with itself. Left unresolved:
  unifying the two into one claim registry is a bigger change than this
  layering was meant to take on, and needs a decision on how the old
  runtime-state indicator and turn activity should relate before it is
  worth doing.

Added a test on SessionPublisher.refresh_activity's own command-origin
resolution (a reply inside an existing thread, where the thread root and
the asking message differ) alongside the SessionTurnActivity-level tests,
since the previous round's tests exercised the resolution this replaces
but never the caller that fed it. All verified via git-stash to fail
without this change (a hard interface break, since the old tests never
passed asked_on at all) and pass with it.
Merge codex/sdk-server-split through c035aae. Move Slack consumers to the
shared server contract and projection while preserving request publication,
addressing-policy checks, activity rendering, and the existing migration chain.
Synchronize embedded connector skills with their updated source files.

Validation: 2,235 backend tests, 5 migration checks, 2,784 desktop tests,
and 717 package tests passed. Package builds, formatting, lint, TypeScript
checks, and Python type checks passed. Browser tests excluded per repository
instructions.
Merge the remote branch through 43be853 while retaining the SDK server
split integration. Carry the original asking message into activity reactions.

Validation: 1,541 Slack and session tests passed. Python lint, formatting,
and type checks passed.
@simonflk-sb
simonflk-sb changed the base branch from worktree-sdk-providers to codex/sdk-server-split September 11, 2026 13:02
Keep the timer and Console link above a persistent tool log. Add targeted request notifications, durable error alerts, and recoverable text fallback for rejected Slack blocks.

Validated with 598 session and Slack regression tests, mypy, Ruff, and a live Slack smoke test.
Retire native progress streams, runtime-state rendering, and obsolete stop handling. Keep SDK activity and shared reactions, and select publication behavior through adapter capabilities.

Update setup docs and tests. Validated with 1,626 collaboration and session tests, 130 focused checks, mypy, Ruff, and a live Slack smoke test.
Merge origin/codex/sdk-server-split through 8861425. Update the reconciler test mock for the new SDK activity synchronization call.
simonflk-sb and others added 4 commits September 17, 2026 16:51
Brings the SDK server split, now up to date with main, under the Slack
session activity work. Four files conflicted.

bridge_core.py: both sides added imports in the same two places. Kept
all of them — the session activity, demo, inbound and outbound layers
from this branch, and PLATFORM_MARKER, platform_on_behalf_of and
normalise_mime_type from the split.

config.py: one new setting from each side, both kept.

test_row_level_security.py: the same collision the split's own merge of
main resolved, reaching here through the older split merge this branch
carries. Resolved the same way — main's one-directional stale and
disagreeing assertions over the combined inventory of both scoping
migrations. The two table entries this branch added by hand go with the
equality assertion they existed to satisfy; both tables are policed by
their own revisions, and the full-chain replay in
test_frozen_ddl_matches_create_all.py checks that against a database.

skill-file.ts: the app's embedded copy of the OpenCode connector skill,
which is generated from connectors/opencode-plugin/skills/switch/SKILL.md
and must not drift from it. Regenerated from the merged source rather
than hand-merged, and connector-assets.test.ts passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch's activity-reaction index and the split branch's invitations
follow-up are both heads after the merge, so alembic upgrade head is
ambiguous and refuses to run. Empty merge revision, as the five before
it in this directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing conflicted here: main added the guard over which modules may
open a session straight from the factory, this branch added three
modules that do. Every entry to them binds a tenant first — an inbound
bridge event, which bridge_core scopes before the handler runs, or the
publication task, created inside that scope and so carrying it in the
task's context. Arriving unbound is not a quiet empty read either:
publication asks for the tenant explicitly, and the tables the card and
activity layers touch are policed by the same function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-existing on this branch, from the Telegram rewrite of the runtime
indicator race test, and the reason its backend CI job cannot go green.
Separate from the merge so it can be dropped on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@simonflk-sb
simonflk-sb marked this pull request as ready for review September 17, 2026 16:55
switch-workforce-manager-interconnectivity and others added 3 commits September 18, 2026 17:31
Brings the Slack session-activity work back on top of the current SDK
server split.

Conflict resolutions:
- compose files and the managed-server env test: kept both sides. The
  messaging-app variables and SESSION_DEMO_ENABLED are independent
  additions to the same list.
- collaboration/adapter.py: union of the two import edits.
- slack/adapter.py connect(): took the upstream form. Its socket branch
  is this branch's body unchanged, with the webhook case added around it.
- slack/adapter.py dispatch_event(): kept the interactive branch from
  this branch, rewritten against the upstream signature — the handler
  now reads envelope_type and payload rather than the socket request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merging the SDK server split into this branch left two alembic heads.
This joins them; it changes no schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Slack adapter crashed on startup with NameError on _TRACE. The
constant and its other uses went when legacy Slack rendering was
retired on this branch; merging the SDK server split back in carried
one surviving debug call across with it. The line only traced the old
agent-sessions rendering, so it goes rather than the constant coming
back.

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.

2 participants