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

@ -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 },
}
}

View File

@ -40,8 +40,9 @@ use crate::dto::{
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto,
RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, ResumableAgentListDto,
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResumableAgentListDto,
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
@ -1076,6 +1077,157 @@ pub async fn change_agent_profile(
.map_err(ErrorDto::from)
}
/// `agent_send` — send a prompt to a live **structured** (chat) session and pump
/// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7).
///
/// Twin of the PTY output pump: resolve the live [`AgentSession`] from the
/// structured registry, open the turn stream (`session.send`), then drain that
/// blocking [`ReplyStream`] on a dedicated OS thread, mapping each
/// [`ReplyEvent`](domain::ports::ReplyEvent) to a [`ReplyChunk`] and forwarding it
/// through the [`ChatBridge`]. The turn ends deterministically at
/// [`ReplyChunk::Final`]; the stream is bounded and closes right after.
///
/// The channel is (re-)registered each call, bumping the bridge **generation** so
/// a previous attach's pump can no longer deliver (generation supersede): only the
/// current generation's channel receives chunks, never a double emission.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the turn fails to start).
#[tauri::command]
pub async fn agent_send(
session_id: String,
prompt: String,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
// Resolve the live structured session (the registry is the single source of
// truth for liveness — no adapter is constructed here, §17.8 rule D).
let session = state
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
// (Re-)register the reply channel; bumps the generation so an earlier attach's
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
// Open the turn stream. A start failure leaves the just-registered channel in
// place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
// which only unregisters on a hard subscribe failure; here the session is
// still live, so we keep the attach and surface the error.
let stream = session
.send(&prompt)
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// Drain the blocking reply iterator on a dedicated OS thread (the stream is a
// synchronous `Iterator`, exactly like the PTY byte stream). It runs to the
// `Final` event (or stream end / superseded channel), then detaches *only its
// own* generation so a concurrent re-attach is never torn down.
let bridge = std::sync::Arc::clone(&state.chat_bridge);
std::thread::spawn(move || {
for event in stream {
let chunk = crate::chat::chunk_from_event(event);
// `send_output` always records into the conversation scrollback; the
// boolean only reflects live delivery. If the view navigated away
// (no channel at this generation) we keep draining so the turn still
// completes and the scrollback stays whole for the next re-attach.
let _ = bridge.send_output(&sid, chunk);
}
bridge.detach_if(&sid, gen);
});
Ok(())
}
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
///
/// Navigation (switching layout/tab) tears the chat view down but must NOT kill
/// the backend session (the AI keeps running in the registry). When the view comes
/// back it calls this, which:
/// 1. reads the session's retained **conversation scrollback** (the chunks already
/// streamed) so the chat can repaint its prior turns,
/// 2. registers the new per-session [`Channel`] in the [`ChatBridge`], bumping the
/// generation so the previous attach's pump can no longer deliver.
///
/// No new turn is started here (a turn is driven by `agent_send`); a pump only
/// runs while a turn is in flight. Returns the scrollback; the frontend replays it
/// then receives subsequent chunks over `on_reply`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id — the caller then falls back to a fresh launch).
#[tauri::command]
pub fn reattach_agent_chat(
session_id: String,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<ReattachChatDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
// A missing live session means the conversation is gone (closed/never live) —
// surfaced as NOT_FOUND so the caller falls back to opening a fresh cell.
if state.structured_sessions.session(&sid).is_none() {
return Err(ErrorDto::from(AppError::NotFound(format!(
"structured session {sid}"
))));
}
// (1) Snapshot the conversation scrollback before swapping the channel.
let scrollback = state.chat_bridge.scrollback(&sid);
// (2) Register the new channel, superseding any previous attach (the bumped
// generation makes the prior pump's `detach_if` a no-op, so it can't tear down
// this live channel — generation supersede, no double emission).
let _gen = state.chat_bridge.register(sid, on_reply);
Ok(ReattachChatDto {
session_id,
scrollback,
})
}
/// `close_agent_session` — shut a live structured session down and tear its
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
///
/// Removes the session from the structured registry (so liveness checks no longer
/// see it), `shutdown`s it polymorphically (kills the underlying process/SDK;
/// idempotent by the port contract), and unregisters it from the [`ChatBridge`].
/// The chat twin of `close_terminal`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the shutdown fails).
#[tauri::command]
pub async fn close_agent_session(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
// Remove from the registry first so concurrent liveness checks stop seeing it;
// we then own the only handle to shut down (outside the registry lock).
let session = state
.structured_sessions
.remove(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
let result = session
.shutdown()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)));
// Tear down the transport regardless of the shutdown outcome (the session is
// already out of the registry; the cell is gone).
state.chat_bridge.unregister(&sid);
result
}
/// `list_resumable_agents` — read-only inventory of an open project's resumable
/// agent cells (§15.2). Each entry carries the agent + its host cell, the CLI
/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag

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")]

