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:
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 :
|
||||
|
||||
@ -222,6 +222,7 @@ fn project_work_state_dto_serialises_live_and_busy_camelcase() {
|
||||
},
|
||||
tickets: vec![],
|
||||
}],
|
||||
conversations: vec![],
|
||||
});
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
@ -279,6 +280,7 @@ fn project_work_state_dto_serialises_tickets_camelcase() {
|
||||
},
|
||||
],
|
||||
}],
|
||||
conversations: vec![],
|
||||
});
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
@ -304,6 +306,80 @@ fn project_work_state_dto_serialises_tickets_camelcase() {
|
||||
assert_eq!(next["requesterLabel"], "User");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_work_state_dto_serialises_conversations_camelcase() {
|
||||
use application::{
|
||||
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
||||
};
|
||||
use domain::{ConversationId, TurnId, TurnRole};
|
||||
|
||||
let requester = AgentId::from_uuid(Uuid::from_u128(20));
|
||||
let ready_conv = ConversationId::from_uuid(Uuid::from_u128(40));
|
||||
let missing_conv = ConversationId::from_uuid(Uuid::from_u128(41));
|
||||
let up_to = TurnId::from_uuid(Uuid::from_u128(50));
|
||||
|
||||
let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState {
|
||||
agents: vec![],
|
||||
conversations: vec![
|
||||
ConversationWorkSummary {
|
||||
conversation_id: ready_conv,
|
||||
status: ConversationPreviewStatus::Ready,
|
||||
objective_preview: Some("Livrer le lot C".to_owned()),
|
||||
summary_preview: Some("Résumé du fil".to_owned()),
|
||||
summary_len: 1024,
|
||||
up_to: Some(up_to),
|
||||
recent_turns: vec![],
|
||||
},
|
||||
ConversationWorkSummary {
|
||||
conversation_id: missing_conv,
|
||||
status: ConversationPreviewStatus::Missing,
|
||||
objective_preview: None,
|
||||
summary_preview: None,
|
||||
summary_len: 0,
|
||||
up_to: None,
|
||||
recent_turns: vec![ConversationTurnWorkPreview {
|
||||
role: TurnRole::Prompt,
|
||||
source: TicketWorkSource::Agent {
|
||||
agent_id: requester,
|
||||
},
|
||||
at_ms: 1_700,
|
||||
text_preview: "Analyse le module".to_owned(),
|
||||
text_len: 17,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let ready = &v["conversations"][0];
|
||||
assert_eq!(ready["conversationId"], ready_conv.to_string());
|
||||
assert_eq!(ready["status"], "ready");
|
||||
assert_eq!(ready["objectivePreview"], "Livrer le lot C");
|
||||
assert_eq!(ready["summaryPreview"], "Résumé du fil");
|
||||
assert_eq!(ready["summaryLen"], 1024);
|
||||
assert_eq!(ready["upTo"], up_to.to_string());
|
||||
assert_eq!(ready["recentTurns"], json!([]));
|
||||
// No snake_case leak.
|
||||
assert!(ready.get("conversation_id").is_none());
|
||||
assert!(ready.get("summary_preview").is_none());
|
||||
assert!(ready.get("up_to").is_none());
|
||||
|
||||
let missing = &v["conversations"][1];
|
||||
assert_eq!(missing["status"], "missing");
|
||||
// Absent previews surface as null (predictable, not omitted).
|
||||
assert!(missing["summaryPreview"].is_null());
|
||||
assert!(missing["upTo"].is_null());
|
||||
let turn = &missing["recentTurns"][0];
|
||||
assert_eq!(turn["role"], "prompt");
|
||||
assert_eq!(turn["source"]["kind"], "agent");
|
||||
assert_eq!(turn["source"]["agentId"], requester.to_string());
|
||||
assert_eq!(turn["atMs"], 1_700);
|
||||
assert_eq!(turn["textPreview"], "Analyse le module");
|
||||
assert_eq!(turn["textLen"], 17);
|
||||
assert!(turn.get("at_ms").is_none());
|
||||
assert!(turn.get("text_preview").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_carries_conversation_id_for_resume() {
|
||||
let raw = json!({
|
||||
|
||||
Reference in New Issue
Block a user