Skip to main content

leviath_runtime/
title.rs

1//! One-shot run-title generation.
2//!
3//! The dashboard displays, searches, and persists `RunMetadata.title`; this
4//! module is what fills it in. At spawn, the daemon marks an eligible run
5//! [`PendingTitle`]; [`dispatch_title`] makes one cheap LLM call over the
6//! task prompt via the `title_bridge` worker, and [`collect_title`]
7//! sanitizes the reply into the metadata. Everything downstream (persistence,
8//! dashboard header, run search) already reads the field.
9//!
10//! Best-effort by design: any failure - no usable provider or model, a full
11//! pool that never frees, a provider error, an empty reply - leaves the title
12//! `None` and the run displays its blueprint name exactly as before. The
13//! title lands on disk with the next persistence write (the run-level
14//! heartbeat guarantees one within a few seconds).
15
16use 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/// The `[title]` config, as a world resource (inserted by the daemon at
25/// setup). Absent in worlds that never title (tests, `lev run` without it).
26#[derive(Resource, Clone)]
27pub struct TitleSettings(pub leviath_core::config::TitleConfig);
28
29/// This run wants a title; `dispatch_title` picks it up on the next tick.
30/// Inserted at spawn for enabled, root, non-empty-task runs only.
31#[derive(Component, Debug, Clone, Copy)]
32pub struct PendingTitle;
33
34/// A title call is in flight. Unlike `AwaitingCompaction`, this does not hold
35/// the agent out of inference - titling runs alongside the first turn.
36#[derive(Component, Debug, Clone, Copy)]
37pub struct AwaitingTitle;
38
39/// The receiving end of the title-outcomes channel, as a world resource.
40#[derive(Resource)]
41pub struct TitleResults(pub UnboundedReceiver<TitleOutcome>);
42
43/// The sending end, cloned into each spawned title job.
44#[derive(Resource)]
45pub struct TitleSink(pub UnboundedSender<TitleOutcome>);
46
47/// Kept small: a title is one short line, and a runaway reply is cut anyway.
48const TITLE_MAX_TOKENS: usize = 64;
49/// How much of the task prompt the model sees. Titles come from the opening
50/// framing of a task, not its appendix.
51const TITLE_TASK_BUDGET: usize = 2_000;
52/// Display cap, in bytes, cut on a char boundary.
53const 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
58/// Resolve which provider/model the title call should use.
59///
60/// `[title]` config wins where set; unset fields fall back to the run's own
61/// first-stage provider and model (from the `provider/model` label). The one
62/// unguessable case - an explicit title provider that differs from the run's,
63/// with no title model - resolves to `None`, because the run's model name
64/// means nothing to another provider.
65fn 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
81/// Build the one-shot titling request over the task prompt.
82fn title_request(task: &str, model: &str) -> InferenceRequest {
83    InferenceRequest {
84        // A system *block*, not a message with `role: "system"`. That is the
85        // portable shape: each provider maps blocks to whatever its API wants,
86        // and Anthropic's Messages API - the default for every shipped
87        // blueprint - rejects a `system` role inside `messages` outright with a
88        // 400. Titling therefore failed for essentially every user, silently,
89        // because a failed title is by design not worth interrupting a run for
90        // and the reason only reached a debug log in a daemon whose output goes
91        // nowhere.
92        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
112/// Reduce a raw model reply to a displayable one-line title: the first
113/// non-empty line, unquoted, capped at [`TITLE_MAX_LEN`]. Empty means "no
114/// title" and the metadata stays untouched.
115fn sanitize_title(raw: &str) -> String {
116    // Reasoning models answer the instruction *after* thinking about it out
117    // loud, so the first line is prose about the task rather than a title. A
118    // real one read "We need to generate a short title for the task. The task:
119    // …", which is what the dashboard then displayed.
120    //
121    // The rule that separates them without guessing at content: a title fits
122    // the display cap, and reasoning does not. So the first line short enough
123    // to *be* a title wins, and a reply with no such line falls back to the
124    // first line truncated - which is exactly what this did before.
125    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
142/// Drop a `<think>…</think>` block, which several models emit around their
143/// reasoning. An unclosed tag drops the rest, which is the safe reading: what
144/// follows an opening tag is reasoning until something says otherwise.
145fn strip_reasoning(raw: &str) -> String {
146    let mut out = String::with_capacity(raw.len());
147    let mut rest = raw;
148    // `split_once` rather than `find` plus a slice: the crate forbids string
149    // indexing, and this needs no indices anyway.
150    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
161/// What `dispatch_title` selects.
162///
163/// `&'static` is bevy's `WorldQuery` convention, not a claim about
164/// lifetimes: the borrow is bound when the query is fetched.
165type TitleQuery = (Entity, &'static RunMetadata);
166
167/// Dispatch system: start the title call for each [`PendingTitle`] run.
168///
169/// A full pool leaves the marker in place to retry next tick; every other
170/// dead end (no settings resource, no resolvable provider/model, provider
171/// not registered) drops the marker so the query empties instead of spinning.
172pub 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; // pool full - retry next tick
203        };
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
225/// What `collect_title` selects.
226///
227/// `&'static` is bevy's `WorldQuery` convention, not a claim about
228/// lifetimes: the borrow is bound when the query is fetched.
229type CollectTitleQuery = (
230    &'static mut RunMetadata,
231    Option<&'static mut crate::persistence::TokenTotals>,
232);
233
234/// Collect system: store each finished title into its run's metadata. A
235/// provider error or empty reply changes nothing; either way the in-flight
236/// marker comes off. What the call billed is counted either way - see the note
237/// in the body.
238pub 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; // stale: agent cancelled/despawned since dispatch
248        };
249        crate::tick_scope::enter(outcome.entity);
250        // Counted before the reply is examined. A title the sanitizer rejects
251        // was still served and still billed, and a run that reports less
252        // because its title came back empty would be reporting the one thing
253        // this cannot depend on.
254        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                    // No stage of its own: titling runs once at spawn, beside
262                    // the run rather than inside any of its stages.
263                    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    /// A provider whose single call yields a fixed reply or a fixed error.
293    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    /// A world with the title lane wired over the given provider outcome, plus
353    /// the receiver the dispatched job reports into.
354    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    /// The permit must come back within the deadline even when the provider
398    /// never answers - a hung title call once held its pool slot forever.
399    #[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        // Trait obligations on the mock; only `infer` matters to this test.
425        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        // Await the spawned job's report, then hand it to the collector
463        // through the results resource.
464        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        // A ghost entity id: spawn then despawn.
518        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); // must not panic
530    }
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        // Slot occupied: the marker stays so the next tick retries.
577        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        // A different provider cannot use the run's model name.
616        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        // A label without a slash carries no provider/model split.
627        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        // One message, the task. This used to assert two, pinning the shape
640        // that Anthropic rejects - the test agreed with the code and both were
641        // wrong, which is how titling shipped broken for the default provider.
642        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        // Keep the mock's non-`infer` trait methods measured.
653        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    /// The one that mattered: the instruction goes in a system *block*, not a
680    /// message with `role: "system"`.
681    ///
682    /// Anthropic's Messages API accepts only `user` and `assistant` roles in
683    /// `messages` and rejects anything else with a 400, and it is the default
684    /// provider for every blueprint Leviath ships. So the old shape meant no
685    /// run ever got a title, and nothing said so: a failed title is
686    /// deliberately not worth interrupting a run for, and the reason reached
687    /// only a debug log in a daemon whose output goes to /dev/null.
688    #[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    /// A reasoning model answers after thinking out loud, and the thinking is
703    /// not a title. This is the reply shape that actually reached a dashboard:
704    /// the first line was prose about the task, and it got displayed.
705    #[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        // The same, wrapped in the tags several models use.
713        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        // An unclosed tag drops what follows: it is all reasoning until
719        // something says otherwise, and no title beats a wrong one.
720        assert_eq!(sanitize_title("<think>thinking with no end"), "");
721    }
722
723    /// A compliant reply is untouched, including one long enough to need
724    /// cutting - there is no shorter line to prefer, so it is still cut.
725    #[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    /// The title call bills like any other and used to be counted like none:
736    /// its outcome channel carried the reply the collector wanted and dropped
737    /// the usage nobody read, so a run's reported spend was short by one call
738    /// it had definitely made.
739    #[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        // And the reply still lands - counting it did not cost the feature.
775        assert_eq!(
776            world.get::<RunMetadata>(entity).unwrap().title.as_deref(),
777            Some("Release notes")
778        );
779    }
780
781    /// A reply the sanitizer throws away was still served and still billed.
782    /// Counting only titles that survive would make a run's reported cost
783    /// depend on whether the model happened to answer usefully.
784    #[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            // Sanitizes to nothing, so no title is stored.
798            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    /// A call that never reached a provider has nothing to attribute. Reporting
824    /// a zero-token call would put a point on a token chart for a request that
825    /// was never made.
826    #[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}