View File

@ -5,13 +5,15 @@
//! ([`state::AppState`], the composition root),
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3.
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its
//! structured-chat twin ([`chat::ChatBridge`]) for §17.
//!
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod chat;
pub mod commands;
pub mod dto;
pub mod events;
@ -127,6 +129,9 @@ pub fn run() {
commands::delete_agent,
commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,
commands::inspect_conversation,
commands::create_template,

View File

@ -48,6 +48,7 @@ use infrastructure::{
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
use crate::chat::ChatBridge;
use crate::pty::PtyBridge;
/// Everything the IPC layer needs at runtime, managed by Tauri.
@ -120,6 +121,14 @@ pub struct AppState {
pub event_bus: Arc<TokioBroadcastEventBus>,
/// Generic PTY↔Channel bridge registry (consumed by L3).
pub pty_bridge: Arc<PtyBridge>,
/// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec
/// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4)
/// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture.
pub structured_sessions: Arc<StructuredSessions>,
/// Pont réponses structurées ↔ Channel (jumeau de [`PtyBridge`], §17.7). Route
/// les [`ReplyChunk`](crate::dto::ReplyChunk) d'un tour vers la bonne cellule
/// chat et retient le scrollback de conversation pour la ré-attache.
pub chat_bridge: Arc<ChatBridge>,
// --- Agents (L6) ---
/// Create a project agent from scratch.
pub create_agent: Arc<CreateAgentFromScratch>,
@ -389,6 +398,10 @@ impl AppState {
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
let pty_bridge = Arc::new(PtyBridge::new());
// Twin of the PTY bridge for structured chat sessions (§17.7): routes a
// turn's ReplyChunks to the owning chat cell and retains the conversation
// scrollback for re-attach.
let chat_bridge = Arc::new(ChatBridge::new());
// --- Agent context store + use cases (L6) ---
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
@ -746,6 +759,8 @@ impl AppState {
terminal_sessions,
event_bus,
pty_bridge,
structured_sessions,
chat_bridge,
create_agent,
list_agents,
read_agent_context,

View File

@ -0,0 +1,364 @@
//! L1 tests for [`ChatBridge`] — the structured-reply ↔ Channel registry, the
//! twin of [`PtyBridge`] (ARCHITECTURE §17.7) — and for [`chunk_from_event`], the
//! single `ReplyEvent → ReplyChunk` translation point.
//!
//! These exercise the load-bearing transport logic the D4 commands are thin
//! shells over: the `agent_send` pump's delivery + scrollback recording, the
//! `reattach_agent_chat` repaint-without-respawn, the `close_agent_session`
//! teardown, and — the most critical invariant — generation supersede (no double
//! emission after a re-attach). A real [`tauri::ipc::Channel`] built from a
//! capturing closure is used (no Tauri runtime, no real CLI/PTY/session).
use std::sync::{Arc, Mutex};
use tauri::ipc::{Channel, InvokeResponseBody};
use app_tauri_lib::chat::{chunk_from_event, ChatBridge};
use app_tauri_lib::dto::ReplyChunk;
use domain::ids::SessionId;
use domain::ports::ReplyEvent;
use uuid::Uuid;
/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
///
/// `ReplyChunk` is `Serialize`/`Deserialize`, so chunks arrive as a JSON string
/// in an `InvokeResponseBody::Json`; we parse them back to `ReplyChunk` for
/// assertions (round-tripping the exact camelCase tagged shape on the way).
fn capturing_channel(sink: Arc<Mutex<Vec<ReplyChunk>>>) -> Channel<ReplyChunk> {
Channel::new(move |body: InvokeResponseBody| {
let chunk: ReplyChunk = match body {
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
InvokeResponseBody::Raw(b) => serde_json::from_slice(&b).unwrap(),
};
sink.lock().unwrap().push(chunk);
Ok(())
})
}
fn sid() -> SessionId {
SessionId::from_uuid(Uuid::new_v4())
}
fn delta(s: &str) -> ReplyChunk {
ReplyChunk::TextDelta { text: s.to_owned() }
}
fn final_chunk(s: &str) -> ReplyChunk {
ReplyChunk::Final {
content: s.to_owned(),
}
}
// ---------------------------------------------------------------------------
// chunk_from_event — exhaustive mapping (zone 6)
// ---------------------------------------------------------------------------
#[test]
fn chunk_from_event_maps_text_delta() {
assert_eq!(
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
ReplyChunk::TextDelta { text: "hi".into() }
);
}
#[test]
fn chunk_from_event_maps_tool_activity() {
assert_eq!(
chunk_from_event(ReplyEvent::ToolActivity {
label: "reads file".into()
}),
ReplyChunk::ToolActivity {
label: "reads file".into()
}
);
}
#[test]
fn chunk_from_event_maps_final() {
assert_eq!(
chunk_from_event(ReplyEvent::Final {
content: "done".into()
}),
ReplyChunk::Final {
content: "done".into()
}
);
}
// ---------------------------------------------------------------------------
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
// ---------------------------------------------------------------------------
/// Replays a turn's events through the bridge exactly as the `agent_send` pump
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
/// end. This is the body of the spawned pump thread, run inline.
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
for event in events {
let _ = bridge.send_output(session, chunk_from_event(event));
}
bridge.detach_if(session, gen);
}
#[test]
fn pump_delivers_deltas_then_exactly_one_final_in_order() {
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![
ReplyEvent::TextDelta { text: "Hel".into() },
ReplyEvent::TextDelta { text: "lo".into() },
ReplyEvent::Final {
content: "Hello".into(),
},
],
);
let got = sink.lock().unwrap();
assert_eq!(
got.as_slice(),
&[delta("Hel"), delta("lo"), final_chunk("Hello")],
"deltas in order, then the single Final"
);
// Exactly one Final, and it is last (deterministic terminal chunk).
let finals = got.iter().filter(|c| matches!(c, ReplyChunk::Final { .. })).count();
assert_eq!(finals, 1, "exactly one Final per turn");
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
}
#[test]
fn pump_with_only_a_final_delivers_just_the_final() {
// Zero deltas is valid (deltas*); the turn still ends on exactly one Final.
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![ReplyEvent::Final {
content: "ok".into(),
}],
);
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
}
// ---------------------------------------------------------------------------
// Generation supersede — the critical invariant (zone 1)
// ---------------------------------------------------------------------------
/// A `reattach_agent_chat` mid-turn: a first pump is in flight (old generation)
/// when the view re-attaches (new channel, bumped generation). The old pump's
/// remaining chunks must NOT reach the *new* channel, and when the old pump ends
/// its `detach_if(old_gen)` must be a no-op (never tearing down the live one).
#[test]
fn reattach_supersedes_old_pump_no_double_emission() {
let bridge = ChatBridge::new();
let session = sid();
let old = Arc::new(Mutex::new(Vec::new()));
let new = Arc::new(Mutex::new(Vec::new()));
// Turn starts: old channel registered, pump emits a first delta.
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
bridge.send_output(&session, delta("a")); // delivered to old
// View navigates away & back → reattach registers a NEW channel (bumps gen).
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
// Old pump keeps draining its (now stale) stream, then ends with its own gen.
bridge.send_output(&session, delta("b")); // recorded to scrollback, routed to NEW channel
bridge.detach_if(&session, old_gen); // MUST be a no-op (newer gen present)
// The new (live) attach must NOT have been detached: it is still deliverable.
assert!(
bridge.send_output(&session, final_chunk("done")),
"live re-attach channel must survive a superseded pump's detach_if"
);
// The OLD channel only ever saw the single pre-reattach delta — no double emit.
assert_eq!(
old.lock().unwrap().as_slice(),
&[delta("a")],
"superseded channel receives nothing after the reattach"
);
}
#[test]
fn detach_if_current_generation_stops_delivery() {
// When the pump that owns the current generation ends (e.g. Final reached and
// no reattach happened), detach_if drops the channel: further output is not
// delivered (turn over) but the session stays known until close.
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
bridge.send_output(&session, final_chunk("end"));
bridge.detach_if(&session, gen);
// Channel detached: a stray chunk is recorded to scrollback but not delivered.
assert!(!bridge.send_output(&session, delta("late")));
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("end")]);
// Session is still tracked (scrollback survives until close).
assert_eq!(bridge.active_sessions(), 1);
}
// ---------------------------------------------------------------------------
// reattach_agent_chat: scrollback repaint without re-spawn (zone 3)
// ---------------------------------------------------------------------------
#[test]
fn scrollback_accumulates_every_routed_chunk_in_order() {
let bridge = ChatBridge::new();
let session = sid();
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
pump_turn(
&bridge,
&session,
gen,
vec![
ReplyEvent::TextDelta { text: "x".into() },
ReplyEvent::ToolActivity { label: "runs".into() },
ReplyEvent::Final { content: "x".into() },
],
);
assert_eq!(
bridge.scrollback(&session),
vec![
delta("x"),
ReplyChunk::ToolActivity { label: "runs".into() },
final_chunk("x"),
],
"scrollback retains the full conversation, in order"
);
}
#[test]
fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
// Models reattach_agent_chat: a first turn streamed, the view detached, then a
// new channel registers. The scrollback survives the re-register (no session
// method is called — no re-spawn), and is what the command returns.
let bridge = ChatBridge::new();
let session = sid();
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
pump_turn(
&bridge,
&session,
g0,
vec![
ReplyEvent::TextDelta { text: "Hi".into() },
ReplyEvent::Final {
content: "Hi".into(),
},
],
);
// Snapshot exactly what reattach_agent_chat returns BEFORE swapping channel.
let repainted = bridge.scrollback(&session);
assert_eq!(repainted, vec![delta("Hi"), final_chunk("Hi")]);
// Re-attach: register a fresh channel. Scrollback is preserved (not cleared,
// not duplicated), generation bumped.
let new_sink = Arc::new(Mutex::new(Vec::new()));
let g1 = bridge.register(session, capturing_channel(Arc::clone(&new_sink)));
assert_eq!(g1, g0 + 1, "re-attach bumps the generation");
assert_eq!(
bridge.scrollback(&session),
repainted,
"scrollback unchanged by re-attach (no re-send, no re-spawn)"
);
// The fresh channel got nothing yet: reattach does NOT replay over the channel
// (the command returns the scrollback for the front to replay itself).
assert!(
new_sink.lock().unwrap().is_empty(),
"re-attach must not push the old turns onto the new channel"
);
}
#[test]
fn scrollback_of_unknown_session_is_empty() {
let bridge = ChatBridge::new();
assert!(bridge.scrollback(&sid()).is_empty());
}
// ---------------------------------------------------------------------------
// close_agent_session: unregister purges channel AND scrollback (zone 4)
// ---------------------------------------------------------------------------
#[test]
fn unregister_purges_channel_and_scrollback() {
let bridge = ChatBridge::new();
let session = sid();
let sink = Arc::new(Mutex::new(Vec::new()));
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
pump_turn(
&bridge,
&session,
gen,
vec![ReplyEvent::Final {
content: "bye".into(),
}],
);
assert_eq!(bridge.active_sessions(), 1);
assert!(!bridge.scrollback(&session).is_empty());
bridge.unregister(&session);
assert_eq!(bridge.active_sessions(), 0, "session removed");
assert!(
bridge.scrollback(&session).is_empty(),
"scrollback purged on close"
);
assert!(
!bridge.send_output(&session, delta("z")),
"no delivery after close"
);
// The closed channel must not receive the post-close chunk.
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
}
// ---------------------------------------------------------------------------
// Registry basics, mirrored from pty_bridge.rs for parity
// ---------------------------------------------------------------------------
#[test]
fn register_returns_monotonic_generation_per_session() {
let bridge = ChatBridge::new();
let session = sid();
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
assert_eq!(g0, 0);
assert_eq!(g1, 1);
assert_eq!(bridge.active_sessions(), 1, "same id replaced, not added");
}
#[test]
fn send_output_to_unknown_session_returns_false() {
let bridge = ChatBridge::new();
assert!(!bridge.send_output(&sid(), delta("x")));
}
#[test]
fn register_same_session_replaces_channel() {
let bridge = ChatBridge::new();
let session = sid();
let first = Arc::new(Mutex::new(Vec::new()));
let second = Arc::new(Mutex::new(Vec::new()));
bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second)));
bridge.send_output(&session, delta("9"));
assert!(first.lock().unwrap().is_empty(), "old channel unused");
assert_eq!(second.lock().unwrap().as_slice(), &[delta("9")]);
}

