feat(agent): commandes Tauri + ChatBridge streaming (D4) — §17

Expose l'exécution structurée à l'UI (jumeau du chemin PTY) :
- ChatBridge (chat.rs) : pompe ReplyStream -> Channel<ReplyChunk>,
  generation-tracked (anti-double-pompe) ; scrollback de conversation
  côté transport (le port AgentSession est sans mémoire), purgé à la
  fermeture, préservé à la ré-attache.
- Commandes agent_send / reattach_agent_chat / close_agent_session.
- DTO : ReplyChunk (textDelta/toolActivity/final, camelCase tagué kind),
  ReattachChatDto, CellKind {pty,chat} + champ cellKind dérivé sur
  TerminalSessionDto (chat ssi LaunchAgentOutput.structured = Some).
- Wiring composition root : StructuredSessions + ChatBridge dans AppState,
  via la factory déjà injectée (aucun new d'adapter — règle D §17.8).
- From<AgentSessionError> for AppError (mappe sur PROCESS).

Tests (QA) : 35 unitaires verts — generation supersede validé par mutation
test, séquence deltas*+Final, reattach sans re-spawn, teardown shutdown+
unregister, DTO cellKind + round-trip ReplyChunk, non-régression PTY.
cargo test --workspace : 803 passed, 0 failed.

Reste D5 : AgentChatView (frontend) + routage cellKind dans LayoutGrid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:15:08 +02:00
parent 050afa7d24
commit f4d5727a69
12 changed files with 1153 additions and 9 deletions

View File

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