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:
@ -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