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 }
203 Some(())
204 }
205
206 fn tool_event(&mut self) -> Option<()> {
207 if self.tool_events == MAX_TOOL_EVENTS {
208 return None;
209 }
210 self.tool_events += 1;
211 Some(())
212 }
213
214 fn field(&mut self, value: &str) -> Option<()> {
215 reserve(&mut self.bytes_left, value.len())?;
216 self.tools.write_u64(value.len() as u64);
217 self.tools.write(value.as_bytes());
218 Some(())
219 }
220}
221
222fn reserve(remaining: &mut usize, amount: usize) -> Option<()> {
223 *remaining = remaining.checked_sub(amount)?;
224 Some(())
225}
226
227fn validate_json(
231 value: &Value,
232 depth: usize,
233 bytes_left: &mut usize,
234 nodes_left: &mut usize,
235) -> Option<()> {
236 if depth > MAX_JSON_DEPTH {
237 return None;
238 }
239 reserve(nodes_left, 1)?;
240 reserve(bytes_left, 1)?;
241 match value {
242 Value::String(text) => reserve(bytes_left, text.len())?,
243 Value::Array(values) => {
244 if values.len() > *nodes_left {
245 return None;
246 }
247 for value in values {
248 validate_json(value, depth + 1, bytes_left, nodes_left)?;
249 }
250 }
251 Value::Object(values) => {
252 if values.len() > *nodes_left {
253 return None;
254 }
255 for (key, value) in values {
256 reserve(bytes_left, key.len())?;
257 validate_json(value, depth + 1, bytes_left, nodes_left)?;
258 }
259 }
260 Value::Null | Value::Bool(_) | Value::Number(_) => {}
261 }
262 Some(())
263}
264
265struct HashWriter {
268 hash: DefaultHasher,
269 left: usize,
270 written: usize,
271}
272
273impl HashWriter {
274 fn new(left: usize) -> Self {
275 Self {
276 hash: DefaultHasher::new(),
277 left,
278 written: 0,
279 }
280 }
281}
282
283impl Write for HashWriter {
284 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
285 reserve(&mut self.left, bytes.len())
286 .ok_or_else(|| io::Error::other("feedback byte budget exceeded"))?;
287 self.hash.write(bytes);
288 self.written += bytes.len();
289 Ok(bytes.len())
290 }
291
292 fn flush(&mut self) -> io::Result<()> {
293 Ok(())
294 }
295}
296
297fn write_canonical_json(writer: &mut HashWriter, value: &Value) -> io::Result<()> {
302 match value {
303 Value::Null => writer.write_all(b"null"),
304 Value::Bool(true) => writer.write_all(b"true"),
305 Value::Bool(false) => writer.write_all(b"false"),
306 Value::Number(number) => write!(writer, "{number}"),
310 Value::String(text) => write_json_string(writer, text),
311 Value::Array(values) => {
312 writer.write_all(b"[")?;
313 for (index, value) in values.iter().enumerate() {
314 if index != 0 {
315 writer.write_all(b",")?;
316 }
317 write_canonical_json(writer, value)?;
318 }
319 writer.write_all(b"]")
320 }
321 Value::Object(values) => {
322 let mut entries: Vec<_> = values.iter().collect();
324 entries.sort_unstable_by_key(|(a, _)| *a);
325 writer.write_all(b"{")?;
326 for (index, (key, value)) in entries.into_iter().enumerate() {
327 if index != 0 {
328 writer.write_all(b",")?;
329 }
330 write_json_string(writer, key)?;
331 writer.write_all(b":")?;
332 write_canonical_json(writer, value)?;
333 }
334 writer.write_all(b"}")
335 }
336 }
337}
338
339fn write_json_string(writer: &mut HashWriter, text: &str) -> io::Result<()> {
340 serde_json::to_writer(writer, text)
343 .map_err(|_| io::Error::other("feedback JSON serialization failed"))
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use serde_json::json;
350 use crate::{AgentEvent, SessionEvent};
351
352 fn text(value: &str) -> StreamEvent {
353 StreamEvent::Llm(LlmEvent::Text(value.into()))
354 }
355
356 fn call(id: &str, name: &str, input: Value) -> StreamEvent {
357 StreamEvent::Llm(LlmEvent::ToolUse {
358 tool_name: name.into(),
359 tool_id: id.into(),
360 input,
361 })
362 }
363
364 fn result(id: &str, output: &str) -> StreamEvent {
365 StreamEvent::Llm(LlmEvent::ToolResult {
366 tool_id: id.into(),
367 result: output.into(),
368 })
369 }
370
371 #[test]
372 fn retry_feedback_ignores_discarded_attempt_but_keeps_earlier_tools() {
373 let mut tracker = Tracker::default();
374 let previous = call("p", "read", json!({"path":"before"}));
375 let after = call("q", "write", json!({"path":"after"}));
376 assert_eq!(
377 run(&mut tracker, &[previous.clone(), after.clone()]),
378 "changed"
379 );
380 assert_eq!(
381 run(
382 &mut tracker,
383 &[
384 previous,
385 StreamEvent::Llm(LlmEvent::ResponseStart),
386 call("bad", "bash", json!({"command":"not executed"})),
387 text("discard me"),
388 StreamEvent::Llm(LlmEvent::ResponseReset),
389 StreamEvent::Llm(LlmEvent::ResponseStart),
390 after,
391 ]
392 ),
393 "repeated"
394 );
395 }
396
397 fn run(tracker: &mut Tracker, events: &[StreamEvent]) -> &'static str {
398 tracker.begin_turn("model", "effort");
399 for event in events {
400 tracker.observe(event);
401 }
402 tracker.finish()
403 }
404
405 fn scoped(tracker: &mut Tracker, model: &str, effort: &str) -> &'static str {
406 tracker.begin_turn(model, effort);
407 tracker.observe(&text("same output"));
408 tracker.finish()
409 }
410
411 #[test]
412 fn first_changed_then_repeated_and_changed() {
413 let mut tracker = Tracker::default();
414 assert_eq!(run(&mut tracker, &[text("A")]), "changed");
415 assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
416 assert_eq!(run(&mut tracker, &[text("B")]), "changed");
417 assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
418 }
419
420 #[test]
421 fn recognizes_a_b_a_and_keeps_exactly_four_successful_signatures() {
422 let mut tracker = Tracker::default();
423 for value in ["A", "B"] {
424 assert_eq!(run(&mut tracker, &[text(value)]), "changed");
425 }
426 assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
427
428 let mut tracker = Tracker::default();
429 for value in ["A", "B", "C", "D"] {
430 assert_eq!(run(&mut tracker, &[text(value)]), "changed");
431 }
432 assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
433 assert_eq!(run(&mut tracker, &[text("E")]), "changed");
434 assert_eq!(run(&mut tracker, &[text("B")]), "changed");
435 }
436
437 #[test]
438 fn ring_counts_turns_not_distinct_signatures() {
439 let mut tracker = Tracker::default();
440 assert_eq!(run(&mut tracker, &[text("A")]), "changed");
441 assert_eq!(run(&mut tracker, &[text("B")]), "changed");
442 for _ in 0..3 {
443 assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
444 }
445 assert_eq!(run(&mut tracker, &[text("A")]), "changed");
446 }
447
448 #[test]
449 fn arbitrary_tool_ids_and_accompanying_prose_are_ignored() {
450 let mut tracker = Tracker::default();
451 assert_eq!(
452 run(
453 &mut tracker,
454 &[
455 text("before"),
456 call("old-id", "read", json!({"path": "a"})),
457 text("between"),
458 result("old-id", "contents"),
459 text("after"),
460 ],
461 ),
462 "changed"
463 );
464 assert_eq!(
465 run(
466 &mut tracker,
467 &[
468 call("new-id", "read", json!({"path": "a"})),
469 result("unrelated-result-id", "contents"),
470 text("completely different explanation"),
471 ],
472 ),
473 "repeated"
474 );
475 assert_eq!(
476 run(
477 &mut tracker,
478 &[
479 call("", "read", json!({"path": "a"})),
480 result("", "contents"),
481 ],
482 ),
483 "repeated"
484 );
485 }
486
487 #[test]
488 fn changed_tool_names_inputs_or_results_are_changed() {
489 let mut tracker = Tracker::default();
490 for (name, input, output) in [
491 ("read", json!({"path": "a"}), "one"),
492 ("read", json!({"path": "b"}), "one"),
493 ("read", json!({"path": "b"}), "two"),
494 ("other", json!({"path": "b"}), "two"),
495 ] {
496 assert_eq!(
497 run(
498 &mut tracker,
499 &[call("id", name, input), result("id", output)]
500 ),
501 "changed"
502 );
503 }
504 }
505
506 #[test]
507 fn unique_results_never_look_repeated_just_because_calls_repeat() {
508 let mut tracker = Tracker::default();
509 for index in 0..12 {
510 assert_eq!(
511 run(
512 &mut tracker,
513 &[
514 call("id", "poll", json!({})),
515 result("id", &format!("output {index}")),
516 ],
517 ),
518 "changed"
519 );
520 }
521 }
522
523 #[test]
524 fn canonicalizes_object_keys_recursively_but_not_array_order_or_types() {
525 let mut tracker = Tracker::default();
526 let a: Value = serde_json::from_str(r#"{"z":[{"b":2,"a":1}],"a":true}"#).unwrap();
527 let b: Value = serde_json::from_str(r#"{"a":true,"z":[{"a":1,"b":2}]}"#).unwrap();
528 assert_eq!(run(&mut tracker, &[call("a", "tool", a)]), "changed");
529 assert_eq!(run(&mut tracker, &[call("b", "tool", b)]), "repeated");
530 for input in [
531 json!([1, 2]),
532 json!([2, 1]),
533 json!(["2", "1"]),
534 json!("[2,1]"),
535 ] {
536 assert_eq!(run(&mut tracker, &[call("id", "tool", input)]), "changed");
537 }
538 }
539
540 #[test]
541 fn tool_event_order_is_significant() {
542 let mut tracker = Tracker::default();
543 let a = call("a", "one", json!(null));
544 let b = call("b", "two", json!(null));
545 let x = result("a", "x");
546 let y = result("b", "y");
547 assert_eq!(
548 run(&mut tracker, &[a.clone(), b.clone(), x.clone(), y.clone()]),
549 "changed"
550 );
551 assert_eq!(
552 run(&mut tracker, &[b.clone(), a.clone(), x.clone(), y.clone()]),
553 "changed"
554 );
555 assert_eq!(run(&mut tracker, &[b, a, y, x]), "changed");
556 }
557
558 #[test]
559 fn type_tags_and_field_lengths_prevent_concatenation_aliases() {
560 let mut tracker = Tracker::default();
561 assert_eq!(run(&mut tracker, &[text("abc")]), "changed");
562 assert_eq!(run(&mut tracker, &[result("", "abc")]), "changed");
563 assert_eq!(
564 run(&mut tracker, &[result("", "ab"), result("", "c")]),
565 "changed"
566 );
567 assert_eq!(
568 run(&mut tracker, &[result("", "a"), result("", "bc")]),
569 "changed"
570 );
571 assert_eq!(run(&mut tracker, &[call("", "ab", json!("c"))]), "changed");
572 assert_eq!(run(&mut tracker, &[call("", "a", json!("bc"))]), "changed");
573 }
574
575 #[test]
576 fn text_chunk_boundaries_do_not_affect_signature() {
577 let mut tracker = Tracker::default();
578 let whole = " \r\nCafé 🦀\n";
579 assert_eq!(run(&mut tracker, &[text(whole)]), "changed");
580 for split in whole
581 .char_indices()
582 .map(|(index, _)| index)
583 .chain([whole.len()])
584 {
585 assert_eq!(
586 run(
587 &mut tracker,
588 &[text(&whole[..split]), text(""), text(&whole[split..])]
589 ),
590 "repeated"
591 );
592 }
593 let chunks: Vec<_> = whole.chars().map(|ch| text(&ch.to_string())).collect();
594 assert_eq!(run(&mut tracker, &chunks), "repeated");
595 }
596
597 #[test]
598 fn nonempty_text_uses_exact_bytes_not_trimmed_or_normalized_bytes() {
599 let mut tracker = Tracker::default();
600 for value in ["answer", " answer", "answer ", "Answer", "answer\n"] {
601 assert_eq!(run(&mut tracker, &[text(value)]), "changed");
602 }
603 }
604
605 #[test]
606 fn thinking_notices_history_agent_events_and_deltas_are_ignored() {
607 let mut tracker = Tracker::default();
608 let ignored = [
610 StreamEvent::Llm(LlmEvent::Thinking("private reasoning".into())),
611 StreamEvent::Llm(LlmEvent::ToolUseStart {
612 tool_name: "unfinished".into(),
613 tool_id: "id".into(),
614 }),
615 StreamEvent::Llm(LlmEvent::ToolUseDelta {
616 tool_id: "id".into(),
617 delta: "{incomplete".into(),
618 }),
619 StreamEvent::Llm(LlmEvent::ToolResultDelta {
620 tool_id: "id".into(),
621 delta: "partial output".into(),
622 }),
623 StreamEvent::Session(SessionEvent::Notice("notice".into())),
624 StreamEvent::Session(SessionEvent::MessageHistory(vec![std::sync::Arc::new(
625 json!({
626 "role": "user", "content": "user goal/history is not output"
627 }),
628 )])),
629 StreamEvent::Agent(AgentEvent::SteeringDelivered {
630 message: "new goal".into(),
631 }),
632 StreamEvent::Session(SessionEvent::Done),
633 ];
634 assert_eq!(run(&mut tracker, &ignored), "empty");
635 assert_eq!(run(&mut tracker, &[text("answer")]), "changed");
636 tracker.begin_turn("model", "effort");
637 tracker.observe(&text("ans"));
638 for event in &ignored {
639 tracker.observe(event);
640 }
641 tracker.observe(&text("wer"));
642 assert_eq!(tracker.finish(), "repeated");
643 }
644
645 #[test]
646 fn empty_or_whitespace_only_output_never_becomes_repeated() {
647 let mut tracker = Tracker::default();
648 for _ in 0..6 {
649 assert_eq!(run(&mut tracker, &[]), "empty");
650 assert_eq!(
651 run(&mut tracker, &[text(""), text(" \t\r\n\u{2003}")]),
652 "empty"
653 );
654 assert_eq!(
655 run(
656 &mut tracker,
657 &[StreamEvent::Llm(LlmEvent::Thinking("alone".into()))]
658 ),
659 "empty"
660 );
661 }
662 assert_eq!(run(&mut tracker, &[text("A")]), "changed");
663 for _ in 0..6 {
664 assert_eq!(run(&mut tracker, &[]), "empty");
665 }
666 assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
667 }
668
669 #[test]
670 fn final_tools_are_meaningful_even_with_empty_output() {
671 let mut tracker = Tracker::default();
672 assert_eq!(
673 run(&mut tracker, &[call("", "tool", json!(null))]),
674 "changed"
675 );
676 assert_eq!(
677 run(&mut tracker, &[call("new", "tool", json!(null))]),
678 "repeated"
679 );
680 assert_eq!(run(&mut tracker, &[result("", "")]), "changed");
681 assert_eq!(run(&mut tracker, &[result("new", "")]), "repeated");
682 }
683
684 #[test]
685 fn exact_model_and_effort_changes_reset_the_ring() {
686 let mut tracker = Tracker::default();
687 assert_eq!(scoped(&mut tracker, "model", "high"), "changed");
688 assert_eq!(scoped(&mut tracker, "model", "high"), "repeated");
689 for (model, effort) in [
690 ("Model", "high"),
691 ("model", "high"),
692 ("model", "HIGH"),
693 ("model", "high "),
694 ("model", "high"),
695 ("ab", "c"),
696 ("a", "bc"),
697 ] {
698 assert_eq!(scoped(&mut tracker, model, effort), "changed");
699 }
700 }
701
702 #[test]
703 fn oversized_scope_is_unknown_without_retaining_unbounded_metadata() {
704 let mut tracker = Tracker::default();
705 assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
706 assert_eq!(
707 scoped(&mut tracker, &"m".repeat(MAX_SCOPE_BYTES + 1), ""),
708 "unknown"
709 );
710 assert_eq!(
711 scoped(&mut tracker, "m", &"e".repeat(MAX_SCOPE_BYTES)),
712 "unknown"
713 );
714 assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
715 }
716
717 #[test]
718 fn finish_and_observe_outside_a_turn_do_not_account_twice() {
719 let mut tracker = Tracker::default();
720 tracker.observe(&text("unowned"));
721 assert_eq!(tracker.finish(), "unknown");
722 assert_eq!(run(&mut tracker, &[text("A")]), "changed");
723 assert_eq!(run(&mut tracker, &[text("B")]), "changed");
724 for _ in 0..8 {
725 tracker.observe(&text("unowned"));
726 assert_eq!(tracker.finish(), "unknown");
727 }
728 assert_eq!(run(&mut tracker, &[text("C")]), "changed");
729 assert_eq!(run(&mut tracker, &[text("D")]), "changed");
730 assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
731 assert_eq!(run(&mut tracker, &[text("unowned")]), "changed");
732 }
733
734 #[test]
735 fn interrupted_turn_is_discarded_without_losing_completed_signatures() {
736 let mut tracker = Tracker::default();
737 assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
738 tracker.begin_turn("model", "effort");
739 tracker.observe(&text("interrupted"));
740 assert_eq!(run(&mut tracker, &[text("completed")]), "repeated");
742 assert_eq!(run(&mut tracker, &[text("interrupted")]), "changed");
743 tracker.begin_turn("different model", "effort");
744 assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
745 }
746
747 #[test]
748 fn text_budget_boundary_and_overflow_do_not_compare_truncated_prefixes() {
749 let mut tracker = Tracker::default();
750 let at_limit = text(&"x".repeat(MAX_BYTES));
751 assert_eq!(
752 run(&mut tracker, std::slice::from_ref(&at_limit)),
753 "changed"
754 );
755 assert_eq!(
756 run(&mut tracker, std::slice::from_ref(&at_limit)),
757 "repeated"
758 );
759 for _ in 0..6 {
760 assert_eq!(run(&mut tracker, &[at_limit.clone(), text("x")]), "unknown");
763 }
764 assert_eq!(
765 run(&mut tracker, std::slice::from_ref(&at_limit)),
766 "repeated"
767 );
768 assert_eq!(
769 run(&mut tracker, &[text(&"x".repeat(MAX_BYTES + 1))]),
770 "unknown"
771 );
772 }
773
774 #[test]
775 fn tool_event_limit_is_inclusive_and_overflow_is_sticky() {
776 let mut tracker = Tracker::default();
777 for expected in ["changed", "repeated"] {
778 tracker.begin_turn("model", "effort");
779 for _ in 0..MAX_TOOL_EVENTS {
780 tracker.observe(&result("ignored", ""));
781 }
782 assert_eq!(tracker.finish(), expected);
783 }
784 tracker.begin_turn("model", "effort");
785 for _ in 0..=MAX_TOOL_EVENTS {
786 tracker.observe(&result("ignored", ""));
787 }
788 tracker.observe(&text("cannot recover"));
789 assert_eq!(tracker.finish(), "unknown");
790 assert_eq!(run(&mut tracker, &[text("cannot recover")]), "changed");
791 }
792
793 #[test]
794 fn byte_budget_is_shared_by_text_names_inputs_and_results() {
795 let mut tracker = Tracker::default();
796 let events = [
797 text(&"x".repeat(MAX_BYTES - 7)),
798 call("id", "t", json!(null)), result("id", "ok"), ];
801 assert_eq!(run(&mut tracker, &events), "changed");
802 tracker.begin_turn("model", "effort");
803 for event in &events {
804 tracker.observe(event);
805 }
806 tracker.observe(&text("!"));
807 assert_eq!(tracker.finish(), "unknown");
808 assert_eq!(
809 run(&mut tracker, &[result("", &"x".repeat(MAX_BYTES + 1))]),
810 "unknown"
811 );
812 assert_eq!(
813 run(
814 &mut tracker,
815 &[call("", &"x".repeat(MAX_BYTES + 1), json!(null))]
816 ),
817 "unknown"
818 );
819 }
820
821 #[test]
822 fn ignored_content_does_not_consume_the_budget() {
823 let mut tracker = Tracker::default();
824 let ignored = StreamEvent::Llm(LlmEvent::Thinking("x".repeat(MAX_BYTES + 1)));
825 assert_eq!(run(&mut tracker, &[ignored, text("answer")]), "changed");
826 assert_eq!(run(&mut tracker, &[text("answer")]), "repeated");
827 }
828
829 #[test]
830 fn oversized_json_and_escaped_serialization_overflow_are_unknown() {
831 let mut tracker = Tracker::default();
832 for input in [
833 Value::String("x".repeat(MAX_BYTES + 1)),
834 Value::String("\n".repeat(MAX_BYTES / 2)),
836 ] {
837 assert_eq!(run(&mut tracker, &[call("", "tool", input)]), "unknown");
838 }
839 assert_eq!(
840 run(&mut tracker, &[call("", "tool", json!("small"))]),
841 "changed"
842 );
843 assert_eq!(
844 run(&mut tracker, &[call("", "tool", json!("small"))]),
845 "repeated"
846 );
847 }
848
849 #[test]
850 fn json_depth_and_total_node_limits_fail_closed() {
851 let mut tracker = Tracker::default();
852 let mut nested = Value::Null;
853 for _ in 0..MAX_JSON_DEPTH {
854 nested = Value::Array(vec![nested]);
855 }
856 assert_eq!(
857 run(&mut tracker, &[call("", "tool", nested.clone())]),
858 "changed"
859 );
860 assert_eq!(
861 run(
862 &mut tracker,
863 &[call("", "tool", Value::Array(vec![nested]))]
864 ),
865 "unknown"
866 );
867 assert_eq!(
868 run(
869 &mut tracker,
870 &[call(
871 "",
872 "tool",
873 Value::Array(vec![Value::Null; MAX_JSON_NODES])
874 )]
875 ),
876 "unknown"
877 );
878 let half = call(
879 "",
880 "tool",
881 Value::Array(vec![Value::Null; MAX_JSON_NODES / 2]),
882 );
883 assert_eq!(run(&mut tracker, &[half.clone(), half]), "unknown");
884 }
885}