Skip to content

feat: surface agent-to-agent communication in a read-only desk transcript view — hive moves, asides, referrals, episode outcome #2115

Description

@Al629176

Summary

Two HiveMind PRs (#2097, #2109) landed the full deliberation engine: private asides, cross-desk referral, episode outcome reporting, and a vending-machine bundle that exercises all of it live. What they did not add is a UI that surfaces any of it.

The result today: an operator opening a desk with HiveMind enabled sees a wall of flat chat bubbles — !propose, !evidence, !commit, and aside rows all render identically to ordinary replies. The structure the deliberation engine produced (who spoke, in what role, to whom, with what outcome) is invisible. An operator cannot tell a quorum commit from a stray remark, cannot see that an aside happened, and cannot trace a cross-desk referral back to the answer that resolved it.

This issue tracks three deliverables that together close that gap, reusing what is already in the event log:

  1. Backend projection — extend MessageView with the four HiveMind fields the frontend needs and project them from AgentReply.
  2. Frontend message rendering — render HiveMind moves with distinct visual treatment in the existing MessageRow.tsx / MessageTimeline.tsx.
  3. Desk transcript view — a new read-only route (#/desks/{id}/transcript) that renders a complete episode-grouped deliberation log.

Problem

1 — MessageView projects none of the HiveMind fields

AgentReply in src/ports/types.rs already carries:

// src/ports/types.rs:1058
#[serde(default, skip_serializing_if = "Vec::is_empty")]
audience: Vec<String>,

But MessageView in src/server/chat_history.rs:

// src/server/chat_history.rs:300-406
pub struct MessageView {
    pub id: String,
    pub author: String,
    pub text: String,
    // no audience, no hive_move, no episode_id, no elided
}

And every projection site hard-codes the empty vec rather than reading from the event:

// src/server/chat_history.rs:1206 (and ~18 more sites)
audience: Vec::new(),

So the console never learns an aside was sent, which members it was between, or whether to show the content or an elided stub.

2 — No hive_move tag

A deliberation move marker (!propose, !evidence, !support, !commit, !question, !defer, !aside @peer) is embedded in the reply text and parsed only by tinyhivemind at step time. MessageView exposes the raw text. The frontend would have to re-implement the same prefix grammar to extract the move kind — brittle and already done on the server.

The server should extract the move kind at projection time and expose it as a nullable typed field.

3 — No episode_id — messages from one episode cannot be grouped

An episode's messages are scattered through the desk journal interleaved with whatever else happened. There is no grouping key. The frontend cannot tell where an episode started, where it ended, or which commit row closed it. The HIVE_REPORT_AUTHOR summary (src/hivemind/episode.rs:1043) that closes each episode is journaled as a plain AgentReply — indistinguishable from a normal reply once the agent_id is the only marker.

4 — No elided flag on aside rows

A viewer not in an aside's audience should see a stub ([aside between fleet_tech and route_planner]) rather than content. Today the history read applies no viewer-scoped elision — it returns all content to all viewers (src/server/chat_history.rs project arm matches AgentReply without filtering on audience). This is documented as correct for operators (docs/spec/runtime/hivemind-asides.md, "An operator and every person reads every aside in full"), so viewer-scoped elision only applies to agent peers. But the console should be able to render the elided stub without re-running the episode.

5 — The frontend has no HiveMind-aware rendering

frontend/src/views/chat/MessageRow.tsx renders every AgentReply identically. There is no:

  • Visual badge for the move kind (propose / evidence / support / commit / question / defer)
  • Aside indicator or elided stub
  • Episode grouping or episode-outcome chip naming the commit and the committer
  • Cross-desk referral sub-thread linking back to the referring desk

Open issues that this closes or partially addresses: #1956 (first-person-collapsed transcript), #1957 (missed teammate reply after rebind), #1959 (desk_context has no production consumer).


Solution

Layer 1 — MessageView: four new fields

// src/server/chat_history.rs — MessageView additions
pub struct MessageView {
    // ...existing fields unchanged...

    /// The HiveMind deliberation move kind for this row, when the reply was
    /// written in the context of a desk episode.
    ///
    /// Extracted from the text prefix at projection time so the frontend does
    /// not re-implement the move grammar. `None` for ordinary (non-episode)
    /// replies and for every reply journaled before this field existed.
    pub hive_move: Option<HiveMoveKind>,

    /// The episode this row belongs to, identified by the trigger message's
    /// event sequence (the `OperatorMessage` that opened the episode).
    ///
    /// Carries the same value for every message the episode journals, so the
    /// frontend can group them without scanning for the HIVE_REPORT_AUTHOR row.
    /// `None` for non-episode replies.
    pub episode_id: Option<String>,

    /// The aside audience for this row, when it is narrower than the full desk.
    ///
    /// Empty = desk-visible (the ordinary case). Non-empty = this row is a
    /// private aside between the author and these members. The author is not
    /// repeated in the list.
    pub audience: Vec<String>,

    /// Whether this row's content is elided for the requesting viewer.
    ///
    /// `true` when the row is a private aside and the viewer is not its author
    /// or in its audience. The `text` field is empty when `elided` is `true`;
    /// the `audience` field still carries the participants so the frontend can
    /// render `[aside between fleet_tech and route_planner]`.
    ///
    /// Always `false` for operators and people (they read every aside in full).
    pub elided: bool,
}

/// The deliberation move a HiveMind episode member made.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HiveMoveKind {
    Propose, Evidence, Support, Commit, Question, Defer, Aside,
    /// Catch-all for a move prefix the projection could not parse.
    Unknown,
}

Projection (project fn in chat_history.rs): when matching CompanyEvent::AgentReply { audience, text, .. }, extract the move kind from the text prefix, pass audience through, compute elided from the viewer's identity vs. the audience list.

Episode id: thread the trigger's EventSeq through EpisodeDriver::run (src/hivemind/episode.rs:211) and journal it on every AgentReply the episode writes (a new episode_id: Option<String> field on AgentReply, additive: #[serde(default, skip_serializing_if = "Option::is_none")]).

Layer 2 — Frontend: HiveMind-aware message rendering

frontend/src/lib/chat.ts — extend the Message type:

export type HiveMoveKind =
  | 'propose' | 'evidence' | 'support' | 'commit'
  | 'question' | 'defer' | 'aside' | 'unknown';

export interface Message {
  // ...existing fields...
  hiveMove?: HiveMoveKind;
  episodeId?: string;
  audience?: string[];
  elided?: boolean;
}

MessageRow.tsx — two new rendering modes:

  1. Move badge — when hiveMove is set, render a left-border stripe whose color encodes the move kind (propose = blue, evidence = amber, support = green, commit = emerald + bold, question = purple, defer = slate, aside = dashed border). The badge sits left of the author name so the room's deliberation structure reads as a visual rhythm even when text is collapsed.

  2. Aside / elided — when audience.length > 0, render a compact aside between {author} and {audience} label in the header. When elided: true, replace the bubble body with an italicized stub and suppress tool steps.

MessageTimeline.tsx — episode grouping:

Collect consecutive messages sharing the same episodeId into a collapsible <EpisodeBlock>. The block header shows participant avatars, turn count, and an outcome chip ("committed" / "deadlocked" / "exhausted" / "deferred") extracted from the HIVE_REPORT_AUTHOR row that closes the episode. Default to expanded for the most recent episode, collapsed for older ones.

Cross-desk referral sub-threads: a row whose agent_id is HIVE_REFERRAL_AUTHOR renders as a quoted block with an outbound arrow and the referred desk's name; the answer row that follows it is indented under it.

Layer 3 — New route: #/desks/{id}/transcript

A dedicated read-only view, separate from the conversational #/chat/{desk_id} route.

What it is not: it is not a chat. No message composer, no send button, no reply affordance. It is an observer surface — the operator watches what the room produced.

What it shows:

  • Full episode history for the desk, paginated oldest-first within episodes (reverse chronological across episodes so the most recent work is at the top).
  • Episode blocks (as above) with expand / collapse controls.
  • A compact stat row per episode: N turns · M members · outcome · duration.
  • Aside rows with full content for operators; elided stubs for non-audience peers.
  • Cross-desk referral branches, indented under the row that triggered the referral.
  • A "live" indicator when an episode is currently running (the InflightRunBar SSE stream already carries this signal).

Route registration (frontend/src/App.tsx): add /desks/:deskId/transcript beside the existing /chat/:threadId routes.

Navigation entry: the desk's channel header (ChatHeader.tsx) gains a Transcript tab/link that opens the view for the current desk. The link is only rendered when the desk has hive.enabled set in the manifest (or defaults to enabled by having >= 2 members).

Sidebar: the desk's row in ChannelRail.tsx gets a HiveMind indicator icon so the operator can see at a glance which desks are running episodes.


Acceptance criteria

  • MessageView gains four fieldshive_move: Option<HiveMoveKind>, episode_id: Option<String>, audience: Vec<String>, elided: bool — each additive (#[serde(default)], skip_serializing_if) so no stored record needs migrating and every existing test compiles unchanged.
  • AgentReply gains episode_id — journaled by EpisodeDriver::run for every message the episode writes; the trigger's event seq (cast to string) is the id; additive on the same terms.
  • Move extraction is server-side — the prefix grammar (!propose, !evidence, etc.) is parsed once in the project fn in chat_history.rs and never duplicated in the frontend. Unknown prefixes yield HiveMoveKind::Unknown, not an error.
  • Aside projection is viewer-scoped — a viewer whose id is not in the aside's audience (and is not an operator) receives text: "", elided: true. Operators always receive the full text. The viewer id is the already-resolved by from the auth context.
  • Move badge renders in MessageRow.tsx — each HiveMoveKind has a distinct left-border color and a text label ("proposes", "supports", "commits", etc.) beside the author. No badge when hiveMove is absent.
  • Aside UI — a row with audience set renders an aside header label; an elided row renders the stub instead of message content, with no tool-call steps shown.
  • Episode blocks in MessageTimeline.tsx — messages sharing an episodeId are grouped; the block header shows outcome and stat line; most-recent episode is expanded by default.
  • #/desks/{id}/transcript route — new read-only view, no composer, paginated episode history, collapsible episode blocks, live indicator, aside rendering, cross-desk referral indentation.
  • NavigationChatHeader.tsx on a HiveMind desk shows a Transcript tab/link; ChannelRail.tsx shows a HiveMind indicator on hive-enabled desks.
  • Playwright spec: desk-transcript.spec.ts — drives the vending_machine_co bundle (already in companies/): loads #/desks/ops/transcript, asserts episode blocks are present, asserts the commit row has the commit badge, asserts aside rows show the audience label. Uses mock-plan scripting following orchestration-simulation.spec.ts — not gated on LIVE_BRAIN.
  • Existing tests unchangedcargo test passes; the four new additive fields on AgentReply / MessageView do not break any deserialization or assertion.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestpriority: p3Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions