diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 1bf8c52..654e439 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -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, } +/// 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 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 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, + /// Bounded excerpt of the handoff summary, when present. + pub summary_preview: Option, + /// 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, + /// Bounded, recent turns from the log fallback. + pub recent_turns: Vec, +} + +impl From 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, + /// Best-effort summaries of the conversations referenced by the tickets, + /// joined frontend-side via `tickets[].conversationId`. + pub conversations: Vec, } impl From for ProjectWorkStateDto { @@ -1634,6 +1729,11 @@ impl From for ProjectWorkStateDto { .collect(), }) .collect(), + conversations: state + .conversations + .into_iter() + .map(ConversationWorkSummaryDto::from) + .collect(), } } } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 1c89bbe..63af650 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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** +/// (`/.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> { + Some(Arc::new(FsConversationLog::new(root)) as Arc) + } +} + /// 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, + Arc::new(AppConversationLogProvider) + as Arc, + ), + ); // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 80b6c12..4532582 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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!({ diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 50e0cb6..4e40b5c 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -119,6 +119,8 @@ pub use terminal::{ }; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; pub use workstate::{ - AgentTicketState, AgentWorkState, GetProjectWorkState, GetProjectWorkStateInput, - LiveWorkSession, ProjectWorkState, TicketWorkSource, TicketWorkStatus, + AgentTicketState, AgentWorkState, ConversationLogProvider, ConversationPreviewStatus, + ConversationTurnWorkPreview, ConversationWorkSummary, GetProjectWorkState, + GetProjectWorkStateInput, LiveWorkSession, ProjectWorkState, TicketWorkSource, + TicketWorkStatus, }; diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index b1eebb9..a7ea42f 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -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, + /// 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, +} + +/// 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, + /// Bounded excerpt of the handoff summary, when a handoff is readable. + pub summary_preview: Option, + /// 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, + /// Bounded, recent turns from the log fallback (empty when a handoff is `Ready`). + pub recent_turns: Vec, +} + +/// 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>; } /// Read model for one manifest agent. @@ -128,10 +211,17 @@ pub struct GetProjectWorkState { live: Arc, input: Arc, queue: Arc, + /// Per-root handoff source (primary), wired best-effort; `None` ⇒ no summaries. + handoffs: Option>, + /// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns. + logs: Option>, } 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, @@ -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, + logs: Arc, + ) -> Self { + self.handoffs = Some(handoffs); + self.logs = Some(logs); + self + } + /// Executes the read-only aggregation. /// /// # Errors @@ -192,7 +298,169 @@ impl GetProjectWorkState { }) }) .collect::, 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 { + 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 { + 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>, + log_store: &Option>, +) -> 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>, + conversation: ConversationId, +) -> Option> { + 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) - } } -/// 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::>().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::>().join(" "); + if normalised.chars().count() <= max_chars { normalised } else { - normalised.chars().take(TASK_PREVIEW_MAX_CHARS).collect() + normalised.chars().take(max_chars).collect() } } diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs index cd8c5da..b2aaf2b 100644 --- a/crates/application/tests/workstate.rs +++ b/crates/application/tests/workstate.rs @@ -8,8 +8,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{ - GetProjectWorkState, GetProjectWorkStateInput, LiveSessionKind, LiveSessions, - StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus, + ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState, + GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions, + TerminalSessions, TicketWorkSource, TicketWorkStatus, }; use domain::mailbox::{ AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket, @@ -18,9 +19,10 @@ use domain::ports::{ AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError, }; use domain::{ - Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, ConversationId, InputMediator, - InputSource, ManifestEntry, MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, - PtySize, RemoteRef, SessionId, SessionKind, TerminalSession, TicketId, + Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, ConversationId, ConversationLog, + ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource, ManifestEntry, + MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, + SessionKind, TerminalSession, TicketId, TurnId, TurnRole, }; use uuid::Uuid; @@ -162,6 +164,131 @@ impl AgentQueueSnapshot for FakeQueue { } } +/// Configurable per-conversation outcome for the fake handoff store. +/// +/// Absence is modelled by simply not configuring a conversation (see the `_` arm +/// of [`FakeHandoffStore::load`]), so it needs no dedicated variant. +#[derive(Clone)] +enum HandoffOutcome { + /// A readable handoff (status Ready). + Present(Handoff), + /// An unreadable handoff (status Partial/Unavailable depending on the log). + Error, +} + +#[derive(Default)] +struct FakeHandoffStore { + outcomes: Mutex>, +} + +impl FakeHandoffStore { + fn set(&self, conversation: ConversationId, outcome: HandoffOutcome) { + self.outcomes.lock().unwrap().insert(conversation, outcome); + } +} + +#[async_trait] +impl HandoffStore for FakeHandoffStore { + async fn load(&self, conversation: ConversationId) -> Result, StoreError> { + match self.outcomes.lock().unwrap().get(&conversation) { + Some(HandoffOutcome::Present(handoff)) => Ok(Some(handoff.clone())), + Some(HandoffOutcome::Error) => Err(StoreError::Io("handoff unreadable".to_owned())), + // Unconfigured conversation ⇒ no handoff yet (never an error). + None => Ok(None), + } + } + + async fn save( + &self, + _conversation: ConversationId, + _handoff: Handoff, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +/// Configurable per-conversation outcome for the fake conversation log. +#[derive(Clone)] +enum LogOutcome { + /// A readable thread (possibly empty). + Turns(Vec), + /// An unreadable thread. + Error, +} + +#[derive(Default)] +struct FakeLog { + outcomes: Mutex>, +} + +impl FakeLog { + fn set(&self, conversation: ConversationId, outcome: LogOutcome) { + self.outcomes.lock().unwrap().insert(conversation, outcome); + } +} + +#[async_trait] +impl ConversationLog for FakeLog { + async fn append( + &self, + _conversation: ConversationId, + _turn: ConversationTurn, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn read( + &self, + _conversation: ConversationId, + _since: Option, + ) -> Result, StoreError> { + Ok(Vec::new()) + } + + async fn last( + &self, + conversation: ConversationId, + n: usize, + ) -> Result, StoreError> { + match self.outcomes.lock().unwrap().get(&conversation) { + Some(LogOutcome::Turns(turns)) => { + let start = turns.len().saturating_sub(n); + Ok(turns[start..].to_vec()) + } + Some(LogOutcome::Error) => Err(StoreError::Io("log unreadable".to_owned())), + None => Ok(Vec::new()), + } + } +} + +/// Stateless providers handing the same fake stores back regardless of root. +struct FakeHandoffProvider(Arc); + +impl HandoffProvider for FakeHandoffProvider { + fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.0) as Arc) + } +} + +struct FakeLogProvider(Arc); + +impl ConversationLogProvider for FakeLogProvider { + fn conversation_log_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.0) as Arc) + } +} + +fn turn(id: u128, conversation: ConversationId, role: TurnRole, text: &str) -> ConversationTurn { + ConversationTurn::new( + TurnId::from_uuid(Uuid::from_u128(id)), + conversation, + 1_700, + InputSource::Human, + role, + text, + ) +} + fn snapshot( id: u128, position: u32, @@ -259,6 +386,52 @@ fn fixture(agents: &[Agent]) -> Fixture { } } +/// Conversation id minted by [`snapshot`] for a ticket of the given `id`. +fn conv_of(id: u128) -> ConversationId { + ConversationId::from_uuid(Uuid::from_u128(id + 1000)) +} + +struct ConvFixture { + usecase: GetProjectWorkState, + queue: Arc, + input: Arc, + handoffs: Arc, + logs: Arc, + project: Project, +} + +/// Fixture wiring the best-effort conversation sources (handoff + log) onto the +/// read model, exposing the fake stores so each test configures their outcomes. +fn conv_fixture(agents: &[Agent]) -> ConvFixture { + let pty = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let live = Arc::new(LiveSessions::new(pty, structured)); + let input = Arc::new(FakeInput::default()); + let queue = Arc::new(FakeQueue::default()); + let handoffs = Arc::new(FakeHandoffStore::default()); + let logs = Arc::new(FakeLog::default()); + let usecase = GetProjectWorkState::new( + Arc::new(FakeContexts { + manifest: manifest(agents), + }), + live, + Arc::clone(&input) as Arc, + Arc::clone(&queue) as Arc, + ) + .with_conversation_sources( + Arc::new(FakeHandoffProvider(Arc::clone(&handoffs))) as Arc, + Arc::new(FakeLogProvider(Arc::clone(&logs))) as Arc, + ); + ConvFixture { + usecase, + queue, + input, + handoffs, + logs, + project: project(), + } +} + #[tokio::test] async fn workstate_lists_manifest_agents_idle_without_live_sessions() { let a = agent(10, "alpha"); @@ -517,3 +690,292 @@ async fn workstate_maps_human_and_agent_ticket_sources() { ); assert_eq!(tickets[1].requester_label, "Main"); } + +// --------------------------------------------------------------------------- +// Lot C — conversation summaries (best-effort, read-only) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn workstate_has_no_conversations_without_tickets() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + assert!(out.conversations.is_empty()); +} + +#[tokio::test] +async fn workstate_dedups_conversation_ids_from_tickets() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + let shared = ConversationId::from_uuid(Uuid::from_u128(7777)); + // Two tickets pointing at the *same* conversation must yield one summary. + let mut t1 = snapshot(1, 0, InputSource::agent(from), "Main", "first"); + t1.conversation = shared; + let mut t2 = snapshot(2, 1, InputSource::agent(from), "Main", "second"); + t2.conversation = shared; + f.queue.set(a.id, vec![t1, t2]); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + assert_eq!(out.conversations.len(), 1); + assert_eq!(out.conversations[0].conversation_id, shared); +} + +#[tokio::test] +async fn workstate_conversation_ready_from_handoff() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + let up_to = TurnId::from_uuid(Uuid::from_u128(500)); + f.handoffs.set( + conv, + HandoffOutcome::Present(Handoff::new( + "Résumé du fil", + up_to, + Some("Livrer le lot C".to_owned()), + )), + ); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let summary = &out.conversations[0]; + assert_eq!(summary.conversation_id, conv); + assert_eq!(summary.status, ConversationPreviewStatus::Ready); + assert_eq!(summary.summary_preview.as_deref(), Some("Résumé du fil")); + assert_eq!(summary.summary_len, "Résumé du fil".chars().count()); + assert_eq!( + summary.objective_preview.as_deref(), + Some("Livrer le lot C") + ); + assert_eq!(summary.up_to, Some(up_to)); + assert!( + summary.recent_turns.is_empty(), + "Ready uses the handoff, not turns" + ); +} + +#[tokio::test] +async fn workstate_conversation_missing_with_bounded_recent_turns() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + // No handoff (Absent) but a readable log of 5 turns ⇒ Missing + last 3. + f.logs.set( + conv, + LogOutcome::Turns(vec![ + turn(1, conv, TurnRole::Prompt, "t1"), + turn(2, conv, TurnRole::Response, "t2"), + turn(3, conv, TurnRole::Prompt, "t3"), + turn(4, conv, TurnRole::Response, "t4"), + turn(5, conv, TurnRole::Prompt, "t5"), + ]), + ); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let summary = &out.conversations[0]; + assert_eq!(summary.status, ConversationPreviewStatus::Missing); + assert!(summary.summary_preview.is_none()); + assert_eq!(summary.summary_len, 0); + assert_eq!(summary.recent_turns.len(), 3, "bounded to RECENT_TURNS_MAX"); + // The *last* three turns, in order. + assert_eq!(summary.recent_turns[0].text_preview, "t3"); + assert_eq!(summary.recent_turns[2].text_preview, "t5"); +} + +#[tokio::test] +async fn workstate_conversation_partial_when_handoff_errors_but_log_ok() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + f.handoffs.set(conv, HandoffOutcome::Error); + f.logs.set( + conv, + LogOutcome::Turns(vec![turn(1, conv, TurnRole::Response, "only turn")]), + ); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let summary = &out.conversations[0]; + assert_eq!(summary.status, ConversationPreviewStatus::Partial); + assert!(summary.summary_preview.is_none(), "no summary on partial"); + assert_eq!(summary.recent_turns.len(), 1); + assert_eq!(summary.recent_turns[0].text_preview, "only turn"); +} + +#[tokio::test] +async fn workstate_conversation_unavailable_when_handoff_and_log_fail() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + f.handoffs.set(conv, HandoffOutcome::Error); + f.logs.set(conv, LogOutcome::Error); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let summary = &out.conversations[0]; + assert_eq!(summary.status, ConversationPreviewStatus::Unavailable); + assert!(summary.summary_preview.is_none()); + assert!(summary.recent_turns.is_empty()); +} + +#[tokio::test] +async fn workstate_preview_failure_preserves_agents_live_busy_tickets() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + // Both sources KO for this conversation ⇒ Unavailable, but the rest stands. + f.handoffs.set(conv, HandoffOutcome::Error); + f.logs.set(conv, LogOutcome::Error); + let busy = AgentBusyState::Busy { + ticket: ticket_id(1), + since_ms: 9, + }; + f.input.set_busy(a.id, busy); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + // Agents/busy/tickets fully intact despite the failed preview. + assert_eq!(out.agents.len(), 1); + assert_eq!(out.agents[0].busy, busy); + assert_eq!(out.agents[0].tickets.len(), 1); + assert_eq!( + out.agents[0].tickets[0].status, + TicketWorkStatus::InProgress + ); + assert_eq!( + out.conversations[0].status, + ConversationPreviewStatus::Unavailable + ); +} + +#[tokio::test] +async fn workstate_conversation_previews_truncated_and_normalised() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + // Whitespace runs to normalise; lengths well over each cap (480 / 160). + // Each " s " collapses to one " s" (2 chars) ⇒ pick counts past the caps. + let long_summary = format!("start{}end", " s ".repeat(300)); + let long_objective = format!("goal{}done", " o ".repeat(120)); + f.handoffs.set( + conv, + HandoffOutcome::Present(Handoff::new( + long_summary.clone(), + TurnId::from_uuid(Uuid::from_u128(1)), + Some(long_objective), + )), + ); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let summary = &out.conversations[0]; + let preview = summary.summary_preview.as_deref().unwrap(); + assert_eq!(preview.chars().count(), 480, "summary capped"); + assert!(!preview.contains(" "), "summary whitespace normalised"); + assert_eq!( + summary.summary_len, + long_summary.chars().count(), + "original length preserved" + ); + let objective = summary.objective_preview.as_deref().unwrap(); + assert_eq!(objective.chars().count(), 160, "objective capped"); + assert!(!objective.contains(" "), "objective whitespace normalised"); +} + +#[tokio::test] +async fn workstate_conversation_turn_text_preview_truncated() { + let a = agent(10, "alpha"); + let f = conv_fixture(std::slice::from_ref(&a)); + let from = aid(20); + f.queue.set( + a.id, + vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")], + ); + let conv = conv_of(1); + let long_text = format!("begin{}fin", " w ".repeat(120)); + f.logs.set( + conv, + LogOutcome::Turns(vec![turn(1, conv, TurnRole::Prompt, &long_text)]), + ); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let turn_preview = &out.conversations[0].recent_turns[0]; + assert_eq!( + turn_preview.text_preview.chars().count(), + 220, + "turn capped" + ); + assert!(!turn_preview.text_preview.contains(" "), "normalised"); + assert_eq!(turn_preview.text_len, long_text.chars().count()); +}