feat(agent): commandes Tauri + ChatBridge streaming (D4) — §17

Expose l'exécution structurée à l'UI (jumeau du chemin PTY) :
- ChatBridge (chat.rs) : pompe ReplyStream -> Channel<ReplyChunk>,
  generation-tracked (anti-double-pompe) ; scrollback de conversation
  côté transport (le port AgentSession est sans mémoire), purgé à la
  fermeture, préservé à la ré-attache.
- Commandes agent_send / reattach_agent_chat / close_agent_session.
- DTO : ReplyChunk (textDelta/toolActivity/final, camelCase tagué kind),
  ReattachChatDto, CellKind {pty,chat} + champ cellKind dérivé sur
  TerminalSessionDto (chat ssi LaunchAgentOutput.structured = Some).
- Wiring composition root : StructuredSessions + ChatBridge dans AppState,
  via la factory déjà injectée (aucun new d'adapter — règle D §17.8).
- From<AgentSessionError> for AppError (mappe sur PROCESS).

Tests (QA) : 35 unitaires verts — generation supersede validé par mutation
test, séquence deltas*+Final, reattach sans re-spawn, teardown shutdown+
unregister, DTO cellKind + round-trip ReplyChunk, non-régression PTY.
cargo test --workspace : 803 passed, 0 failed.

Reste D5 : AgentChatView (frontend) + routage cellKind dans LayoutGrid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:15:08 +02:00
parent 050afa7d24
commit f4d5727a69
12 changed files with 1153 additions and 9 deletions

View File

@ -237,6 +237,27 @@ pub struct TerminalSessionDto {
/// resumes. `None` for a plain terminal, a resume, or a degraded launch.
#[serde(skip_serializing_if = "Option::is_none")]
pub assigned_conversation_id: Option<String>,
/// How the frontend should render the cell hosting this session (§17.6):
/// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by
/// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal
/// (xterm). **Derived**, not a layout field: it follows the presence of a
/// structured session descriptor on the launch output — a single source of
/// truth. Plain terminals and the non-launch construction paths default to
/// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO
/// always serialises with `cellKind: "pty"`).
pub cell_kind: CellKind,
}
/// Whether a session's hosting cell renders as a structured chat view or a raw
/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire;
/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CellKind {
/// A raw PTY terminal cell (xterm) — the historical default.
Pty,
/// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`.
Chat,
}
impl From<OpenTerminalOutput> for TerminalSessionDto {
@ -248,6 +269,7 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
rows: s.pty_size.rows,
cols: s.pty_size.cols,
assigned_conversation_id: None,
cell_kind: CellKind::Pty,
}
}
}
@ -1143,6 +1165,15 @@ pub struct LaunchAgentRequestDto {
impl From<LaunchAgentOutput> for TerminalSessionDto {
fn from(out: LaunchAgentOutput) -> Self {
let assigned_conversation_id = out.assigned_conversation_id;
// §17.6: the cell kind is *derived* from the launch routing. A structured
// descriptor (`structured: Some(..)`) means LaunchAgent routed to an
// AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒
// terminal cell. Single source of truth, no layout migration.
let cell_kind = if out.structured.is_some() {
CellKind::Chat
} else {
CellKind::Pty
};
let s = out.session;
Self {
session_id: s.id.to_string(),
@ -1150,6 +1181,7 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
rows: s.pty_size.rows,
cols: s.pty_size.cols,
assigned_conversation_id,
cell_kind,
}
}
}
@ -1200,10 +1232,63 @@ impl From<TerminalSession> for TerminalSessionDto {
rows: s.pty_size.rows,
cols: s.pty_size.cols,
assigned_conversation_id: None,
cell_kind: CellKind::Pty,
}
}
}
// ---------------------------------------------------------------------------
// Structured chat sessions (§17 — D4)
// ---------------------------------------------------------------------------
/// One incremental chunk of a structured agent reply, streamed over the chat
/// session's [`tauri::ipc::Channel`] (ARCHITECTURE §17.7). The serialised wire
/// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn
/// event to one of these and pushes it to the frontend `AgentChatView`.
///
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"`), so
/// the front branches without positional parsing. `Final` is the **deterministic
/// terminal** chunk of a turn — after it the turn is frozen and the stream ends.
/// `Deserialize` is derived too so tests (and a mock gateway) can round-trip it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ReplyChunk {
/// An assistant text fragment (incremental chat rendering).
#[serde(rename_all = "camelCase")]
TextDelta {
/// The text fragment.
text: String,
},
/// A human-readable tool-activity badge (best-effort observability).
#[serde(rename_all = "camelCase")]
ToolActivity {
/// The human-readable activity label.
label: String,
},
/// The deterministic end-of-turn chunk carrying the aggregated final content.
#[serde(rename_all = "camelCase")]
Final {
/// The aggregated final content of the turn.
content: String,
},
}
/// Response DTO for `reattach_agent_chat`: the retained **conversation
/// scrollback** of a still-live structured session, replayed into the
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachChatDto {
/// The session that was re-attached (echoed back for the frontend).
pub session_id: String,
/// The chunks already streamed for this conversation, in order. The frontend
/// replays them to rebuild the visible turns, then receives subsequent chunks
/// over the freshly-registered channel.
pub scrollback: Vec<ReplyChunk>,
}
/// Request DTO for `inspect_conversation` (T7).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]