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(
|
||||
// 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!({
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<HashMap<ConversationId, HandoffOutcome>>,
|
||||
}
|
||||
|
||||
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<Option<Handoff>, 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<ConversationTurn>),
|
||||
/// An unreadable thread.
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeLog {
|
||||
outcomes: Mutex<HashMap<ConversationId, LogOutcome>>,
|
||||
}
|
||||
|
||||
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<TurnId>,
|
||||
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn last(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
n: usize,
|
||||
) -> Result<Vec<ConversationTurn>, 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<FakeHandoffStore>);
|
||||
|
||||
impl HandoffProvider for FakeHandoffProvider {
|
||||
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
|
||||
Some(Arc::clone(&self.0) as Arc<dyn HandoffStore>)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeLogProvider(Arc<FakeLog>);
|
||||
|
||||
impl ConversationLogProvider for FakeLogProvider {
|
||||
fn conversation_log_for(&self, _root: &ProjectPath) -> Option<Arc<dyn ConversationLog>> {
|
||||
Some(Arc::clone(&self.0) as Arc<dyn ConversationLog>)
|
||||
}
|
||||
}
|
||||
|
||||
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<FakeQueue>,
|
||||
input: Arc<FakeInput>,
|
||||
handoffs: Arc<FakeHandoffStore>,
|
||||
logs: Arc<FakeLog>,
|
||||
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<dyn InputMediator>,
|
||||
Arc::clone(&queue) as Arc<dyn AgentQueueSnapshot>,
|
||||
)
|
||||
.with_conversation_sources(
|
||||
Arc::new(FakeHandoffProvider(Arc::clone(&handoffs))) as Arc<dyn HandoffProvider>,
|
||||
Arc::new(FakeLogProvider(Arc::clone(&logs))) as Arc<dyn ConversationLogProvider>,
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user