use std::collections::VecDeque;
use std::sync::Mutex;
use super::*;
use crate::entities::subagent::RunOutcome;
use crate::shared::api::ChatRequest;
use crate::shared::api::contract::{ChatStream, ToolCallDelta};
pub(super) struct Script {
pub(super) chunks: Vec<ChatChunk>,
pub(super) hang: bool,
}
pub(super) struct ScriptRecorder {
requests: Mutex<Vec<ChatRequest>>,
scripts: Mutex<VecDeque<Script>>,
}
impl ScriptRecorder {
pub(super) fn new(scripts: Vec<Script>) -> Arc<Self> {
Arc::new(Self {
requests: Mutex::new(Vec::new()),
scripts: Mutex::new(scripts.into()),
})
}
pub(super) fn requests(&self) -> Vec<ChatRequest> {
self.requests.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl EngineBackend for ScriptRecorder {
async fn chat_stream(
&self,
req: ChatRequest,
cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<ChatStream> {
self.requests.lock().unwrap().push(req);
let script = self.scripts.lock().unwrap().pop_front().unwrap_or(Script {
chunks: vec![ChatChunk::Finished(FinishReason::Stop)],
hang: false,
});
let s = async_stream::stream! {
for chunk in script.chunks {
yield chunk;
}
if script.hang {
cancel.cancelled().await;
yield ChatChunk::Finished(FinishReason::Cancelled);
}
};
Ok(Box::pin(s))
}
}
pub(super) fn call(id: &str, name: &str, args: &str) -> Script {
Script {
chunks: vec![
ChatChunk::ToolCall(ToolCallDelta {
thought_signature: None,
index: 0,
id: Some(id.into()),
name: Some(name.into()),
arguments: args.into(),
}),
ChatChunk::Finished(FinishReason::ToolCalls),
],
hang: false,
}
}
pub(super) fn text(t: &str) -> Script {
Script {
chunks: vec![
ChatChunk::Text(t.into()),
ChatChunk::Finished(FinishReason::Stop),
],
hang: false,
}
}
pub(super) fn hang(prefix: &str) -> Script {
Script {
chunks: vec![ChatChunk::Text(prefix.into())],
hang: true,
}
}
const DELEGATE: &str =
r#"{"name":"Critic","system_message":"be harsh","message":"what time is it"}"#;
pub(super) async fn run_turn(
scripts: Vec<Script>,
cfg: AppConfig,
) -> (tempfile::TempDir, Arc<ScriptRecorder>, Vec<AppEvent>, Uuid) {
let backend = ScriptRecorder::new(scripts);
let (dir, cmd_tx, mut evt_rx, handle) =
spawn_orch_cfg(Some(backend.clone() as Arc<dyn EngineBackend>), cfg);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated { id: chat_id, .. } = active else {
unreachable!()
};
cmd_tx
.send(AppCommand::SendMessage("delegate this".into()))
.unwrap();
let mut events = Vec::new();
while let Some(ev) = evt_rx.recv().await {
let done = matches!(ev, AppEvent::Finished { .. });
events.push(ev);
if done {
break;
}
}
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
(dir, backend, events, chat_id)
}
pub(super) fn load(root: &std::path::Path, id: Uuid) -> Chat {
Storage::open(Paths::with_root(root))
.unwrap()
.json()
.load_chat(id)
.unwrap()
.unwrap()
}
fn tool_names(req: &ChatRequest) -> Vec<String> {
req.tools.iter().map(|t| t.name.to_string()).collect()
}
#[tokio::test]
async fn subagent_runs_with_tools_and_lands_on_the_record() {
let (dir, backend, events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
call("c2", "current_time", "{}"),
text("it is noon"),
text("done: noon"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
assert_eq!(chat.messages.len(), 4, "{:?}", chat.messages);
let record = &chat.messages[1].tool_calls[0];
assert_eq!(record.name, "call_subagent");
let run = record
.subagent
.as_deref()
.expect("the run is on the record");
assert_eq!(run.name.as_deref(), Some("Critic"));
assert_eq!(run.title, "Critic");
assert_eq!(run.system_message, "be harsh");
assert_eq!(run.outcome, Some(RunOutcome::Completed));
assert!(run.finished_at.is_some());
assert_eq!(run.messages.len(), 4, "{:?}", run.messages);
assert_eq!(run.messages[0].role, MessageRole::User);
assert_eq!(run.messages[0].text, "what time is it");
assert_eq!(run.messages[1].tool_calls[0].name, "current_time");
assert!(run.messages[1].tool_calls[0].subagent.is_none());
assert_eq!(run.messages[2].role, MessageRole::Tool);
assert_eq!(run.final_reply(), Some("it is noon"));
assert!(run.tokens > 0, "the run's cost is recorded");
let result = chat.messages[2].text.as_str();
assert!(result.starts_with("it is noon"), "{result}");
assert!(
result.contains(&crate::features::chat_links::uri(run.id)),
"{result}"
);
assert_eq!(chat.messages[3].text, "done: noon");
let reqs = backend.requests();
assert_eq!(reqs.len(), 4, "parent, child, child, parent");
let child = &reqs[1];
assert_eq!(child.system.as_deref(), Some("be harsh"));
assert_eq!(child.messages.len(), 1);
assert_eq!(child.messages[0].content, "what time is it");
let names = tool_names(child);
assert!(names.contains(&"current_time".to_string()), "{names:?}");
assert!(!names.contains(&"call_subagent".to_string()), "{names:?}");
assert!(!names.contains(&"history_read".to_string()), "{names:?}");
assert!(!names.contains(&"get_self_model".to_string()), "{names:?}");
assert!(tool_names(&reqs[0]).contains(&"call_subagent".to_string()));
assert_eq!(reqs[2].messages.len(), 3);
let cards: Vec<&AppEvent> = events
.iter()
.filter(|e| matches!(e, AppEvent::ToolCall { .. }))
.collect();
assert_eq!(cards.len(), 1);
assert!(
matches!(cards[0], AppEvent::ToolCall { name, call_id, .. } if name == "call_subagent" && call_id == "c1")
);
let started: Vec<(String, String)> = events
.iter()
.filter_map(|e| match e {
AppEvent::ToolCallStarted { call_id, name, .. } => {
Some((call_id.clone(), name.clone()))
}
_ => None,
})
.collect();
assert_eq!(
started,
vec![("c1".to_string(), "call_subagent".to_string())]
);
let first_started = events
.iter()
.position(|e| matches!(e, AppEvent::ToolCallStarted { .. }))
.unwrap();
let first_card = events
.iter()
.position(|e| matches!(e, AppEvent::ToolCall { .. }))
.unwrap();
assert!(first_started < first_card);
assert!(
!events
.iter()
.any(|e| matches!(e, AppEvent::Chunk { text, .. } if text == "it is noon")),
"the child's text must not reach the parent's bubble"
);
let max_completion = events
.iter()
.filter_map(|e| match e {
AppEvent::TokenUsage { completion, .. } => Some(*completion),
_ => None,
})
.max()
.unwrap_or(0);
assert_eq!(max_completion, 2);
let chip: Vec<Option<(String, u32, Option<String>)>> = events
.iter()
.filter_map(|e| match e {
AppEvent::SubagentProgress { progress, .. } => Some(
progress
.as_ref()
.map(|p| (p.name.clone(), p.round, p.tool.clone())),
),
_ => None,
})
.collect();
let critic = |round: u32, tool: Option<&str>| {
Some(("Critic".to_string(), round, tool.map(str::to_string)))
};
assert_eq!(
chip,
vec![
critic(1, None),
critic(1, Some("current_time")),
critic(2, None),
None
]
);
}
#[tokio::test]
async fn a_landed_transcript_is_auto_titled_under_both_modes() {
for mode in [
crate::shared::config::AutoTitleMode::AfterUserMessage,
crate::shared::config::AutoTitleMode::AfterAssistantReply,
] {
let (dir, chat_id, _) = delegated().await;
let mut cfg = no_auto_cfg();
cfg.interface.auto_title = mode;
let backend = ScriptRecorder::new(vec![
call("c2", "call_subagent", DELEGATE),
text("it is one"),
text("done: one"),
text("Second opinion"),
]);
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(
dir.path(),
Some(backend.clone() as Arc<dyn EngineBackend>),
cfg,
);
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("delegate again".into()))
.unwrap();
let renamed = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatRenamed { .. }))
.await
.unwrap_or_else(|| panic!("no title landed under {mode:?}"));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
let new_run = chat.children().last().unwrap();
assert!(
matches!(&renamed, AppEvent::ChatRenamed { id, title } if *id == new_run.id && title == "Second opinion"),
"{renamed:?} under {mode:?}"
);
assert_eq!(new_run.title, "Second opinion");
assert!(
!new_run.renamed_manually,
"an automatic title is not a manual one"
);
assert_eq!(
chat.title, "Новый чат",
"the parent was not retitled: {mode:?}"
);
let title_req = backend.requests().last().unwrap().clone();
assert!(
title_req.messages[0].content.contains("it is one"),
"{:?}",
title_req.messages[0].content
);
}
}
#[tokio::test]
async fn subagent_cannot_nest() {
let (dir, _backend, _events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
call("c2", "call_subagent", DELEGATE),
text("gave up"),
text("ok"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert!(run.messages[1].tool_calls[0].subagent.is_none());
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::default());
assert_eq!(
run.messages[2].text,
loc.tf("loop.tool_disabled", &[("name", "call_subagent")])
);
assert_eq!(run.outcome, Some(RunOutcome::Completed));
}
#[tokio::test]
async fn subagent_identity_effects_stay_on_the_run() {
let (dir, _backend, _events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
call(
"c2",
"set_system_message",
r#"{"system_message":"new persona"}"#,
),
text("changed"),
text("ok"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert_eq!(run.system_message, "new persona");
assert_ne!(chat.system_message, "new persona");
}
#[tokio::test]
async fn subagent_timeout_lands_a_partial_run_and_tells_the_parent() {
let mut cfg = no_auto_cfg();
cfg.tools.subagent_run_timeout_secs = 1;
let (dir, backend, _events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
hang("thinking…"),
text("it timed out, so:"),
],
cfg,
)
.await;
let chat = load(dir.path(), chat_id);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert_eq!(run.outcome, Some(RunOutcome::TimedOut));
assert_eq!(run.messages.len(), 1);
let result = &chat.messages[2].text;
assert!(result.contains("chat://"), "{result}");
assert!(result.contains("1 s") || result.contains("1 с"), "{result}");
assert_eq!(chat.messages[3].text, "it timed out, so:");
assert_eq!(backend.requests().len(), 3);
}
#[tokio::test]
async fn a_filtered_subagent_tells_the_parent_why_its_reply_is_short() {
let (dir, _backend, _events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
Script {
chunks: vec![
ChatChunk::Text("It is".into()),
ChatChunk::Finished(FinishReason::Filtered),
],
hang: false,
},
text("the critic was cut off, so:"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert_eq!(run.outcome, Some(RunOutcome::Completed));
let result = &chat.messages[2].text;
assert!(result.starts_with("It is"), "the fragment stays: {result}");
let said_filtered = [crate::shared::i18n::Lang::En, crate::shared::i18n::Lang::Ru]
.iter()
.any(|&lang| {
let status = crate::shared::i18n::locale(lang).t("tool.call_subagent.result.filtered");
let head = status.split("{address}").next().unwrap();
result.contains(head)
});
assert!(said_filtered, "the parent must be told: {result}");
assert_eq!(chat.messages[3].text, "the critic was cut off, so:");
}
#[tokio::test]
async fn subagent_cancel_lands_the_run_as_cancelled() {
let backend = ScriptRecorder::new(vec![
call("c1", "call_subagent", DELEGATE),
hang("thinking…"),
]);
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(
Some(backend.clone() as Arc<dyn EngineBackend>),
no_auto_cfg(),
);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated { id: chat_id, .. } = active else {
unreachable!()
};
cmd_tx
.send(AppCommand::SendMessage("delegate this".into()))
.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while backend.requests().len() < 2 {
assert!(
tokio::time::Instant::now() < deadline,
"the child never started"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
cmd_tx.send(AppCommand::Cancel).unwrap();
let finished = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
assert!(matches!(
finished,
AppEvent::Finished {
reason: FinishReason::Cancelled,
..
}
));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert_eq!(run.outcome, Some(RunOutcome::Cancelled));
assert!(chat.messages[2].text.contains("chat://"));
}
#[tokio::test]
async fn subagent_with_an_empty_message_is_a_tool_error_without_a_run() {
let (dir, backend, _events, chat_id) = run_turn(
vec![
call(
"c1",
"call_subagent",
r#"{"system_message":"x","message":" "}"#,
),
text("sorry"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
assert!(chat.messages[1].tool_calls[0].subagent.is_none());
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::default());
assert!(
chat.messages[2]
.text
.contains(loc.t("tool.call_subagent.err.message_empty")),
"{}",
chat.messages[2].text
);
assert_eq!(backend.requests().len(), 2);
}
async fn running_delegation() -> (
tempfile::TempDir,
Arc<ScriptRecorder>,
UnboundedSender<AppCommand>,
UnboundedReceiver<AppEvent>,
tokio::task::JoinHandle<()>,
Uuid,
Uuid,
Uuid,
) {
let backend = ScriptRecorder::new(vec![
call("c1", "call_subagent", DELEGATE),
call("c2", "current_time", "{}"),
hang("thinking…"),
]);
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(
Some(backend.clone() as Arc<dyn EngineBackend>),
no_auto_cfg(),
);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated { id: chat_id, .. } = active else {
unreachable!()
};
cmd_tx
.send(AppCommand::NewChat { profile_id: None })
.unwrap();
let other = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated { id: other_id, .. } = other else {
unreachable!()
};
cmd_tx.send(AppCommand::SwitchChat(chat_id)).unwrap();
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == chat_id),
)
.await
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("delegate this".into()))
.unwrap();
let list = wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::ChatList(chats)
if chats.iter().any(|c| c.id == chat_id
&& c.children.iter().any(|r| r.running && r.message_count == 2)))
})
.await
.expect("the running transcript with its first round on the list");
let AppEvent::ChatList(chats) = list else {
unreachable!()
};
let run_id = chats.iter().find(|c| c.id == chat_id).unwrap().children[0].id;
(
dir, backend, cmd_tx, evt_rx, handle, chat_id, run_id, other_id,
)
}
#[tokio::test]
async fn a_running_transcript_is_listed_opens_and_survives_the_switch() {
let (dir, backend, cmd_tx, mut evt_rx, handle, chat_id, run_id, _) = running_delegation().await;
cmd_tx.send(AppCommand::SwitchChat(run_id)).unwrap();
let opened = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated {
id,
messages,
child,
live_turn,
..
} = opened
else {
unreachable!()
};
assert_eq!(id, run_id);
assert_eq!(messages.len(), 3, "{messages:?}");
assert_eq!(messages[0].text, "what time is it");
assert_eq!(messages[1].tool_calls[0].name, "current_time");
assert!(child.is_some());
let live = live_turn.expect("the turn is in flight on this transcript");
assert_ne!(live.stream, live.turn);
assert_eq!(
live.partial.as_ref().map(|p| p.text.as_str()),
Some("thinking…"),
"{live:?}"
);
assert_eq!(
backend.requests().len(),
3,
"parent, child, child (hanging)"
);
cmd_tx.send(AppCommand::SwitchChat(chat_id)).unwrap();
let back = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let AppEvent::ChatActivated {
id,
messages,
live_turn,
..
} = back
else {
unreachable!()
};
assert_eq!(id, chat_id);
assert_eq!(messages.len(), 1, "no round of the parent has filed yet");
let live = live_turn.expect("the turn is in flight on its chat");
assert_eq!(live.stream, live.turn);
let partial = live.partial.expect("the round in progress");
assert_eq!(partial.tools.len(), 1, "{partial:?}");
assert_eq!(partial.tools[0].name, "call_subagent");
assert!(partial.tools[0].result.is_none(), "still running");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
!evt_rx_has_finished(&mut evt_rx),
"the switch must not have cancelled the turn"
);
cmd_tx.send(AppCommand::Cancel).unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
let list = wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::ChatList(chats)
if chats.iter().any(|c| c.id == chat_id && c.children.len() == 1 && !c.children[0].running))
})
.await;
assert!(list.is_some(), "the landed row is no longer marked running");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
assert!(
!evt_rx_has_activation(&mut evt_rx),
"no re-activation at landing any more"
);
let chat = load(dir.path(), chat_id);
assert_eq!(chat.messages.len(), 3, "{:?}", chat.messages);
let landed = chat
.child(run_id)
.expect("the landed run keeps the running id");
assert_eq!(landed.outcome, Some(RunOutcome::Cancelled));
assert!(landed.messages.len() >= 3);
}
fn evt_rx_has_activation(rx: &mut UnboundedReceiver<AppEvent>) -> bool {
let mut seen = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::ChatActivated { .. }) {
seen = true;
}
}
seen
}
fn evt_rx_has_finished(rx: &mut UnboundedReceiver<AppEvent>) -> bool {
let mut seen = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::Finished { .. }) {
seen = true;
}
}
seen
}
#[tokio::test]
async fn a_switch_to_a_third_chat_still_cancels_the_turn() {
let (_dir, _backend, cmd_tx, mut evt_rx, handle, _chat_id, _run_id, other) =
running_delegation().await;
cmd_tx.send(AppCommand::SwitchChat(other)).unwrap();
let finished = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.expect("leaving the turn cancels it");
assert!(matches!(
finished,
AppEvent::Finished {
reason: FinishReason::Cancelled,
..
}
));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn renaming_a_running_transcript_lands_on_the_record() {
let (dir, _backend, cmd_tx, mut evt_rx, handle, chat_id, run_id, _) =
running_delegation().await;
cmd_tx
.send(AppCommand::RenameChat {
id: run_id,
title: "Моё имя".into(),
})
.unwrap();
let renamed = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatRenamed { .. }))
.await
.unwrap();
assert!(matches!(renamed, AppEvent::ChatRenamed { id, .. } if id == run_id));
cmd_tx.send(AppCommand::Cancel).unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
let run = chat.child(run_id).unwrap();
assert_eq!(run.title, "Моё имя");
assert!(run.renamed_manually);
}
#[test]
fn a_filed_round_grows_the_open_transcript() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let chat = Chat::from_profile(
&crate::entities::profile::Profile::new("P", "sys"),
"Родитель",
);
let parent = chat.id;
orch.chats.push(chat);
let generation = Uuid::new_v4();
orch.inflight = Some(super::super::InflightTurn {
generation,
chat: parent,
rounds: Vec::new(),
partial: Default::default(),
children: Vec::new(),
continuation: false,
});
let mut run = crate::entities::subagent::SubagentRun::fixture("Критик", &["задание"]);
run.outcome = None;
let run_id = run.id;
orch.handle_progress(
generation,
super::super::generation::TurnProgress::ChildStarted(Box::new(run)),
);
let listed = loop {
match rx.try_recv().unwrap() {
AppEvent::ChatList(chats) => break chats,
_ => continue,
}
};
let row = &listed.iter().find(|c| c.id == parent).unwrap().children[0];
assert!(row.running && row.id == run_id);
orch.active_id = Some(parent);
orch.handle_progress(
generation,
super::super::generation::TurnProgress::ChildRoundFiled {
run: run_id,
messages: vec![Message::assistant("раз")],
},
);
let mut grew = false;
while let Ok(ev) = rx.try_recv() {
grew |= matches!(ev, AppEvent::TranscriptGrew { .. });
}
assert!(!grew);
orch.active_id = Some(run_id);
orch.handle_progress(
generation,
super::super::generation::TurnProgress::ChildRoundFiled {
run: run_id,
messages: vec![Message::assistant("два")],
},
);
let grew = loop {
match rx.try_recv().unwrap() {
AppEvent::TranscriptGrew { id, messages } => break (id, messages),
_ => continue,
}
};
assert_eq!(grew.0, run_id);
assert_eq!(grew.1[0].text, "два");
assert_eq!(
orch.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.run
.messages
.len(),
3
);
orch.handle_progress(
Uuid::new_v4(),
super::super::generation::TurnProgress::ChildRoundFiled {
run: run_id,
messages: vec![Message::assistant("чужое")],
},
);
assert_eq!(
orch.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.run
.messages
.len(),
3
);
assert!(orch.parent_of(run_id) == Some(parent));
assert_eq!(orch.first_match_in_chat(run_id, "задание"), None);
}
#[test]
fn the_parents_round_in_progress_is_mirrored_for_a_return() {
use super::super::generation::{StreamStep, TurnProgress};
let (_d, mut orch, mut rx) = bare_orch_rx();
let chat = Chat::from_profile(&crate::entities::profile::Profile::new("P", "sys"), "Чат");
let chat_id = chat.id;
orch.chats.push(chat);
let generation = Uuid::new_v4();
orch.inflight = Some(super::super::InflightTurn {
generation,
chat: chat_id,
rounds: Vec::new(),
partial: Default::default(),
children: Vec::new(),
continuation: false,
});
let step = |s: StreamStep| TurnProgress::OwnStep(s);
orch.handle_progress(generation, step(StreamStep::Thoughts("план".into())));
orch.handle_progress(generation, step(StreamStep::Chunk("Смотрю".into())));
orch.handle_progress(
generation,
step(StreamStep::ToolStarted {
call_id: "c1".into(),
name: "web_search".into(),
arguments: "{}".into(),
}),
);
orch.handle_progress(
generation,
step(StreamStep::ToolCall {
call_id: "c1".into(),
name: "web_search".into(),
arguments: "{}".into(),
result: "ок".into(),
images: 0,
}),
);
orch.handle_progress(
generation,
step(StreamStep::ToolStarted {
call_id: "c2".into(),
name: "call_subagent".into(),
arguments: "{}".into(),
}),
);
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(
ev,
AppEvent::Chunk { .. } | AppEvent::ToolCallStarted { .. }
),
"{ev:?}"
);
}
orch.active_id = None;
orch.activate_focused(chat_id, None);
let live = loop {
match rx.try_recv().unwrap() {
AppEvent::ChatActivated { live_turn, .. } => break live_turn.unwrap(),
_ => continue,
}
};
let partial = live.partial.unwrap();
assert_eq!(
(partial.text.as_str(), partial.thoughts.as_str()),
("Смотрю", "план")
);
assert_eq!(partial.tools.len(), 2);
assert_eq!(
partial.tools[0].result.as_ref().map(|r| r.0.as_str()),
Some("ок")
);
assert!(partial.tools[1].result.is_none());
orch.handle_progress(
generation,
TurnProgress::RoundFiled(vec![Message::assistant("Смотрю")]),
);
let t = orch.inflight.as_ref().unwrap();
assert_eq!(t.rounds.len(), 1);
assert!(t.partial.text.is_empty() && t.partial.tools.is_empty());
orch.handle_progress(generation, step(StreamStep::Chunk("черновик".into())));
orch.handle_progress(generation, step(StreamStep::Rewrite));
assert!(orch.inflight.as_ref().unwrap().partial.text.is_empty());
}
#[test]
fn the_childs_stream_is_kept_and_forwarded_to_the_open_transcript() {
use super::super::generation::{StreamStep, TurnProgress};
let (_d, mut orch, mut rx) = bare_orch_rx();
let chat = Chat::from_profile(
&crate::entities::profile::Profile::new("P", "sys"),
"Родитель",
);
let parent = chat.id;
orch.chats.push(chat);
let generation = Uuid::new_v4();
orch.inflight = Some(super::super::InflightTurn {
generation,
chat: parent,
rounds: Vec::new(),
partial: Default::default(),
children: Vec::new(),
continuation: false,
});
let mut run = crate::entities::subagent::SubagentRun::fixture("Критик", &["задание"]);
run.outcome = None;
let run_id = run.id;
orch.handle_progress(generation, TurnProgress::ChildStarted(Box::new(run)));
let stream = orch
.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.stream;
assert_ne!(stream, Uuid::nil(), "a stream id of its own");
assert_ne!(stream, generation);
orch.active_id = Some(parent);
orch.handle_progress(
generation,
TurnProgress::ChildStep {
run: run_id,
step: StreamStep::Thoughts("думаю".into()),
},
);
orch.handle_progress(
generation,
TurnProgress::ChildStep {
run: run_id,
step: StreamStep::Chunk("нача".into()),
},
);
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::Chunk { .. } | AppEvent::Thoughts { .. }),
"not forwarded while the parent is open: {ev:?}"
);
}
let partial = orch
.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.partial
.clone();
assert_eq!(
(partial.text.as_str(), partial.thoughts.as_str()),
("нача", "думаю")
);
orch.activate_focused(run_id, None);
let activated = loop {
match rx.try_recv().unwrap() {
AppEvent::ChatActivated { live_turn, .. } => break live_turn.unwrap(),
_ => continue,
}
};
assert_eq!((activated.turn, activated.stream), (generation, stream));
assert_eq!(activated.partial.unwrap().text, "нача");
orch.handle_progress(
generation,
TurnProgress::ChildStep {
run: run_id,
step: StreamStep::Chunk("ло".into()),
},
);
orch.handle_progress(
generation,
TurnProgress::ChildStep {
run: run_id,
step: StreamStep::ToolStarted {
call_id: "c9".into(),
name: "web_search".into(),
arguments: "{}".into(),
},
},
);
let mut seen = Vec::new();
while let Ok(ev) = rx.try_recv() {
match ev {
AppEvent::Chunk {
generation_id,
text,
} => seen.push(("chunk", generation_id, text)),
AppEvent::ToolCallStarted {
generation_id,
name,
..
} => seen.push(("started", generation_id, name)),
_ => {}
}
}
assert_eq!(
seen,
vec![
("chunk", stream, "ло".to_string()),
("started", stream, "web_search".to_string())
]
);
assert_eq!(
orch.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.partial
.text,
"начало"
);
orch.handle_progress(
generation,
TurnProgress::ChildRoundFiled {
run: run_id,
messages: vec![Message::assistant("начало")],
},
);
assert!(
orch.inflight
.as_ref()
.unwrap()
.child(run_id)
.unwrap()
.partial
.text
.is_empty()
);
orch.handle_progress(
generation,
TurnProgress::ChildEnded {
run: run_id,
outcome: RunOutcome::Completed,
finished_at: chrono::Utc::now(),
tokens: 3,
},
);
let finished = loop {
match rx.try_recv().unwrap() {
AppEvent::Finished {
generation_id,
reason,
..
} => break (generation_id, reason),
_ => continue,
}
};
assert_eq!(finished, (stream, FinishReason::Stop));
}
async fn delegated() -> (tempfile::TempDir, Uuid, Uuid) {
let (dir, _backend, _events, chat_id) = run_turn(
vec![
call("c1", "call_subagent", DELEGATE),
text("it is noon"),
text("done: noon"),
],
no_auto_cfg(),
)
.await;
let chat = load(dir.path(), chat_id);
let run_id = chat.messages[1].tool_calls[0]
.subagent
.as_deref()
.unwrap()
.id;
(dir, chat_id, run_id)
}
pub(super) async fn reopen(
root: &std::path::Path,
) -> (
UnboundedSender<AppCommand>,
UnboundedReceiver<AppEvent>,
tokio::task::JoinHandle<()>,
Vec<crate::entities::chat::ChatSummary>,
AppEvent,
) {
let backend = ScriptRecorder::new(Vec::new()) as Arc<dyn EngineBackend>;
let mut cfg = Storage::open(Paths::with_root(root))
.unwrap()
.json()
.load_config()
.unwrap_or_default();
cfg.interface.auto_title = crate::shared::config::AutoTitleMode::Off;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(root, Some(backend), cfg);
let mut list = Vec::new();
let first = loop {
let ev = tokio::time::timeout(std::time::Duration::from_secs(10), evt_rx.recv())
.await
.expect("the bootstrap activation")
.expect("the event channel");
match ev {
AppEvent::ChatList(chats) => list = chats,
AppEvent::ChatActivated { .. } => break ev,
_ => {}
}
};
(cmd_tx, evt_rx, handle, list, first)
}
#[tokio::test]
async fn the_list_nests_the_transcript_under_its_parent() {
let (dir, chat_id, run_id) = delegated().await;
let (cmd_tx, _evt_rx, handle, chats, _) = reopen(dir.path()).await;
let parent = chats.iter().find(|c| c.id == chat_id).unwrap();
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].id, run_id);
assert_eq!(parent.children[0].title, "Critic");
assert_eq!(parent.children[0].outcome, Some(RunOutcome::Completed));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn opening_a_transcript_activates_it_read_only_with_its_names() {
let (dir, chat_id, run_id) = delegated().await;
let (cmd_tx, mut evt_rx, handle, _, _) = reopen(dir.path()).await;
cmd_tx.send(AppCommand::SwitchChat(run_id)).unwrap();
let ev = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == run_id),
)
.await
.unwrap();
let AppEvent::ChatActivated {
title,
messages,
draft,
child,
compaction,
..
} = ev
else {
unreachable!()
};
assert_eq!(title, "Critic");
assert_eq!(messages.len(), 2);
assert!(draft.is_empty());
assert!(compaction.is_none());
let child = child.expect("a transcript announces its parent");
assert_eq!(child.parent, chat_id);
assert_eq!(child.system_message, "be harsh");
let names = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::CharacterNames(_)))
.await
.unwrap();
let AppEvent::CharacterNames(names) = names else {
unreachable!()
};
assert_eq!(names.assistant, "Critic");
assert!(!names.user.is_empty());
cmd_tx
.send(AppCommand::SendMessage("hello?".into()))
.unwrap();
let restored = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::RestoreInput(_)))
.await
.unwrap();
assert!(matches!(restored, AppEvent::RestoreInput(t) if t == "hello?"));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
assert_eq!(chat.messages.len(), 4);
let cfg = Storage::open(Paths::with_root(dir.path()))
.unwrap()
.json()
.load_config()
.unwrap();
assert_eq!(cfg.last_active_chat, Some(run_id));
}
#[tokio::test]
async fn a_remembered_transcript_is_restored_at_startup() {
let (dir, _chat_id, run_id) = delegated().await;
let (cmd_tx, _evt_rx, handle, _, _) = reopen(dir.path()).await;
cmd_tx.send(AppCommand::SwitchChat(run_id)).unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let (cmd_tx, _evt_rx, handle, _, first) = reopen(dir.path()).await;
assert!(matches!(first, AppEvent::ChatActivated { id, child: Some(_), .. } if id == run_id));
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn renaming_a_transcript_sticks_and_is_manual() {
let (dir, chat_id, run_id) = delegated().await;
let (cmd_tx, mut evt_rx, handle, _, _) = reopen(dir.path()).await;
cmd_tx
.send(AppCommand::RenameChat {
id: run_id,
title: "Harsh critic".into(),
})
.unwrap();
let renamed = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatRenamed { .. }))
.await
.unwrap();
assert!(
matches!(renamed, AppEvent::ChatRenamed { id, title } if id == run_id && title == "Harsh critic")
);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
let run = chat.child(run_id).unwrap();
assert_eq!(run.title, "Harsh critic");
assert!(run.renamed_manually);
assert_ne!(chat.title, "Harsh critic");
}
#[tokio::test]
async fn delete_and_clone_refuse_a_transcript_and_a_cloned_parent_reids_its_runs() {
let (dir, chat_id, run_id) = delegated().await;
let (cmd_tx, mut evt_rx, handle, _, _) = reopen(dir.path()).await;
for cmd in [
AppCommand::DeleteChat(run_id),
AppCommand::CloneChat(run_id),
] {
cmd_tx.send(cmd).unwrap();
let err = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatListError(_)))
.await
.unwrap();
assert!(matches!(err, AppEvent::ChatListError(_)));
}
cmd_tx.send(AppCommand::CloneChat(chat_id)).unwrap();
let list = wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::ChatList(c) if c.len() == 2),
)
.await
.unwrap();
let AppEvent::ChatList(chats) = list else {
unreachable!()
};
let clone = chats.iter().find(|c| c.id != chat_id).unwrap();
assert_eq!(clone.children.len(), 1, "the transcript was copied along");
assert_ne!(clone.children[0].id, run_id, "under an id of its own");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
assert!(chat.child(run_id).is_some());
}
#[tokio::test]
async fn copying_a_transcript_labels_the_roles_as_its_own() {
let (dir, _chat_id, run_id) = delegated().await;
let (cmd_tx, mut evt_rx, handle, _, _) = reopen(dir.path()).await;
cmd_tx.send(AppCommand::CopyChat(run_id)).unwrap();
let copied = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::CopyToClipboard(_)))
.await
.unwrap();
let AppEvent::CopyToClipboard(text) = copied else {
unreachable!()
};
assert!(text.contains("Critic"), "{text}");
assert!(text.contains("it is noon"), "{text}");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn a_requested_title_for_a_transcript_lands_on_it() {
let (dir, chat_id, run_id) = delegated().await;
let backend = ScriptRecorder::new(vec![text("Noon check")]) as Arc<dyn EngineBackend>;
let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(dir.path(), Some(backend), no_auto_cfg());
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
cmd_tx.send(AppCommand::AutoRenameChat(run_id)).unwrap();
let renamed = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatRenamed { .. }))
.await
.unwrap();
assert!(
matches!(renamed, AppEvent::ChatRenamed { id, title } if id == run_id && title == "Noon check")
);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = load(dir.path(), chat_id);
assert_eq!(chat.child(run_id).unwrap().title, "Noon check");
assert!(!chat.child(run_id).unwrap().renamed_manually);
}