1use 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 scope: Option<(Box<str>, Box<str>)>,
31 recent: [Option<u64>; RECENT_SIGNATURES],
32 next: usize,
33 current: Option<Turn>,
34}
35
36impl Tracker {
37 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 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 turn.unknown = true;
75 }
76 }
77 }
78
79 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 turn.tools.write_u64(turn.tool_events as u64);
94 turn.tools.finish()
95 } else if turn.meaningful_text {
96 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 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 reserve(&mut self.bytes_left, text.len())?;
169 self.text_bytes += text.len();
170 self.meaningful_text |= !text.trim().is_empty();
171 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); self.field(tool_name)?;
181
182 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); 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
229fn 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
267struct 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
299fn 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 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 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 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 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 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 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)), result("id", "ok"), ];
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 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}