Skip to main content

capsule_runtime/
session_status.rs

1//! Where an agent session stands: what the runtime-provided `session-status` tool reports, the
2//! hard deadline a session runs under, and the one notice an agent is given before it.
3//!
4//! [`SessionStatus`] holds no number of its own. It is fed at the funnels that already produce
5//! each one — the trace writer's `inference`, `task_start` and `task_end` writes, and the http
6//! agent loop's compaction check — and a call reads the spend meter as it stands. So every field a
7//! call returns agrees with the trace written before it.
8
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Mutex;
11use std::time::{Duration, Instant, SystemTime};
12
13use serde::Serialize;
14use serde_json::Value;
15
16use crate::agent::{DriverUsage, OccupancyBasis};
17use crate::model_limits::{BudgetBasis, InputBudget};
18use crate::process_driver::QuotaWindow;
19use crate::spend::SpendMeter;
20
21/// The runtime-provided tool every agent capsule has, on both transports, with no grant.
22pub const SESSION_STATUS_TOOL: &str = "session-status";
23
24/// What a field reads when its limit is not set.
25const NONE: &str = "none";
26
27/// What a field reads when the runtime does not have the count.
28const UNKNOWN: &str = "unknown";
29
30/// The longest default notice margin: five minutes.
31const DEFAULT_NOTICE_CEILING_SECS: u64 = 300;
32
33/// The longest deadline a session runs under: ten years. A larger one overflows the clocks
34/// the deadline is read against.
35pub const MAX_DEADLINE_SECS: u64 = 315_360_000;
36
37/// The hidden `mur run` flag a formation launcher and a child launcher pass a deadline down with:
38/// the deadline's wall moment in milliseconds since the Unix epoch.
39pub const INHERITED_DEADLINE_FLAG: &str = "--inherited-deadline";
40
41/// Where a session's deadline came from.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum DeadlineSource {
44    /// The session's own `mur run --deadline`.
45    Own,
46    /// The deadline of the formation or the capsule that launched the session.
47    Inherited,
48}
49
50impl DeadlineSource {
51    /// The `source` value the trace records carry.
52    pub const fn as_str(self) -> &'static str {
53        match self {
54            Self::Own => "own",
55            Self::Inherited => "inherited",
56        }
57    }
58}
59
60/// The hard limit a session runs under: its own `mur run --deadline`, or the moment it inherited
61/// from the formation or capsule that launched it.
62///
63/// Both clocks are read once, at the session's start. The monotonic one is what the runtime
64/// enforces and counts down against; the wall one is only what `at` is spelled from, so a clock
65/// change during the session moves neither the enforcement nor `left_secs`.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct SessionDeadline {
68    at: Instant,
69    at_wall: SystemTime,
70    secs: u64,
71    source: DeadlineSource,
72}
73
74impl SessionDeadline {
75    /// The deadline `secs` after a start read as `started` and `started_wall`. A `secs` above
76    /// [`MAX_DEADLINE_SECS`] is taken as that bound.
77    pub fn after(started: Instant, started_wall: SystemTime, secs: u64) -> Self {
78        let secs = secs.min(MAX_DEADLINE_SECS);
79        let length = Duration::from_secs(secs);
80        Self {
81            at: started + length,
82            at_wall: started_wall + length,
83            secs,
84            source: DeadlineSource::Own,
85        }
86    }
87
88    /// The inherited deadline at the wall moment `at_wall`, for a session whose start was read as
89    /// `started` and `started_wall`. What is left is `at_wall - started_wall`: zero when the moment
90    /// has passed, and at most [`MAX_DEADLINE_SECS`], beyond which `at` is that bound.
91    pub fn until(started: Instant, started_wall: SystemTime, at_wall: SystemTime) -> Self {
92        let bound = Duration::from_secs(MAX_DEADLINE_SECS);
93        let left = at_wall
94            .duration_since(started_wall)
95            .unwrap_or(Duration::ZERO);
96        let (left, at_wall) = if left > bound {
97            (bound, started_wall + bound)
98        } else {
99            (left, at_wall)
100        };
101        let secs = left.as_secs() + u64::from(left.subsec_nanos() > 0);
102        Self {
103            at: started + left,
104            at_wall,
105            secs,
106            source: DeadlineSource::Inherited,
107        }
108    }
109
110    /// [`SessionDeadline::until`] the moment `millis` milliseconds after the Unix epoch, as
111    /// [`INHERITED_DEADLINE_FLAG`] carries it. A moment the wall clock cannot hold is taken as the
112    /// bound.
113    pub fn from_unix_millis(started: Instant, started_wall: SystemTime, millis: u64) -> Self {
114        let bound = started_wall + Duration::from_secs(MAX_DEADLINE_SECS);
115        let at_wall = SystemTime::UNIX_EPOCH
116            .checked_add(Duration::from_millis(millis))
117            .unwrap_or(bound);
118        Self::until(started, started_wall, at_wall)
119    }
120
121    /// `at` in whole milliseconds since the Unix epoch, the value [`INHERITED_DEADLINE_FLAG`]
122    /// carries. Truncated, so the moment a session inherits is never later than this one and
123    /// spells the same second.
124    pub fn at_unix_millis(&self) -> u64 {
125        self.at_wall
126            .duration_since(SystemTime::UNIX_EPOCH)
127            .map_or(0, |since| {
128                u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
129            })
130    }
131
132    /// The session's deadline: the earlier of its own and the inherited one, the inherited one on a
133    /// tie, and `None` when it has neither. Both must have been read against the same start.
134    pub fn earliest(own: Option<Self>, inherited: Option<Self>) -> Option<Self> {
135        match (own, inherited) {
136            (Some(own), Some(inherited)) if own.at < inherited.at => Some(own),
137            (_, Some(inherited)) => Some(inherited),
138            (own, None) => own,
139        }
140    }
141
142    /// Seconds from the session's start to `at`: the `--deadline` value for an own deadline, and
143    /// rounded up, `0` once passed, for an inherited one.
144    pub fn secs(&self) -> u64 {
145        self.secs
146    }
147
148    /// Where the deadline came from.
149    pub fn source(&self) -> DeadlineSource {
150        self.source
151    }
152
153    /// When the runtime ends the session, as RFC 3339 UTC to the second.
154    pub fn at_rfc3339(&self) -> String {
155        chrono::DateTime::<chrono::Utc>::from(self.at_wall)
156            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
157    }
158
159    /// How long is left at `now`; zero once the deadline has passed.
160    pub fn left(&self, now: Instant) -> Duration {
161        self.at.saturating_duration_since(now)
162    }
163
164    /// The monotonic instant the session ends at.
165    pub(crate) fn instant(&self) -> Instant {
166        self.at
167    }
168}
169
170/// The notice margin a deadline of `deadline_secs` gets when `lifecycle.deadline_notice_secs` is
171/// not declared: five minutes, or a tenth of the deadline when that is shorter, and at least one
172/// second.
173pub fn default_notice_secs(deadline_secs: u64) -> u64 {
174    (deadline_secs / 10).clamp(1, DEFAULT_NOTICE_CEILING_SECS)
175}
176
177/// The margin in force: the declared `lifecycle.deadline_notice_secs`, or the default for the
178/// deadline. `0` sends no notice.
179pub fn notice_secs(declared: Option<u64>, deadline_secs: u64) -> u64 {
180    declared.unwrap_or_else(|| default_notice_secs(deadline_secs))
181}
182
183/// The one deadline notice, taken by the first dispatched tool result inside the margin.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub(crate) struct DeadlineNotice {
186    /// `SessionDeadline::at_rfc3339`.
187    pub(crate) at: String,
188    /// Whole seconds left when it was taken.
189    pub(crate) left_secs: u64,
190    /// The margin it was due under.
191    pub(crate) notice_secs: u64,
192}
193
194impl DeadlineNotice {
195    /// The line appended after the tool result's fence.
196    pub(crate) fn text(&self) -> String {
197        format!(
198            "Session deadline: the runtime ends this session at {}, in {} seconds.",
199            self.at, self.left_secs
200        )
201    }
202}
203
204/// The spend meter as a call reads it.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub(crate) struct SpendReading {
207    /// `SpendMeter::used`: tokens settled against the ceilings.
208    pub(crate) used: u64,
209    /// `inference.max_session_tokens`.
210    pub(crate) ceiling: Option<u64>,
211    /// The machine ledger's day total and `spend.machine_tokens_per_day`, when this session keeps
212    /// the ledger.
213    pub(crate) machine: Option<(u64, u64)>,
214}
215
216impl SpendReading {
217    /// Read `meter` now, the machine ledger's file included.
218    pub(crate) fn of(meter: &SpendMeter) -> Self {
219        Self {
220            used: meter.used(),
221            ceiling: meter.session_ceiling(),
222            machine: meter.machine_reading(),
223        }
224    }
225}
226
227/// How the context occupancy was last read.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229struct ContextReading {
230    used_tokens: u32,
231    window_tokens: Option<u32>,
232    budget_tokens: Option<u32>,
233    budget_basis: BudgetBasis,
234    measured_by: OccupancyBasis,
235}
236
237/// What the funnels have fed so far.
238#[derive(Debug, Default)]
239struct Fed {
240    /// The active task and when its `task_start` was written.
241    task: Option<(String, Instant)>,
242    /// `TraceWriter::task_turns` as of the last `inference` record.
243    task_turns: u32,
244    /// The active task's `lifecycle.keep_working` continuations, as of its last `keep_working`
245    /// record.
246    keep_working_used: u32,
247    /// The active task's `lifecycle.done_check` reopens, as of its last reopening `done_check`
248    /// record.
249    done_check_reopens: u32,
250    input: Option<u64>,
251    output: Option<u64>,
252    cached: Option<u64>,
253    cache_write: Option<u64>,
254    /// `inference` records that carried no token count.
255    unreported_calls: u64,
256    context: Option<ContextReading>,
257    /// The latest subscription window the harness reported under each name, in the order the
258    /// names were first reported.
259    quota: Vec<QuotaWindow>,
260}
261
262/// One session's standing, shared by the store, the trace writer and the agent loop.
263#[derive(Debug)]
264pub(crate) struct SessionStatus {
265    started: Instant,
266    /// `transport: process`: the harness keeps the context, and its counts are the tokens.
267    harness_transport: bool,
268    /// `inference.max_turns`.
269    turn_cap: Option<u32>,
270    deadline: Option<SessionDeadline>,
271    /// The notice margin in force; `0` sends none.
272    notice_secs: u64,
273    /// `lifecycle.keep_working.limit`; `None` when the option is off.
274    keep_working_limit: Option<u32>,
275    /// `lifecycle.done_check.limit`; `None` when the option is off.
276    done_check_limit: Option<u32>,
277    notice_taken: AtomicBool,
278    fed: Mutex<Fed>,
279}
280
281impl SessionStatus {
282    /// A session that started at `started`. `notice_secs` is [`notice_secs`]'s answer, and is
283    /// read only when there is a deadline. `keep_working_limit` is `lifecycle.keep_working.limit`,
284    /// `None` when the capsule does not set the option, and `done_check_limit` is
285    /// `lifecycle.done_check.limit` on the same terms.
286    pub(crate) fn new(
287        started: Instant,
288        harness_transport: bool,
289        turn_cap: Option<u32>,
290        deadline: Option<SessionDeadline>,
291        notice_secs: u64,
292        keep_working_limit: Option<u32>,
293        done_check_limit: Option<u32>,
294    ) -> Self {
295        Self {
296            started,
297            harness_transport,
298            turn_cap,
299            deadline,
300            notice_secs,
301            keep_working_limit,
302            done_check_limit,
303            notice_taken: AtomicBool::new(false),
304            fed: Mutex::new(Fed::default()),
305        }
306    }
307
308    fn fed(&self) -> std::sync::MutexGuard<'_, Fed> {
309        // Every update under the lock is a handful of assignments that cannot panic midway.
310        self.fed
311            .lock()
312            .unwrap_or_else(std::sync::PoisonError::into_inner)
313    }
314
315    /// The session's deadline: the earliest bound placed on it, if any.
316    pub(crate) fn deadline(&self) -> Option<SessionDeadline> {
317        self.deadline
318    }
319
320    /// The notice margin in force, `0` for none; meaningless without a deadline.
321    pub(crate) fn notice_secs(&self) -> u64 {
322        self.notice_secs
323    }
324
325    /// Fed by `TraceWriter::write_task_start`.
326    pub(crate) fn task_started(&self, task_id: &str, at: Instant) {
327        let mut fed = self.fed();
328        fed.task = Some((task_id.to_string(), at));
329        fed.task_turns = 0;
330        fed.keep_working_used = 0;
331        fed.done_check_reopens = 0;
332    }
333
334    /// Fed by `TraceWriter::write_keep_working` with the continuation's 1-based `number`.
335    pub(crate) fn keep_working_sent(&self, number: u32) {
336        self.fed().keep_working_used = number;
337    }
338
339    /// Fed by `TraceWriter::write_done_check` with the task's reopens so far, when a run reopened
340    /// it.
341    pub(crate) fn done_check_reopened(&self, reopens: u32) {
342        self.fed().done_check_reopens = reopens;
343    }
344
345    /// Fed by `TraceWriter::write_task_end`.
346    pub(crate) fn task_ended(&self) {
347        self.fed().task = None;
348    }
349
350    /// The active task, for the records a call writes.
351    pub(crate) fn task_id(&self) -> Option<String> {
352        self.fed().task.as_ref().map(|(id, _)| id.clone())
353    }
354
355    /// Fed by `TraceWriter::write_inference_event` with each `inference` record's counts, after
356    /// the writer's own counters moved: `task_turns` is `TraceWriter::task_turns` as it now stands.
357    ///
358    /// The provider's report is `usage` under `transport: http`; under `transport: process` it is
359    /// the harness's `input_tokens` / `output_tokens` with `usage`'s cache counts. A record whose
360    /// report carries no count is an unreported call.
361    pub(crate) fn record_inference(
362        &self,
363        task_turns: u32,
364        input_tokens: Option<u64>,
365        output_tokens: Option<u64>,
366        usage: Option<&DriverUsage>,
367    ) {
368        let (input, output) = if self.harness_transport {
369            (input_tokens, output_tokens)
370        } else {
371            (
372                usage.and_then(|u| u.input_tokens),
373                usage.and_then(|u| u.output_tokens),
374            )
375        };
376        let cached = usage.and_then(|u| u.cached_tokens);
377        let cache_write = usage.and_then(|u| u.cache_write_tokens);
378        let mut guard = self.fed();
379        let fed = &mut *guard;
380        fed.task_turns = task_turns;
381        if [input, output, cached, cache_write]
382            .iter()
383            .all(Option::is_none)
384        {
385            fed.unreported_calls = fed.unreported_calls.saturating_add(1);
386            return;
387        }
388        for (sum, count) in [
389            (&mut fed.input, input),
390            (&mut fed.output, output),
391            (&mut fed.cached, cached),
392            (&mut fed.cache_write, cache_write),
393        ] {
394            if let Some(count) = count {
395                *sum = Some(sum.unwrap_or(0).saturating_add(count));
396            }
397        }
398    }
399
400    /// Fed by `TraceWriter::write_harness_quota` with one validated report. Each window replaces
401    /// the whole of the one held under its name, so a member the new report leaves out is gone;
402    /// a name not seen before is added after the others.
403    pub(crate) fn record_quota(&self, windows: &[QuotaWindow]) {
404        let mut fed = self.fed();
405        for window in windows {
406            match fed.quota.iter_mut().find(|held| held.name == window.name) {
407                Some(held) => *held = window.clone(),
408                None => fed.quota.push(window.clone()),
409            }
410        }
411    }
412
413    /// Fed by the http agent loop right after its compaction check: the occupancy that check
414    /// compared, the window of the choice that served the call (`None` when it has none), the
415    /// input budget the check compared against (`None` with no window) and how the count was
416    /// made.
417    pub(crate) fn set_context(
418        &self,
419        used_tokens: u32,
420        window_tokens: Option<u32>,
421        budget: Option<InputBudget>,
422        measured_by: OccupancyBasis,
423    ) {
424        self.fed().context = Some(ContextReading {
425            used_tokens,
426            window_tokens,
427            budget_tokens: budget.map(|budget| budget.tokens),
428            budget_basis: budget.map_or(BudgetBasis::None, |budget| budget.basis),
429            measured_by,
430        });
431    }
432
433    /// The `session-status` result as it stands now.
434    pub(crate) fn snapshot(&self, spend: SpendReading) -> StatusReport {
435        self.snapshot_at(Instant::now(), spend)
436    }
437
438    /// The `session-status` result as it stands at `now`.
439    pub(crate) fn snapshot_at(&self, now: Instant, spend: SpendReading) -> StatusReport {
440        let fed = self.fed();
441        let whole_secs = |since: Instant| now.saturating_duration_since(since).as_secs();
442        let time = TimeReport {
443            elapsed_secs: whole_secs(self.started),
444            task_elapsed_secs: fed.task.as_ref().map_or_else(
445                || Value::from(UNKNOWN),
446                |(_, at)| Value::from(whole_secs(*at)),
447            ),
448            deadline: self.deadline.map_or_else(
449                || Value::from(NONE),
450                |deadline| {
451                    serde_json::json!({
452                        "at": deadline.at_rfc3339(),
453                        "left_secs": deadline.left(now).as_secs(),
454                    })
455                },
456            ),
457        };
458        let context = match (self.harness_transport, fed.context) {
459            (false, Some(reading)) => serde_json::json!({
460                "used_tokens": reading.used_tokens,
461                "window_tokens": or_none(reading.window_tokens),
462                "budget_tokens": or_none(reading.budget_tokens),
463                "budget_basis": reading.budget_basis.wire_name(),
464                "measured_by": reading.measured_by,
465            }),
466            _ => Value::from(UNKNOWN),
467        };
468        StatusReport {
469            time,
470            turns: TurnsReport {
471                used: fed.task_turns,
472                cap: or_none(self.turn_cap),
473            },
474            keep_working: self.keep_working_limit.map_or_else(
475                || Value::from(NONE),
476                |limit| {
477                    serde_json::json!({
478                        "used": fed.keep_working_used,
479                        "left": limit.saturating_sub(fed.keep_working_used),
480                    })
481                },
482            ),
483            done_check: self.done_check_limit.map_or_else(
484                || Value::from(NONE),
485                |limit| {
486                    serde_json::json!({
487                        "used": fed.done_check_reopens,
488                        "left": limit.saturating_sub(fed.done_check_reopens),
489                    })
490                },
491            ),
492            context,
493            quota: (self.harness_transport && !fed.quota.is_empty())
494                .then(|| fed.quota.iter().map(QuotaReport::of).collect()),
495            tokens: TokensReport {
496                input: or_unknown(fed.input),
497                output: or_unknown(fed.output),
498                cached: or_unknown(fed.cached),
499                cache_write: or_unknown(fed.cache_write),
500                unreported_calls: fed.unreported_calls,
501            },
502            spend: SpendReport {
503                tokens: spend.used,
504                ceiling: or_none(spend.ceiling),
505                machine: spend.machine.map_or_else(
506                    || Value::from(NONE),
507                    |(tokens_today, ceiling)| {
508                        serde_json::json!({"tokens_today": tokens_today, "ceiling": ceiling})
509                    },
510                ),
511            },
512        }
513    }
514
515    /// The deadline notice, when it is due at `now` and has not been taken: once per session, from
516    /// the moment no more than the margin is left. Never with no deadline or a margin of `0`.
517    pub(crate) fn take_due_notice(&self, now: Instant) -> Option<DeadlineNotice> {
518        let deadline = self.deadline?;
519        if self.notice_secs == 0 {
520            return None;
521        }
522        let left = deadline.left(now);
523        if left > Duration::from_secs(self.notice_secs) {
524            return None;
525        }
526        if self.notice_taken.swap(true, Ordering::AcqRel) {
527            return None;
528        }
529        Some(DeadlineNotice {
530            at: deadline.at_rfc3339(),
531            left_secs: left.as_secs(),
532            notice_secs: self.notice_secs,
533        })
534    }
535}
536
537fn or_none<T: Into<Value>>(value: Option<T>) -> Value {
538    value.map_or_else(|| Value::from(NONE), Into::into)
539}
540
541fn or_unknown<T: Into<Value>>(value: Option<T>) -> Value {
542    value.map_or_else(|| Value::from(UNKNOWN), Into::into)
543}
544
545/// One `session-status` result: what the model is handed and what `session_status.status` records.
546#[derive(Debug, Clone, PartialEq, Serialize)]
547pub(crate) struct StatusReport {
548    time: TimeReport,
549    turns: TurnsReport,
550    /// `{used, left}` under `lifecycle.keep_working`; `"none"` without it.
551    keep_working: Value,
552    /// `{used, left}` under `lifecycle.done_check`; `"none"` without it.
553    done_check: Value,
554    /// An object under `transport: http`; `"unknown"` under `transport: process`.
555    context: Value,
556    /// The latest window per name the harness reported, under `transport: process` once it has
557    /// reported one; left out otherwise.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    quota: Option<Vec<QuotaReport>>,
560    tokens: TokensReport,
561    spend: SpendReport,
562}
563
564/// One subscription window as `session-status` reports it. A member the harness did not report
565/// is left out.
566#[derive(Debug, Clone, PartialEq, Serialize)]
567struct QuotaReport {
568    name: String,
569    #[serde(skip_serializing_if = "Option::is_none")]
570    used: Option<f64>,
571    /// RFC 3339 UTC to the second, as `time.deadline.at` is spelled.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    resets_at: Option<String>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    window_minutes: Option<u64>,
576    #[serde(skip_serializing_if = "Option::is_none")]
577    state: Option<&'static str>,
578}
579
580impl QuotaReport {
581    fn of(window: &QuotaWindow) -> Self {
582        Self {
583            name: window.name.clone(),
584            used: window.used,
585            resets_at: window.resets_at.and_then(rfc3339_of_unix_millis),
586            window_minutes: window.window_minutes,
587            state: window
588                .state
589                .map(crate::process_driver::QuotaState::wire_name),
590        }
591    }
592}
593
594/// `millis` after the Unix epoch as RFC 3339 UTC to the second; `None` past what the calendar
595/// holds.
596fn rfc3339_of_unix_millis(millis: u64) -> Option<String> {
597    let millis = i64::try_from(millis).ok()?;
598    chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
599        .map(|at| at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
600}
601
602#[derive(Debug, Clone, PartialEq, Serialize)]
603struct TimeReport {
604    elapsed_secs: u64,
605    task_elapsed_secs: Value,
606    deadline: Value,
607}
608
609#[derive(Debug, Clone, PartialEq, Serialize)]
610struct TurnsReport {
611    used: u32,
612    cap: Value,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize)]
616struct TokensReport {
617    input: Value,
618    output: Value,
619    cached: Value,
620    cache_write: Value,
621    unreported_calls: u64,
622}
623
624#[derive(Debug, Clone, PartialEq, Serialize)]
625struct SpendReport {
626    tokens: u64,
627    ceiling: Value,
628    machine: Value,
629}
630
631/// The opening and the fields every description states, in the order the result carries them.
632const FIELDS: &str = "This session's state now, as one JSON object; no input. time: \
633     elapsed_secs (session), task_elapsed_secs (current task), deadline {at: RFC 3339 UTC \
634     session end, left_secs to it}. turns: used, cap (this task's inference calls). \
635     keep_working: used, left (lifecycle.keep_working messages that kept this task working \
636     past a turn end). done_check: used, left (lifecycle.done_check reopens of this task on \
637     a failed command).";
638
639/// The `spend` field and the value rules every description shares.
640const SPEND_AND_VALUES: &str = "spend: tokens (this session's, as the spend ceilings count \
641     them), session ceiling, machine {tokens_today, daily ceiling}; counted in tokens, no \
642     money amount is kept. Unset limit: \"none\". Unknown count: \"unknown\". Seconds are whole.";
643
644/// `session-status`'s description under `transport: http`. Fixed, so the tool array's bytes do
645/// not change from one request to the next.
646pub fn description_http() -> String {
647    format!(
648        "{FIELDS} context: used_tokens (conversation size at last count), window_tokens \
649         (model context window), budget_tokens (most input tokens per call; compaction measures \
650         against it), budget_basis (\"max_input_tokens\": provider input ceiling; \
651         \"context_window_less_output_cap\": window less output cap), measured_by (\"actual\": \
652         provider's count of the last call; \"estimate\": runtime's count). tokens: \
653         provider-reported input, output, cached, cache_write for this session's calls, \
654         and unreported_calls it reported none for. {SPEND_AND_VALUES}"
655    )
656}
657
658/// `session-status`'s description under `transport: process`. Fixed, as
659/// [`description_http`] is.
660pub fn description_process() -> String {
661    format!(
662        "{FIELDS} Turns are counted as they close. context: \"unknown\"; the harness keeps its \
663         own context. tokens: harness-reported input, output, cached, cache_write for \
664         this session's closed turns, and unreported_calls, turns it reported none for. \
665         quota, once the harness reports one: its latest subscription windows, name, used (0-1), \
666         resets_at, window_minutes, state; unreported members are left out. {SPEND_AND_VALUES}"
667    )
668}
669
670/// `session-status`'s input schema: no input.
671pub const INPUT_SCHEMA: &str = r#"{"type":"object","properties":{}}"#;
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::process_driver::QuotaState;
677    use serde_json::json;
678
679    fn usage(
680        input: Option<u64>,
681        output: Option<u64>,
682        cached: Option<u64>,
683        cache_write: Option<u64>,
684    ) -> DriverUsage {
685        DriverUsage {
686            input_tokens: input,
687            output_tokens: output,
688            cached_tokens: cached,
689            cache_write_tokens: cache_write,
690            thinking_tokens: None,
691        }
692    }
693
694    fn no_spend() -> SpendReading {
695        SpendReading {
696            used: 0,
697            ceiling: None,
698            machine: None,
699        }
700    }
701
702    fn report(status: &SessionStatus, now: Instant, spend: SpendReading) -> Value {
703        serde_json::to_value(status.snapshot_at(now, spend)).unwrap()
704    }
705
706    /// The tool reports neither a CPU-time limit nor a shell grace period, and its description
707    /// promises neither.
708    #[test]
709    fn session_status_names_no_cpu_limit_and_no_shell_grace() {
710        let started = Instant::now();
711        let status = SessionStatus::new(started, false, Some(20), None, 300, None, None);
712        let text = serde_json::to_string(&status.snapshot_at(started, no_spend())).unwrap();
713        for description in [description_http(), description_process()] {
714            for named in [text.as_str(), description.as_str()] {
715                let lower = named.to_lowercase();
716                for word in ["cpu", "grace", "background"] {
717                    assert!(!lower.contains(word), "{word}: {named}");
718                }
719            }
720        }
721    }
722
723    #[test]
724    fn session_status_reports_every_fed_value_on_http() {
725        let started = Instant::now();
726        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
727        let deadline = SessionDeadline::after(started, wall, 7_200);
728        let status = SessionStatus::new(started, false, Some(20), Some(deadline), 300, None, None);
729        status.task_started("tsk_1", started + Duration::from_secs(44));
730        status.record_inference(
731            1,
732            Some(900),
733            Some(40),
734            Some(&usage(Some(1_000), Some(50), Some(600), Some(100))),
735        );
736        status.record_inference(
737            2,
738            Some(950),
739            Some(30),
740            Some(&usage(Some(1_200), Some(70), Some(0), Some(10))),
741        );
742        status.set_context(
743            61_234,
744            Some(200_000),
745            Some(InputBudget {
746                tokens: 191_808,
747                basis: BudgetBasis::ContextWindowLessOutputCap,
748            }),
749            OccupancyBasis::Estimate,
750        );
751        let now = started + Duration::from_millis(1_834_900);
752        let spend = SpendReading {
753            used: 1_920,
754            ceiling: Some(1_000_000),
755            machine: Some((5_000, 9_000_000)),
756        };
757        assert_eq!(
758            report(&status, now, spend),
759            json!({
760                "time": {"elapsed_secs": 1834, "task_elapsed_secs": 1790,
761                         "deadline": {"at": "2026-10-20T14:00:00Z", "left_secs": 5365}},
762                "turns": {"used": 2, "cap": 20},
763                "keep_working": "none",
764                "done_check": "none",
765                "context": {"used_tokens": 61234, "window_tokens": 200000,
766                            "budget_tokens": 191808,
767                            "budget_basis": "context_window_less_output_cap",
768                            "measured_by": "estimate"},
769                "tokens": {"input": 2200, "output": 120, "cached": 600, "cache_write": 110,
770                           "unreported_calls": 0},
771                "spend": {"tokens": 1920, "ceiling": 1000000,
772                          "machine": {"tokens_today": 5000, "ceiling": 9000000}},
773            })
774        );
775        // The wire text keeps the documented field order.
776        let text = serde_json::to_string(&status.snapshot_at(now, spend)).unwrap();
777        let order: Vec<usize> = [
778            "\"time\"",
779            "\"turns\"",
780            "\"keep_working\"",
781            "\"done_check\"",
782            "\"context\"",
783            "\"tokens\"",
784            "\"spend\"",
785        ]
786        .iter()
787        .map(|key| text.find(key).unwrap())
788        .collect();
789        assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{text}");
790    }
791
792    #[test]
793    fn session_status_says_none_for_unset_limits_and_unknown_for_missing_counts() {
794        let started = Instant::now();
795        let status = SessionStatus::new(started, false, None, None, 0, None, None);
796        assert_eq!(
797            report(&status, started, no_spend()),
798            json!({
799                "time": {"elapsed_secs": 0, "task_elapsed_secs": "unknown", "deadline": "none"},
800                "turns": {"used": 0, "cap": "none"},
801                "keep_working": "none",
802                "done_check": "none",
803                "context": "unknown",
804                "tokens": {"input": "unknown", "output": "unknown", "cached": "unknown",
805                           "cache_write": "unknown", "unreported_calls": 0},
806                "spend": {"tokens": 0, "ceiling": "none", "machine": "none"},
807            })
808        );
809
810        status.task_started("tsk_1", started);
811        // A record with no usage is an unreported call; one with only some members leaves the
812        // others unknown.
813        status.record_inference(1, Some(900), Some(40), None);
814        status.record_inference(
815            2,
816            Some(900),
817            Some(40),
818            Some(&usage(Some(7), None, None, None)),
819        );
820        status.set_context(940, None, None, OccupancyBasis::Estimate);
821        let value = report(&status, started + Duration::from_secs(3), no_spend());
822        assert_eq!(
823            value["tokens"],
824            json!({"input": 7, "output": "unknown", "cached": "unknown",
825                   "cache_write": "unknown", "unreported_calls": 1})
826        );
827        assert_eq!(
828            value["context"],
829            json!({"used_tokens": 940, "window_tokens": "none", "budget_tokens": "none",
830                   "budget_basis": "none", "measured_by": "estimate"})
831        );
832        assert_eq!(value["turns"]["used"], 2);
833        assert_eq!(value["time"]["task_elapsed_secs"], 3);
834
835        // The provider's count of the last call, when it reported one, is named as such.
836        status.set_context(1_010, None, None, OccupancyBasis::Actual);
837        assert_eq!(
838            report(&status, started, no_spend())["context"],
839            json!({"used_tokens": 1010, "window_tokens": "none", "budget_tokens": "none",
840                   "budget_basis": "none", "measured_by": "actual"})
841        );
842    }
843
844    #[test]
845    fn session_status_under_process_reads_the_harness_counts_and_no_context() {
846        let started = Instant::now();
847        let status = SessionStatus::new(started, true, Some(10), None, 0, None, None);
848        status.task_started("tsk_1", started);
849        status.record_inference(
850            1,
851            Some(3_000),
852            Some(200),
853            Some(&usage(None, None, Some(2_500), Some(400))),
854        );
855        status.record_inference(2, None, None, None);
856        // A process session is never fed a context reading, and one fed anyway is not reported.
857        status.set_context(1, Some(2), None, OccupancyBasis::Estimate);
858        let value = report(&status, started, no_spend());
859        assert_eq!(value["context"], json!("unknown"));
860        assert_eq!(
861            value["tokens"],
862            json!({"input": 3000, "output": 200, "cached": 2500, "cache_write": 400,
863                   "unreported_calls": 1})
864        );
865        assert_eq!(value["turns"], json!({"used": 2, "cap": 10}));
866    }
867
868    fn window(
869        name: &str,
870        used: Option<f64>,
871        resets_at: Option<u64>,
872        window_minutes: Option<u64>,
873        state: Option<QuotaState>,
874    ) -> QuotaWindow {
875        QuotaWindow {
876            name: name.to_string(),
877            used,
878            resets_at,
879            window_minutes,
880            state,
881        }
882    }
883
884    /// A later report replaces a window of the same name whole, a member it leaves out is gone,
885    /// a window it does not name stands, and the order is the order names were first reported.
886    #[test]
887    fn session_status_quota_keeps_the_latest_window_per_name() {
888        let started = Instant::now();
889        let status = SessionStatus::new(started, true, None, None, 0, None, None);
890        assert!(report(&status, started, no_spend()).get("quota").is_none());
891        status.record_quota(&[
892            window(
893                "five-hour",
894                Some(0.42),
895                Some(1_792_497_600_999),
896                Some(300),
897                Some(QuotaState::Ok),
898            ),
899            window("seven-day", Some(0.1), None, Some(10_080), None),
900        ]);
901        assert_eq!(
902            report(&status, started, no_spend())["quota"],
903            json!([
904                {"name": "five-hour", "used": 0.42, "resets_at": "2026-10-20T12:00:00Z",
905                 "window_minutes": 300, "state": "ok"},
906                {"name": "seven-day", "used": 0.1, "window_minutes": 10080}
907            ])
908        );
909        status.record_quota(&[
910            window("extra", None, None, None, None),
911            window(
912                "five-hour",
913                Some(0.97),
914                None,
915                None,
916                Some(QuotaState::Warning),
917            ),
918        ]);
919        assert_eq!(
920            report(&status, started, no_spend())["quota"],
921            json!([
922                {"name": "five-hour", "used": 0.97, "state": "warning"},
923                {"name": "seven-day", "used": 0.1, "window_minutes": 10080},
924                {"name": "extra"}
925            ])
926        );
927    }
928
929    /// An http session never reports `quota`, even one fed a report.
930    #[test]
931    fn session_status_quota_is_never_on_http() {
932        let started = Instant::now();
933        let status = SessionStatus::new(started, false, None, None, 0, None, None);
934        status.record_quota(&[window("five-hour", Some(1.0), None, None, None)]);
935        assert!(report(&status, started, no_spend()).get("quota").is_none());
936    }
937
938    #[test]
939    fn session_status_restarts_the_task_clock_and_turns_at_each_task() {
940        let started = Instant::now();
941        let status = SessionStatus::new(started, false, None, None, 0, None, None);
942        status.task_started("tsk_1", started);
943        status.record_inference(1, None, None, None);
944        status.task_ended();
945        assert_eq!(status.task_id(), None);
946        status.task_started("tsk_2", started + Duration::from_secs(10));
947        assert_eq!(status.task_id().as_deref(), Some("tsk_2"));
948        let value = report(&status, started + Duration::from_secs(12), no_spend());
949        assert_eq!(value["turns"]["used"], 0);
950        assert_eq!(value["time"]["task_elapsed_secs"], 2);
951        assert_eq!(value["time"]["elapsed_secs"], 12);
952        // Tokens are the session's, not the task's.
953        assert_eq!(value["tokens"]["unreported_calls"], 1);
954    }
955
956    /// `keep_working` counts the active task's continuations against the limit, from zero at each
957    /// task's start.
958    #[test]
959    fn session_status_counts_keep_working_per_task_against_the_limit() {
960        let started = Instant::now();
961        let status = SessionStatus::new(started, false, None, None, 0, Some(3), None);
962        assert_eq!(
963            report(&status, started, no_spend())["keep_working"],
964            json!({"used": 0, "left": 3})
965        );
966        status.task_started("tsk_1", started);
967        assert_eq!(
968            report(&status, started, no_spend())["keep_working"],
969            json!({"used": 0, "left": 3})
970        );
971        status.keep_working_sent(1);
972        status.keep_working_sent(2);
973        assert_eq!(
974            report(&status, started, no_spend())["keep_working"],
975            json!({"used": 2, "left": 1})
976        );
977        status.task_ended();
978        status.task_started("tsk_2", started);
979        assert_eq!(
980            report(&status, started, no_spend())["keep_working"],
981            json!({"used": 0, "left": 3})
982        );
983    }
984
985    #[test]
986    fn session_status_default_notice_secs_is_five_minutes_a_tenth_or_one_second() {
987        assert_eq!(default_notice_secs(7_200), 300);
988        assert_eq!(default_notice_secs(600), 60);
989        assert_eq!(default_notice_secs(5), 1);
990        assert_eq!(default_notice_secs(1), 1);
991        assert_eq!(notice_secs(Some(0), 7_200), 0);
992        assert_eq!(notice_secs(Some(90), 150), 90);
993        assert_eq!(notice_secs(None, 150), 15);
994    }
995
996    #[test]
997    fn session_status_take_due_notice_fires_once_inside_the_margin() {
998        let started = Instant::now();
999        let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
1000        let status = SessionStatus::new(started, false, None, Some(deadline), 20, None, None);
1001        assert_eq!(
1002            status.take_due_notice(started + Duration::from_secs(9)),
1003            None
1004        );
1005        let notice = status
1006            .take_due_notice(started + Duration::from_secs(10))
1007            .expect("due once 20 seconds are left");
1008        assert_eq!(notice.left_secs, 20);
1009        assert_eq!(notice.notice_secs, 20);
1010        assert_eq!(
1011            notice.text(),
1012            format!(
1013                "Session deadline: the runtime ends this session at {}, in 20 seconds.",
1014                deadline.at_rfc3339()
1015            )
1016        );
1017        assert_eq!(
1018            status.take_due_notice(started + Duration::from_secs(11)),
1019            None
1020        );
1021        assert_eq!(
1022            status.take_due_notice(started + Duration::from_secs(40)),
1023            None
1024        );
1025    }
1026
1027    #[test]
1028    fn session_status_take_due_notice_never_fires_at_zero_or_without_a_deadline() {
1029        let started = Instant::now();
1030        let deadline = SessionDeadline::after(started, SystemTime::now(), 30);
1031        let silent = SessionStatus::new(started, false, None, Some(deadline), 0, None, None);
1032        assert_eq!(
1033            silent.take_due_notice(started + Duration::from_secs(29)),
1034            None
1035        );
1036        let unbounded = SessionStatus::new(started, false, None, None, 300, None, None);
1037        assert_eq!(unbounded.take_due_notice(started), None);
1038        // A deadline already past still gives its one notice, saying no time is left.
1039        let past = SessionStatus::new(started, false, None, Some(deadline), 5, None, None);
1040        let notice = past
1041            .take_due_notice(started + Duration::from_secs(31))
1042            .unwrap();
1043        assert_eq!(notice.left_secs, 0);
1044    }
1045
1046    #[test]
1047    fn session_status_deadline_spells_at_in_utc_to_the_second() {
1048        let started = Instant::now();
1049        let wall = SystemTime::UNIX_EPOCH + Duration::from_millis(1_792_497_600_750);
1050        let deadline = SessionDeadline::after(started, wall, 600);
1051        assert_eq!(deadline.at_rfc3339(), "2026-10-20T12:10:00Z");
1052        assert_eq!(deadline.secs(), 600);
1053        assert_eq!(
1054            deadline.left(started + Duration::from_secs(700)),
1055            Duration::ZERO
1056        );
1057    }
1058
1059    #[test]
1060    fn session_status_deadline_beyond_the_bound_is_taken_as_the_bound() {
1061        let started = Instant::now();
1062        let deadline = SessionDeadline::after(started, SystemTime::now(), u64::MAX);
1063        assert_eq!(deadline.secs(), MAX_DEADLINE_SECS);
1064        assert_eq!(
1065            deadline.left(started),
1066            Duration::from_secs(MAX_DEADLINE_SECS)
1067        );
1068        assert!(deadline.at_rfc3339().ends_with('Z'));
1069    }
1070
1071    #[test]
1072    fn session_status_until_leaves_the_difference_zero_when_past_and_the_bound_at_most() {
1073        let started = Instant::now();
1074        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1075        let ahead = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
1076        assert_eq!(ahead.left(started), Duration::from_secs(90));
1077        assert_eq!(ahead.secs(), 90);
1078        assert_eq!(ahead.source(), DeadlineSource::Inherited);
1079        assert_eq!(ahead.at_rfc3339(), "2026-10-20T12:01:30Z");
1080
1081        let past = SessionDeadline::until(started, wall, wall - Duration::from_secs(5));
1082        assert_eq!(past.left(started), Duration::ZERO);
1083        assert_eq!(past.secs(), 0);
1084        // A passed moment still spells the moment it was.
1085        assert_eq!(past.at_rfc3339(), "2026-10-20T11:59:55Z");
1086
1087        let far = SessionDeadline::until(
1088            started,
1089            wall,
1090            wall + Duration::from_secs(MAX_DEADLINE_SECS + 10),
1091        );
1092        assert_eq!(far.left(started), Duration::from_secs(MAX_DEADLINE_SECS));
1093        assert_eq!(far.secs(), MAX_DEADLINE_SECS);
1094    }
1095
1096    #[test]
1097    fn session_status_until_rounds_secs_up() {
1098        let started = Instant::now();
1099        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1100        let deadline = SessionDeadline::until(started, wall, wall + Duration::from_millis(29_001));
1101        assert_eq!(deadline.secs(), 30);
1102        assert_eq!(deadline.left(started), Duration::from_millis(29_001));
1103        let whole = SessionDeadline::until(started, wall, wall + Duration::from_secs(29));
1104        assert_eq!(whole.secs(), 29);
1105        let instant = SessionDeadline::until(started, wall, wall + Duration::from_nanos(1));
1106        assert_eq!(instant.secs(), 1);
1107    }
1108
1109    #[test]
1110    fn session_status_unix_millis_round_trip_to_the_same_second() {
1111        let started = Instant::now();
1112        let wall = SystemTime::UNIX_EPOCH + Duration::from_nanos(1_792_497_600_999_999_999);
1113        let own = SessionDeadline::after(started, wall, 60);
1114        assert_eq!(own.source(), DeadlineSource::Own);
1115        let millis = own.at_unix_millis();
1116        assert_eq!(millis, 1_792_497_660_999);
1117        // A child that starts 3.5 s later inherits the same moment, to the millisecond.
1118        let child_started = started + Duration::from_millis(3_500);
1119        let child_wall = wall + Duration::from_millis(3_500);
1120        let inherited = SessionDeadline::from_unix_millis(child_started, child_wall, millis);
1121        assert_eq!(inherited.at_rfc3339(), own.at_rfc3339());
1122        assert_eq!(inherited.at_unix_millis(), millis);
1123        assert_eq!(inherited.secs(), 57);
1124        assert!(own.left(child_started) - inherited.left(child_started) < Duration::from_millis(1));
1125
1126        // A moment the wall clock cannot hold is the bound, not a panic.
1127        let overflow = SessionDeadline::from_unix_millis(started, wall, u64::MAX);
1128        assert_eq!(overflow.secs(), MAX_DEADLINE_SECS);
1129        assert_eq!(
1130            overflow.left(started),
1131            Duration::from_secs(MAX_DEADLINE_SECS)
1132        );
1133        let epoch = SessionDeadline::from_unix_millis(started, wall, 0);
1134        assert_eq!(epoch.left(started), Duration::ZERO);
1135    }
1136
1137    #[test]
1138    fn session_status_earliest_keeps_the_earlier_and_the_inherited_on_a_tie() {
1139        let started = Instant::now();
1140        let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(1_792_497_600);
1141        let own = SessionDeadline::after(started, wall, 60);
1142        let sooner = SessionDeadline::until(started, wall, wall + Duration::from_secs(30));
1143        let later = SessionDeadline::until(started, wall, wall + Duration::from_secs(90));
1144        let tie = SessionDeadline::until(started, wall, wall + Duration::from_secs(60));
1145
1146        assert_eq!(
1147            SessionDeadline::earliest(Some(own), Some(sooner)),
1148            Some(sooner)
1149        );
1150        assert_eq!(SessionDeadline::earliest(Some(own), Some(later)), Some(own));
1151        let tied = SessionDeadline::earliest(Some(own), Some(tie)).unwrap();
1152        assert_eq!(tied.source(), DeadlineSource::Inherited);
1153        assert_eq!(tied.secs(), 60);
1154        assert_eq!(SessionDeadline::earliest(Some(own), None), Some(own));
1155        assert_eq!(SessionDeadline::earliest(None, Some(later)), Some(later));
1156        assert_eq!(SessionDeadline::earliest(None, None), None);
1157        assert_eq!(DeadlineSource::Own.as_str(), "own");
1158        assert_eq!(DeadlineSource::Inherited.as_str(), "inherited");
1159    }
1160
1161    /// `done_check` counts the active task's reopens by `lifecycle.done_check` against the limit,
1162    /// from zero at each task's start.
1163    #[test]
1164    fn session_status_counts_done_check_reopens_per_task_against_the_limit() {
1165        let started = Instant::now();
1166        let status = SessionStatus::new(started, false, None, None, 0, None, Some(3));
1167        assert_eq!(
1168            report(&status, started, no_spend())["done_check"],
1169            json!({"used": 0, "left": 3})
1170        );
1171        assert_eq!(report(&status, started, no_spend())["keep_working"], "none");
1172        status.task_started("tsk_1", started);
1173        status.done_check_reopened(1);
1174        status.done_check_reopened(2);
1175        assert_eq!(
1176            report(&status, started, no_spend())["done_check"],
1177            json!({"used": 2, "left": 1})
1178        );
1179        status.task_ended();
1180        status.task_started("tsk_2", started);
1181        assert_eq!(
1182            report(&status, started, no_spend())["done_check"],
1183            json!({"used": 0, "left": 3})
1184        );
1185    }
1186
1187    /// Every field the report carries is named, with the facts that are not in the field names:
1188    /// what each count counts, the sentinels and the unit of spend. Nothing advises when to call.
1189    #[test]
1190    fn session_status_descriptions_name_every_field_and_no_timing_advice() {
1191        let shared = [
1192            "This session's state now, as one JSON object; no input.",
1193            "time: elapsed_secs (session), task_elapsed_secs (current task), deadline {at: RFC \
1194             3339 UTC session end, left_secs to it}.",
1195            "turns: used, cap (this task's inference calls).",
1196            "keep_working: used, left (lifecycle.keep_working messages that kept this task \
1197             working past a turn end).",
1198            "done_check: used, left (lifecycle.done_check reopens of this task on a failed \
1199             command).",
1200            " input, output, cached, cache_write for this session's ",
1201            "unreported_calls",
1202            "spend: tokens (this session's, as the spend ceilings count them), session ceiling, \
1203             machine {tokens_today, daily ceiling}; counted in tokens, no money amount is kept.",
1204            "Unset limit: \"none\".",
1205            "Unknown count: \"unknown\".",
1206            "Seconds are whole.",
1207        ];
1208        let http = description_http();
1209        let process = description_process();
1210        for (description, own) in [
1211            (
1212                &http,
1213                &[
1214                    "used_tokens (conversation size at last count)",
1215                    "window_tokens (model context window)",
1216                    "budget_tokens (most input tokens per call; compaction measures against it)",
1217                    "budget_basis",
1218                    "\"max_input_tokens\": provider input ceiling",
1219                    "\"context_window_less_output_cap\": window less output cap",
1220                    "measured_by",
1221                    "\"actual\": provider's count of the last call",
1222                    "\"estimate\": runtime's count",
1223                    "context: used_tokens",
1224                    "tokens: provider-reported input, output, cached, cache_write for this \
1225                     session's calls, and unreported_calls it reported none for.",
1226                ][..],
1227            ),
1228            (
1229                &process,
1230                &[
1231                    "Turns are counted as they close",
1232                    "context: \"unknown\"; the harness keeps its own context",
1233                    "tokens: harness-reported input, output, cached, cache_write for this \
1234                     session's closed turns",
1235                    "unreported_calls, turns it reported none for",
1236                    "quota, once the harness reports one: its latest subscription windows, name, \
1237                     used (0-1), resets_at, window_minutes, state; unreported members are left out.",
1238                ][..],
1239            ),
1240        ] {
1241            for fact in shared.iter().chain(own) {
1242                assert!(description.contains(fact), "{fact} missing: {description}");
1243            }
1244            let lower = description.to_lowercase();
1245            for advice in ["when", "should", "call this", "periodically"] {
1246                assert!(!lower.contains(advice), "{advice}: {description}");
1247            }
1248        }
1249        for http_only in [
1250            "used_tokens",
1251            "window_tokens",
1252            "budget_tokens",
1253            "measured_by",
1254        ] {
1255            assert!(!process.contains(http_only), "{http_only}: {process}");
1256        }
1257        assert!(!http.contains("quota"), "{http}");
1258    }
1259}