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

@ -9,11 +9,12 @@
use serde::{Deserialize, Serialize};
use application::{
AgentTicketState, AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput,
HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput,
AgentTicketState, AppError, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput,
HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput,
ProjectWorkState, TicketWorkSource, TicketWorkStatus,
};
use domain::{AgentBusyState, Project, ProjectId};
use domain::{AgentBusyState, Project, ProjectId, TurnRole};
/// Request DTO for the `health` command.
#[derive(Debug, Clone, Default, Deserialize)]
@ -1603,12 +1604,106 @@ pub struct AgentWorkStateDto {
pub tickets: Vec<AgentTicketStateDto>,
}
/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ConversationPreviewStatusDto {
/// A handoff was present and usable.
Ready,
/// No handoff yet; `recentTurns` may carry the log fallback.
Missing,
/// The handoff was unreadable but the log fallback was readable.
Partial,
/// Neither the handoff nor the log could be read.
Unavailable,
}
impl From<ConversationPreviewStatus> for ConversationPreviewStatusDto {
fn from(status: ConversationPreviewStatus) -> Self {
match status {
ConversationPreviewStatus::Ready => Self::Ready,
ConversationPreviewStatus::Missing => Self::Missing,
ConversationPreviewStatus::Partial => Self::Partial,
ConversationPreviewStatus::Unavailable => Self::Unavailable,
}
}
}
/// One recent turn surfaced in a conversation summary's log fallback.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationTurnWorkPreviewDto {
/// Nature of the turn (`prompt`/`response`/`toolActivity`).
pub role: TurnRole,
/// Origin of the turn (human operator or a delegating agent).
pub source: TicketWorkSourceDto,
/// Timestamp (epoch milliseconds) of the turn.
pub at_ms: u64,
/// Bounded excerpt of the turn text.
pub text_preview: String,
/// Character length of the original (un-truncated) turn text.
pub text_len: usize,
}
impl From<ConversationTurnWorkPreview> for ConversationTurnWorkPreviewDto {
fn from(turn: ConversationTurnWorkPreview) -> Self {
Self {
role: turn.role,
source: turn.source.into(),
at_ms: turn.at_ms,
text_preview: turn.text_preview,
text_len: turn.text_len,
}
}
}
/// Best-effort, read-only summary of one conversation visible through the tickets.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationWorkSummaryDto {
/// The conversation (pair) this summary describes.
pub conversation_id: String,
/// How much could be derived.
pub status: ConversationPreviewStatusDto,
/// Bounded excerpt of the handoff objective, when present.
pub objective_preview: Option<String>,
/// Bounded excerpt of the handoff summary, when present.
pub summary_preview: Option<String>,
/// Character length of the original (un-truncated) handoff summary (`0` when none).
pub summary_len: usize,
/// Cursor (last turn id) covered by the handoff summary, when present.
pub up_to: Option<String>,
/// Bounded, recent turns from the log fallback.
pub recent_turns: Vec<ConversationTurnWorkPreviewDto>,
}
impl From<ConversationWorkSummary> for ConversationWorkSummaryDto {
fn from(summary: ConversationWorkSummary) -> Self {
Self {
conversation_id: summary.conversation_id.to_string(),
status: summary.status.into(),
objective_preview: summary.objective_preview,
summary_preview: summary.summary_preview,
summary_len: summary.summary_len,
up_to: summary.up_to.map(|cursor| cursor.to_string()),
recent_turns: summary
.recent_turns
.into_iter()
.map(ConversationTurnWorkPreviewDto::from)
.collect(),
}
}
}
/// Project-level read model for conversation/delegation UX.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectWorkStateDto {
/// Manifest agents in manifest order, enriched with live/busy state.
pub agents: Vec<AgentWorkStateDto>,
/// Best-effort summaries of the conversations referenced by the tickets,
/// joined frontend-side via `tickets[].conversationId`.
pub conversations: Vec<ConversationWorkSummaryDto>,
}
impl From<ProjectWorkState> for ProjectWorkStateDto {
@ -1634,6 +1729,11 @@ impl From<ProjectWorkState> for ProjectWorkStateDto {
.collect(),
})
.collect(),
conversations: state
.conversations
.into_iter()
.map(ConversationWorkSummaryDto::from)
.collect(),
}
}
}

View File

@ -110,6 +110,25 @@ impl application::HandoffProvider for AppHandoffProvider {
}
}
/// Implémente [`ConversationLogProvider`](application::ConversationLogProvider) (Lot C)
/// en matérialisant un [`FsConversationLog`] ciblant le **project root** courant.
///
/// Jumeau stateless de [`AppHandoffProvider`] : le read-model work-state est unique
/// pour tous les projets, alors que le log est **par project root**
/// (`<root>/.ideai/conversations/`). On construit donc un log frais par appel, ciblant
/// le bon dossier. Sert de **repli** lecture seule (`last(_, 3)`) aux résumés de
/// conversation. Sans état (zéro champ), partagé via un simple `Arc`.
struct AppConversationLogProvider;
impl application::ConversationLogProvider for AppConversationLogProvider {
fn conversation_log_for(
&self,
root: &domain::project::ProjectPath,
) -> Option<Arc<dyn domain::ConversationLog>> {
Some(Arc::new(FsConversationLog::new(root)) as Arc<dyn domain::ConversationLog>)
}
}
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours.
@ -1031,12 +1050,22 @@ impl AppState {
Arc::clone(&terminal_sessions),
Arc::clone(&structured_sessions),
));
let get_project_work_state = Arc::new(GetProjectWorkState::new(
Arc::clone(&contexts_port),
Arc::clone(&live_sessions),
Arc::clone(&input_mediator),
queue_snapshot,
));
// Lot C — résumés de conversation best-effort : on câble les sources par
// project root (handoff = primaire, log = repli `last(_, 3)`). Lecture seule,
// aucune persistance ; un échec de preview ne bloque ni live/busy ni tickets.
let get_project_work_state = Arc::new(
GetProjectWorkState::new(
Arc::clone(&contexts_port),
Arc::clone(&live_sessions),
Arc::clone(&input_mediator),
queue_snapshot,
)
.with_conversation_sources(
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
Arc::new(AppConversationLogProvider)
as Arc<dyn application::ConversationLogProvider>,
),
);
// --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :