Skip to main content

agent_engine/extensions/
feedback.rs

1//! Bounded, process-local feedback for one caller-owned foreground turn.
2//!
3//! The caller must filter ownership, call `begin_turn` before observing, and call
4//! `finish` only after success. Session/history, agent, and partial tool events
5//! are never inspected. Starting again abandons any unfinished observation.
6//!
7//! This is a byte-fingerprint heuristic, not proof of progress or equality:
8//! `DefaultHasher` collisions can produce false repeats, and its fingerprints
9//! are neither cryptographic nor stable across Rust versions. Only bounded
10//! model/effort metadata and private hash state survive calls; no response/input
11//! content is retained, logged, exported, or read from storage.
12
13use std::collections::hash_map::DefaultHasher;
14use std::hash::Hasher;
15use std::io::{self, Write};
16
17use serde_json::Value;
18use crate::{LlmEvent, StreamEvent};
19
20const RECENT_SIGNATURES: usize = 4;
21const MAX_BYTES: usize = 1024 * 1024;
22const MAX_TOOL_EVENTS: usize = 1024;
23const MAX_JSON_NODES: usize = 16 * 1024;
24const MAX_JSON_DEPTH: usize = 64;
25const MAX_SCOPE_BYTES: usize = 4096;
26
27#[derive(Default)]
28pub(crate) struct Tracker {
29    // Exact, bounded metadata: do not normalize or fingerprint the scope key.
30    scope: Option<(Box<str>, Box<str>)>,
31    recent: [Option<u64>; RECENT_SIGNATURES],
32    next: usize,
33    current: Option<Turn>,
34}
35
36impl Tracker {
37    /// Begin an owned turn, discarding any unfinished (failed/interrupted) turn.
38    /// Changing either exact scope string clears the comparison window, even
39    /// when the new turn is subsequently abandoned. Oversized metadata is unknown.
40    pub(crate) fn begin_turn(&mut self, model: &str, effort: &str) {
41        self.current = None;
42        if model.len() > MAX_SCOPE_BYTES || effort.len() > MAX_SCOPE_BYTES - model.len() {
43            self.scope = None;
44            self.clear_recent();
45            self.current = Some(Turn {
46                unknown: true,
47                ..Turn::default()
48            });
49            return;
50        }
51
52        let same_scope = self
53            .scope
54            .as_ref()
55            .is_some_and(|(m, e)| m.as_ref() == model && e.as_ref() == effort);
56        if !same_scope {
57            self.clear_recent();
58            self.scope = Some((model.into(), effort.into()));
59        }
60        self.current = Some(Turn::default());
61    }
62
63    /// Observe only final LLM output; everything outside an active turn is ignored.
64    pub(crate) fn observe(&mut self, event: &StreamEvent) {
65        let Some(turn) = self.current.as_mut() else {
66            return;
67        };
68        if turn.unknown {
69            return;
70        }
71        if let StreamEvent::Llm(event) = event {
72            if turn.observe(event).is_none() {
73                // Never compare a truncated prefix, nor recover within this turn.
74                turn.unknown = true;
75            }
76        }
77    }
78
79    /// Commit a successful turn once. Repeated calls without `begin_turn` return
80    /// `unknown` and cannot insert another signature. Empty/unknown turns do not
81    /// enter or clear the ring; their non-repeated labels break a caller's streak.
82    pub(crate) fn finish(&mut self) -> &'static str {
83        let Some(mut turn) = self.current.take() else {
84            return "unknown";
85        };
86        if turn.unknown {
87            return "unknown";
88        }
89
90        let signature = if turn.tool_events != 0 {
91            // Any final tool event, including a standalone result, is meaningful.
92            // Accompanying assistant prose is excluded from this signature.
93            turn.tools.write_u64(turn.tool_events as u64);
94            turn.tools.finish()
95        } else if turn.meaningful_text {
96            // One length for the entire byte stream, never one per chunk.
97            turn.text.write_u64(turn.text_bytes as u64);
98            turn.text.finish()
99        } else {
100            return "empty";
101        };
102
103        let repeated = self.recent.contains(&Some(signature));
104        self.recent[self.next] = Some(signature);
105        self.next = (self.next + 1) % RECENT_SIGNATURES;
106        if repeated {
107            "repeated"
108        } else {
109            "changed"
110        }
111    }
112
113    fn clear_recent(&mut self) {
114        self.recent = [None; RECENT_SIGNATURES];
115        self.next = 0;
116    }
117}
118
119#[derive(Clone)]
120struct Turn {
121    text: DefaultHasher,
122    tools: DefaultHasher,
123    text_bytes: usize,
124    meaningful_text: bool,
125    tool_events: usize,
126    bytes_left: usize,
127    json_nodes_left: usize,
128    unknown: bool,
129    response_baseline: Option<Box<Turn>>,
130}
131
132impl Default for Turn {
133    fn default() -> Self {
134        let mut text = DefaultHasher::new();
135        let mut tools = DefaultHasher::new();
136        // Domain separation for text-only and tool-based signatures.
137        text.write_u8(1);
138        tools.write_u8(2);
139        Self {
140            text,
141            tools,
142            text_bytes: 0,
143            meaningful_text: false,
144            tool_events: 0,
145            bytes_left: MAX_BYTES,
146            json_nodes_left: MAX_JSON_NODES,
147            unknown: false,
148            response_baseline: None,
149        }
150    }
151}
152
153impl Turn {
154    fn observe(&mut self, event: &LlmEvent) -> Option<()> {
155        match event {
156            LlmEvent::ResponseStart => {
157                self.response_baseline = None;
158                self.response_baseline = Some(Box::new(self.clone()));
159            }
160            LlmEvent::ResponseReset => {
161                if let Some(baseline) = self.response_baseline.take() {
162                    *self = *baseline;
163                }
164            }
165            LlmEvent::Text(text) => {
166                // Count all final text against the budget, even when tools make
167                // it irrelevant to equality. Ignored event types cost no budget.
168                reserve(&mut self.bytes_left, text.len())?;
169                self.text_bytes += text.len();
170                self.meaningful_text |= !text.trim().is_empty();
171                // DefaultHasher's incremental byte writes concatenate chunks;
172                // Hash::hash(str) would instead frame each chunk separately.
173                self.text.write(text.as_bytes());
174            }
175            LlmEvent::ToolUse {
176                tool_name, input, ..
177            } => {
178                self.tool_event()?;
179                self.tools.write_u8(1); // Call: framed name + framed JSON digest.
180                self.field(tool_name)?;
181
182                // Check borrowed shape/lengths BEFORE sorting or serializing.
183                // This bounds recursion, allocation, string scanning, and total
184                // JSON traversal across the turn, not just the bytes written.
185                let mut lower_bound_left = self.bytes_left;
186                validate_json(input, 0, &mut lower_bound_left, &mut self.json_nodes_left)?;
187                let mut writer = HashWriter::new(self.bytes_left);
188                write_canonical_json(&mut writer, input).ok()?;
189                reserve(&mut self.bytes_left, writer.written)?;
190                self.tools.write_u64(writer.written as u64);
191                self.tools.write_u64(writer.hash.finish());
192            }
193            LlmEvent::ToolResult { result, .. } => {
194                self.tool_event()?;
195                self.tools.write_u8(2); // Result, in observed order; no ID lookup.
196                self.field(result)?;
197            }
198            LlmEvent::Thinking(_)
199            | LlmEvent::ToolUseStart { .. }
200            | LlmEvent::ToolUseDelta { .. }
201            | LlmEvent::ToolResultDelta { .. }
202            | LlmEvent::ToolQueued { .. }
203            | LlmEvent::ToolStarted { .. } => {}
204        }
205        Some(())
206    }
207
208    fn tool_event(&mut self) -> Option<()> {
209        if self.tool_events == MAX_TOOL_EVENTS {
210            return None;
211        }
212        self.tool_events += 1;
213        Some(())
214    }
215
216    fn field(&mut self, value: &str) -> Option<()> {
217        reserve(&mut self.bytes_left, value.len())?;
218        self.tools.write_u64(value.len() as u64);
219        self.tools.write(value.as_bytes());
220        Some(())
221    }
222}
223
224fn reserve(remaining: &mut usize, amount: usize) -> Option<()> {
225    *remaining = remaining.checked_sub(amount)?;
226    Some(())
227}
228
229/// A cheap lower bound on encoded bytes, plus strict node/depth bounds. No clone
230/// of JSON or allocation proportional to unchecked input size. A node requires
231/// at least one encoded byte; raw string/key lengths are further lower bounds.
232fn validate_json(
233    value: &Value,
234    depth: usize,
235    bytes_left: &mut usize,
236    nodes_left: &mut usize,
237) -> Option<()> {
238    if depth > MAX_JSON_DEPTH {
239        return None;
240    }
241    reserve(nodes_left, 1)?;
242    reserve(bytes_left, 1)?;
243    match value {
244        Value::String(text) => reserve(bytes_left, text.len())?,
245        Value::Array(values) => {
246            if values.len() > *nodes_left {
247                return None;
248            }
249            for value in values {
250                validate_json(value, depth + 1, bytes_left, nodes_left)?;
251            }
252        }
253        Value::Object(values) => {
254            if values.len() > *nodes_left {
255                return None;
256            }
257            for (key, value) in values {
258                reserve(bytes_left, key.len())?;
259                validate_json(value, depth + 1, bytes_left, nodes_left)?;
260            }
261        }
262        Value::Null | Value::Bool(_) | Value::Number(_) => {}
263    }
264    Some(())
265}
266
267/// In-memory, all-or-error writer: an overflow never yields a usable prefix.
268/// Actual serialized bytes (including escaping) consume the remaining budget.
269struct HashWriter {
270    hash: DefaultHasher,
271    left: usize,
272    written: usize,
273}
274
275impl HashWriter {
276    fn new(left: usize) -> Self {
277        Self {
278            hash: DefaultHasher::new(),
279            left,
280            written: 0,
281        }
282    }
283}
284
285impl Write for HashWriter {
286    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
287        reserve(&mut self.left, bytes.len())
288            .ok_or_else(|| io::Error::other("feedback byte budget exceeded"))?;
289        self.hash.write(bytes);
290        self.written += bytes.len();
291        Ok(bytes.len())
292    }
293
294    fn flush(&mut self) -> io::Result<()> {
295        Ok(())
296    }
297}
298
299/// Compact canonical JSON: recursively sorted object keys, ordered arrays,
300/// serde string escaping and serde_json's number spelling (not numeric semantic
301/// normalization). Call ONLY after validate_json. Sorting holds borrowed entries
302/// bounded by MAX_JSON_NODES across all active recursion frames, never raw copies.
303fn write_canonical_json(writer: &mut HashWriter, value: &Value) -> io::Result<()> {
304    match value {
305        Value::Null => writer.write_all(b"null"),
306        Value::Bool(true) => writer.write_all(b"true"),
307        Value::Bool(false) => writer.write_all(b"false"),
308        // Display is serde_json's JSON number representation and writes directly
309        // to our budget. Unlike to_string(), it cannot allocate a huge temporary
310        // if another dependency enables serde_json's arbitrary_precision feature.
311        Value::Number(number) => write!(writer, "{number}"),
312        Value::String(text) => write_json_string(writer, text),
313        Value::Array(values) => {
314            writer.write_all(b"[")?;
315            for (index, value) in values.iter().enumerate() {
316                if index != 0 {
317                    writer.write_all(b",")?;
318                }
319                write_canonical_json(writer, value)?;
320            }
321            writer.write_all(b"]")
322        }
323        Value::Object(values) => {
324            // Do not depend on whether serde_json's preserve_order is enabled.
325            let mut entries: Vec<_> = values.iter().collect();
326            entries.sort_unstable_by_key(|(a, _)| *a);
327            writer.write_all(b"{")?;
328            for (index, (key, value)) in entries.into_iter().enumerate() {
329                if index != 0 {
330                    writer.write_all(b",")?;
331                }
332                write_json_string(writer, key)?;
333                writer.write_all(b":")?;
334                write_canonical_json(writer, value)?;
335            }
336            writer.write_all(b"}")
337        }
338    }
339}
340
341fn write_json_string(writer: &mut HashWriter, text: &str) -> io::Result<()> {
342    // text.len() has already been bounded, so serde cannot scan a giant string
343    // before reaching the writer's budget check. Never retain serializer errors.
344    serde_json::to_writer(writer, text)
345        .map_err(|_| io::Error::other("feedback JSON serialization failed"))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use serde_json::json;
352    use crate::{AgentEvent, SessionEvent};
353
354    fn text(value: &str) -> StreamEvent {
355        StreamEvent::Llm(LlmEvent::Text(value.into()))
356    }
357
358    fn call(id: &str, name: &str, input: Value) -> StreamEvent {
359        StreamEvent::Llm(LlmEvent::ToolUse {
360            tool_name: name.into(),
361            tool_id: id.into(),
362            input,
363        })
364    }
365
366    fn result(id: &str, output: &str) -> StreamEvent {
367        StreamEvent::Llm(LlmEvent::ToolResult {
368            tool_id: id.into(),
369            result: output.into(),
370        })
371    }
372
373    #[test]
374    fn retry_feedback_ignores_discarded_attempt_but_keeps_earlier_tools() {
375        let mut tracker = Tracker::default();
376        let previous = call("p", "read", json!({"path":"before"}));
377        let after = call("q", "write", json!({"path":"after"}));
378        assert_eq!(
379            run(&mut tracker, &[previous.clone(), after.clone()]),
380            "changed"
381        );
382        assert_eq!(
383            run(
384                &mut tracker,
385                &[
386                    previous,
387                    StreamEvent::Llm(LlmEvent::ResponseStart),
388                    call("bad", "bash", json!({"command":"not executed"})),
389                    text("discard me"),
390                    StreamEvent::Llm(LlmEvent::ResponseReset),
391                    StreamEvent::Llm(LlmEvent::ResponseStart),
392                    after,
393                ]
394            ),
395            "repeated"
396        );
397    }
398
399    fn run(tracker: &mut Tracker, events: &[StreamEvent]) -> &'static str {
400        tracker.begin_turn("model", "effort");
401        for event in events {
402            tracker.observe(event);
403        }
404        tracker.finish()
405    }
406
407    fn scoped(tracker: &mut Tracker, model: &str, effort: &str) -> &'static str {
408        tracker.begin_turn(model, effort);
409        tracker.observe(&text("same output"));
410        tracker.finish()
411    }
412
413    #[test]
414    fn first_changed_then_repeated_and_changed() {
415        let mut tracker = Tracker::default();
416        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
417        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
418        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
419        assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
420    }
421
422    #[test]
423    fn recognizes_a_b_a_and_keeps_exactly_four_successful_signatures() {
424        let mut tracker = Tracker::default();
425        for value in ["A", "B"] {
426            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
427        }
428        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
429
430        let mut tracker = Tracker::default();
431        for value in ["A", "B", "C", "D"] {
432            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
433        }
434        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
435        assert_eq!(run(&mut tracker, &[text("E")]), "changed");
436        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
437    }
438
439    #[test]
440    fn ring_counts_turns_not_distinct_signatures() {
441        let mut tracker = Tracker::default();
442        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
443        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
444        for _ in 0..3 {
445            assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
446        }
447        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
448    }
449
450    #[test]
451    fn arbitrary_tool_ids_and_accompanying_prose_are_ignored() {
452        let mut tracker = Tracker::default();
453        assert_eq!(
454            run(
455                &mut tracker,
456                &[
457                    text("before"),
458                    call("old-id", "read", json!({"path": "a"})),
459                    text("between"),
460                    result("old-id", "contents"),
461                    text("after"),
462                ],
463            ),
464            "changed"
465        );
466        assert_eq!(
467            run(
468                &mut tracker,
469                &[
470                    call("new-id", "read", json!({"path": "a"})),
471                    result("unrelated-result-id", "contents"),
472                    text("completely different explanation"),
473                ],
474            ),
475            "repeated"
476        );
477        assert_eq!(
478            run(
479                &mut tracker,
480                &[
481                    call("", "read", json!({"path": "a"})),
482                    result("", "contents"),
483                ],
484            ),
485            "repeated"
486        );
487    }
488
489    #[test]
490    fn changed_tool_names_inputs_or_results_are_changed() {
491        let mut tracker = Tracker::default();
492        for (name, input, output) in [
493            ("read", json!({"path": "a"}), "one"),
494            ("read", json!({"path": "b"}), "one"),
495            ("read", json!({"path": "b"}), "two"),
496            ("other", json!({"path": "b"}), "two"),
497        ] {
498            assert_eq!(
499                run(
500                    &mut tracker,
501                    &[call("id", name, input), result("id", output)]
502                ),
503                "changed"
504            );
505        }
506    }
507
508    #[test]
509    fn unique_results_never_look_repeated_just_because_calls_repeat() {
510        let mut tracker = Tracker::default();
511        for index in 0..12 {
512            assert_eq!(
513                run(
514                    &mut tracker,
515                    &[
516                        call("id", "poll", json!({})),
517                        result("id", &format!("output {index}")),
518                    ],
519                ),
520                "changed"
521            );
522        }
523    }
524
525    #[test]
526    fn canonicalizes_object_keys_recursively_but_not_array_order_or_types() {
527        let mut tracker = Tracker::default();
528        let a: Value = serde_json::from_str(r#"{"z":[{"b":2,"a":1}],"a":true}"#).unwrap();
529        let b: Value = serde_json::from_str(r#"{"a":true,"z":[{"a":1,"b":2}]}"#).unwrap();
530        assert_eq!(run(&mut tracker, &[call("a", "tool", a)]), "changed");
531        assert_eq!(run(&mut tracker, &[call("b", "tool", b)]), "repeated");
532        for input in [
533            json!([1, 2]),
534            json!([2, 1]),
535            json!(["2", "1"]),
536            json!("[2,1]"),
537        ] {
538            assert_eq!(run(&mut tracker, &[call("id", "tool", input)]), "changed");
539        }
540    }
541
542    #[test]
543    fn tool_event_order_is_significant() {
544        let mut tracker = Tracker::default();
545        let a = call("a", "one", json!(null));
546        let b = call("b", "two", json!(null));
547        let x = result("a", "x");
548        let y = result("b", "y");
549        assert_eq!(
550            run(&mut tracker, &[a.clone(), b.clone(), x.clone(), y.clone()]),
551            "changed"
552        );
553        assert_eq!(
554            run(&mut tracker, &[b.clone(), a.clone(), x.clone(), y.clone()]),
555            "changed"
556        );
557        assert_eq!(run(&mut tracker, &[b, a, y, x]), "changed");
558    }
559
560    #[test]
561    fn type_tags_and_field_lengths_prevent_concatenation_aliases() {
562        let mut tracker = Tracker::default();
563        assert_eq!(run(&mut tracker, &[text("abc")]), "changed");
564        assert_eq!(run(&mut tracker, &[result("", "abc")]), "changed");
565        assert_eq!(
566            run(&mut tracker, &[result("", "ab"), result("", "c")]),
567            "changed"
568        );
569        assert_eq!(
570            run(&mut tracker, &[result("", "a"), result("", "bc")]),
571            "changed"
572        );
573        assert_eq!(run(&mut tracker, &[call("", "ab", json!("c"))]), "changed");
574        assert_eq!(run(&mut tracker, &[call("", "a", json!("bc"))]), "changed");
575    }
576
577    #[test]
578    fn text_chunk_boundaries_do_not_affect_signature() {
579        let mut tracker = Tracker::default();
580        let whole = " \r\nCafé 🦀\n";
581        assert_eq!(run(&mut tracker, &[text(whole)]), "changed");
582        for split in whole
583            .char_indices()
584            .map(|(index, _)| index)
585            .chain([whole.len()])
586        {
587            assert_eq!(
588                run(
589                    &mut tracker,
590                    &[text(&whole[..split]), text(""), text(&whole[split..])]
591                ),
592                "repeated"
593            );
594        }
595        let chunks: Vec<_> = whole.chars().map(|ch| text(&ch.to_string())).collect();
596        assert_eq!(run(&mut tracker, &chunks), "repeated");
597    }
598
599    #[test]
600    fn nonempty_text_uses_exact_bytes_not_trimmed_or_normalized_bytes() {
601        let mut tracker = Tracker::default();
602        for value in ["answer", " answer", "answer ", "Answer", "answer\n"] {
603            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
604        }
605    }
606
607    #[test]
608    fn thinking_notices_history_agent_events_and_deltas_are_ignored() {
609        let mut tracker = Tracker::default();
610        // Synthetic history is supplied but never traversed by the tracker.
611        let ignored = [
612            StreamEvent::Llm(LlmEvent::Thinking("private reasoning".into())),
613            StreamEvent::Llm(LlmEvent::ToolUseStart {
614                tool_name: "unfinished".into(),
615                tool_id: "id".into(),
616            }),
617            StreamEvent::Llm(LlmEvent::ToolUseDelta {
618                tool_id: "id".into(),
619                delta: "{incomplete".into(),
620            }),
621            StreamEvent::Llm(LlmEvent::ToolResultDelta {
622                tool_id: "id".into(),
623                delta: "partial output".into(),
624            }),
625            StreamEvent::Session(SessionEvent::Notice("notice".into())),
626            StreamEvent::Session(SessionEvent::MessageHistory(vec![std::sync::Arc::new(
627                json!({
628                    "role": "user", "content": "user goal/history is not output"
629                }),
630            )])),
631            StreamEvent::Agent(AgentEvent::SteeringDelivered {
632                message: "new goal".into(),
633            }),
634            StreamEvent::Session(SessionEvent::Done),
635        ];
636        assert_eq!(run(&mut tracker, &ignored), "empty");
637        assert_eq!(run(&mut tracker, &[text("answer")]), "changed");
638        tracker.begin_turn("model", "effort");
639        tracker.observe(&text("ans"));
640        for event in &ignored {
641            tracker.observe(event);
642        }
643        tracker.observe(&text("wer"));
644        assert_eq!(tracker.finish(), "repeated");
645    }
646
647    #[test]
648    fn empty_or_whitespace_only_output_never_becomes_repeated() {
649        let mut tracker = Tracker::default();
650        for _ in 0..6 {
651            assert_eq!(run(&mut tracker, &[]), "empty");
652            assert_eq!(
653                run(&mut tracker, &[text(""), text(" \t\r\n\u{2003}")]),
654                "empty"
655            );
656            assert_eq!(
657                run(
658                    &mut tracker,
659                    &[StreamEvent::Llm(LlmEvent::Thinking("alone".into()))]
660                ),
661                "empty"
662            );
663        }
664        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
665        for _ in 0..6 {
666            assert_eq!(run(&mut tracker, &[]), "empty");
667        }
668        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
669    }
670
671    #[test]
672    fn final_tools_are_meaningful_even_with_empty_output() {
673        let mut tracker = Tracker::default();
674        assert_eq!(
675            run(&mut tracker, &[call("", "tool", json!(null))]),
676            "changed"
677        );
678        assert_eq!(
679            run(&mut tracker, &[call("new", "tool", json!(null))]),
680            "repeated"
681        );
682        assert_eq!(run(&mut tracker, &[result("", "")]), "changed");
683        assert_eq!(run(&mut tracker, &[result("new", "")]), "repeated");
684    }
685
686    #[test]
687    fn exact_model_and_effort_changes_reset_the_ring() {
688        let mut tracker = Tracker::default();
689        assert_eq!(scoped(&mut tracker, "model", "high"), "changed");
690        assert_eq!(scoped(&mut tracker, "model", "high"), "repeated");
691        for (model, effort) in [
692            ("Model", "high"),
693            ("model", "high"),
694            ("model", "HIGH"),
695            ("model", "high "),
696            ("model", "high"),
697            ("ab", "c"),
698            ("a", "bc"),
699        ] {
700            assert_eq!(scoped(&mut tracker, model, effort), "changed");
701        }
702    }
703
704    #[test]
705    fn oversized_scope_is_unknown_without_retaining_unbounded_metadata() {
706        let mut tracker = Tracker::default();
707        assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
708        assert_eq!(
709            scoped(&mut tracker, &"m".repeat(MAX_SCOPE_BYTES + 1), ""),
710            "unknown"
711        );
712        assert_eq!(
713            scoped(&mut tracker, "m", &"e".repeat(MAX_SCOPE_BYTES)),
714            "unknown"
715        );
716        assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
717    }
718
719    #[test]
720    fn finish_and_observe_outside_a_turn_do_not_account_twice() {
721        let mut tracker = Tracker::default();
722        tracker.observe(&text("unowned"));
723        assert_eq!(tracker.finish(), "unknown");
724        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
725        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
726        for _ in 0..8 {
727            tracker.observe(&text("unowned"));
728            assert_eq!(tracker.finish(), "unknown");
729        }
730        assert_eq!(run(&mut tracker, &[text("C")]), "changed");
731        assert_eq!(run(&mut tracker, &[text("D")]), "changed");
732        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
733        assert_eq!(run(&mut tracker, &[text("unowned")]), "changed");
734    }
735
736    #[test]
737    fn interrupted_turn_is_discarded_without_losing_completed_signatures() {
738        let mut tracker = Tracker::default();
739        assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
740        tracker.begin_turn("model", "effort");
741        tracker.observe(&text("interrupted"));
742        // No finish on failure; the next begin discards that observation.
743        assert_eq!(run(&mut tracker, &[text("completed")]), "repeated");
744        assert_eq!(run(&mut tracker, &[text("interrupted")]), "changed");
745        tracker.begin_turn("different model", "effort");
746        assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
747    }
748
749    #[test]
750    fn text_budget_boundary_and_overflow_do_not_compare_truncated_prefixes() {
751        let mut tracker = Tracker::default();
752        let at_limit = text(&"x".repeat(MAX_BYTES));
753        assert_eq!(
754            run(&mut tracker, std::slice::from_ref(&at_limit)),
755            "changed"
756        );
757        assert_eq!(
758            run(&mut tracker, std::slice::from_ref(&at_limit)),
759            "repeated"
760        );
761        for _ in 0..6 {
762            // This non-repeated label breaks a prior repeated streak. Neither
763            // overflow nor its otherwise matching prefix enters the ring.
764            assert_eq!(run(&mut tracker, &[at_limit.clone(), text("x")]), "unknown");
765        }
766        assert_eq!(
767            run(&mut tracker, std::slice::from_ref(&at_limit)),
768            "repeated"
769        );
770        assert_eq!(
771            run(&mut tracker, &[text(&"x".repeat(MAX_BYTES + 1))]),
772            "unknown"
773        );
774    }
775
776    #[test]
777    fn tool_event_limit_is_inclusive_and_overflow_is_sticky() {
778        let mut tracker = Tracker::default();
779        for expected in ["changed", "repeated"] {
780            tracker.begin_turn("model", "effort");
781            for _ in 0..MAX_TOOL_EVENTS {
782                tracker.observe(&result("ignored", ""));
783            }
784            assert_eq!(tracker.finish(), expected);
785        }
786        tracker.begin_turn("model", "effort");
787        for _ in 0..=MAX_TOOL_EVENTS {
788            tracker.observe(&result("ignored", ""));
789        }
790        tracker.observe(&text("cannot recover"));
791        assert_eq!(tracker.finish(), "unknown");
792        assert_eq!(run(&mut tracker, &[text("cannot recover")]), "changed");
793    }
794
795    #[test]
796    fn byte_budget_is_shared_by_text_names_inputs_and_results() {
797        let mut tracker = Tracker::default();
798        let events = [
799            text(&"x".repeat(MAX_BYTES - 7)),
800            call("id", "t", json!(null)), // 1 byte name + 4 bytes JSON.
801            result("id", "ok"),           // 2 bytes result: exactly the limit.
802        ];
803        assert_eq!(run(&mut tracker, &events), "changed");
804        tracker.begin_turn("model", "effort");
805        for event in &events {
806            tracker.observe(event);
807        }
808        tracker.observe(&text("!"));
809        assert_eq!(tracker.finish(), "unknown");
810        assert_eq!(
811            run(&mut tracker, &[result("", &"x".repeat(MAX_BYTES + 1))]),
812            "unknown"
813        );
814        assert_eq!(
815            run(
816                &mut tracker,
817                &[call("", &"x".repeat(MAX_BYTES + 1), json!(null))]
818            ),
819            "unknown"
820        );
821    }
822
823    #[test]
824    fn ignored_content_does_not_consume_the_budget() {
825        let mut tracker = Tracker::default();
826        let ignored = StreamEvent::Llm(LlmEvent::Thinking("x".repeat(MAX_BYTES + 1)));
827        assert_eq!(run(&mut tracker, &[ignored, text("answer")]), "changed");
828        assert_eq!(run(&mut tracker, &[text("answer")]), "repeated");
829    }
830
831    #[test]
832    fn oversized_json_and_escaped_serialization_overflow_are_unknown() {
833        let mut tracker = Tracker::default();
834        for input in [
835            Value::String("x".repeat(MAX_BYTES + 1)),
836            // Raw text passes preflight, but JSON escaping exceeds the budget.
837            Value::String("\n".repeat(MAX_BYTES / 2)),
838        ] {
839            assert_eq!(run(&mut tracker, &[call("", "tool", input)]), "unknown");
840        }
841        assert_eq!(
842            run(&mut tracker, &[call("", "tool", json!("small"))]),
843            "changed"
844        );
845        assert_eq!(
846            run(&mut tracker, &[call("", "tool", json!("small"))]),
847            "repeated"
848        );
849    }
850
851    #[test]
852    fn json_depth_and_total_node_limits_fail_closed() {
853        let mut tracker = Tracker::default();
854        let mut nested = Value::Null;
855        for _ in 0..MAX_JSON_DEPTH {
856            nested = Value::Array(vec![nested]);
857        }
858        assert_eq!(
859            run(&mut tracker, &[call("", "tool", nested.clone())]),
860            "changed"
861        );
862        assert_eq!(
863            run(
864                &mut tracker,
865                &[call("", "tool", Value::Array(vec![nested]))]
866            ),
867            "unknown"
868        );
869        assert_eq!(
870            run(
871                &mut tracker,
872                &[call(
873                    "",
874                    "tool",
875                    Value::Array(vec![Value::Null; MAX_JSON_NODES])
876                )]
877            ),
878            "unknown"
879        );
880        let half = call(
881            "",
882            "tool",
883            Value::Array(vec![Value::Null; MAX_JSON_NODES / 2]),
884        );
885        assert_eq!(run(&mut tracker, &[half.clone(), half]), "unknown");
886    }
887}