Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/openplotva-app/src/agent_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ impl DialogToolbox for AgentContextTools {
user_id: ctx.user_id,
user_full_name: ctx.user_full_name,
prompt: step.prompt,
caption: if step.caption.trim().is_empty() {
ctx.message_text
} else {
step.caption
},
thread_id: ctx.thread_id,
is_forum: ctx.thread_id.is_some(),
})
Expand Down
240 changes: 239 additions & 1 deletion crates/openplotva-app/src/dialog_jobs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3115,6 +3115,7 @@ impl DialogToolCallHistoryStore for ToolHistoryStub {
#[derive(Clone, Default)]
struct MaterializerStub {
history: Vec<HistoryMessage>,
observed: Arc<Mutex<Vec<serde_json::Value>>>,
}

#[derive(Clone, Copy, Default)]
Expand Down Expand Up @@ -3161,11 +3162,37 @@ impl DialogInputMaterializer for FailingInjectedMaterializer {

impl MaterializerStub {
fn with_history(history: Vec<HistoryMessage>) -> Self {
Self { history }
Self {
history,
..Default::default()
}
}
}

impl DialogInputMaterializer for MaterializerStub {
fn observe_dialog_messages<'a>(
&'a self,
_params: &'a DialogJobParams,
after: i32,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<serde_json::Value>, String>> + Send + 'a>,
> {
Box::pin(async move {
Ok(self
.observed
.lock()
.expect("observed messages")
.iter()
.filter(|message| {
message["message_id"]
.as_i64()
.is_some_and(|id| id > i64::from(after))
})
.cloned()
.collect())
})
}

fn materialize_dialog_input<'a>(
&'a self,
params: &'a DialogJobParams,
Expand Down Expand Up @@ -6318,6 +6345,217 @@ fn step_context(request: &openplotva_dialog::ChatStepRequest) -> Vec<String> {
.collect()
}

#[tokio::test]
async fn explicit_image_request_repairs_text_into_a_native_draw() -> Result<(), Box<dyn Error>> {
let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?;
for queued in [true, false] {
let queue = InMemoryTaskQueue::new();
let mut params = dialog_params("Плотва, нарисуй рыбу в космосе");
params.meta = serde_json::json!({"requested_tool": "draw_image"});
queue.assign(DIALOG_AIFARM_QUEUE_NAME, new_dialog_job_at(params, now));
let provider = StepProviderStub::with_steps(vec![
Ok(step_text("Сначала я подумаю, как изобразить эту рыбу.")),
Ok(step_tools(
"",
vec![(
"draw",
openplotva_dialog::ToolStep {
step: "draw_image".into(),
prompt: "A fish floating in space, cinematic lighting".into(),
caption: "Рыба в космосе".into(),
..Default::default()
},
)],
)),
Ok(step_text("Сервис рисования сейчас недоступен.")),
]);
let toolbox = Arc::new(if queued {
SessionToolboxStub::with_queued_draw("image-ticket")
} else {
SessionToolboxStub::default()
});
let wiring = session_wiring(toolbox.clone(), None);
let effects = EffectsStub::default();
let outcomes = crate::dialog_turn::DialogTurnObserver::new(
crate::dialog_turn::RuntimeTurnOutcomeBuffer::new(8),
None,
);
let report = process_dialog_job_once_in_queue_with_materializer_history_and_retry_at(
&queue,
&provider,
&effects,
&BasicDialogInputMaterializer,
&NoopDialogToolCallHistoryStore,
session_options(now, &outcomes, &wiring),
)
.await;
assert!(report.completed, "{report:?}");
assert!(!report.failed, "{report:?}");
assert_eq!(report.regenerations, 1);
let requests = provider.requests();
assert_eq!(requests[0].required_tool.as_deref(), Some("draw_image"));
assert_eq!(requests[1].required_tool.as_deref(), Some("draw_image"));
assert!(
step_context(&requests[1])
.iter()
.any(|context| context.contains("explicitly requested draw_image"))
);
assert!(effects.intermediates().is_empty());
assert!(
effects
.sent()
.iter()
.all(|(_, text)| !text.contains("Сначала я подумаю"))
);
let draws = toolbox.draw_requests.lock().expect("draw requests");
assert_eq!(draws.len(), 1);
assert_eq!(draws[0].caption, "Рыба в космосе");
assert_eq!(
draws[0].context.message_text,
"Плотва, нарисуй рыбу в космосе"
);
if !queued {
assert_eq!(requests[2].required_tool, None);
assert_eq!(effects.sent()[0].1, "Сервис рисования сейчас недоступен.");
}
}
Ok(())
}

#[tokio::test]
async fn explicit_image_request_never_publishes_a_promise_after_exhausted_repairs()
-> Result<(), Box<dyn Error>> {
let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?;
let queue = InMemoryTaskQueue::new();
let mut params = dialog_params("Плотва, нарисуй рыбу");
params.meta = serde_json::json!({"requested_tool": "draw_image"});
queue.assign(DIALOG_AIFARM_QUEUE_NAME, new_dialog_job_at(params, now));
let provider =
StepProviderStub::with_steps((0..3).map(|_| Ok(step_text("Сейчас нарисую."))).collect());
let wiring = session_wiring(Arc::new(SessionToolboxStub::default()), None);
let effects = EffectsStub::default();
let outcomes = crate::dialog_turn::DialogTurnObserver::new(
crate::dialog_turn::RuntimeTurnOutcomeBuffer::new(8),
None,
);
let report = process_dialog_job_once_in_queue_with_materializer_history_and_retry_at(
&queue,
&provider,
&effects,
&BasicDialogInputMaterializer,
&NoopDialogToolCallHistoryStore,
session_options(now, &outcomes, &wiring),
)
.await;
assert!(!report.sent_answer, "{report:?}");
assert!(effects.sent().is_empty());
assert!(effects.intermediates().is_empty());
assert_eq!(provider.requests().len(), 3);
assert!(
report
.empty_answer_error
.as_deref()
.is_some_and(|error| error.contains("requested image tool"))
);
Ok(())
}

#[tokio::test]
async fn newer_initiator_image_intent_replaces_the_previous_attempt() -> Result<(), Box<dyn Error>>
{
let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?;
for observed_first in [false, true] {
for (sender, requested, expected) in [
(7, "draw_image", Some("draw_image")),
(7, "", None),
(8, "draw_image", None),
] {
let registry = Arc::new(crate::dialog_turn::DialogSessionRegistry::new());
let key = crate::dialog_turn::SessionKey::new(42, Some(9));
let toolbox = Arc::new(SessionToolboxStub::default());
let materializer = MaterializerStub::default();
let observed_messages = materializer.observed.clone();
let wiring = crate::dialog_turn::SessionWorkerWiring {
registry: registry.clone(),
..session_wiring(toolbox.clone(), None)
};
let queue = InMemoryTaskQueue::new();
let mut params = dialog_params("нарисуй рыбу");
params.meta = serde_json::json!({"requested_tool": "draw_image"});
let job = queue.assign(DIALOG_AIFARM_QUEUE_NAME, new_dialog_job_at(params, now));
let draw = |id, prompt: &str| {
step_tools(
"",
vec![(
id,
openplotva_dialog::ToolStep {
step: "draw_image".into(),
prompt: prompt.into(),
..Default::default()
},
)],
)
};
let provider = StepProviderStub::with_steps(vec![
Ok(draw("old", "fish")),
Ok(if expected.is_some() {
draw("new", "cat")
} else {
step_text("Приняла новое сообщение.")
}),
Ok(step_text("Рисование сейчас недоступно.")),
])
.with_on_call(Box::new(move |index| {
if index != 1 {
return;
}
let mut params = dialog_params("нарисуй кота");
params.message_id += 1;
params.user_id = sender;
params.meta = serde_json::json!({"requested_tool": requested});
if observed_first {
observed_messages
.lock()
.expect("observed messages")
.push(serde_json::json!({
"message_id": params.message_id, "user_id": sender,
"text": params.message_text, "meta": {}
}));
}
assert!(registry.inject(key, job, crate::dialog_turn::InjectedMessage { params }));
}));
let effects = EffectsStub::default();
let outcomes = crate::dialog_turn::DialogTurnObserver::new(
crate::dialog_turn::RuntimeTurnOutcomeBuffer::new(8),
None,
);
let report = process_dialog_job_once_in_queue_with_materializer_history_and_retry_at(
&queue,
&provider,
&effects,
&materializer,
&NoopDialogToolCallHistoryStore,
session_options(now, &outcomes, &wiring),
)
.await;
assert!(report.completed, "{report:?}");
let requests = provider.requests();
assert_eq!(requests[0].required_tool.as_deref(), Some("draw_image"));
assert_eq!(
requests[1].required_tool.as_deref(),
expected,
"sender={sender}, requested={requested}"
);
if expected.is_some() {
let draws = toolbox.draw_requests.lock().expect("draws");
assert_eq!(draws.len(), 2);
assert_eq!(draws[1].context.message_text, "нарисуй кота");
}
}
}
Ok(())
}

#[tokio::test]
async fn session_returns_rejected_duplicate_draft_to_the_agent() -> Result<(), Box<dyn Error>> {
let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?;
Expand Down
Loading
Loading