feat(workstate): résumés de conversation dans le read-model (Lot C backend)

Étend le read-model work-state pour exposer un résumé par conversation
(participants, dernier message/activité, compteurs) câblé jusqu'au DTO
camelCase de l'état app-tauri.

- application : assemblage des résumés de conversation dans le work-state.
- app-tauri : DTO camelCase des résumés + exposition dans l'état.

Tests verts : application workstate (21), app-tauri dto_agents (21),
aucun warning Rust.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:28:55 +02:00
parent e5dd4f82f5
commit e9edadca50
6 changed files with 971 additions and 27 deletions

View File

@ -5,18 +5,20 @@
//! beyond loading the manifest through the existing context store and creates no
//! durable projection.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use domain::input::{AgentBusyState, InputSource};
use domain::ports::AgentContextStore;
use domain::{
AgentId, AgentQueueSnapshot, ConversationId, InputMediator, NodeId, ProfileId, Project,
QueuedTicketSnapshot, SessionId, TicketId,
AgentId, AgentQueueSnapshot, ConversationId, ConversationLog, ConversationTurn, Handoff,
HandoffStore, InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot,
SessionId, TicketId, TurnId, TurnRole,
};
use crate::error::AppError;
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
use crate::HandoffProvider;
/// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`].
///
@ -24,6 +26,18 @@ use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
/// excerpt, with [`AgentTicketState::task_len`] signalling there is more.
const TASK_PREVIEW_MAX_CHARS: usize = 160;
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::summary_preview`].
const HANDOFF_PREVIEW_MAX_CHARS: usize = 480;
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::objective_preview`].
const OBJECTIVE_PREVIEW_MAX_CHARS: usize = 160;
/// Maximum number of recent turns surfaced in a [`ConversationWorkSummary::recent_turns`] fallback.
const RECENT_TURNS_MAX: usize = 3;
/// Maximum length (in characters) of a [`ConversationTurnWorkPreview::text_preview`].
const TURN_PREVIEW_MAX_CHARS: usize = 220;
/// Input for [`GetProjectWorkState::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetProjectWorkStateInput {
@ -36,6 +50,75 @@ pub struct GetProjectWorkStateInput {
pub struct ProjectWorkState {
/// Agents in manifest order.
pub agents: Vec<AgentWorkState>,
/// Best-effort summaries of the conversations visible through the agents'
/// tickets, joined frontend-side via `tickets[].conversation_id`. Built from
/// the [`HandoffStore`] (primary) with a bounded [`ConversationLog`] fallback;
/// a preview failure never blocks `agents`.
pub conversations: Vec<ConversationWorkSummary>,
}
/// Best-effort, read-only summary of one conversation visible through the tickets.
///
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
/// [`ConversationLog`] fallback — never a durable projection, never a repair of the
/// underlying log/handoff. Joined frontend-side to tickets via
/// [`ConversationWorkSummary::conversation_id`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationWorkSummary {
/// The conversation (pair) this summary describes.
pub conversation_id: ConversationId,
/// How much could be derived (see [`ConversationPreviewStatus`]).
pub status: ConversationPreviewStatus,
/// Bounded excerpt of the handoff objective, when present.
pub objective_preview: Option<String>,
/// Bounded excerpt of the handoff summary, when a handoff is readable.
pub summary_preview: Option<String>,
/// Character length of the **original** (un-truncated) handoff summary (`0` when none).
pub summary_len: usize,
/// Cursor (last [`TurnId`]) covered by the handoff summary, when present.
pub up_to: Option<TurnId>,
/// Bounded, recent turns from the log fallback (empty when a handoff is `Ready`).
pub recent_turns: Vec<ConversationTurnWorkPreview>,
}
/// How much of a [`ConversationWorkSummary`] could be derived, best-effort.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversationPreviewStatus {
/// A handoff was present and usable: summary/objective/cursor are filled.
Ready,
/// No handoff yet; `recent_turns` carries the log fallback if any.
Missing,
/// The handoff was unreadable but the log fallback was readable.
Partial,
/// Neither the handoff nor the log could be read.
Unavailable,
}
/// One recent turn, projected for the conversation-summary fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationTurnWorkPreview {
/// Nature of the turn (prompt, response, tool activity).
pub role: TurnRole,
/// Origin of the turn (human operator or a delegating agent).
pub source: TicketWorkSource,
/// Timestamp (epoch milliseconds) of the turn.
pub at_ms: u64,
/// Bounded, whitespace-normalised excerpt of the turn text.
pub text_preview: String,
/// Character length of the **original** (un-truncated) turn text.
pub text_len: usize,
}
/// Builds the [`ConversationLog`] bound to a given project `root`, best-effort.
///
/// Twin of [`HandoffProvider`]: the work-state read model is a singleton (the project
/// root arrives per call via [`GetProjectWorkStateInput`]) while the `Fs*` adapters fix
/// their root at construction. This port materialises the log targeting the **right**
/// folder on each call; `None` ⇒ no log fallback (zero regression for call
/// sites/tests that do not wire it). Implemented in `app-tauri` (sole owner of `Fs*`).
pub trait ConversationLogProvider: Send + Sync {
/// Builds the [`ConversationLog`] whose persistence targets `root`, or `None`.
fn conversation_log_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationLog>>;
}
/// Read model for one manifest agent.
@ -128,10 +211,17 @@ pub struct GetProjectWorkState {
live: Arc<LiveSessions>,
input: Arc<dyn InputMediator>,
queue: Arc<dyn AgentQueueSnapshot>,
/// Per-root handoff source (primary), wired best-effort; `None` ⇒ no summaries.
handoffs: Option<Arc<dyn HandoffProvider>>,
/// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns.
logs: Option<Arc<dyn ConversationLogProvider>>,
}
impl GetProjectWorkState {
/// Builds the read-model use case from existing stores/registries.
///
/// Conversation summaries are off by default; wire them with
/// [`Self::with_conversation_sources`].
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
@ -144,9 +234,25 @@ impl GetProjectWorkState {
live,
input,
queue,
handoffs: None,
logs: None,
}
}
/// Wires the best-effort conversation-summary sources (Lot C): the handoff
/// store (primary) and the conversation log (bounded fallback), both resolved
/// per project root. Builder so existing call sites/tests stay unchanged.
#[must_use]
pub fn with_conversation_sources(
mut self,
handoffs: Arc<dyn HandoffProvider>,
logs: Arc<dyn ConversationLogProvider>,
) -> Self {
self.handoffs = Some(handoffs);
self.logs = Some(logs);
self
}
/// Executes the read-only aggregation.
///
/// # Errors
@ -192,7 +298,169 @@ impl GetProjectWorkState {
})
})
.collect::<Result<Vec<_>, AppError>>()?;
Ok(ProjectWorkState { agents })
// Lot C — best-effort conversation summaries. Only the conversations
// visible through the projected tickets are summarised, deduplicated in
// first-seen order. A preview failure degrades the per-conversation status
// (never an `AppError`): `agents`/live/busy/tickets stay intact above.
let conversations = self
.summarise_conversations(&input.project.root, &agents)
.await;
Ok(ProjectWorkState {
agents,
conversations,
})
}
/// Derives the best-effort summaries of the conversations referenced by the
/// projected tickets. Resolves the per-root handoff/log stores once, then folds
/// each distinct conversation id through [`conversation_summary`].
async fn summarise_conversations(
&self,
root: &ProjectPath,
agents: &[AgentWorkState],
) -> Vec<ConversationWorkSummary> {
let conversation_ids = distinct_conversation_ids(agents);
if conversation_ids.is_empty() {
return Vec::new();
}
let handoff_store = self
.handoffs
.as_ref()
.and_then(|provider| provider.handoff_store_for(root));
let log_store = self
.logs
.as_ref()
.and_then(|provider| provider.conversation_log_for(root));
let mut summaries = Vec::with_capacity(conversation_ids.len());
for conversation in conversation_ids {
summaries.push(conversation_summary(conversation, &handoff_store, &log_store).await);
}
summaries
}
}
/// Collects the conversation ids referenced by the agents' tickets, deduplicated in
/// first-seen (FIFO) order so the summary list mirrors the panel's ordering.
fn distinct_conversation_ids(agents: &[AgentWorkState]) -> Vec<ConversationId> {
let mut seen = HashSet::new();
let mut ids = Vec::new();
for agent in agents {
for ticket in &agent.tickets {
if seen.insert(ticket.conversation_id) {
ids.push(ticket.conversation_id);
}
}
}
ids
}
/// Derives one conversation summary, best-effort, following the Lot C algorithm:
/// handoff present ⇒ `Ready`; absent ⇒ `Missing` (+ log fallback); unreadable but
/// log readable ⇒ `Partial`; both KO ⇒ `Unavailable`. Never returns an error.
async fn conversation_summary(
conversation: ConversationId,
handoff_store: &Option<Arc<dyn HandoffStore>>,
log_store: &Option<Arc<dyn ConversationLog>>,
) -> ConversationWorkSummary {
// A handoff store absent from the wiring is treated as "no handoff yet"
// (`Missing` path), never as an error: the read model stays best-effort.
let handoff = match handoff_store {
Some(store) => Some(store.load(conversation).await),
None => None,
};
match handoff {
Some(Ok(Some(handoff))) => ready_summary(conversation, &handoff),
Some(Ok(None)) | None => {
// Handoff legitimately absent: status stays Missing even if the log
// fallback is itself unreadable (then simply no recent turns).
let recent_turns = recent_turns(log_store, conversation)
.await
.unwrap_or_default();
ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Missing,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns,
}
}
Some(Err(_)) => match recent_turns(log_store, conversation).await {
// Handoff unreadable but the log answered: partial view from the turns.
Some(recent_turns) => ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Partial,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns,
},
// Both sources failed: nothing usable to show.
None => ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Unavailable,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns: Vec::new(),
},
},
}
}
/// Projects a readable [`Handoff`] into a `Ready` summary with bounded previews.
fn ready_summary(conversation: ConversationId, handoff: &Handoff) -> ConversationWorkSummary {
ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Ready,
objective_preview: handoff
.objective
.as_deref()
.map(|objective| preview(objective, OBJECTIVE_PREVIEW_MAX_CHARS)),
summary_preview: Some(preview(&handoff.summary_md, HANDOFF_PREVIEW_MAX_CHARS)),
summary_len: handoff.summary_md.chars().count(),
up_to: Some(handoff.up_to),
recent_turns: Vec::new(),
}
}
/// Reads the bounded recent-turns fallback for `conversation`.
///
/// Returns `Some(turns)` (possibly empty) when a log store is wired and answers,
/// `None` when there is no log store or the read failed — the caller uses that
/// distinction to separate `Partial` from `Unavailable`. Never reads the full thread
/// (only [`ConversationLog::last`]) and re-caps defensively to [`RECENT_TURNS_MAX`].
async fn recent_turns(
log_store: &Option<Arc<dyn ConversationLog>>,
conversation: ConversationId,
) -> Option<Vec<ConversationTurnWorkPreview>> {
let store = log_store.as_ref()?;
match store.last(conversation, RECENT_TURNS_MAX).await {
Ok(turns) => Some(
turns
.into_iter()
.take(RECENT_TURNS_MAX)
.map(turn_preview)
.collect(),
),
Err(_) => None,
}
}
/// Projects one [`ConversationTurn`] into its bounded read-model preview.
fn turn_preview(turn: ConversationTurn) -> ConversationTurnWorkPreview {
ConversationTurnWorkPreview {
role: turn.role,
source: turn.source.into(),
at_ms: turn.at_ms,
text_preview: preview(&turn.text, TURN_PREVIEW_MAX_CHARS),
text_len: turn.text.chars().count(),
}
}
@ -216,17 +484,24 @@ fn ticket_state(snapshot: QueuedTicketSnapshot, busy_ticket: Option<TicketId>) -
}
}
/// Builds a bounded, whitespace-normalised excerpt of a task for the UI panel.
/// Builds the bounded task excerpt for the UI panel (see [`preview`]).
///
/// The original length is reported separately as [`AgentTicketState::task_len`].
fn task_preview(task: &str) -> String {
preview(task, TASK_PREVIEW_MAX_CHARS)
}
/// Builds a bounded, whitespace-normalised excerpt of `text` for the UI.
///
/// Trims, collapses any run of whitespace to a single space, then truncates to
/// [`TASK_PREVIEW_MAX_CHARS`] characters (never mid-codepoint). The original length
/// is reported separately as [`AgentTicketState::task_len`].
fn task_preview(task: &str) -> String {
let normalised = task.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.chars().count() <= TASK_PREVIEW_MAX_CHARS {
/// `max_chars` characters (never mid-codepoint). The original length is reported
/// separately by each caller's `*_len` field.
fn preview(text: &str, max_chars: usize) -> String {
let normalised = text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.chars().count() <= max_chars {
normalised
} else {
normalised.chars().take(TASK_PREVIEW_MAX_CHARS).collect()
normalised.chars().take(max_chars).collect()
}
}