View File

@ -0,0 +1,211 @@
//! L1 tests for the D4 structured-chat DTO contract (ARCHITECTURE §17.7):
//! - `ReplyChunk` tagged camelCase round-trip (`kind` + payload),
//! - `ReattachChatDto` camelCase wire shape (typed scrollback),
//! - the **derived** `cellKind` on `TerminalSessionDto` (`chat` ⇔
//! `structured: Some(..)`, `pty` otherwise),
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
//! `cellKind` and PTY paths keep `"pty"`.
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::project::ProjectPath;
use domain::SessionId;
use serde_json::json;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6)
// ---------------------------------------------------------------------------
#[test]
fn reply_chunk_text_delta_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::TextDelta {
text: "hello".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "textDelta", "text": "hello" }));
}
#[test]
fn reply_chunk_tool_activity_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::ToolActivity {
label: "reads file".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" }));
}
#[test]
fn reply_chunk_final_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Final {
content: "done".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "final", "content": "done" }));
}
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity { label: "runs".into() },
ReplyChunk::Final { content: "y".into() },
] {
let v = serde_json::to_value(&chunk).unwrap();
let back: ReplyChunk = serde_json::from_value(v).unwrap();
assert_eq!(back, chunk, "round-trip preserves the variant + payload");
}
}
#[test]
fn reply_chunk_deserialises_from_camel_case_wire_payload() {
// The shape the frontend (or a mock gateway) emits.
let back: ReplyChunk =
serde_json::from_value(json!({ "kind": "textDelta", "text": "hi" })).unwrap();
assert_eq!(back, ReplyChunk::TextDelta { text: "hi".into() });
}
#[test]
fn reply_chunk_rejects_snake_case_tag() {
// Guard: a snake_case `text_delta` is NOT a valid wire tag (contract is camelCase).
let r: Result<ReplyChunk, _> =
serde_json::from_value(json!({ "kind": "text_delta", "text": "x" }));
assert!(r.is_err(), "snake_case kind must not deserialise");
}
// ---------------------------------------------------------------------------
// ReattachChatDto — typed scrollback, camelCase (zone 5)
// ---------------------------------------------------------------------------
#[test]
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
let dto = ReattachChatDto {
session_id: "sess-1".into(),
scrollback: vec![
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final { content: "Hi".into() },
],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"sessionId": "sess-1",
"scrollback": [
{ "kind": "textDelta", "text": "Hi" },
{ "kind": "final", "content": "Hi" },
],
})
);
assert!(v.get("session_id").is_none(), "no snake_case leak");
}
#[test]
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
let dto = ReattachChatDto {
session_id: "s".into(),
scrollback: vec![],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["scrollback"], json!([]));
}
// ---------------------------------------------------------------------------
// CellKind enum wire shape
// ---------------------------------------------------------------------------
#[test]
fn cell_kind_serialises_lowercase_pty_and_chat() {
assert_eq!(serde_json::to_value(CellKind::Pty).unwrap(), json!("pty"));
assert_eq!(serde_json::to_value(CellKind::Chat).unwrap(), json!("chat"));
}
// ---------------------------------------------------------------------------
// cellKind derivation on TerminalSessionDto (zone 5)
// ---------------------------------------------------------------------------
fn agent_session(session_id: u128) -> (SessionId, TerminalSession) {
let sid = SessionId::from_uuid(Uuid::from_u128(session_id));
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
let size = PtySize::new(24, 80).unwrap();
let mut session =
TerminalSession::starting(sid, node_id, cwd, SessionKind::Agent { agent_id }, size);
session.status = SessionStatus::Running;
(sid, session)
}
#[test]
fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
let (sid, session) = agent_session(7);
let descriptor = StructuredSessionDescriptor {
session_id: sid,
agent_id: AgentId::from_uuid(Uuid::from_u128(9)),
node_id: NodeId::from_uuid(Uuid::from_u128(8)),
conversation_id: None,
};
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: Some(descriptor),
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Chat);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["cellKind"], "chat", "structured ⇒ chat on the wire");
}
#[test]
fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
let (_sid, session) = agent_session(7);
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["cellKind"], "pty");
}
// ---------------------------------------------------------------------------
// Non-regression: cellKind is always present & "pty" on the historical paths
// ---------------------------------------------------------------------------
#[test]
fn terminal_session_dto_from_domain_session_is_always_pty() {
// From<TerminalSession> (e.g. open_terminal / change_agent_profile relaunch).
let (_sid, session) = agent_session(11);
let dto = TerminalSessionDto::from(session);
assert_eq!(dto.cell_kind, CellKind::Pty);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v["cellKind"], "pty",
"the new field is always present on the PTY path (non-breaking shape)"
);
}
#[test]
fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
// Guard the exact historical key set + the new derived field for a PTY launch.
let (sid, session) = agent_session(12);
let out = LaunchAgentOutput {
session,
assigned_conversation_id: None,
structured: None,
};
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
assert_eq!(v["sessionId"], sid.to_string());
assert_eq!(v["cwd"], "/tmp/project");
assert_eq!(v["rows"], 24);
assert_eq!(v["cols"], 80);
assert_eq!(v["cellKind"], "pty");
// assignedConversationId omitted when None (skip_serializing_if) — unchanged.
assert!(v.get("assignedConversationId").is_none());
assert!(v.get("session_id").is_none(), "no snake_case leak");
}