1use bevy_ecs::prelude::{Commands, Component, Entity, Query, Res, ResMut, Resource, With, Without};
17use leviath_providers::InferenceRequest;
18use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
19
20use crate::persistence::RunMetadata;
21use crate::pipeline::{InferenceStage, Providers};
22use crate::title_bridge::{TitleJob, TitleOutcome, run_title_job};
23
24#[derive(Resource, Clone)]
27pub struct TitleSettings(pub leviath_core::config::TitleConfig);
28
29#[derive(Component, Debug, Clone, Copy)]
32pub struct PendingTitle;
33
34#[derive(Component, Debug, Clone, Copy)]
37pub struct AwaitingTitle;
38
39#[derive(Resource)]
41pub struct TitleResults(pub UnboundedReceiver<TitleOutcome>);
42
43#[derive(Resource)]
45pub struct TitleSink(pub UnboundedSender<TitleOutcome>);
46
47const TITLE_MAX_TOKENS: usize = 64;
49const TITLE_TASK_BUDGET: usize = 2_000;
52const TITLE_MAX_LEN: usize = 80;
54
55const TITLE_SYSTEM_PROMPT: &str = "Reply with only a short title for the given task, \
56 at most 8 words. No quotes, no trailing punctuation, no explanation.";
57
58fn resolve_title_model(
66 settings: &leviath_core::config::TitleConfig,
67 run_model_label: Option<&str>,
68) -> Option<(String, String)> {
69 let run = run_model_label.and_then(|label| label.split_once('/'));
70 let provider = settings
71 .provider
72 .clone()
73 .or_else(|| run.map(|(p, _)| p.to_string()))?;
74 let model = settings.model.clone().or_else(|| match run {
75 Some((run_provider, run_model)) if run_provider == provider => Some(run_model.to_string()),
76 _ => None,
77 })?;
78 Some((provider, model))
79}
80
81fn title_request(task: &str, model: &str) -> InferenceRequest {
83 InferenceRequest {
84 system: vec![leviath_providers::SystemBlock {
93 text: TITLE_SYSTEM_PROMPT.to_string(),
94 cache_hint: leviath_core::CacheHint::Never,
95 }],
96 messages: vec![leviath_providers::Message {
97 role: "user".to_string(),
98 content: leviath_core::truncate_at_boundary(task, TITLE_TASK_BUDGET)
99 .to_string()
100 .into(),
101 cache_breakpoint: false,
102 }],
103 model: model.to_string(),
104 max_tokens: TITLE_MAX_TOKENS,
105 temperature: 0.2,
106 tools: Vec::new(),
107 extra: serde_json::Value::Null,
108 request_timeout_secs: None,
109 }
110}
111
112fn sanitize_title(raw: &str) -> String {
116 let stripped = strip_reasoning(raw);
126 let lines: Vec<&str> = stripped
127 .lines()
128 .map(str::trim)
129 .filter(|l| !l.is_empty())
130 .collect();
131 let unquote = |l: &str| l.trim_matches(['"', '\'', '`']).trim().to_string();
132 let chosen = lines
133 .iter()
134 .map(|l| unquote(l))
135 .find(|l| l.chars().count() <= TITLE_MAX_LEN)
136 .unwrap_or_else(|| lines.first().map(|l| unquote(l)).unwrap_or_default());
137 leviath_core::truncate_at_boundary(&chosen, TITLE_MAX_LEN)
138 .trim_end()
139 .to_string()
140}
141
142fn strip_reasoning(raw: &str) -> String {
146 let mut out = String::with_capacity(raw.len());
147 let mut rest = raw;
148 while let Some((before, after)) = rest.split_once("<think>") {
151 out.push_str(before);
152 match after.split_once("</think>") {
153 Some((_, tail)) => rest = tail,
154 None => return out,
155 }
156 }
157 out.push_str(rest);
158 out
159}
160
161type TitleQuery = (Entity, &'static RunMetadata);
166
167pub fn dispatch_title(
173 agents: Query<TitleQuery, (With<PendingTitle>, Without<AwaitingTitle>)>,
174 settings: Option<Res<TitleSettings>>,
175 stage: Res<InferenceStage>,
176 providers: Res<Providers>,
177 sink: Res<TitleSink>,
178 mut commands: Commands,
179) {
180 crate::tick_scope::clear();
181 for (entity, meta) in agents.iter() {
182 crate::tick_scope::enter(entity);
183 let resolved = settings
184 .as_ref()
185 .filter(|s| s.0.enabled)
186 .and_then(|s| resolve_title_model(&s.0, meta.model.as_deref()));
187 let Some((provider_name, model)) = resolved else {
188 tracing::debug!(run_id = %meta.run_id, "no usable title provider/model; skipping");
189 commands.entity(entity).remove::<PendingTitle>();
190 continue;
191 };
192 let Some(provider) = providers.0.get(&provider_name) else {
193 tracing::debug!(
194 run_id = %meta.run_id,
195 provider = %provider_name,
196 "title provider not registered; skipping"
197 );
198 commands.entity(entity).remove::<PendingTitle>();
199 continue;
200 };
201 let Some(permit) = stage.pools.try_acquire(&model) else {
202 continue; };
204
205 stage.runtime.spawn(run_title_job(
206 TitleJob {
207 entity,
208 provider,
209 provider_name: provider_name.clone(),
210 model: model.clone(),
211 request: title_request(&meta.task, &model),
212 permit,
213 },
214 std::time::Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
215 sink.0.clone(),
216 stage.wake.clone(),
217 ));
218 commands
219 .entity(entity)
220 .remove::<PendingTitle>()
221 .insert(AwaitingTitle);
222 }
223}
224
225type CollectTitleQuery = (
230 &'static mut RunMetadata,
231 Option<&'static mut crate::persistence::TokenTotals>,
232);
233
234pub fn collect_title(
239 mut results: ResMut<TitleResults>,
240 mut agents: Query<CollectTitleQuery, With<AwaitingTitle>>,
241 persist: Option<Res<crate::pipeline::PersistenceStage>>,
242 mut commands: Commands,
243) {
244 crate::tick_scope::clear();
245 while let Ok(outcome) = results.0.try_recv() {
246 let Ok((mut meta, mut totals)) = agents.get_mut(outcome.entity) else {
247 continue; };
249 crate::tick_scope::enter(outcome.entity);
250 if let Some(usage) = &outcome.usage {
255 crate::inference_usage::record_call(
256 totals.as_deref_mut(),
257 persist.as_deref(),
258 Some(&meta),
259 &crate::inference_usage::CallUsage {
260 kind: leviath_core::run_archive::InferenceKind::Title,
261 stage: "",
264 iteration: 0,
265 provider: &outcome.provider_name,
266 model: &outcome.model,
267 usage,
268 },
269 );
270 }
271 if let Ok(raw) = outcome.result {
272 let title = sanitize_title(&raw);
273 if !title.is_empty() {
274 meta.title = Some(title);
275 }
276 }
277 commands.entity(outcome.entity).remove::<AwaitingTitle>();
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use bevy_ecs::schedule::Schedule;
285 use bevy_ecs::world::World;
286 use leviath_providers::{Provider, ProviderError};
287 use std::sync::Arc;
288 use tokio::runtime::Handle;
289 use tokio::sync::Notify;
290 use tokio::sync::mpsc;
291
292 struct Scripted(Result<&'static str, &'static str>);
294
295 #[async_trait::async_trait]
296 impl Provider for Scripted {
297 async fn infer(
298 &self,
299 _r: &InferenceRequest,
300 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
301 match self.0 {
302 Ok(reply) => Ok(leviath_providers::InferenceResponse {
303 content: reply.to_string(),
304 tool_calls: vec![],
305 tokens_used: leviath_providers::TokenUsage {
306 prompt_tokens: 1,
307 completion_tokens: 1,
308 total_tokens: 2,
309 cached_tokens: 0,
310 cache_write_tokens: 0,
311 },
312 finish_reason: leviath_providers::FinishReason::Complete,
313 }),
314 Err(msg) => Err(ProviderError::Other(msg.to_string())),
315 }
316 }
317 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
318 1
319 }
320 fn max_context_tokens(&self, _m: &str) -> usize {
321 100_000
322 }
323 fn name(&self) -> &str {
324 "mock"
325 }
326 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
327 leviath_providers::ModelCapabilities::default()
328 }
329 }
330
331 fn metadata(model: Option<&str>) -> RunMetadata {
332 RunMetadata {
333 run_id: "run-t".to_string(),
334 agent_name: "titled".to_string(),
335 agent_path: "/a".to_string(),
336 task: "summarize the release notes".to_string(),
337 model: model.map(str::to_string),
338 workdir: "/w".to_string(),
339 num_stages: 1,
340 started_at: 0,
341 parent_run_id: None,
342 metadata: Default::default(),
343 callback_url: None,
344 callback_secret: None,
345 title: None,
346 unattended: false,
347 read_paths: None,
348 output_request: None,
349 }
350 }
351
352 fn build_world(
355 reply: Result<&'static str, &'static str>,
356 pools: crate::inference_pool::InferencePools,
357 ) -> (World, mpsc::UnboundedReceiver<TitleOutcome>) {
358 let mut registry = crate::ProviderRegistry::new();
359 registry.register("mock".to_string(), Arc::new(Scripted(reply)));
360 let (title_tx, title_rx) = mpsc::unbounded_channel();
361 let (inf_tx, _inf_rx) = mpsc::unbounded_channel();
362 let (ttx, _trx) = mpsc::unbounded_channel();
363 let (ctx, _crx) = mpsc::unbounded_channel();
364 let (cstx, _csrx) = mpsc::unbounded_channel();
365 let mut world = World::new();
366 world.insert_resource(Providers(registry));
367 world.insert_resource(InferenceStage {
368 pools: Arc::new(pools),
369 outcomes: inf_tx,
370 transition_outcomes: ttx,
371 compaction_outcomes: ctx,
372 content_summary_outcomes: cstx,
373 wake: Arc::new(Notify::new()),
374 runtime: Handle::current(),
375 exact_token_counting: false,
376 });
377 world.insert_resource(TitleSink(title_tx));
378 (world, title_rx)
379 }
380
381 fn run_dispatch(world: &mut World) {
382 let mut schedule = Schedule::default();
383 schedule.add_systems(dispatch_title);
384 schedule.run(world);
385 }
386
387 fn run_collect(world: &mut World) {
388 let mut schedule = Schedule::default();
389 schedule.add_systems(collect_title);
390 schedule.run(world);
391 }
392
393 fn default_pools() -> crate::inference_pool::InferencePools {
394 crate::inference_pool::InferencePools::new(crate::inference_pool::InferencePoolConfig::new())
395 }
396
397 #[tokio::test]
400 async fn title_deadline_frees_the_slot_when_the_provider_hangs() {
401 struct Hang;
402 #[async_trait::async_trait]
403 impl Provider for Hang {
404 async fn infer(
405 &self,
406 _r: &InferenceRequest,
407 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
408 std::future::pending().await
409 }
410 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
411 1
412 }
413 fn max_context_tokens(&self, _m: &str) -> usize {
414 100_000
415 }
416 fn name(&self) -> &str {
417 "hang"
418 }
419 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
420 leviath_providers::ModelCapabilities::default()
421 }
422 }
423
424 assert_eq!(Hang.count_tokens("t", "m").await, 1);
426 assert_eq!(Hang.max_context_tokens("m"), 100_000);
427 assert_eq!(Hang.name(), "hang");
428 let _ = Hang.capabilities("m");
429 let pools = crate::inference_pool::InferencePools::new(
430 crate::inference_pool::InferencePoolConfig::new(),
431 );
432 let (tx, mut rx) = mpsc::unbounded_channel();
433 run_title_job(
434 TitleJob {
435 entity: bevy_ecs::entity::Entity::PLACEHOLDER,
436 provider: Arc::new(Hang),
437 provider_name: "mock".to_string(),
438 model: "m".to_string(),
439 request: title_request("task", "m"),
440 permit: pools.try_acquire("m").expect("free"),
441 },
442 std::time::Duration::from_millis(5),
443 tx,
444 Arc::new(Notify::new()),
445 )
446 .await;
447 let outcome = rx.recv().await.expect("an outcome is always reported");
448 let err = outcome.result.expect_err("the deadline must surface");
449 assert!(err.to_string().contains("deadline"), "{err}");
450 }
451
452 #[tokio::test]
453 async fn dispatch_and_collect_set_the_title() {
454 let (mut world, title_rx) = build_world(Ok("\"Release notes digest\"\n"), default_pools());
455 world.insert_resource(TitleSettings(config(None, None)));
456 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
457
458 run_dispatch(&mut world);
459 assert!(world.get::<PendingTitle>(e).is_none());
460 assert!(world.get::<AwaitingTitle>(e).is_some());
461
462 let mut title_rx = title_rx;
465 let outcome = title_rx.recv().await.expect("job reported");
466 assert_eq!(outcome.entity, e);
467 let (tx, rx) = mpsc::unbounded_channel();
468 tx.send(outcome).unwrap();
469 world.insert_resource(TitleResults(rx));
470
471 run_collect(&mut world);
472 assert_eq!(
473 world.get::<RunMetadata>(e).unwrap().title.as_deref(),
474 Some("Release notes digest")
475 );
476 assert!(world.get::<AwaitingTitle>(e).is_none());
477 }
478
479 #[tokio::test]
480 async fn provider_error_leaves_the_title_unset() {
481 let (mut world, mut title_rx) = build_world(Err("boom"), default_pools());
482 world.insert_resource(TitleSettings(config(None, None)));
483 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
484
485 run_dispatch(&mut world);
486 let outcome = title_rx.recv().await.expect("job reported");
487 assert!(outcome.result.is_err());
488 let (tx, rx) = mpsc::unbounded_channel();
489 tx.send(outcome).unwrap();
490 world.insert_resource(TitleResults(rx));
491
492 run_collect(&mut world);
493 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
494 assert!(world.get::<AwaitingTitle>(e).is_none());
495 }
496
497 #[tokio::test]
498 async fn whitespace_reply_leaves_the_title_unset() {
499 let (mut world, mut title_rx) = build_world(Ok(" \n \n"), default_pools());
500 world.insert_resource(TitleSettings(config(None, None)));
501 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
502
503 run_dispatch(&mut world);
504 let outcome = title_rx.recv().await.expect("job reported");
505 let (tx, rx) = mpsc::unbounded_channel();
506 tx.send(outcome).unwrap();
507 world.insert_resource(TitleResults(rx));
508
509 run_collect(&mut world);
510 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
511 }
512
513 #[tokio::test]
514 async fn collect_skips_a_despawned_agent() {
515 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
516 let (tx, rx) = mpsc::unbounded_channel();
517 let ghost = world.spawn(metadata(Some("mock/m"))).id();
519 world.despawn(ghost);
520 tx.send(TitleOutcome {
521 entity: ghost,
522 result: Ok("t".to_string()),
523 usage: None,
524 provider_name: "mock".to_string(),
525 model: "m".to_string(),
526 })
527 .unwrap();
528 world.insert_resource(TitleResults(rx));
529 run_collect(&mut world); }
531
532 #[tokio::test]
533 async fn dispatch_without_settings_drops_the_marker() {
534 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
535 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
536 run_dispatch(&mut world);
537 assert!(world.get::<PendingTitle>(e).is_none());
538 assert!(world.get::<AwaitingTitle>(e).is_none());
539 }
540
541 #[tokio::test]
542 async fn dispatch_with_disabled_settings_drops_the_marker() {
543 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
544 world.insert_resource(TitleSettings(leviath_core::config::TitleConfig {
545 enabled: false,
546 provider: None,
547 model: None,
548 }));
549 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
550 run_dispatch(&mut world);
551 assert!(world.get::<PendingTitle>(e).is_none());
552 assert!(world.get::<AwaitingTitle>(e).is_none());
553 }
554
555 #[tokio::test]
556 async fn dispatch_with_unregistered_provider_drops_the_marker() {
557 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
558 world.insert_resource(TitleSettings(config(Some("nowhere"), Some("m"))));
559 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
560 run_dispatch(&mut world);
561 assert!(world.get::<PendingTitle>(e).is_none());
562 assert!(world.get::<AwaitingTitle>(e).is_none());
563 }
564
565 #[tokio::test]
566 async fn dispatch_retries_while_the_pool_is_full() {
567 let mut cfg = crate::inference_pool::InferencePoolConfig::new();
568 cfg.set_limit("m", 1);
569 let pools = crate::inference_pool::InferencePools::new(cfg);
570 let held = pools.try_acquire("m").unwrap();
571 let (mut world, _title_rx) = build_world(Ok("t"), pools);
572 world.insert_resource(TitleSettings(config(None, None)));
573 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
574
575 run_dispatch(&mut world);
576 assert!(world.get::<PendingTitle>(e).is_some());
578 assert!(world.get::<AwaitingTitle>(e).is_none());
579 drop(held);
580 }
581
582 fn config(provider: Option<&str>, model: Option<&str>) -> leviath_core::config::TitleConfig {
583 leviath_core::config::TitleConfig {
584 enabled: true,
585 provider: provider.map(str::to_string),
586 model: model.map(str::to_string),
587 }
588 }
589
590 #[test]
591 fn resolve_prefers_the_configured_pair() {
592 assert_eq!(
593 resolve_title_model(
594 &config(Some("openai"), Some("gpt-5-mini")),
595 Some("anthropic/m")
596 ),
597 Some(("openai".to_string(), "gpt-5-mini".to_string()))
598 );
599 }
600
601 #[test]
602 fn resolve_falls_back_to_the_runs_provider_and_model() {
603 assert_eq!(
604 resolve_title_model(&config(None, None), Some("anthropic/claude-x")),
605 Some(("anthropic".to_string(), "claude-x".to_string()))
606 );
607 }
608
609 #[test]
610 fn resolve_borrows_the_runs_model_only_for_the_same_provider() {
611 assert_eq!(
612 resolve_title_model(&config(Some("anthropic"), None), Some("anthropic/claude-x")),
613 Some(("anthropic".to_string(), "claude-x".to_string()))
614 );
615 assert_eq!(
617 resolve_title_model(&config(Some("openai"), None), Some("anthropic/claude-x")),
618 None
619 );
620 }
621
622 #[test]
623 fn resolve_gives_up_without_any_provider_or_model() {
624 assert_eq!(resolve_title_model(&config(None, None), None), None);
625 assert_eq!(resolve_title_model(&config(None, Some("m")), None), None);
626 assert_eq!(
628 resolve_title_model(&config(None, None), Some("bare-label")),
629 None
630 );
631 }
632
633 #[test]
634 fn title_request_truncates_the_task_and_carries_the_model() {
635 let long_task = "x".repeat(5_000);
636 let req = title_request(&long_task, "gpt-5-mini");
637 assert_eq!(req.model, "gpt-5-mini");
638 assert_eq!(req.max_tokens, TITLE_MAX_TOKENS);
639 assert_eq!(req.messages.len(), 1);
643 let expected: leviath_providers::MessageContent =
644 leviath_core::truncate_at_boundary(&long_task, TITLE_TASK_BUDGET)
645 .to_string()
646 .into();
647 assert_eq!(req.messages[0].content, expected);
648 }
649
650 #[tokio::test]
651 async fn scripted_provider_metadata_is_exercised() {
652 let p = Scripted(Ok("t"));
654 assert_eq!(p.name(), "mock");
655 assert_eq!(p.count_tokens("t", "m").await, 1);
656 assert_eq!(p.max_context_tokens("m"), 100_000);
657 let default = leviath_providers::ModelCapabilities::default();
658 assert_eq!(
659 p.capabilities("m").max_output_tokens,
660 default.max_output_tokens
661 );
662 }
663
664 #[test]
665 fn sanitize_takes_the_first_line_unquoted_and_capped() {
666 assert_eq!(
667 sanitize_title("\"Fix the login bug\"\nextra"),
668 "Fix the login bug"
669 );
670 assert_eq!(
671 sanitize_title("\n\n 'Tidy: workspace' \n"),
672 "Tidy: workspace"
673 );
674 assert_eq!(sanitize_title(" \n\t\n"), "");
675 let long = "word ".repeat(40);
676 assert!(sanitize_title(&long).len() <= TITLE_MAX_LEN);
677 }
678
679 #[test]
689 fn the_title_request_carries_its_instruction_as_a_system_block() {
690 let request = title_request("tidy the kitchen", "claude-sonnet-4-6");
691
692 assert_eq!(request.system.len(), 1);
693 assert!(request.system[0].text.contains("short title"));
694 assert!(
695 request.messages.iter().all(|m| m.role != "system"),
696 "no provider is obliged to accept a system role in messages"
697 );
698 assert_eq!(request.messages.len(), 1);
699 assert_eq!(request.messages[0].role, "user");
700 }
701
702 #[test]
706 fn sanitize_skips_reasoning_and_takes_the_line_that_is_a_title() {
707 let reply = "We need to generate a short title for the task. The task: \
708 \"Research leviath.dev and report what the docs cover\" - so \
709 something short and descriptive.\n\nLeviath Docs Coverage";
710 assert_eq!(sanitize_title(reply), "Leviath Docs Coverage");
711
712 let tagged = "<think>The user wants a title. Keep it under eight words \
714 and avoid punctuation at the end.</think>\nRetry Backoff \
715 Refactor";
716 assert_eq!(sanitize_title(tagged), "Retry Backoff Refactor");
717
718 assert_eq!(sanitize_title("<think>thinking with no end"), "");
721 }
722
723 #[test]
726 fn sanitize_leaves_an_ordinary_reply_alone() {
727 assert_eq!(
728 sanitize_title("Tidy the kitchen sink"),
729 "Tidy the kitchen sink"
730 );
731 let long = "x".repeat(TITLE_MAX_LEN * 2);
732 assert_eq!(sanitize_title(&long).chars().count(), TITLE_MAX_LEN);
733 }
734
735 #[test]
740 fn a_title_call_is_counted_against_the_run_that_paid_for_it() {
741 let mut world = World::new();
742 let entity = world
743 .spawn((
744 metadata(Some("mock/m")),
745 AwaitingTitle,
746 crate::persistence::TokenTotals::default(),
747 ))
748 .id();
749 let (tx, rx) = mpsc::unbounded_channel();
750 tx.send(TitleOutcome {
751 entity,
752 result: Ok("Release notes".to_string()),
753 usage: Some(leviath_providers::TokenUsage {
754 prompt_tokens: 900,
755 completion_tokens: 12,
756 cached_tokens: 3,
757 cache_write_tokens: 4,
758 total_tokens: 912,
759 }),
760 provider_name: "mock".to_string(),
761 model: "m".to_string(),
762 })
763 .unwrap();
764 world.insert_resource(TitleResults(rx));
765 run_collect(&mut world);
766
767 let totals = world
768 .get::<crate::persistence::TokenTotals>(entity)
769 .expect("totals");
770 assert_eq!(totals.prompt_tokens, 900);
771 assert_eq!(totals.completion_tokens, 12);
772 assert_eq!(totals.cached_tokens, 3);
773 assert_eq!(totals.cache_write_tokens, 4);
774 assert_eq!(
776 world.get::<RunMetadata>(entity).unwrap().title.as_deref(),
777 Some("Release notes")
778 );
779 }
780
781 #[test]
785 fn a_rejected_title_is_still_counted() {
786 let mut world = World::new();
787 let entity = world
788 .spawn((
789 metadata(Some("mock/m")),
790 AwaitingTitle,
791 crate::persistence::TokenTotals::default(),
792 ))
793 .id();
794 let (tx, rx) = mpsc::unbounded_channel();
795 tx.send(TitleOutcome {
796 entity,
797 result: Ok(" ".to_string()),
799 usage: Some(leviath_providers::TokenUsage {
800 prompt_tokens: 500,
801 completion_tokens: 1,
802 cached_tokens: 0,
803 cache_write_tokens: 0,
804 total_tokens: 501,
805 }),
806 provider_name: "mock".to_string(),
807 model: "m".to_string(),
808 })
809 .unwrap();
810 world.insert_resource(TitleResults(rx));
811 run_collect(&mut world);
812
813 assert!(world.get::<RunMetadata>(entity).unwrap().title.is_none());
814 assert_eq!(
815 world
816 .get::<crate::persistence::TokenTotals>(entity)
817 .unwrap()
818 .prompt_tokens,
819 500
820 );
821 }
822
823 #[test]
827 fn a_failed_title_call_adds_nothing() {
828 let mut world = World::new();
829 let entity = world
830 .spawn((
831 metadata(Some("mock/m")),
832 AwaitingTitle,
833 crate::persistence::TokenTotals::default(),
834 ))
835 .id();
836 let (tx, rx) = mpsc::unbounded_channel();
837 tx.send(TitleOutcome {
838 entity,
839 result: Err(ProviderError::Other("down".to_string())),
840 usage: None,
841 provider_name: "mock".to_string(),
842 model: "m".to_string(),
843 })
844 .unwrap();
845 world.insert_resource(TitleResults(rx));
846 run_collect(&mut world);
847
848 let totals = world
849 .get::<crate::persistence::TokenTotals>(entity)
850 .expect("totals");
851 assert_eq!(totals.prompt_tokens, 0);
852 assert_eq!(totals.completion_tokens, 0);
853 }
854}