feat(chat): rooms instead of threads — multi-agent, multi-human - #5254
Open
vibegui wants to merge 5 commits into
Open
feat(chat): rooms instead of threads — multi-agent, multi-human#5254vibegui wants to merge 5 commits into
vibegui wants to merge 5 commits into
Conversation
The sidebar listed threads flat (or by status), which reads as an inbox rather than a workspace. Group them by agent instead: a room is an agent — a code agent is its repo, a plain agent its subject — and the threads inside it are the conversations held there. No schema change: `groupThreadsByVirtualMcp` already existed and was unused, and threads already carry `virtual_mcp_id`. Rooms become the default view; List and Status stay available in the filter popover. Co-Authored-By: Claude <noreply@anthropic.com>
An "@agent" mention used to expand into a "[DELEGATE TO AGENT …] use the subtask tool" instruction, so the current agent answered on the other agent's behalf and the room never heard the agent itself. Now the mention hands the turn to that agent: it answers in the thread, as itself. The backend was already per-turn — `dispatch-run` resolves the agent from `input.agent.id` on the request and never reads `thread.virtual_mcp_id` — so this needs no migration and no new column. The mention is recorded as `metadata.agent` on the user message, which is also what later readers use to tell who spoke. The correctness core is `buildRoomTranscript`: the model context carries only `role`, so without it an agent reads another agent's replies as its own past words and starts contradicting itself. It rewrites the history for whoever is about to speak — own turns stay `assistant`, another agent's become `user` messages labelled `[Name]: …`, humans get named once more than one has spoken. Tool parts are flattened out (a tool call can't ride on a `user` message, and an orphaned tool result breaks strict providers). Threads with no `metadata.agent` — every thread written before this — pass through untouched. `build-improve-prompt-doc` relied on the delegation directive; its test now asserts the routing instead of the removed text. Co-Authored-By: Claude <noreply@anthropic.com>
Threads were writable by their creator alone, which is right for a personal chat and wrong for a room. Add an opt-in `metadata.shared`: a shared room accepts posts from any member of the organization, a personal chat stays owner-only — so this does not undo the deliberate rule that a teammate's chat is read-only. Both guards (the API's dispatch check and the composer's read-only state) now call the same `canWriteToThread` predicate, so the UI cannot offer a write the server will reject. Org membership is already proven upstream by the org-scoped route and the org-scoped thread fetch; the predicate only decides owner-vs-room. An unknown actor (session still loading) is denied rather than assumed. The toggle has no UI entry point yet — rooms can only be opened through the thread API until one is added. Co-Authored-By: Claude <noreply@anthropic.com>
The write guard lived in `dispatch-run`, which runs inside the DBOS workflow — so posting to someone else's chat returned 202 and then died asynchronously, after the user message had already been persisted. Move the check into the route's `validate()`, where the thread row and the caller are both already in hand: an unauthorized post is now a 403 on the POST with nothing written. `dispatch-run` keeps the same check as defense in depth for callers that reach it by another path. Placing it before model resolution also makes the contract testable without an AI provider, which is what the new e2e spec relies on: a teammate is refused from a personal chat (403, no message row) and admitted to a shared room (202, message row lands), and a second agent can take a turn in a thread the first one opened without the room changing its primary agent. Co-Authored-By: Claude <noreply@anthropic.com>
Shared rooms were enforced end to end but unreachable: nothing in the UI could set `metadata.shared`, and a room full of people rendered every message the same way. - `RoomToggle` in the chat panel header flips the current thread between a personal chat and a shared room. Owner-only — a teammate can already read the thread, but who may write to it is the owner's call — and it stays hidden until the row has a `created_by` to compare against, so an optimistic row can't show a control that would act on the wrong thread. - A user message now carries its sender's avatar and name, but only in a shared room: in a personal chat every message is yours and the name is noise. Together with the agent label added earlier, a room now shows both halves of "who said this" — which human, which agent. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rooms, not threads — and more than one agent in them
Two distinctions drive this branch:
This PR ships the second one and the multi-participant plumbing it needs. Research behind it (Buzz) is at the bottom.
How to verify (5 minutes, no AI provider needed)
room-transcript.test.tsis the one to read first — it's the whole multi-agent correctness argument in 9 cases, no mocks, no DB.In the app (
bun run dev):@SomeOtherAgent do X→ that agent answers, in this thread, labelled with its avatar and name.E2E (
packages/e2e/tests/chat-room.spec.ts): three cases over the real route — teammate refused from a personal chat (403, nothing written), teammate admitted to a shared room (202, message row lands), a second agent takes a turn without the room changing its primary agent.What's here, and why it's shaped this way
1. Sidebar: rooms instead of a thread list
A room is an agent — a code agent is its repo, a plain agent its subject.
groupThreadsByVirtualMcpalready existed and was unused; this wires it up as the default view. List and Status stay in the filter popover. No schema change.2. Several agents answering in one thread
@agentused to expand into a[DELEGATE TO AGENT …] use the subtask toolinstruction — so the current agent answered on the other one's behalf and the room never heard the agent itself. Now the mention hands the turn to that agent.Why no migration:
dispatch-runalready resolved the agent frominput.agent.idon the request and never readthreads.virtual_mcp_id. The single-agent-ness was a client convention (the?virtualmcpid=URL param). The mention is recorded inmetadata.agenton the user message —thread_messages.metadatais free-form JSON that the history read path already preserves, so authorship needed no column. Migration 057 deliberately collapsedagent_idsinto a singularvirtual_mcp_id; this does not re-add an array. The thread keeps one primary agent (the room's), and authorship lives per message, which is where it belongs.The correctness core is
buildRoomTranscript(apps/api/src/harnesses/decopilot/room-transcript.ts). The model context carries onlyrole. Without this, agent B reads agent A's replies as its own past words — it contradicts itself, apologises for things it never said, "continues" work it never began. So history is rewritten for whoever is about to speak:assistant;usermessages labelled[Mario]: …;usermessage, and an orphaned tool result breaks strict providers;(worked on this without replying)rather than empty text, which some providers reject;metadata.agent— every thread written before this — pass through untouched.Applied in
context-loader.ts, one seam, so every decopilot run gets it.3. Shared rooms (multi-human)
Threads were writable by their creator alone — right for a personal chat, wrong for a room. Opt-in
metadata.shared.Why a flag and not "every thread is a room": the owner-only rule was added deliberately (#4230 made teammates' threads read-only). Flipping it wholesale would silently undo that for everyone. A room is now something you open, and a personal chat still behaves exactly as it did.
Why one predicate:
canWriteToThreadinpackages/sharedis called by the route, bydispatch-run, and by the composer. Three call sites, one rule — they cannot drift, and the composer can never offer a write the server will reject. Unknown actor (session still loading) is denied rather than assumed; a non-booleansharedis ignored rather than trusted.Why the guard moved: it lived in
dispatch-run, inside the DBOS workflow — so an unauthorized post returned 202 and died asynchronously after the user message had been persisted. It now runs in the route'svalidate(), where the thread row and caller are already in hand: 403 on the POST, nothing written.dispatch-runkeeps the check as defense in depth. Doing it before model resolution is also what lets the e2e prove the contract with no AI provider configured.4. UI
RoomTogglein the chat panel header flips personal ↔ shared. Owner-only, and hidden until the row has acreated_byto compare against so an optimistic row can't act on the wrong thread. (There was no thread-actions menu anywhere to hang this on —renameexists in the store and is called from nowhere — so it's a single icon button rather than a new dropdown surface.)Trade-offs and what I deliberately did not do
room_memberstable.metadata.sharedis a 1-bit stand-in: a room is open to the whole org or to nobody. Per-room membership, invites and scoped guests are the real model (see the Buzz notes) and a much larger change.@mention wins. No parallel replies from two agents to the same message.authorcolumn is the honest fix once agents are principals.deriveOthersThreadLabel(the sandbox auto-start gate) still uses raw ownership, notcanWriteToThread— booting a sandbox on someone else's branch is a different decision from posting a message, and I didn't want to loosen it by accident.Research: what Buzz teaches us
Read alongside Buzz (
VISION.md,VISION_PROJECTS.md,VISION_AGENT.md,migrations/0001_initial_schema.sql) and the Raft write-up. Buzz is a Slack-like workspace where humans and agents are members of the same channels; a branch is a channel and that channel is the PR + CI dashboard + discussion.Where we're already aligned
events— one signed, partitioned append-only logthread_message_parts— stream-of-record with foldsubscriptions+delivery_log, at-least-onceworkflows,workflow_runs,scheduled_workflow_firescommunity= tenant, URL selects it/api/:org/...thread_metadata(parent/root/depth), N levelsWhere we're stronger, and shouldn't copy them
MCP as the product: bindings, virtual MCPs, tool aggregation, credential vault, OAuth proxy, an AI-provider layer with tiers and credits, multiple harnesses (claude-code / codex / decopilot) with sandbox + branch + preview. Buzz uses MCP as agent plumbing, not as a control plane. That layer is our asset.
Where we're fundamentally wrong
1. An agent is not a principal — it's a connection. In Buzz an agent is a row in the same
userstable as humans: its own keypair,role='bot'inchannel_members, andagent_owner_pubkeyso it inherits its owner's access — remove the human and every agent of theirs loses access instantly. In Studio an agent is aconnectionsrow of typeVIRTUAL: a tool, not a subject. Hence it can't be a member, can't have presence, can't claim work, can't hold authority. Most of this PR is a workaround for that missing principal model. This is the deep fix.2. There is no room membership. Our access is org-wide plus thread-owner. There is no "who is in this room" — so no participant list, no invite, no scoped guest, no per-room privacy.
metadata.sharedis a 1-bit stand-in for aroom_memberstable. In Buzz, channel membership is the only gate, and it's the same gate for humans, agents and guests.3. A message has a
role, not an author. That's problem 1 expressed in the schema.buildRoomTranscriptis a patch over a missing author column. In Buzz authorship is cryptographic — every event is signed by a pubkey.4. No presence. Buzz shows "2s (2 agents)" working in a channel. A room without presence doesn't feel like a room; it feels like an inbox.
5. Agents don't watch the room. In Buzz an agent subscribes to a channel — that's how "an agent watching the incident channel answers on its own" happens at all. In Studio an agent acts only when a human sends a turn or a cron fires. We already have the event bus; it just isn't wired to conversation. Cheapest gap to close, highest return.
6. One log, three lenses — we have N models and N screens. Stream / Forum / Workflow are renderings of the same event log, which is why a Buzz channel can be the PR and the CI dashboard and the discussion at once. Our board, automations, PRs and threads are separate models: nothing to search, replay or subscribe to in one place.
7. No attention model. Buzz defaults to zero notifications, urgency only in DMs. A room without an attention policy becomes noise — the Raft piece reaches the same conclusion from the other direction ("what changes is what reaches you").
Suggested order
agent-as-principal (1 + 3) → room membership (2) → agent subscribes to the room (5) → presence (4). Items 6 and 7 are strategy, not sprint. None of this needs cryptographic identity: the lesson from Buzz is the model of subjects and belonging, not Nostr.
🤖 Generated with Claude Code