use super::*;
use crate::app::events::FileProgress;
use crate::entities::attachment::AttachMode;
use crate::shared::api::EmbedRole;
use crate::shared::config::AttachmentSettings;
use tokio_util::sync::CancellationToken;
fn write_file(dir: &tempfile::TempDir, name: &str, body: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, body).unwrap();
path.to_string_lossy().into_owned()
}
async fn wait_attached(
rx: &mut UnboundedReceiver<AppEvent>,
) -> crate::entities::attachment::AttachmentInfo {
let ev = wait_for(rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Attached { .. }))
})
.await
.expect("an Attached event");
match ev {
AppEvent::FileProgress(FileProgress::Attached { info, .. }) => info,
_ => unreachable!(),
}
}
#[tokio::test]
async fn attached_file_reaches_the_model_and_persists_in_the_chat() {
let backend = CapturingBackend::new();
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend.clone()), no_auto_cfg());
let path = write_file(&dir, "notes.md", "секретное число 4242");
cmd_tx
.send(AppCommand::FileAttach { path: path.clone() })
.unwrap();
let info = wait_attached(&mut evt_rx).await;
assert_eq!(info.name, "notes.md");
assert_eq!(info.mode, AttachMode::Inline, "a small file is inlined");
cmd_tx
.send(AppCommand::SendMessage("что в файле?".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
let req = backend.last_request();
let system = req.system.expect("a system prompt");
assert!(system.contains("секретное число 4242"), "{system}");
assert!(system.contains("notes.md"), "{system}");
assert_eq!(req.messages.len(), 1, "the file isn't a message");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chats = crate::shared::storage::Storage::open(Paths::with_root(dir.path()))
.unwrap()
.json()
.load_chats()
.unwrap();
let saved = &chats[0].attachments;
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].name, "notes.md");
assert!(saved[0].text.contains("4242"));
}
#[tokio::test]
async fn removing_an_attachment_takes_it_out_of_the_request() {
let backend = CapturingBackend::new();
let (dir, cmd_tx, mut evt_rx, _handle) = spawn_orch(Some(backend.clone()));
let path = write_file(&dir, "secret.txt", "содержимое-маркер");
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
wait_attached(&mut evt_rx).await;
cmd_tx
.send(AppCommand::FileRemove {
target: "secret.txt".into(),
})
.unwrap();
wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Removed { .. }))
})
.await
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
let req = backend.last_request();
let system = req.system.unwrap_or_default();
assert!(
!system.contains("содержимое-маркер"),
"/file remove must take the text out of what the model sees: {system}"
);
}
#[tokio::test]
async fn a_file_over_the_budget_is_attached_by_reference_not_refused() {
let config = AppConfig {
attachments: AttachmentSettings {
max_file_tokens: 10, excerpt_tokens: 5,
..Default::default()
},
..Default::default()
};
let (dir, cmd_tx, mut evt_rx, _handle) = spawn_orch_cfg(None, config);
let body = "начало файла ".to_string() + &"наполнитель ".repeat(50);
let path = write_file(&dir, "big.txt", &body);
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
let info = wait_attached(&mut evt_rx).await;
assert_eq!(
info.mode,
AttachMode::ByReference,
"a file over the budget switches mode instead of being refused"
);
cmd_tx.send(AppCommand::FileList).unwrap();
let ev = wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Listed { .. }))
})
.await
.unwrap();
match ev {
AppEvent::FileProgress(FileProgress::Listed { items, .. }) => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "big.txt");
}
_ => unreachable!(),
}
cmd_tx
.send(AppCommand::FileRemove {
target: "#1".into(),
})
.unwrap();
wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Removed { .. }))
})
.await
.unwrap();
}
#[tokio::test]
async fn a_name_two_attachments_share_removes_nothing_and_names_both() {
let backend = CapturingBackend::new();
let (dir, cmd_tx, mut evt_rx, _handle) = spawn_orch_cfg(Some(backend.clone()), no_auto_cfg());
for folder in ["a", "b"] {
std::fs::create_dir_all(dir.path().join(folder)).unwrap();
let path = write_file(
&dir,
&format!("{folder}/notes.md"),
&format!("marker-from-{folder}"),
);
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
wait_attached(&mut evt_rx).await;
}
let is_listed = |e: &AppEvent| matches!(e, AppEvent::FileProgress(FileProgress::Listed { .. }));
let listed = |ev: Option<AppEvent>| match ev {
Some(AppEvent::FileProgress(FileProgress::Listed { items, .. })) => items,
other => panic!("not a listing: {other:?}"),
};
let outcome = |e: &AppEvent| {
matches!(
e,
AppEvent::FileProgress(FileProgress::Removed { .. } | FileProgress::Failed(_))
)
};
cmd_tx.send(AppCommand::FileList).unwrap();
let items = listed(wait_for(&mut evt_rx, is_listed).await);
let sources: Vec<String> = items.iter().map(|i| i.source.clone()).collect();
assert_eq!(sources.len(), 2);
cmd_tx
.send(AppCommand::FileRemove {
target: "notes.md".into(),
})
.unwrap();
match wait_for(&mut evt_rx, outcome).await {
Some(AppEvent::FileProgress(FileProgress::Failed(msg))) => {
assert!(msg.contains(&format!("#1 {}", sources[0])), "{msg}");
assert!(msg.contains(&format!("#2 {}", sources[1])), "{msg}");
}
other => panic!("a shared name removed something: {other:?}"),
}
cmd_tx.send(AppCommand::FileList).unwrap();
assert_eq!(
listed(wait_for(&mut evt_rx, is_listed).await).len(),
2,
"nothing was removed"
);
cmd_tx
.send(AppCommand::FileRemove {
target: "#2".into(),
})
.unwrap();
match wait_for(&mut evt_rx, outcome).await {
Some(AppEvent::FileProgress(FileProgress::Removed { name, source })) => {
assert_eq!(name, "notes.md");
assert_eq!(
source.as_deref(),
Some(sources[1].as_str()),
"the note says which one went"
);
}
other => panic!("#2 was not removed: {other:?}"),
}
cmd_tx.send(AppCommand::SendMessage("hi".into())).unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
let system = backend.last_request().system.unwrap_or_default();
assert!(
system.contains("marker-from-a") && !system.contains("marker-from-b"),
"exactly the second file left the request: {system}"
);
cmd_tx
.send(AppCommand::FileRemove {
target: "notes.md".into(),
})
.unwrap();
match wait_for(&mut evt_rx, outcome).await {
Some(AppEvent::FileProgress(FileProgress::Removed { source, .. })) => {
assert_eq!(source, None)
}
other => panic!("the last notes.md was not removed: {other:?}"),
}
}
#[tokio::test]
async fn reattaching_the_same_file_replaces_the_previous_snapshot() {
let (dir, cmd_tx, mut evt_rx, _handle) = spawn_orch(None);
let path = write_file(&dir, "draft.txt", "первая версия");
cmd_tx
.send(AppCommand::FileAttach { path: path.clone() })
.unwrap();
wait_attached(&mut evt_rx).await;
std::fs::write(dir.path().join("draft.txt"), "вторая версия, длиннее").unwrap();
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
wait_attached(&mut evt_rx).await;
cmd_tx.send(AppCommand::FileList).unwrap();
let ev = wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Listed { .. }))
})
.await
.unwrap();
match ev {
AppEvent::FileProgress(FileProgress::Listed { items, .. }) => {
assert_eq!(items.len(), 1, "no duplicate entry for the same file");
}
_ => unreachable!(),
}
}
#[tokio::test]
async fn attachment_read_sees_the_chat_files_through_the_turn_snapshot() {
use crate::features::tools::attachment::{ATTACHMENT_READ_ID, AttachmentRead};
use crate::features::tools::{Tool, ToolParams, TurnInfo};
let (dir, mut orch) = bare_orch();
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, "t");
let body = "страница один и её содержимое\n".repeat(20);
chat.attachments
.push(crate::entities::attachment::Attachment::new(
"doc.txt",
"/tmp/doc.txt",
body.clone(),
body.len(),
AttachMode::ByReference,
));
let chat_id = chat.id;
orch.profiles.push(profile.clone());
orch.chats.push(chat);
orch.active_id = Some(chat_id);
let chat = orch.chats.iter().find(|c| c.id == chat_id).unwrap();
let ctx = crate::features::tools::ToolContext::new(
orch.tool_deps(Arc::new(MockBackend::scripted(vec![]))),
ToolParams::from_config(&orch.config),
TurnInfo {
profile_id: profile.id,
chat_id,
system_message: chat.system_message.clone(),
effective_sampling: Default::default(),
last_user_message_at: None,
attachments: Arc::from(chat.attachments.clone()),
workspace: chat.workspace.clone(),
workspace_journal: None,
files_dir: None,
files: Arc::from(Vec::new()),
inputs: Arc::from(Vec::new()),
images: Arc::from(Vec::new()),
stages_files: false,
history: None,
other_chats: Arc::from(Vec::new()),
lang: crate::shared::i18n::Lang::Ru,
cancel: tokio_util::sync::CancellationToken::new(),
model_name: None,
engine_mode: Default::default(),
sessions: None,
silent_lane: false,
},
);
let out = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": "doc.txt", "page": 1}))
.await
.unwrap()
.result;
assert!(out.contains("страница один"), "{out}");
assert!(orch.registry.get(ATTACHMENT_READ_ID).is_some());
drop(dir);
}
fn indexing_config() -> AppConfig {
AppConfig {
attachments: AttachmentSettings {
max_file_tokens: 10, excerpt_tokens: 5,
..Default::default()
},
rag: crate::shared::config::RagSettings {
chunk_target_chars: 60,
chunk_overlap_chars: 10,
chunk_max_chars: 120,
},
..Default::default()
}
}
async fn wait_indexed(rx: &mut UnboundedReceiver<AppEvent>) -> Result<usize, String> {
let ev = wait_for(rx, |e| {
matches!(
e,
AppEvent::FileProgress(FileProgress::Indexed { .. })
| AppEvent::FileProgress(FileProgress::IndexSkipped { .. })
)
})
.await
.expect("an indexing outcome");
match ev {
AppEvent::FileProgress(FileProgress::Indexed { chunks, .. }) => Ok(chunks),
AppEvent::FileProgress(FileProgress::IndexSkipped { reason, .. }) => Err(reason),
_ => unreachable!(),
}
}
#[tokio::test]
async fn a_by_reference_file_is_indexed_and_searchable_within_the_chat() {
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(None, indexing_config());
let body = format!(
"{}\nрецепт борща со свёклой и капустой\n{}",
"наполнитель наполнитель ".repeat(20),
"прочий текст прочий текст ".repeat(20)
);
let path = write_file(&dir, "book.txt", &body);
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
let info = wait_attached(&mut evt_rx).await;
assert_eq!(info.mode, AttachMode::ByReference);
let chunks = wait_indexed(&mut evt_rx).await.expect("an index was built");
assert!(chunks > 1, "the fixture must produce several fragments");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let storage = crate::shared::storage::Storage::open(Paths::with_root(dir.path())).unwrap();
let chat = &storage.json().load_chats().unwrap()[0];
let attachment = &chat.attachments[0];
assert_eq!(
storage.db().attachment_indexed_ids(chat.id).unwrap(),
vec![attachment.id]
);
let embedder = crate::shared::api::mock::MockEmbedder::new(16);
let query = embedder
.embed(vec!["борщ со свёклой".into()], EmbedRole::Passage)
.await
.unwrap()
.remove(0);
let hits = storage.db().attachment_search(chat.id, &query, 3).unwrap();
assert!(
hits.iter().any(|h| h.text.contains("рецепт борща")),
"the index must find the fragment: {hits:?}"
);
assert!(
storage
.db()
.attachment_search(Uuid::new_v4(), &query, 3)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn an_inline_file_is_not_indexed() {
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
let path = write_file(&dir, "small.txt", "короткая заметка");
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
assert_eq!(wait_attached(&mut evt_rx).await.mode, AttachMode::Inline);
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let storage = crate::shared::storage::Storage::open(Paths::with_root(dir.path())).unwrap();
let chat = &storage.json().load_chats().unwrap()[0];
assert!(
storage
.db()
.attachment_indexed_ids(chat.id)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn removing_an_attachment_drops_its_index() {
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(None, indexing_config());
let path = write_file(&dir, "book.txt", &"текст документа ".repeat(40));
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
wait_attached(&mut evt_rx).await;
wait_indexed(&mut evt_rx).await.expect("an index was built");
cmd_tx
.send(AppCommand::FileRemove {
target: "book.txt".into(),
})
.unwrap();
wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Removed { .. }))
})
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let storage = crate::shared::storage::Storage::open(Paths::with_root(dir.path())).unwrap();
let chat = &storage.json().load_chats().unwrap()[0];
assert!(
storage
.db()
.attachment_indexed_ids(chat.id)
.unwrap()
.is_empty(),
"the removed file's fragments must go with it"
);
}
#[tokio::test]
async fn attaching_a_missing_file_reports_an_error() {
let (dir, cmd_tx, mut evt_rx, _handle) = spawn_orch(None);
let path = dir.path().join("nope.txt").to_string_lossy().into_owned();
cmd_tx.send(AppCommand::FileAttach { path }).unwrap();
let ev = wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::FileProgress(FileProgress::Failed(_)))
})
.await
.unwrap();
match ev {
AppEvent::FileProgress(FileProgress::Failed(msg)) => assert!(!msg.is_empty()),
_ => unreachable!(),
}
}
fn turn_ctx(
orch: &Orchestrator,
profile_id: Uuid,
chat_id: Uuid,
attachments: Vec<crate::entities::attachment::Attachment>,
) -> crate::features::tools::ToolContext {
use crate::features::tools::{ToolParams, TurnInfo};
crate::features::tools::ToolContext::new(
orch.tool_deps(Arc::new(MockBackend::scripted(vec![]))),
ToolParams::from_config(&orch.config),
TurnInfo {
profile_id,
chat_id,
system_message: String::new(),
effective_sampling: Default::default(),
last_user_message_at: None,
attachments: Arc::from(attachments),
workspace: None,
workspace_journal: None,
files_dir: None,
files: Arc::from(Vec::new()),
inputs: Arc::from(Vec::new()),
images: Arc::from(Vec::new()),
stages_files: false,
history: None,
other_chats: Arc::from(Vec::new()),
lang: crate::shared::i18n::Lang::Ru,
cancel: CancellationToken::new(),
model_name: None,
engine_mode: Default::default(),
sessions: None,
silent_lane: false,
},
)
}
fn made_up_attachment(
name: &str,
source: &str,
text: &str,
) -> crate::entities::attachment::Attachment {
crate::entities::attachment::Attachment::new(
name,
source,
text.to_string(),
text.len(),
AttachMode::ByReference,
)
}
#[tokio::test]
async fn an_attachment_from_a_tool_is_readable_in_the_next_round_of_the_same_turn() {
use crate::features::tools::Tool;
use crate::features::tools::attachment::AttachmentRead;
let (_d, orch) = bare_orch();
let body = "расшифровка речи, строка за строкой\n".repeat(20);
let attachment = made_up_attachment("видео — расшифровка.txt", "youtube:abc#transcript", &body);
let effects = vec![crate::features::tools::ChatEffect::AddAttachment(Box::new(
attachment.clone(),
))];
let mut ctx = turn_ctx(&orch, Uuid::new_v4(), Uuid::new_v4(), vec![]);
let before = AttachmentRead
.invoke(&ctx, serde_json::json!({"name": attachment.name}))
.await
.unwrap()
.result;
assert!(!before.contains("строка за строкой"), "{before}");
super::super::generation::sync_attachments(&mut ctx, &effects);
let after = AttachmentRead
.invoke(
&ctx,
serde_json::json!({"name": attachment.name, "page": 1}),
)
.await
.unwrap()
.result;
assert!(
after.contains("строка за строкой"),
"the next round must see the attachment: {after}"
);
super::super::generation::sync_attachments(&mut ctx, &effects);
assert_eq!(ctx.attachments.len(), 1);
}
#[tokio::test]
async fn a_tool_produced_attachment_is_persisted_and_replaces_the_previous_one() {
let (_d, mut orch, mut rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "t");
let chat_id = chat.id;
orch.profiles.push(profile);
orch.chats.push(chat);
orch.active_id = Some(chat_id);
let source = "youtube:abc#transcript@0:40-1:20";
let first = made_up_attachment("first.txt", source, "первый вариант расшифровки");
let gen_id = Uuid::new_v4();
orch.gen_state.begin(gen_id, CancellationToken::new());
orch.handle_done(super::super::generation::GenResult {
usage: None,
continuation: None,
images_withheld: 0,
id: gen_id,
chat_id,
messages: vec![Message::assistant("готово")],
effects: vec![crate::features::tools::ChatEffect::AddAttachment(Box::new(
first.clone(),
))],
deleted: vec![],
});
let stored = &orch
.chats
.iter()
.find(|c| c.id == chat_id)
.unwrap()
.attachments;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, first.id);
assert_eq!(stored[0].mode, AttachMode::ByReference);
let mut attached = false;
let mut chip = false;
while let Ok(e) = rx.try_recv() {
match e {
AppEvent::FileProgress(FileProgress::Attached { .. }) => attached = true,
AppEvent::Attachments(items) => chip = !items.is_empty(),
_ => {}
}
}
assert!(attached, "the feed note is part of attaching");
assert!(chip, "so is the status-bar chip");
let second = made_up_attachment("second.txt", source, "исправленная расшифровка");
let gen_id = Uuid::new_v4();
orch.gen_state.begin(gen_id, CancellationToken::new());
orch.handle_done(super::super::generation::GenResult {
usage: None,
continuation: None,
images_withheld: 0,
id: gen_id,
chat_id,
messages: vec![],
effects: vec![crate::features::tools::ChatEffect::AddAttachment(Box::new(
second.clone(),
))],
deleted: vec![],
});
let stored = &orch
.chats
.iter()
.find(|c| c.id == chat_id)
.unwrap()
.attachments;
assert_eq!(stored.len(), 1, "same source → replaced, not duplicated");
assert_eq!(stored[0].id, second.id);
}
struct AttachingTool {
text: String,
}
#[async_trait::async_trait]
impl crate::features::tools::Tool for AttachingTool {
fn id(&self) -> String {
"fake_attach".into()
}
fn description(&self, _loc: &crate::shared::i18n::Locale) -> String {
"attaches something".into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::ExternalWorld
}
fn ui_label(&self) -> &'static str {
"fake attach"
}
async fn invoke(
&self,
_ctx: &crate::features::tools::ToolContext,
_args: serde_json::Value,
) -> anyhow::Result<crate::features::tools::ToolOutcome> {
Ok(crate::features::tools::ToolOutcome::with_effects(
"attached as fake.txt",
vec![crate::features::tools::ChatEffect::AddAttachment(Box::new(
made_up_attachment("fake.txt", "fake:source", &self.text),
))],
))
}
}
#[tokio::test]
async fn the_loop_lets_the_next_round_read_what_the_previous_one_attached() {
use crate::features::tools::ToolRegistry;
use crate::features::tools::attachment::{ATTACHMENT_READ_ID, AttachmentRead};
use crate::shared::api::contract::ToolCallDelta;
use crate::shared::server::ServerStatus;
let call = |id: &str, name: &str, args: &str| {
vec![
ChatChunk::ToolCall(ToolCallDelta {
thought_signature: None,
index: 0,
id: Some(id.into()),
name: Some(name.into()),
arguments: args.into(),
}),
ChatChunk::Finished(FinishReason::ToolCalls),
]
};
let backend = Arc::new(MockBackend::sequence(vec![
call("c1", "fake_attach", "{}"),
call("c2", ATTACHMENT_READ_ID, r#"{"name":"fake.txt","page":1}"#),
vec![
ChatChunk::Text("готово".into()),
ChatChunk::Finished(FinishReason::Stop),
],
])) as Arc<dyn EngineBackend>;
let (_d, mut orch, mut rx) = bare_orch_rx();
let body = "строка расшифровки, слышимая в ролике\n".repeat(20);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(AttachingTool { text: body.clone() }));
registry.register(Arc::new(AttachmentRead));
orch.registry = Arc::new(registry);
orch.engines.backend = Some(backend.clone());
orch.engines.server_status = ServerStatus::Ready;
let mut profile = Profile::new("P", "sys");
profile.enabled_tools = vec!["fake_attach".into(), ATTACHMENT_READ_ID.into()];
let chat = Chat::from_profile(&profile, "t");
let chat_id = chat.id;
orch.profiles.push(profile);
orch.chats.push(chat);
orch.active_id = Some(chat_id);
orch.handle_send("посмотри ролик".into());
let mut read_back = None;
while let Some(ev) = wait_for(&mut rx, |e| {
matches!(e, AppEvent::ToolCall { .. } | AppEvent::Finished { .. })
})
.await
{
match ev {
AppEvent::ToolCall { name, result, .. } if name == ATTACHMENT_READ_ID => {
read_back = Some(result);
break;
}
AppEvent::Finished { .. } => break,
_ => {}
}
}
let read_back = read_back.expect("the second round called attachment_read");
assert!(
read_back.contains("строка расшифровки"),
"the next round must read what the previous one attached: {read_back}"
);
}