feat(agent): commandes Tauri + ChatBridge streaming (D4) — §17
Expose l'exécution structurée à l'UI (jumeau du chemin PTY) :
- ChatBridge (chat.rs) : pompe ReplyStream -> Channel<ReplyChunk>,
generation-tracked (anti-double-pompe) ; scrollback de conversation
côté transport (le port AgentSession est sans mémoire), purgé à la
fermeture, préservé à la ré-attache.
- Commandes agent_send / reattach_agent_chat / close_agent_session.
- DTO : ReplyChunk (textDelta/toolActivity/final, camelCase tagué kind),
ReattachChatDto, CellKind {pty,chat} + champ cellKind dérivé sur
TerminalSessionDto (chat ssi LaunchAgentOutput.structured = Some).
- Wiring composition root : StructuredSessions + ChatBridge dans AppState,
via la factory déjà injectée (aucun new d'adapter — règle D §17.8).
- From<AgentSessionError> for AppError (mappe sur PROCESS).
Tests (QA) : 35 unitaires verts — generation supersede validé par mutation
test, séquence deltas*+Final, reattach sans re-spawn, teardown shutdown+
unregister, DTO cellKind + round-trip ReplyChunk, non-régression PTY.
cargo test --workspace : 803 passed, 0 failed.
Reste D5 : AgentChatView (frontend) + routage cellKind dans LayoutGrid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
185
crates/app-tauri/src/chat.rs
Normal file
185
crates/app-tauri/src/chat.rs
Normal file
@ -0,0 +1,185 @@
|
||||
//! Generic **structured reply ↔ Tauri Channel** bridge infrastructure.
|
||||
//!
|
||||
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
|
||||
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
|
||||
//! bridge routes typed [`ReplyChunk`]s — the serialised
|
||||
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
|
||||
//! the chat cell that owns the session.
|
||||
//!
|
||||
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
||||
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
||||
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
|
||||
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
|
||||
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
|
||||
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
|
||||
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
||||
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
||||
//! port is a per-turn stream, not a persistent byte ring). So the bridge itself
|
||||
//! retains the rendered chunks per session — the **conversation scrollback** —
|
||||
//! so a re-attach can repaint the turns already streamed, exactly as the PTY
|
||||
//! adapter's ring buffer lets `reattach_terminal` repaint xterm. This lives on
|
||||
//! the transport side (D4 owns transport), keeping the domain port pure.
|
||||
//!
|
||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||
//! [`send_output`]: ChatBridge::send_output
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
|
||||
use crate::dto::ReplyChunk;
|
||||
|
||||
/// Per-session transport state: the current attach generation, its output
|
||||
/// channel, and the conversation scrollback recorded so far.
|
||||
struct ChatEntry {
|
||||
/// Monotonic generation of the current (re-)attach (anti double-pump).
|
||||
generation: u64,
|
||||
/// The output channel of the current attach, if one is registered. `None`
|
||||
/// when the session has been recorded (scrollback kept) but no view is
|
||||
/// currently attached — the pump then only appends to the scrollback.
|
||||
channel: Option<Channel<ReplyChunk>>,
|
||||
/// Every chunk routed for this session, in order — the conversation
|
||||
/// scrollback replayed on re-attach. Bounded only by the conversation length
|
||||
/// (a turn count), like the PTY ring buffer is bounded by its byte capacity.
|
||||
scrollback: Vec<ReplyChunk>,
|
||||
}
|
||||
|
||||
/// Registry mapping live structured (chat) sessions to their reply [`Channel`]
|
||||
/// plus a retained conversation scrollback.
|
||||
///
|
||||
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
||||
/// the twin of [`crate::pty::PtyBridge`].
|
||||
#[derive(Default)]
|
||||
pub struct ChatBridge {
|
||||
entries: Mutex<HashMap<SessionId, ChatEntry>>,
|
||||
}
|
||||
|
||||
impl ChatBridge {
|
||||
/// Creates an empty bridge.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or replaces) the reply channel for a session and returns the
|
||||
/// **generation** of this registration. Each call for a session bumps the
|
||||
/// generation, so the caller's pump can later tear down *only its own*
|
||||
/// registration via [`unregister_if`](Self::unregister_if). The retained
|
||||
/// conversation scrollback is **preserved** across re-attaches (only the
|
||||
/// channel and generation change), mirroring how the PTY ring buffer survives
|
||||
/// a `reattach_terminal`.
|
||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
match map.get_mut(&session) {
|
||||
Some(entry) => {
|
||||
entry.generation = entry.generation.wrapping_add(1);
|
||||
entry.channel = Some(channel);
|
||||
entry.generation
|
||||
}
|
||||
None => {
|
||||
map.insert(
|
||||
session,
|
||||
ChatEntry {
|
||||
generation: 0,
|
||||
channel: Some(channel),
|
||||
scrollback: Vec::new(),
|
||||
},
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conversation scrollback retained for a session (the chunks
|
||||
/// already streamed), or an empty vector if the session is unknown.
|
||||
///
|
||||
/// Called by `reattach_agent_chat` to repaint the prior turns into the
|
||||
/// re-mounting chat view before the new stream is wired — the typed twin of
|
||||
/// `PtyPort::scrollback`.
|
||||
#[must_use]
|
||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Removes a session's transport state **and** its retained scrollback
|
||||
/// unconditionally (chat cell explicitly closed). Twin of
|
||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches a session's channel **only if** `gen` is still the current
|
||||
/// generation, leaving the scrollback intact. A pump calls this when its turn
|
||||
/// stream ends: if the session was re-attached meanwhile (newer generation),
|
||||
/// this is a no-op so the dying pump never detaches the live channel that
|
||||
/// superseded it. Twin of
|
||||
/// [`PtyBridge::unregister_if`](crate::pty::PtyBridge::unregister_if), but it
|
||||
/// keeps the conversation scrollback (the conversation outlives a single
|
||||
/// turn's pump — closing the cell is what discards it, via [`unregister`]).
|
||||
///
|
||||
/// [`unregister`]: ChatBridge::unregister
|
||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
if let Some(entry) = map.get_mut(session) {
|
||||
if entry.generation == gen {
|
||||
entry.channel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a chunk in the session's scrollback and, if a view is attached at
|
||||
/// the current generation, forwards it to that channel.
|
||||
///
|
||||
/// Returns `true` if the chunk was delivered to a live channel, `false` if no
|
||||
/// channel is currently attached (e.g. the view navigated away — the chunk is
|
||||
/// still retained in scrollback for the next re-attach) or the send failed.
|
||||
/// The pump keeps draining either way so the turn still completes and the
|
||||
/// scrollback stays whole.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||
let Ok(mut map) = self.entries.lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = map.get_mut(session) else {
|
||||
return false;
|
||||
};
|
||||
entry.scrollback.push(chunk.clone());
|
||||
match &entry.channel {
|
||||
Some(channel) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||
/// (kept here so the pump and any test share one mapping).
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text },
|
||||
ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label },
|
||||
ReplyEvent::Final { content } => ReplyChunk::Final { content },
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user