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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user