Rich session activity in the messaging apps, Slack first (CHOO-2621) - #394
simonflk-sb wants to merge 130 commits into
Conversation
…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
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>
|
All contributors have signed the CLA. ✅ |
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>
|
I have read the CLA Document and I hereby sign the CLA |
`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>
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")) |
There was a problem hiding this comment.
json.dumps defaults to ensure_ascii=True, so an emoji counts as 12 bytes here and 4 on the host
There was a problem hiding this comment.
Sent by 🤖
@simonflk.remote-coderfrom 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 870990c — ensure_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.
…k/rich-session-activity-slack
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.
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>
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>
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
Python (a byte-identical lift of
session-v1from the upstream SDK branch)and held as a per-session replica: coalescing, dedup by
(itemId, revision)/
(requestId, revision), and deciding channel root versus a secondarysurface.
buttons plus a text fallback that is answerable by typing (
R1 2), backedby a handle reserved per request so an answer always resolves to the right
card and revision.
reposted, ending on the turn's own state — streamed into a thread as a
planblock where Slack allows it, posted and rewritten at the channelroot otherwise.
Commandfrom 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_richon the adapter port, so which renderer drawsa 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 fixturethrough 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
for connectors that cannot speak the contract.
out of the session replica — session activity and room interaction are
different things.
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.