Skip to main content

zeph_core/agent/
utils.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_llm::provider::{LlmProvider, Message, MessagePart, Role};
5
6use super::{Agent, CODE_CONTEXT_PREFIX};
7use crate::channel::Channel;
8use crate::metrics::{MetricsSnapshot, SECURITY_EVENT_CAP, SecurityEvent};
9use zeph_common::SecurityEventCategory;
10use zeph_tools::FilterStats;
11
12/// Fetch entity/edge/community counts from `store`, defaulting each to `0` on a per-metric error.
13///
14/// Shared by [`Agent::sync_graph_counts`] and the background graph-count-sync tasks in
15/// `persistence::extract` (post-extraction refresh and the periodic count-sync task) so the
16/// three call sites cannot drift apart.
17pub(super) async fn fetch_graph_counts(store: &zeph_memory::graph::GraphStore) -> (u64, u64, u64) {
18    let (entities, edges, communities) = tokio::join!(
19        store.entity_count(),
20        store.active_edge_count(),
21        store.community_count()
22    );
23    (
24        entities.unwrap_or(0).cast_unsigned(),
25        edges.unwrap_or(0).cast_unsigned(),
26        communities.unwrap_or(0).cast_unsigned(),
27    )
28}
29
30impl<C: Channel> Agent<C> {
31    /// Read the community-detection failure counter from `SemanticMemory` and update metrics.
32    pub fn sync_community_detection_failures(&self) {
33        if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
34            let failures = memory.community_detection_failures();
35            self.update_metrics(|m| {
36                m.graph_community_detection_failures = failures;
37            });
38        }
39    }
40
41    /// Sync all graph counters (extraction count/failures) from `SemanticMemory` to metrics.
42    pub fn sync_graph_extraction_metrics(&self) {
43        if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
44            let count = memory.graph_extraction_count();
45            let failures = memory.graph_extraction_failures();
46            self.update_metrics(|m| {
47                m.graph_extraction_count = count;
48                m.graph_extraction_failures = failures;
49            });
50        }
51    }
52
53    /// Fetch entity/edge/community counts from the graph store and write to metrics.
54    pub async fn sync_graph_counts(&self) {
55        let Some(memory) = self.services.memory.persistence.memory.as_ref() else {
56            return;
57        };
58        let Some(store) = memory.graph_store.as_ref() else {
59            return;
60        };
61        let (entities, edges, communities) = fetch_graph_counts(store).await;
62        self.update_metrics(|m| {
63            m.graph_entities_total = entities;
64            m.graph_edges_total = edges;
65            m.graph_communities_total = communities;
66        });
67    }
68
69    /// Perform a real health check on the vector store and update metrics.
70    pub async fn check_vector_store_health(&self, backend_name: &str) {
71        let connected = match self.services.memory.persistence.memory.as_ref() {
72            Some(m) => m.is_vector_store_connected().await,
73            None => false,
74        };
75        let name = backend_name.to_owned();
76        self.update_metrics(|m| {
77            m.qdrant_available = connected;
78            m.vector_backend = name;
79        });
80    }
81
82    /// Fetch compression-guidelines metadata from `SQLite` and write to metrics.
83    ///
84    /// Only fetches version and `created_at`; does not load the full guidelines text.
85    /// Feature-gated: compiled only when `compression-guidelines` is enabled.
86    pub async fn sync_guidelines_status(&self) {
87        let Some(memory) = self.services.memory.persistence.memory.as_ref() else {
88            return;
89        };
90        let cid = self.services.memory.persistence.conversation_id;
91        match memory.sqlite().load_compression_guidelines_meta(cid).await {
92            Ok((version, created_at)) => {
93                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
94                let version_u32 = u32::try_from(version).unwrap_or(0);
95                self.update_metrics(|m| {
96                    m.guidelines_version = version_u32;
97                    m.guidelines_updated_at = created_at;
98                });
99            }
100            Err(e) => {
101                tracing::warn!("failed to sync guidelines status: {e:#}");
102            }
103        }
104    }
105
106    pub(super) fn record_filter_metrics(&mut self, fs: &FilterStats) {
107        let saved = fs.estimated_tokens_saved() as u64;
108        let raw = (fs.raw_chars / 4) as u64;
109        let confidence = fs.confidence;
110        let was_filtered = fs.filtered_chars < fs.raw_chars;
111        self.update_metrics(|m| {
112            m.filter_raw_tokens += raw;
113            m.filter_saved_tokens += saved;
114            m.filter_applications += 1;
115            m.filter_total_commands += 1;
116            if was_filtered {
117                m.filter_filtered_commands += 1;
118            }
119            if let Some(c) = confidence {
120                match c {
121                    zeph_tools::FilterConfidence::Full => {
122                        m.filter_confidence_full += 1;
123                    }
124                    zeph_tools::FilterConfidence::Partial => {
125                        m.filter_confidence_partial += 1;
126                    }
127                    zeph_tools::FilterConfidence::Fallback => {
128                        m.filter_confidence_fallback += 1;
129                    }
130                    _ => {}
131                }
132            }
133        });
134    }
135
136    pub(super) fn update_metrics(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
137        if let Some(ref tx) = self.runtime.metrics.metrics_tx {
138            let elapsed = self.runtime.lifecycle.start_time.elapsed().as_secs();
139            tx.send_modify(|m| {
140                m.uptime_seconds = elapsed;
141                f(m);
142            });
143        }
144    }
145
146    /// Publish the effective context window limit from the active provider's budget into
147    /// [`MetricsSnapshot::context_max_tokens`].
148    ///
149    /// Call after the provider pool is constructed (builder) and on every successful `/provider`
150    /// switch so the TUI context gauge always reflects the active provider's window.
151    /// When no budget is configured the field is set to `0`, which the gauge renders as `"—"`.
152    pub(crate) fn publish_context_budget(&self) {
153        let max_tokens = self
154            .context_manager
155            .budget
156            .as_ref()
157            .map_or(0, |b| b.max_tokens() as u64);
158        self.update_metrics(|m| m.context_max_tokens = max_tokens);
159    }
160
161    /// Flush `metrics.pending_timings` into the rolling window and publish to the metrics snapshot.
162    ///
163    /// Call once per turn after all four phases have written to `pending_timings`.
164    /// Resets `pending_timings` to default after flushing.
165    ///
166    /// When the `profiling` feature is compiled in, per-field values that `MetricsBridge`
167    /// marked as freshly written this turn (`MetricsSnapshot::bridge_timings_written`) take
168    /// precedence over the manual value computed here; unmarked fields keep the manual value.
169    /// This avoids unconditionally clobbering the bridge's span-derived timings every turn
170    /// (#5946) while still working correctly for fields the bridge does not (yet) populate.
171    pub(super) fn flush_turn_timings(&mut self) {
172        #[cfg_attr(not(feature = "profiling"), allow(unused_mut))]
173        let mut timings = std::mem::take(&mut self.runtime.metrics.pending_timings);
174        tracing::debug!(
175            prepare_context_ms = timings.prepare_context_ms,
176            llm_chat_ms = timings.llm_chat_ms,
177            tool_exec_ms = timings.tool_exec_ms,
178            persist_message_ms = timings.persist_message_ms,
179            "turn timings"
180        );
181
182        // #5946 (critic finding S2): read the bridge's per-field "written this turn" mask,
183        // reconcile it into `timings`, AND clear the mask — all inside this one `send_modify`
184        // closure (via `update_metrics`), so the whole read-then-clear is atomic. A previous
185        // version read the mask via a separate `borrow()` and cleared it in a later, independent
186        // `update_metrics` call; a `MetricsBridge::on_close` write landing in the gap between
187        // those two steps would have had its bit and value silently discarded.
188        #[cfg(feature = "profiling")]
189        self.update_metrics(|m| {
190            let mask = m.bridge_timings_written;
191            if mask & crate::metrics_bridge::TimingField::PrepareContext.bridge_bit() != 0 {
192                timings.prepare_context_ms = m.last_turn_timings.prepare_context_ms;
193            }
194            if mask & crate::metrics_bridge::TimingField::LlmChat.bridge_bit() != 0 {
195                timings.llm_chat_ms = m.last_turn_timings.llm_chat_ms;
196            }
197            if mask & crate::metrics_bridge::TimingField::ToolExec.bridge_bit() != 0 {
198                timings.tool_exec_ms = m.last_turn_timings.tool_exec_ms;
199            }
200            // persist_message_ms is intentionally never bridged (#6111) — its real span fires
201            // 7+ times per turn, not once, so `timings.persist_message_ms` always keeps the
202            // manual `Instant::now()` value computed in `agent/mod.rs`.
203            m.bridge_timings_written = 0;
204            // `MetricsBridge::on_close` accumulates `llm_chat_ms` across every `chat_with_tools`
205            // span closed this turn (#6275). Reset it to 0 here, now that it has been read into
206            // `timings` above, so the next turn's accumulation starts fresh instead of adding
207            // onto this turn's total.
208            m.last_turn_timings.llm_chat_ms = 0;
209        });
210
211        if self.runtime.metrics.timing_window.len() >= 10 {
212            self.runtime.metrics.timing_window.pop_front();
213        }
214        self.runtime
215            .metrics
216            .timing_window
217            .push_back(timings.clone());
218
219        let count = self.runtime.metrics.timing_window.len();
220        let mut avg = crate::metrics::TurnTimings::default();
221        let mut max = crate::metrics::TurnTimings::default();
222        for t in &self.runtime.metrics.timing_window {
223            avg.prepare_context_ms = avg.prepare_context_ms.saturating_add(t.prepare_context_ms);
224            avg.llm_chat_ms = avg.llm_chat_ms.saturating_add(t.llm_chat_ms);
225            avg.tool_exec_ms = avg.tool_exec_ms.saturating_add(t.tool_exec_ms);
226            avg.persist_message_ms = avg.persist_message_ms.saturating_add(t.persist_message_ms);
227
228            max.prepare_context_ms = max.prepare_context_ms.max(t.prepare_context_ms);
229            max.llm_chat_ms = max.llm_chat_ms.max(t.llm_chat_ms);
230            max.tool_exec_ms = max.tool_exec_ms.max(t.tool_exec_ms);
231            max.persist_message_ms = max.persist_message_ms.max(t.persist_message_ms);
232        }
233        let n = count as u64;
234        avg.prepare_context_ms /= n;
235        avg.llm_chat_ms /= n;
236        avg.tool_exec_ms /= n;
237        avg.persist_message_ms /= n;
238
239        let total_ms = timings
240            .prepare_context_ms
241            .saturating_add(timings.llm_chat_ms)
242            .saturating_add(timings.tool_exec_ms)
243            .saturating_add(timings.persist_message_ms);
244
245        self.update_metrics(|m| {
246            m.last_turn_timings = timings;
247            m.avg_turn_timings = avg;
248            m.max_turn_timings = max;
249            m.timing_sample_count = n;
250        });
251
252        if let Some(ref recorder) = self.runtime.metrics.histogram_recorder {
253            recorder.observe_turn_duration(std::time::Duration::from_millis(total_ms));
254        }
255    }
256
257    /// Push the current classifier metrics snapshot into `MetricsSnapshot`.
258    ///
259    /// Call this after any classifier invocation (injection, PII, feedback) so the TUI panel
260    /// reflects the latest p50/p95 values. No-op when classifier metrics are not configured.
261    pub(super) fn push_classifier_metrics(&self) {
262        if let Some(ref m) = self.runtime.metrics.classifier_metrics {
263            let snapshot = m.snapshot();
264            self.update_metrics(|ms| ms.classifier = snapshot);
265        }
266    }
267
268    pub(super) fn push_security_event(
269        &self,
270        category: SecurityEventCategory,
271        source: &str,
272        detail: impl Into<String>,
273    ) {
274        if let Some(ref tx) = self.runtime.metrics.metrics_tx {
275            let event = SecurityEvent::new(category, source, detail);
276            let elapsed = self.runtime.lifecycle.start_time.elapsed().as_secs();
277            tx.send_modify(|m| {
278                m.uptime_seconds = elapsed;
279                if m.security_events.len() >= SECURITY_EVENT_CAP {
280                    m.security_events.pop_front();
281                }
282                m.security_events.push_back(event);
283            });
284        }
285    }
286
287    pub(super) fn recompute_prompt_tokens(&mut self) {
288        self.runtime.providers.cached_prompt_tokens = self
289            .msg
290            .messages
291            .iter()
292            .map(|m| self.runtime.metrics.token_counter.count_message_tokens(m) as u64)
293            .sum();
294    }
295
296    pub(super) fn push_message(&mut self, msg: Message) {
297        self.runtime.providers.cached_prompt_tokens +=
298            self.runtime
299                .metrics
300                .token_counter
301                .count_message_tokens(&msg) as u64;
302        if msg.role == zeph_llm::provider::Role::Assistant {
303            self.services.session.last_assistant_at = Some(std::time::Instant::now());
304        }
305        self.msg.track_single_message(msg.role, true);
306        self.msg.messages.push(msg);
307        // Detect MagicDoc headers in tool output after pushing the message.
308        self.detect_magic_docs_in_messages();
309    }
310
311    /// Like [`Self::push_message`], but splices `msg` at `index` instead of appending it at the
312    /// true end — for repairing an out-of-order shutdown-flush tombstone (see
313    /// `shutdown::flush_orphaned_tool_use_on_shutdown`) where a later turn's message may already
314    /// have been appended after the orphaned assistant message this tombstone must immediately
315    /// follow. Token accounting and `MagicDoc` detection are position-independent, so both are
316    /// shared with `push_message`.
317    pub(super) fn insert_message(&mut self, index: usize, msg: Message) {
318        self.runtime.providers.cached_prompt_tokens +=
319            self.runtime
320                .metrics
321                .token_counter
322                .count_message_tokens(&msg) as u64;
323        if msg.role == zeph_llm::provider::Role::Assistant {
324            self.services.session.last_assistant_at = Some(std::time::Instant::now());
325        }
326        self.msg.track_single_message(msg.role, true);
327        self.msg.messages.insert(index, msg);
328        self.detect_magic_docs_in_messages();
329    }
330
331    pub(crate) fn record_cost_and_cache(&self, input_tokens: u64, output_tokens: u64) {
332        let (cache_write, cache_read) = self.provider.last_cache_usage().unwrap_or((0, 0));
333
334        if let Some(ref tracker) = self.runtime.metrics.cost_tracker {
335            let provider_name = if self.runtime.config.active_provider_name.is_empty() {
336                self.provider.name()
337            } else {
338                self.runtime.config.active_provider_name.as_str()
339            };
340            tracker.record_usage(
341                provider_name,
342                self.provider.provider_kind_str(),
343                &self.runtime.config.model_name,
344                input_tokens,
345                cache_read,
346                cache_write,
347                output_tokens,
348            );
349            let breakdown = tracker.provider_breakdown();
350            self.update_metrics(|m| {
351                m.cost_spent_cents = tracker.current_spend();
352                m.cache_creation_tokens += cache_write;
353                m.cache_read_tokens += cache_read;
354                m.provider_cost_breakdown = breakdown;
355            });
356        } else if cache_write > 0 || cache_read > 0 {
357            self.update_metrics(|m| {
358                m.cache_creation_tokens += cache_write;
359                m.cache_read_tokens += cache_read;
360            });
361        }
362    }
363
364    /// Build a durable [`zeph_memory::UsageRecord`] for one LLM call, or `None` when cost
365    /// tracking is disabled (mirrors [`Self::record_cost_and_cache`]'s no-op gating, issue
366    /// #6549 — usage-row writes follow the existing `[cost] enabled` switch).
367    ///
368    /// `provider` supplies the call-specific transient state (`last_cache_usage`,
369    /// `last_ttft_ms`, `last_reasoning_tokens`) — pass the exact [`zeph_llm::any::AnyProvider`]
370    /// instance that served this call (e.g. `orchestrator_provider` for `plan.rs`'s
371    /// planner/aggregator sites), since each provider clone owns an independent usage tracker.
372    /// Naming and pricing intentionally always resolve from `self.runtime.config`
373    /// (`active_provider_name`/`model_name`), matching [`Self::record_cost_and_cache`]'s
374    /// existing resolution exactly — mirroring its behavior is what keeps this row's cost
375    /// identical to the value that call folds into the live daily aggregate (M2), even though
376    /// that means a background call served by an alternate provider is still priced against
377    /// the agent's primary model config (pre-existing `record_cost_and_cache` behavior, not
378    /// changed here).
379    ///
380    /// `message_id`/`conversation_id` are left to the caller: the turn-loop path fills
381    /// `message_id` in only once the paired `messages` row is persisted; `plan.rs`/
382    /// `scheduler_loop.rs` background call sites pass `None` for `message_id` (no persisted
383    /// conversational message exists for them).
384    #[allow(clippy::too_many_arguments)] // mirrors CostTracker::record_usage's own allow — a *Params struct would be more verbose without simplifying the call sites
385    pub(crate) fn build_usage_record(
386        &self,
387        provider: &zeph_llm::any::AnyProvider,
388        source: zeph_memory::UsageSource,
389        message_id: Option<zeph_memory::MessageId>,
390        conversation_id: Option<zeph_memory::ConversationId>,
391        input_tokens: u64,
392        output_tokens: u64,
393        latency_ms: u64,
394        stream_ttft_ms: Option<u64>,
395    ) -> Option<zeph_memory::UsageRecord> {
396        let tracker = self.runtime.metrics.cost_tracker.as_ref()?;
397        let (cache_write, cache_read) = provider.last_cache_usage().unwrap_or((0, 0));
398        let reasoning_tokens = provider.last_reasoning_tokens();
399        // Issue #6549 S1: prefer the true first-content-token time captured at the SSE stream
400        // consumption point (speculative-decoding path) over the provider-level TTFB proxy —
401        // `stream_ttft_ms` is `Some` only when this call actually streamed.
402        let ttft_ms = stream_ttft_ms.or_else(|| provider.last_ttft_ms());
403        let provider_name = if self.runtime.config.active_provider_name.is_empty() {
404            self.provider.name()
405        } else {
406            self.runtime.config.active_provider_name.as_str()
407        };
408        let cost_cents = tracker.price_of(
409            &self.runtime.config.model_name,
410            input_tokens,
411            cache_read,
412            cache_write,
413            output_tokens,
414        );
415        // Generation-window throughput: only derivable once TTFT/TTFB is known and the
416        // remaining window (latency - ttft) is positive.
417        let tokens_per_sec = ttft_ms.and_then(|ttft| {
418            if latency_ms <= ttft {
419                return None;
420            }
421            #[allow(clippy::cast_precision_loss)]
422            let window_secs = (latency_ms - ttft) as f64 / 1000.0;
423            #[allow(clippy::cast_precision_loss)]
424            Some(output_tokens as f64 / window_secs)
425        });
426        Some(zeph_memory::UsageRecord {
427            message_id,
428            conversation_id,
429            source,
430            provider_name: provider_name.to_owned(),
431            model_name: self.runtime.config.model_name.clone(),
432            input_tokens,
433            output_tokens,
434            cache_read_tokens: cache_read,
435            cache_write_tokens: cache_write,
436            reasoning_tokens,
437            cost_cents,
438            latency_ms,
439            ttft_ms,
440            tokens_per_sec,
441        })
442    }
443
444    pub(crate) fn record_successful_task(&self) {
445        if let Some(ref tracker) = self.runtime.metrics.cost_tracker {
446            tracker.record_successful_task();
447            self.update_metrics(|m| {
448                m.cost_cps_cents = tracker.cps();
449                m.cost_successful_tasks = tracker.successful_tasks();
450            });
451        }
452    }
453
454    /// Extract a redacted preview of the last assistant message.
455    ///
456    /// Walks `self.msg.messages` in reverse to find the most recent `Role::Assistant`
457    /// message, takes up to `max_chars` Unicode scalar values from `message.content`,
458    /// and applies [`crate::redact::scrub_content`] to redact any secrets.
459    ///
460    /// Returns an empty string when no assistant message exists in the current turn.
461    pub(super) fn last_assistant_preview(&self, max_chars: usize) -> String {
462        let raw = self
463            .msg
464            .messages
465            .iter()
466            .rev()
467            .find(|m| m.role == Role::Assistant)
468            .map_or("", |m| m.content.as_str());
469
470        if raw.is_empty() {
471            return String::new();
472        }
473
474        // Truncate to max_chars before redaction to bound redaction work.
475        let truncated: &str = if raw.chars().count() > max_chars {
476            let end = raw
477                .char_indices()
478                .nth(max_chars)
479                .map_or(raw.len(), |(i, _)| i);
480            &raw[..end]
481        } else {
482            raw
483        };
484
485        crate::redact::scrub_content(truncated).into_owned()
486    }
487
488    /// Inject pre-formatted code context into the message list.
489    /// The caller is responsible for retrieving and formatting the text.
490    pub fn inject_code_context(&mut self, text: &str) {
491        self.remove_code_context_messages();
492        if text.is_empty() || self.msg.messages.len() <= 1 {
493            return;
494        }
495        let content = format!("{CODE_CONTEXT_PREFIX}{text}");
496        self.msg.track_single_message(Role::System, true);
497        self.msg.messages.insert(
498            1,
499            Message::from_parts(
500                Role::System,
501                vec![MessagePart::CodeContext { text: content }],
502            ),
503        );
504    }
505
506    #[must_use]
507    pub fn context_messages(&self) -> &[Message] {
508        &self.msg.messages
509    }
510
511    /// Truncate stale tool result content in old messages to bound in-memory growth.
512    ///
513    /// After the LLM has seen and responded to tool output, the full content is no longer
514    /// needed in the hot message list (it is already persisted to `SQLite`). Truncating keeps
515    /// the in-process message vec small across long sessions.
516    ///
517    /// Skips the last 2 messages so the LLM retains full context for the next turn.
518    ///
519    /// Truncated variants: `MessagePart::ToolResult` (content) and `MessagePart::ToolOutput` (body).
520    pub(super) fn truncate_old_tool_results(&mut self) {
521        const LIMIT: usize = 2048;
522        const SUFFIX: &str = "…[truncated]";
523
524        let len = self.msg.messages.len();
525        if len <= 2 {
526            return;
527        }
528        for msg in &mut self.msg.messages[..len - 2] {
529            for part in &mut msg.parts {
530                match part {
531                    MessagePart::ToolResult { content, .. } if content.len() > LIMIT => {
532                        content.truncate(content.floor_char_boundary(LIMIT));
533                        content.push_str(SUFFIX);
534                    }
535                    MessagePart::ToolOutput { body, .. } if body.len() > LIMIT => {
536                        body.truncate(body.floor_char_boundary(LIMIT));
537                        body.push_str(SUFFIX);
538                    }
539                    _ => {}
540                }
541            }
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::super::agent_tests::{
549        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
550    };
551    use super::*;
552    use zeph_llm::provider::{MessageMetadata, MessagePart};
553    use zeph_memory::graph::GraphStore;
554    use zeph_memory::graph::types::EntityType;
555    use zeph_memory::store::SqliteStore;
556
557    async fn setup_graph_store() -> GraphStore {
558        let sqlite = SqliteStore::new(":memory:").await.unwrap();
559        GraphStore::new(sqlite.pool().clone())
560    }
561
562    #[tokio::test]
563    async fn fetch_graph_counts_empty_store_returns_zeros() {
564        let store = setup_graph_store().await;
565        assert_eq!(fetch_graph_counts(&store).await, (0, 0, 0));
566    }
567
568    #[tokio::test]
569    async fn fetch_graph_counts_reflects_actual_counts() {
570        let store = setup_graph_store().await;
571        let a = store
572            .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
573            .await
574            .unwrap()
575            .0;
576        let b = store
577            .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
578            .await
579            .unwrap()
580            .0;
581        store
582            .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
583            .await
584            .unwrap();
585        store
586            .upsert_community("cluster", "summary", &[a, b], None)
587            .await
588            .unwrap();
589
590        assert_eq!(fetch_graph_counts(&store).await, (2, 1, 1));
591    }
592
593    /// #5677 follow-up: each of the 3 metrics must fall back to `0` independently on its own
594    /// query error, not abort the other two — verified by breaking only `graph_communities`
595    /// while `graph_entities`/`graph_edges` stay intact and populated.
596    #[tokio::test]
597    async fn fetch_graph_counts_falls_back_to_zero_per_field_on_error() {
598        let sqlite = SqliteStore::new(":memory:").await.unwrap();
599        let pool = sqlite.pool().clone();
600        let store = GraphStore::new(pool.clone());
601        let a = store
602            .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
603            .await
604            .unwrap()
605            .0;
606        let b = store
607            .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
608            .await
609            .unwrap()
610            .0;
611        store
612            .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
613            .await
614            .unwrap();
615
616        sqlx::query("DROP TABLE graph_communities")
617            .execute(&pool)
618            .await
619            .unwrap();
620
621        assert_eq!(fetch_graph_counts(&store).await, (2, 1, 0));
622    }
623
624    #[test]
625    fn push_message_increments_cached_tokens() {
626        let provider = mock_provider(vec![]);
627        let channel = MockChannel::new(vec![]);
628        let registry = create_test_registry();
629        let executor = MockToolExecutor::no_tools();
630        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
631
632        let before = agent.runtime.providers.cached_prompt_tokens;
633        let msg = Message {
634            role: Role::User,
635            content: "hello world!!".to_string(),
636            parts: vec![],
637            metadata: MessageMetadata::default(),
638        };
639        let expected_delta = agent
640            .runtime
641            .metrics
642            .token_counter
643            .count_message_tokens(&msg) as u64;
644        agent.push_message(msg);
645        assert_eq!(
646            agent.runtime.providers.cached_prompt_tokens,
647            before + expected_delta
648        );
649    }
650
651    /// #5646: `insert_message` must splice at the given index (not append at the end) while
652    /// still tracking token accounting identically to `push_message` — direct coverage of the
653    /// method itself, complementing its indirect exercise via
654    /// `flush_orphaned_tests::flush_orphaned_inserts_tombstone_immediately_after_orphan_not_at_end`
655    /// and `focus_tests::persist_cancelled_tool_results_some_index_inserts_at_that_position`.
656    #[test]
657    fn insert_message_splices_at_index_and_tracks_tokens() {
658        let provider = mock_provider(vec![]);
659        let channel = MockChannel::new(vec![]);
660        let registry = create_test_registry();
661        let executor = MockToolExecutor::no_tools();
662        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
663
664        agent.msg.messages.push(Message {
665            role: Role::User,
666            content: "first".to_string(),
667            parts: vec![],
668            metadata: MessageMetadata::default(),
669        });
670        agent.msg.messages.push(Message {
671            role: Role::User,
672            content: "third".to_string(),
673            parts: vec![],
674            metadata: MessageMetadata::default(),
675        });
676        let insert_idx = agent.msg.messages.len() - 1;
677        let before_tokens = agent.runtime.providers.cached_prompt_tokens;
678
679        let msg = Message {
680            role: Role::User,
681            content: "second".to_string(),
682            parts: vec![],
683            metadata: MessageMetadata::default(),
684        };
685        let expected_delta = agent
686            .runtime
687            .metrics
688            .token_counter
689            .count_message_tokens(&msg) as u64;
690        agent.insert_message(insert_idx, msg);
691
692        assert_eq!(
693            agent.msg.messages[insert_idx].content, "second",
694            "message must be spliced at the given index"
695        );
696        assert_eq!(
697            agent.msg.messages[insert_idx + 1].content,
698            "third",
699            "the message previously at insert_idx must be pushed one slot forward"
700        );
701        assert_eq!(
702            agent.runtime.providers.cached_prompt_tokens,
703            before_tokens + expected_delta,
704            "insert_message must track token accounting identically to push_message"
705        );
706    }
707
708    #[test]
709    fn recompute_prompt_tokens_matches_sum() {
710        let provider = mock_provider(vec![]);
711        let channel = MockChannel::new(vec![]);
712        let registry = create_test_registry();
713        let executor = MockToolExecutor::no_tools();
714        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
715
716        agent.msg.messages.push(Message {
717            role: Role::User,
718            content: "1234".to_string(),
719            parts: vec![],
720            metadata: MessageMetadata::default(),
721        });
722        agent.msg.messages.push(Message {
723            role: Role::Assistant,
724            content: "5678".to_string(),
725            parts: vec![],
726            metadata: MessageMetadata::default(),
727        });
728
729        agent.recompute_prompt_tokens();
730
731        let expected: u64 = agent
732            .msg
733            .messages
734            .iter()
735            .map(|m| agent.runtime.metrics.token_counter.count_message_tokens(m) as u64)
736            .sum();
737        assert_eq!(agent.runtime.providers.cached_prompt_tokens, expected);
738    }
739
740    #[test]
741    fn inject_code_context_into_messages_with_existing_content() {
742        let provider = mock_provider(vec![]);
743        let channel = MockChannel::new(vec![]);
744        let registry = create_test_registry();
745        let executor = MockToolExecutor::no_tools();
746        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
747
748        // Add a user message so we have more than 1 message
749        agent.push_message(Message {
750            role: Role::User,
751            content: "question".to_string(),
752            parts: vec![],
753            metadata: MessageMetadata::default(),
754        });
755
756        agent.inject_code_context("some code here");
757
758        let found = agent.msg.messages.iter().any(|m| {
759            m.parts.iter().any(|p| {
760                matches!(p, MessagePart::CodeContext { text } if text.contains("some code here"))
761            })
762        });
763        assert!(found, "code context should be injected into messages");
764    }
765
766    #[test]
767    fn inject_code_context_empty_text_is_noop() {
768        let provider = mock_provider(vec![]);
769        let channel = MockChannel::new(vec![]);
770        let registry = create_test_registry();
771        let executor = MockToolExecutor::no_tools();
772        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
773
774        agent.push_message(Message {
775            role: Role::User,
776            content: "question".to_string(),
777            parts: vec![],
778            metadata: MessageMetadata::default(),
779        });
780        let count_before = agent.msg.messages.len();
781
782        agent.inject_code_context("");
783
784        // No code context message inserted for empty text
785        assert_eq!(agent.msg.messages.len(), count_before);
786    }
787
788    #[test]
789    fn inject_code_context_with_single_message_is_noop() {
790        let provider = mock_provider(vec![]);
791        let channel = MockChannel::new(vec![]);
792        let registry = create_test_registry();
793        let executor = MockToolExecutor::no_tools();
794        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
795        // Only system prompt → len == 1 → inject should be noop
796        let count_before = agent.msg.messages.len();
797
798        agent.inject_code_context("some code");
799
800        assert_eq!(agent.msg.messages.len(), count_before);
801    }
802
803    #[test]
804    fn context_messages_returns_all_messages() {
805        let provider = mock_provider(vec![]);
806        let channel = MockChannel::new(vec![]);
807        let registry = create_test_registry();
808        let executor = MockToolExecutor::no_tools();
809        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
810
811        agent.push_message(Message {
812            role: Role::User,
813            content: "test".to_string(),
814            parts: vec![],
815            metadata: MessageMetadata::default(),
816        });
817
818        assert_eq!(agent.context_messages().len(), agent.msg.messages.len());
819    }
820
821    #[test]
822    fn truncate_old_tool_results_truncates_stale_content() {
823        let provider = mock_provider(vec![]);
824        let channel = MockChannel::new(vec![]);
825        let registry = create_test_registry();
826        let executor = MockToolExecutor::no_tools();
827        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
828
829        let big_content = "x".repeat(4096);
830
831        // Message 0 (old) — should be truncated.
832        agent.msg.messages.push(Message {
833            role: Role::User,
834            content: String::new(),
835            parts: vec![MessagePart::ToolResult {
836                tool_use_id: "id1".to_string(),
837                content: big_content.clone(),
838                is_error: false,
839            }],
840            metadata: MessageMetadata::default(),
841        });
842        // Message 1 (old) — ToolOutput should also be truncated.
843        agent.msg.messages.push(Message {
844            role: Role::User,
845            content: String::new(),
846            parts: vec![MessagePart::ToolOutput {
847                tool_name: "shell".into(),
848                body: big_content.clone(),
849                compacted_at: None,
850            }],
851            metadata: MessageMetadata::default(),
852        });
853        // Message 2 (recent) — must NOT be truncated.
854        agent.msg.messages.push(Message {
855            role: Role::Assistant,
856            content: "reply".to_string(),
857            parts: vec![MessagePart::ToolResult {
858                tool_use_id: "id3".to_string(),
859                content: big_content.clone(),
860                is_error: false,
861            }],
862            metadata: MessageMetadata::default(),
863        });
864        // Message 3 (most recent) — must NOT be truncated.
865        agent.msg.messages.push(Message {
866            role: Role::User,
867            content: "last".to_string(),
868            parts: vec![MessagePart::ToolResult {
869                tool_use_id: "id4".to_string(),
870                content: big_content.clone(),
871                is_error: false,
872            }],
873            metadata: MessageMetadata::default(),
874        });
875
876        // Agent::new inserts a system prompt at index 0, so our messages are at 1..=4.
877        let base = agent.msg.messages.len() - 4;
878
879        agent.truncate_old_tool_results();
880
881        // Old ToolResult truncated.
882        if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base].parts[0] {
883            assert!(
884                content.ends_with("…[truncated]"),
885                "msg[base] should be truncated"
886            );
887            assert!(content.len() <= 2048 + 16);
888        } else {
889            panic!("expected ToolResult at msg[base]");
890        }
891
892        // Old ToolOutput truncated.
893        if let MessagePart::ToolOutput { body, .. } = &agent.msg.messages[base + 1].parts[0] {
894            assert!(
895                body.ends_with("…[truncated]"),
896                "msg[base+1] should be truncated"
897            );
898        } else {
899            panic!("expected ToolOutput at msg[base+1]");
900        }
901
902        // Recent messages untouched.
903        if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 2].parts[0] {
904            assert_eq!(content.len(), 4096, "msg[base+2] should NOT be truncated");
905        } else {
906            panic!("expected ToolResult at msg[base+2]");
907        }
908        if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 3].parts[0] {
909            assert_eq!(content.len(), 4096, "msg[base+3] should NOT be truncated");
910        } else {
911            panic!("expected ToolResult at msg[base+3]");
912        }
913    }
914
915    #[test]
916    fn truncate_old_tool_results_noop_when_few_messages() {
917        let provider = mock_provider(vec![]);
918        let channel = MockChannel::new(vec![]);
919        let registry = create_test_registry();
920        let executor = MockToolExecutor::no_tools();
921        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
922
923        let big = "y".repeat(4096);
924        agent.msg.messages.push(Message {
925            role: Role::User,
926            content: String::new(),
927            parts: vec![MessagePart::ToolResult {
928                tool_use_id: "id".to_string(),
929                content: big.clone(),
930                is_error: false,
931            }],
932            metadata: MessageMetadata::default(),
933        });
934        agent.msg.messages.push(Message {
935            role: Role::Assistant,
936            content: "ok".to_string(),
937            parts: vec![MessagePart::ToolResult {
938                tool_use_id: "id2".to_string(),
939                content: big.clone(),
940                is_error: false,
941            }],
942            metadata: MessageMetadata::default(),
943        });
944
945        // Agent::new inserts a system prompt at index 0; our messages are at 1 and 2.
946        let len_before = agent.msg.messages.len();
947        agent.truncate_old_tool_results();
948
949        // Neither message truncated — both fall in the last-2 window (len=3, skip last 2).
950        assert_eq!(agent.msg.messages.len(), len_before);
951        if let MessagePart::ToolResult { content, .. } =
952            &agent.msg.messages[len_before - 2].parts[0]
953        {
954            assert_eq!(
955                content.len(),
956                4096,
957                "second-to-last should not be truncated"
958            );
959        } else {
960            panic!("expected ToolResult");
961        }
962        if let MessagePart::ToolResult { content, .. } =
963            &agent.msg.messages[len_before - 1].parts[0]
964        {
965            assert_eq!(content.len(), 4096, "last should not be truncated");
966        } else {
967            panic!("expected ToolResult");
968        }
969    }
970
971    fn make_timings(ctx: u64, llm: u64, tool: u64, persist: u64) -> crate::metrics::TurnTimings {
972        crate::metrics::TurnTimings {
973            prepare_context_ms: ctx,
974            llm_chat_ms: llm,
975            tool_exec_ms: tool,
976            persist_message_ms: persist,
977        }
978    }
979
980    fn agent_with_metrics_watch() -> (
981        Agent<MockChannel>,
982        tokio::sync::watch::Receiver<crate::metrics::MetricsSnapshot>,
983    ) {
984        let provider = mock_provider(vec![]);
985        let channel = MockChannel::new(vec![]);
986        let registry = create_test_registry();
987        let executor = MockToolExecutor::no_tools();
988        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
989
990        let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
991        agent.runtime.metrics.metrics_tx = Some(tx);
992        (agent, rx)
993    }
994
995    // T1-a: single flush — last_turn_timings equals the flushed value, count == 1.
996    #[test]
997    fn flush_turn_timings_single_flush() {
998        let (mut agent, rx) = agent_with_metrics_watch();
999
1000        agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1001        agent.flush_turn_timings();
1002
1003        let snap = rx.borrow();
1004        assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
1005        assert_eq!(snap.last_turn_timings.llm_chat_ms, 200);
1006        assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
1007        assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
1008        assert_eq!(snap.timing_sample_count, 1);
1009        // avg == last when sample_count == 1
1010        assert_eq!(snap.avg_turn_timings.llm_chat_ms, 200);
1011    }
1012
1013    // T1-b: pending_timings reset to default after flush.
1014    #[test]
1015    fn flush_turn_timings_resets_pending() {
1016        let provider = mock_provider(vec![]);
1017        let channel = MockChannel::new(vec![]);
1018        let registry = create_test_registry();
1019        let executor = MockToolExecutor::no_tools();
1020        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1021
1022        agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1023        agent.flush_turn_timings();
1024
1025        let p = &agent.runtime.metrics.pending_timings;
1026        assert_eq!(p.prepare_context_ms, 0);
1027        assert_eq!(p.llm_chat_ms, 0);
1028        assert_eq!(p.tool_exec_ms, 0);
1029        assert_eq!(p.persist_message_ms, 0);
1030    }
1031
1032    // T1-c: window capped at 10; avg and max computed correctly.
1033    #[test]
1034    fn flush_turn_timings_window_capped_at_10() {
1035        let (mut agent, rx) = agent_with_metrics_watch();
1036
1037        // Push 12 turns: llm_chat_ms = i * 10 for i in 1..=12.
1038        for i in 1_u64..=12 {
1039            agent.runtime.metrics.pending_timings = make_timings(0, i * 10, 0, 0);
1040            agent.flush_turn_timings();
1041        }
1042
1043        let snap = rx.borrow();
1044        // Window holds last 10: turns 3..=12, llm values 30..=120.
1045        assert_eq!(snap.timing_sample_count, 10);
1046        // max = 120
1047        assert_eq!(snap.max_turn_timings.llm_chat_ms, 120);
1048        // avg of 30,40,...,120 = (30+120)*10/2/10 = 75
1049        assert_eq!(snap.avg_turn_timings.llm_chat_ms, 75);
1050    }
1051
1052    // #5946: fields MetricsBridge marked as written this turn keep the bridge's span-derived
1053    // value instead of being clobbered by the manual `Instant::now()` value; unmarked fields
1054    // still fall back to manual. The bitmask is cleared (taken) after the flush.
1055    #[cfg(feature = "profiling")]
1056    #[test]
1057    fn flush_turn_timings_prefers_bridge_value_for_marked_fields() {
1058        let (mut agent, rx) = agent_with_metrics_watch();
1059
1060        if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
1061            tx.send_modify(|m| {
1062                m.last_turn_timings.llm_chat_ms = 999;
1063                m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
1064            });
1065        }
1066
1067        agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1068        agent.flush_turn_timings();
1069
1070        let snap = rx.borrow();
1071        assert_eq!(
1072            snap.last_turn_timings.llm_chat_ms, 999,
1073            "bridge-marked field must keep the bridge value, not the manual one"
1074        );
1075        assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
1076        assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
1077        assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
1078        assert_eq!(
1079            snap.bridge_timings_written, 0,
1080            "bitmask must be cleared after flush"
1081        );
1082    }
1083
1084    // #6275: `MetricsBridge::on_close` accumulates `last_turn_timings.llm_chat_ms` via
1085    // `saturating_add` across every `chat_with_tools` span closed in a turn. Without resetting
1086    // that field back to 0 once `flush_turn_timings` has read it, the next turn's accumulation
1087    // would start on top of the previous turn's total instead of from zero — a slow, silent
1088    // leak across turns rather than a one-turn glitch.
1089    #[cfg(feature = "profiling")]
1090    #[test]
1091    fn flush_turn_timings_resets_bridge_llm_chat_ms_across_turns() {
1092        let (mut agent, rx) = agent_with_metrics_watch();
1093
1094        // Turn 1: bridge reports a real chat_with_tools-derived duration.
1095        if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
1096            tx.send_modify(|m| {
1097                m.last_turn_timings.llm_chat_ms = 500;
1098                m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
1099            });
1100        }
1101        agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1102        agent.flush_turn_timings();
1103        assert_eq!(rx.borrow().last_turn_timings.llm_chat_ms, 500);
1104
1105        // Turn 2: no chat_with_tools span closes this turn (bridge does not mark the bit).
1106        // `last_turn_timings.llm_chat_ms` must have been reset to 0 by turn 1's flush, so
1107        // `MetricsBridge::on_close`'s `saturating_add` (if it fired again) would start fresh —
1108        // and here, since it never fires, the field must simply read 0, not the stale 500.
1109        agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1110        agent.flush_turn_timings();
1111
1112        assert_eq!(
1113            rx.borrow().last_turn_timings.llm_chat_ms,
1114            0,
1115            "llm_chat_ms must not leak turn 1's bridge value into turn 2 (#6275)"
1116        );
1117    }
1118}