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:
185
crates/app-tauri/src/chat.rs
Normal file
185
crates/app-tauri/src/chat.rs
Normal file
@ -0,0 +1,185 @@
|
||||
//! Generic **structured reply ↔ Tauri Channel** bridge infrastructure.
|
||||
//!
|
||||
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
|
||||
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
|
||||
//! bridge routes typed [`ReplyChunk`]s — the serialised
|
||||
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
|
||||
//! the chat cell that owns the session.
|
||||
//!
|
||||
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
||||
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
||||
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
|
||||
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
|
||||
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
|
||||
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
|
||||
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
||||
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
||||
//! port is a per-turn stream, not a persistent byte ring). So the bridge itself
|
||||
//! retains the rendered chunks per session — the **conversation scrollback** —
|
||||
//! so a re-attach can repaint the turns already streamed, exactly as the PTY
|
||||
//! adapter's ring buffer lets `reattach_terminal` repaint xterm. This lives on
|
||||
//! the transport side (D4 owns transport), keeping the domain port pure.
|
||||
//!
|
||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||
//! [`send_output`]: ChatBridge::send_output
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
|
||||
use crate::dto::ReplyChunk;
|
||||
|
||||
/// Per-session transport state: the current attach generation, its output
|
||||
/// channel, and the conversation scrollback recorded so far.
|
||||
struct ChatEntry {
|
||||
/// Monotonic generation of the current (re-)attach (anti double-pump).
|
||||
generation: u64,
|
||||
/// The output channel of the current attach, if one is registered. `None`
|
||||
/// when the session has been recorded (scrollback kept) but no view is
|
||||
/// currently attached — the pump then only appends to the scrollback.
|
||||
channel: Option<Channel<ReplyChunk>>,
|
||||
/// Every chunk routed for this session, in order — the conversation
|
||||
/// scrollback replayed on re-attach. Bounded only by the conversation length
|
||||
/// (a turn count), like the PTY ring buffer is bounded by its byte capacity.
|
||||
scrollback: Vec<ReplyChunk>,
|
||||
}
|
||||
|
||||
/// Registry mapping live structured (chat) sessions to their reply [`Channel`]
|
||||
/// plus a retained conversation scrollback.
|
||||
///
|
||||
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
||||
/// the twin of [`crate::pty::PtyBridge`].
|
||||
#[derive(Default)]
|
||||
pub struct ChatBridge {
|
||||
entries: Mutex<HashMap<SessionId, ChatEntry>>,
|
||||
}
|
||||
|
||||
impl ChatBridge {
|
||||
/// Creates an empty bridge.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or replaces) the reply channel for a session and returns the
|
||||
/// **generation** of this registration. Each call for a session bumps the
|
||||
/// generation, so the caller's pump can later tear down *only its own*
|
||||
/// registration via [`unregister_if`](Self::unregister_if). The retained
|
||||
/// conversation scrollback is **preserved** across re-attaches (only the
|
||||
/// channel and generation change), mirroring how the PTY ring buffer survives
|
||||
/// a `reattach_terminal`.
|
||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
match map.get_mut(&session) {
|
||||
Some(entry) => {
|
||||
entry.generation = entry.generation.wrapping_add(1);
|
||||
entry.channel = Some(channel);
|
||||
entry.generation
|
||||
}
|
||||
None => {
|
||||
map.insert(
|
||||
session,
|
||||
ChatEntry {
|
||||
generation: 0,
|
||||
channel: Some(channel),
|
||||
scrollback: Vec::new(),
|
||||
},
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conversation scrollback retained for a session (the chunks
|
||||
/// already streamed), or an empty vector if the session is unknown.
|
||||
///
|
||||
/// Called by `reattach_agent_chat` to repaint the prior turns into the
|
||||
/// re-mounting chat view before the new stream is wired — the typed twin of
|
||||
/// `PtyPort::scrollback`.
|
||||
#[must_use]
|
||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Removes a session's transport state **and** its retained scrollback
|
||||
/// unconditionally (chat cell explicitly closed). Twin of
|
||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches a session's channel **only if** `gen` is still the current
|
||||
/// generation, leaving the scrollback intact. A pump calls this when its turn
|
||||
/// stream ends: if the session was re-attached meanwhile (newer generation),
|
||||
/// this is a no-op so the dying pump never detaches the live channel that
|
||||
/// superseded it. Twin of
|
||||
/// [`PtyBridge::unregister_if`](crate::pty::PtyBridge::unregister_if), but it
|
||||
/// keeps the conversation scrollback (the conversation outlives a single
|
||||
/// turn's pump — closing the cell is what discards it, via [`unregister`]).
|
||||
///
|
||||
/// [`unregister`]: ChatBridge::unregister
|
||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
if let Some(entry) = map.get_mut(session) {
|
||||
if entry.generation == gen {
|
||||
entry.channel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a chunk in the session's scrollback and, if a view is attached at
|
||||
/// the current generation, forwards it to that channel.
|
||||
///
|
||||
/// Returns `true` if the chunk was delivered to a live channel, `false` if no
|
||||
/// channel is currently attached (e.g. the view navigated away — the chunk is
|
||||
/// still retained in scrollback for the next re-attach) or the send failed.
|
||||
/// The pump keeps draining either way so the turn still completes and the
|
||||
/// scrollback stays whole.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||
let Ok(mut map) = self.entries.lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = map.get_mut(session) else {
|
||||
return false;
|
||||
};
|
||||
entry.scrollback.push(chunk.clone());
|
||||
match &entry.channel {
|
||||
Some(channel) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||
/// (kept here so the pump and any test share one mapping).
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
|
||||
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
|
||||
ReplyEvent::Final { content } => ReplyChunk::Final { content },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user