diff --git a/.ideai/briefs/d4-commandes-bridge-chat.md b/.ideai/briefs/d4-commandes-bridge-chat.md new file mode 100644 index 0000000..d60ef6b --- /dev/null +++ b/.ideai/briefs/d4-commandes-bridge-chat.md @@ -0,0 +1,88 @@ +# Brief Dev — Lot D4 : commandes Tauri + bridge chat (§17.9) + +> Demandé par **Main** à **DevBackend** (dev) + **QA** (test). Cycle §3 : code → tests → vert. +> Périmètre **backend uniquement** (`app-tauri`). Le frontend chat est D5, hors périmètre ici. + +## 0. Où on en est + +Le fil §17 (exécution structurée des agents IA via le port `AgentSession`) est livré +jusqu'à **D3 inclus** : + +- **D0/D1** (`5e10b5e`) : port `domain::ports::AgentSession` + `AgentSessionFactory`, + `ReplyEvent`/`ReplyStream`/`AgentSessionError`, champ `AgentProfile.structured_adapter`, + registre `StructuredSessions`, agrégateur `LiveSessions`, helper `send_blocking`. +- **D2** (`751d94d`) + spikes **S1/S2** (`f104862`) : adapters `ClaudeSdkSession` / + `CodexExecSession` dans `crates/infrastructure/src/session/`, fake CLI + harnais de + conformité, **formats réels** Claude `stream-json` / Codex `exec --json` câblés. +- **D3** (`56913b9`) : `LaunchAgent` route structuré vs PTY ; `LaunchAgentOutput` porte + désormais `structured: Option`. + +**D4 = exposer tout ça à l'UI** : commandes Tauri + pont de streaming, jumeau exact du +chemin PTY existant. Aucun chemin PTY (terminal non-IA) ne doit changer ni régresser. + +## 1. Périmètre D4 (réf. §17.7 et tableau §17.9) + +À livrer dans `crates/app-tauri/src/` : + +1. **`ChatBridge`** — jumeau de `PtyBridge` (`crates/app-tauri/src/pty.rs`), **generation-tracked** + (même mécanique de génération pour éviter la double-pompe lors d'une ré-attache). Il pompe + un `ReplyStream` (events `ReplyEvent` du port) vers un `tauri::ipc::Channel`, en émettant + des `ReplyChunk` (DTO ci-dessous). Vit à côté de `PtyBridge`, ne le remplace pas. +2. **Commandes Tauri** : + - `agent_send(sessionId, prompt)` → pompe les `ReplyEvent` du tour sur le `Channel` + (deltas `TextDelta` → chunks, `ToolActivity` → chunks d'activité, `Final` → chunk final + qui fige le tour). S'appuie sur le registre `StructuredSessions` / `send_blocking` côté + application (déjà livré en D1). + - `reattach_agent_chat(...)` → renvoie le scrollback de conversation + rebranche le `Channel` + (repeint sans re-spawn ; supersede l'ancienne génération). + - `close_agent_session(sessionId)` → `shutdown` (polymorphe) + unregister du registre. +3. **DTO** (`crates/app-tauri/src/dto.rs`) : + - `ReplyChunk` : variantes delta texte / activité outil / final (sérialisation camelCase, + cohérente avec les DTO existants). + - `ReattachChatDto`. + - **`cellKind`** ajouté au DTO de session (terminal `pty` vs chat `chat`), dérivé de la + présence d'un descripteur structuré (`LaunchAgentOutput.structured`). +4. **Wiring composition root** (`state.rs`/`lib.rs`) : injecter les dépendances nécessaires, + enregistrer les nouvelles commandes. **Aucun `new ClaudeSdkSession` ici** — passe par la + factory déjà injectée (règle D du §17.8). + +## 2. Contrat / invariants à respecter + +- **Generation supersede** : une ré-attache invalide l'ancienne pompe ; pas de double émission. +- **`cellKind`** est la seule info dont D5 (frontend) a besoin pour router cellule chat vs + terminal. Stable et explicite au DTO. +- **Isolation parsing** : D4 ne parse aucun format CLI — il consomme des `ReplyEvent` typés. +- **Zéro régression PTY** : le chemin terminal brut (`PtyBridge`, commandes terminal) reste + identique. Les tests PTY existants restent verts. +- Frontières hexagonales (§17.8) : `app-tauri` dépend des ports/registres application, jamais + des adapters infra concrets. + +## 3. Tests attendus (QA — réf. colonne « Tests attendus » D4 du §17.9) + +Crate `app-tauri` (fakes pour la session structurée, pas de vrai CLI) : + +- `agent_send` pompe les events d'un tour sur le `Channel` (séquence deltas… puis `Final`). +- ré-attache → scrollback conversation repeint, **sans** re-spawn de session. +- `close_agent_session` → `shutdown` appelé **et** unregister du registre. +- **generation supersede** : après ré-attache, l'ancienne génération ne pompe plus (pas de + double émission sur le `Channel`). +- DTO : `cellKind` = `chat` pour une sortie `LaunchAgentOutput` avec `structured: Some(..)`, + `pty` sinon ; round-trip `ReplyChunk` (camelCase). +- non-régression : un DTO de session PTY existant sérialise toujours pareil (le nouveau champ + `cellKind` ne casse pas les snapshots — vérifier la valeur par défaut/dérivée). + +## 4. Méthode + +Cycle §3 strict : DevBackend code → QA écrit + exécute les tests → vert avant de clore. +Rapport d'erreurs clair si rouge → correction → re-test. Commit `feat(agent): … (D4) — §17` +quand `cargo test --workspace` est vert. **Ne pas push** (validation Main requise). + +## 5. Références code + +- Jumeau à copier : `crates/app-tauri/src/pty.rs` (`PtyBridge`, generation tracking). +- Source du flux : `domain::ports::{AgentSession, ReplyEvent, ReplyStream}` ; + registre/`send_blocking` : `crates/application/src/agent/structured.rs`. +- Routage déjà fait : `crates/application/src/agent/lifecycle.rs` + (`LaunchAgentOutput.structured`). +- DTO existants : `crates/app-tauri/src/dto.rs` ; commandes : `crates/app-tauri/src/commands.rs`. +- Spec complète : `ARCHITECTURE.md` §17.7 (commandes & DTO) et tableau §17.9 ligne **D4**. diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs new file mode 100644 index 0000000..b272593 --- /dev/null +++ b/crates/app-tauri/src/chat.rs @@ -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>, + /// 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, +} + +/// Registry mapping live structured (chat) sessions to their reply [`Channel`] +/// plus a retained conversation scrollback. +/// +/// Thread-safe; a cloned `Arc` is held in [`crate::state::AppState`], +/// the twin of [`crate::pty::PtyBridge`]. +#[derive(Default)] +pub struct ChatBridge { + entries: Mutex>, +} + +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) -> 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 { + 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 }, + } +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0b976ad..21e96c1 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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, + 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, + state: State<'_, AppState>, +) -> Result { + 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 diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index b8d3f43..7ea972c 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -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, + /// 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 for TerminalSessionDto { @@ -248,6 +269,7 @@ impl From 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 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 for TerminalSessionDto { rows: s.pty_size.rows, cols: s.pty_size.cols, assigned_conversation_id, + cell_kind, } } } @@ -1200,10 +1232,63 @@ impl From 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, +} + /// Request DTO for `inspect_conversation` (T7). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 6983229..072fc21 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 441808e..2bc546a 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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, /// Generic PTY↔Channel bridge registry (consumed by L3). pub pty_bridge: Arc, + /// 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, + /// 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, // --- Agents (L6) --- /// Create a project agent from scratch. pub create_agent: Arc, @@ -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, diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs new file mode 100644 index 0000000..ecdc4df --- /dev/null +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -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` 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>>) -> Channel { + 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) { + 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")]); +} diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs new file mode 100644 index 0000000..016a0e5 --- /dev/null +++ b/crates/app-tauri/tests/dto_chat.rs @@ -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 = + 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 (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"); +} diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 445e201..988272f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -26,7 +26,8 @@ pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, + ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; pub use usecases::{ diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 76438e8..055ccb0 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -6,8 +6,8 @@ //! with one error shape when building its `ErrorDTO`. use domain::ports::{ - EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, - RuntimeError, StoreError, + AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, + RemoteError, RuntimeError, StoreError, }; use domain::{AgentId, NodeId}; @@ -148,3 +148,15 @@ impl From for AppError { Self::Remote(e.to_string()) } } + +impl From for AppError { + /// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single + /// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution + /// failures of a live structured session (a process/SDK conversation), so they + /// fold into [`AppError::Process`] — coherent with [`PtyError`]/[`ProcessError`], + /// the byte-stream twins. The raw CLI JSON never travels through `Decode` (the + /// adapter already redacted it), so this is safe to surface. + fn from(e: AgentSessionError) -> Self { + Self::Process(e.to_string()) + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 9e0a2a3..34f19a1 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -37,8 +37,8 @@ pub use agent::{ ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, - SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, - send_blocking, AGENT_MEMORY_RECALL_BUDGET, + SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, + UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET, }; pub use embedder::{ CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, diff --git a/crates/application/tests/error_codes.rs b/crates/application/tests/error_codes.rs index 59b73bb..32a1dcb 100644 --- a/crates/application/tests/error_codes.rs +++ b/crates/application/tests/error_codes.rs @@ -3,7 +3,8 @@ use application::AppError; use domain::ports::{ - EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, StoreError, + AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, + RemoteError, StoreError, }; #[test] @@ -52,6 +53,31 @@ fn process_pty_runtime_map_to_process() { ); } +#[test] +fn agent_session_errors_all_map_to_process() { + // §17.1 / D4: a structured AgentSession is a live process/SDK conversation, so + // every failure variant folds into PROCESS (the byte-stream twin). Used by the + // agent_send / close_agent_session command error arms. + assert_eq!( + AppError::from(AgentSessionError::Start("no cli".into())).code(), + "PROCESS" + ); + assert_eq!( + AppError::from(AgentSessionError::Io("broken pipe".into())).code(), + "PROCESS" + ); + assert_eq!( + AppError::from(AgentSessionError::Decode("bad schema".into())).code(), + "PROCESS" + ); + assert_eq!(AppError::from(AgentSessionError::Timeout).code(), "PROCESS"); + // The message is carried through onto the single Process shape. + assert_eq!( + AppError::from(AgentSessionError::Io("broken pipe".into())), + AppError::Process(AgentSessionError::Io("broken pipe".into()).to_string()) + ); +} + #[test] fn git_and_remote_map_through() { assert_eq!(AppError::from(GitError::NotFound).code(), "GIT");