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