Files
IdeA/crates/application/tests/workstate.rs
Blomios e9edadca50 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>
2026-06-20 19:28:55 +02:00

982 lines
28 KiB
Rust

//! Tests for the project work-state read model.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{
ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState,
GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions,
TerminalSessions, TicketWorkSource, TicketWorkStatus,
};
use domain::mailbox::{
AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
};
use domain::ports::{
AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError,
};
use domain::{
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;
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn ticket_id(n: u128) -> domain::TicketId {
domain::TicketId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn agent(n: u128, name: &str) -> Agent {
Agent::new(
aid(n),
name,
format!("agents/{name}.md"),
pid(100 + n),
AgentOrigin::Scratch,
false,
)
.unwrap()
}
fn manifest(agents: &[Agent]) -> AgentManifest {
AgentManifest::new(1, agents.iter().map(ManifestEntry::from_agent).collect()).unwrap()
}
#[derive(Clone)]
struct FakeContexts {
manifest: AgentManifest,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeInput {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
}
impl FakeInput {
fn set_busy(&self, agent: AgentId, busy: AgentBusyState) {
self.busy.lock().unwrap().insert(agent, busy);
}
}
impl InputMediator for FakeInput {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
let fut: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>> =
Box::pin(async { Err(MailboxError::Cancelled) });
PendingReply::new(fut)
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, _agent: AgentId) {}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.busy
.lock()
.unwrap()
.get(&agent)
.copied()
.unwrap_or(AgentBusyState::Idle)
}
}
#[derive(Default)]
struct FakeQueue {
queues: Mutex<HashMap<AgentId, Vec<QueuedTicketSnapshot>>>,
}
impl FakeQueue {
fn set(&self, agent: AgentId, tickets: Vec<QueuedTicketSnapshot>) {
self.queues.lock().unwrap().insert(agent, tickets);
}
}
impl AgentQueueSnapshot for FakeQueue {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
self.queues
.lock()
.unwrap()
.get(&agent)
.cloned()
.unwrap_or_default()
}
}
/// 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,
source: InputSource,
requester: &str,
task: &str,
) -> QueuedTicketSnapshot {
QueuedTicketSnapshot {
id: TicketId::from_uuid(Uuid::from_u128(id)),
source,
conversation: ConversationId::from_uuid(Uuid::from_u128(id + 1000)),
requester: requester.to_owned(),
task: task.to_owned(),
position,
}
}
struct FakeSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
fn fake_session(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id })
}
fn insert_pty(
sessions: &TerminalSessions,
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
node_id,
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
struct Fixture {
usecase: GetProjectWorkState,
pty: Arc<TerminalSessions>,
structured: Arc<StructuredSessions>,
input: Arc<FakeInput>,
queue: Arc<FakeQueue>,
project: Project,
}
fn fixture(agents: &[Agent]) -> Fixture {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
let input = Arc::new(FakeInput::default());
let input_port = Arc::clone(&input) as Arc<dyn InputMediator>;
let queue = Arc::new(FakeQueue::default());
let queue_port = Arc::clone(&queue) as Arc<dyn AgentQueueSnapshot>;
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(agents),
}),
live,
input_port,
queue_port,
);
Fixture {
usecase,
pty,
structured,
input,
queue,
project: project(),
}
}
/// 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");
let b = agent(20, "beta");
let f = fixture(&[a.clone(), b.clone()]);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 2);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].name, "alpha");
assert_eq!(out.agents[0].profile_id, a.profile_id);
assert_eq!(out.agents[0].live, None);
assert_eq!(out.agents[0].busy, AgentBusyState::Idle);
assert_eq!(out.agents[1].agent_id, b.id);
}
#[tokio::test]
async fn workstate_attaches_live_pty_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(1), a.id, nid(100));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(1));
assert_eq!(live.node_id, nid(100));
assert_eq!(live.kind, LiveSessionKind::Pty);
}
#[tokio::test]
async fn workstate_attaches_live_structured_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
f.structured.insert(fake_session(sid(2)), a.id, nid(200));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(2));
assert_eq!(live.node_id, nid(200));
assert_eq!(live.kind, LiveSessionKind::Structured);
}
#[tokio::test]
async fn workstate_includes_busy_state_from_input_mediator() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let busy = AgentBusyState::Busy {
ticket: ticket_id(7),
since_ms: 1_234,
};
f.input.set_busy(a.id, busy);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].busy, busy);
}
#[tokio::test]
async fn workstate_ignores_live_agents_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(99), aid(999), nid(999));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].live, None);
}
#[tokio::test]
async fn workstate_agent_without_queue_has_no_tickets() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_lists_two_tickets_in_fifo_order() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets.len(), 2);
assert_eq!(tickets[0].ticket_id, ticket_id(1));
assert_eq!(tickets[0].position, 0);
assert_eq!(tickets[1].ticket_id, ticket_id(2));
assert_eq!(tickets[1].position, 1);
}
#[tokio::test]
async fn workstate_marks_busy_head_in_progress_and_rest_queued() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
f.input.set_busy(
a.id,
AgentBusyState::Busy {
ticket: ticket_id(1),
since_ms: 5,
},
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].status, TicketWorkStatus::InProgress);
assert_eq!(tickets[1].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_marks_all_queued_when_agent_idle() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "first")],
);
// Agent left Idle (default): even the head is only queued, not in-progress.
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].tickets[0].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_ignores_queue_for_agent_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// A queue exists for an agent that is not in the manifest: it must not surface.
f.queue.set(
aid(999),
vec![snapshot(1, 0, InputSource::Human, "User", "ghost")],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_truncates_task_preview_and_keeps_original_len() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// 400 chars with runs of whitespace to normalise; well over the 160 cap.
let long_task = format!("start{}end", " x ".repeat(80));
let original_len = long_task.chars().count();
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::Human, "User", &long_task)],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let ticket = &out.agents[0].tickets[0];
assert_eq!(ticket.task_preview.chars().count(), 160);
assert!(!ticket.task_preview.contains(" "), "whitespace normalised");
assert_eq!(ticket.task_len, original_len);
assert!(ticket.task_len > 160);
}
#[tokio::test]
async fn workstate_maps_human_and_agent_ticket_sources() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::Human, "User", "from human"),
snapshot(2, 1, InputSource::agent(from), "Main", "from agent"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].source, TicketWorkSource::Human);
assert_eq!(tickets[0].requester_label, "User");
assert_eq!(
tickets[1].source,
TicketWorkSource::Agent { agent_id: from }
);
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());
}