1use smallvec::SmallVec;
2
3use crate::{Edit, PartialEdit};
4
5/// Events emitted by `ToolEditParser` as tool call input streams in.
6#[derive(Debug, PartialEq, Eq)]
7pub enum ToolEditEvent {
8 /// A chunk of `old_text` for an edit operation.
9 OldTextChunk {
10 edit_index: usize,
11 chunk: String,
12 done: bool,
13 },
14 /// A chunk of `new_text` for an edit operation.
15 NewTextChunk {
16 edit_index: usize,
17 chunk: String,
18 done: bool,
19 },
20 /// A chunk of content for write/overwrite mode.
21 ContentChunk { chunk: String },
22}
23
24/// Tracks the streaming state of a single edit to detect deltas.
25#[derive(Default, Debug)]
26struct EditStreamState {
27 old_text_emitted_len: usize,
28 old_text_done: bool,
29 new_text_emitted_len: usize,
30 new_text_done: bool,
31}
32
33/// Converts incrementally-growing tool call JSON into a stream of chunk events.
34///
35/// The tool call streaming infrastructure delivers partial JSON objects where
36/// string fields grow over time. This parser compares consecutive partials,
37/// computes the deltas, and emits `ToolEditEvent`s that downstream pipeline
38/// stages (`StreamingFuzzyMatcher` for old_text, `StreamingDiff` for new_text)
39/// can consume incrementally.
40///
41/// Because partial JSON comes through a fixer (`partial-json-fixer`) that
42/// closes incomplete escape sequences, a string can temporarily contain wrong
43/// trailing characters (e.g. a literal `\` instead of `\n`). We handle this
44/// by holding back trailing backslash characters in non-finalized chunks: if
45/// a partial string ends with `\` (0x5C), that byte is not emitted until the
46/// next partial confirms or corrects it. This avoids feeding corrupted bytes
47/// to downstream consumers.
48#[derive(Default, Debug)]
49pub struct ToolEditParser {
50 edit_states: Vec<EditStreamState>,
51 content_emitted_len: usize,
52}
53
54impl ToolEditParser {
55 /// Push a new set of partial edits (from edit mode) and return any events.
56 ///
57 /// Each call should pass the *entire current* edits array as seen in the
58 /// latest partial input. The parser will diff it against its internal state
59 /// to produce only the new events.
60 pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[ToolEditEvent; 4]> {
61 let mut events = SmallVec::new();
62
63 for (index, partial) in edits.iter().enumerate() {
64 if index >= self.edit_states.len() {
65 // A new edit appeared — finalize the previous one if there was one.
66 if let Some(previous) = self.finalize_previous_edit(index) {
67 events.extend(previous);
68 }
69 self.edit_states.push(EditStreamState::default());
70 }
71
72 let state = &mut self.edit_states[index];
73
74 // Process old_text changes.
75 if let Some(old_text) = &partial.old_text
76 && !state.old_text_done
77 {
78 if partial.new_text.is_some() {
79 // new_text appeared, so old_text is done — emit everything.
80 let start = state.old_text_emitted_len.min(old_text.len());
81 let chunk = normalize_done_chunk(old_text[start..].to_string());
82 state.old_text_done = true;
83 state.old_text_emitted_len = old_text.len();
84 events.push(ToolEditEvent::OldTextChunk {
85 edit_index: index,
86 chunk,
87 done: true,
88 });
89 } else {
90 let safe_end = safe_emit_end_for_edit_text(old_text);
91
92 if safe_end > state.old_text_emitted_len {
93 let chunk = old_text[state.old_text_emitted_len..safe_end].to_string();
94 state.old_text_emitted_len = safe_end;
95 events.push(ToolEditEvent::OldTextChunk {
96 edit_index: index,
97 chunk,
98 done: false,
99 });
100 }
101 }
102 }
103
104 // Process new_text changes.
105 if let Some(new_text) = &partial.new_text
106 && !state.new_text_done
107 {
108 let safe_end = safe_emit_end_for_edit_text(new_text);
109
110 if safe_end > state.new_text_emitted_len {
111 let chunk = new_text[state.new_text_emitted_len..safe_end].to_string();
112 state.new_text_emitted_len = safe_end;
113 events.push(ToolEditEvent::NewTextChunk {
114 edit_index: index,
115 chunk,
116 done: false,
117 });
118 }
119 }
120 }
121
122 events
123 }
124
125 /// Push new content and return any events.
126 ///
127 /// Each call should pass the *entire current* content string. The parser
128 /// will diff it against its internal state to emit only the new chunk.
129 pub fn push_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
130 let mut events = SmallVec::new();
131
132 let safe_end = safe_emit_end(content);
133 if safe_end > self.content_emitted_len {
134 let chunk = content[self.content_emitted_len..safe_end].to_string();
135 self.content_emitted_len = safe_end;
136 events.push(ToolEditEvent::ContentChunk { chunk });
137 }
138
139 events
140 }
141
142 /// Finalize all edits with the complete input. This emits `done: true`
143 /// events for any in-progress old_text or new_text that hasn't been
144 /// finalized yet.
145 ///
146 /// `final_edits` should be the fully deserialized final edits array. The
147 /// parser compares against its tracked state and emits any remaining deltas
148 /// with `done: true`.
149 pub fn finalize_edits(&mut self, edits: &[Edit]) -> SmallVec<[ToolEditEvent; 4]> {
150 let mut events = SmallVec::new();
151
152 for (index, edit) in edits.iter().enumerate() {
153 if index >= self.edit_states.len() {
154 // This edit was never seen in partials — emit it fully.
155 if let Some(previous) = self.finalize_previous_edit(index) {
156 events.extend(previous);
157 }
158 self.edit_states.push(EditStreamState::default());
159 }
160
161 let state = &mut self.edit_states[index];
162
163 if !state.old_text_done {
164 let start = state.old_text_emitted_len.min(edit.old_text.len());
165 let chunk = normalize_done_chunk(edit.old_text[start..].to_string());
166 state.old_text_done = true;
167 state.old_text_emitted_len = edit.old_text.len();
168 events.push(ToolEditEvent::OldTextChunk {
169 edit_index: index,
170 chunk,
171 done: true,
172 });
173 }
174
175 if !state.new_text_done {
176 let start = state.new_text_emitted_len.min(edit.new_text.len());
177 let chunk = normalize_done_chunk(edit.new_text[start..].to_string());
178 state.new_text_done = true;
179 state.new_text_emitted_len = edit.new_text.len();
180 events.push(ToolEditEvent::NewTextChunk {
181 edit_index: index,
182 chunk,
183 done: true,
184 });
185 }
186 }
187
188 events
189 }
190
191 /// Finalize content with the complete input.
192 pub fn finalize_content(&mut self, content: &str) -> SmallVec<[ToolEditEvent; 1]> {
193 let mut events = SmallVec::new();
194
195 let start = self.content_emitted_len.min(content.len());
196 if content.len() > start {
197 let chunk = content[start..].to_string();
198 self.content_emitted_len = content.len();
199 events.push(ToolEditEvent::ContentChunk { chunk });
200 }
201
202 events
203 }
204
205 /// When a new edit appears at `index`, finalize the edit at `index - 1`
206 /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized.
207 fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[ToolEditEvent; 2]>> {
208 if new_index == 0 || self.edit_states.is_empty() {
209 return None;
210 }
211
212 let previous_index = new_index - 1;
213 if previous_index >= self.edit_states.len() {
214 return None;
215 }
216
217 let state = &mut self.edit_states[previous_index];
218 let mut events = SmallVec::new();
219
220 // If old_text was never finalized, finalize it now with an empty done chunk.
221 if !state.old_text_done {
222 state.old_text_done = true;
223 events.push(ToolEditEvent::OldTextChunk {
224 edit_index: previous_index,
225 chunk: String::new(),
226 done: true,
227 });
228 }
229
230 // Emit a done event for new_text if not already finalized.
231 if !state.new_text_done {
232 state.new_text_done = true;
233 events.push(ToolEditEvent::NewTextChunk {
234 edit_index: previous_index,
235 chunk: String::new(),
236 done: true,
237 });
238 }
239
240 Some(events)
241 }
242}
243
244/// Returns the byte position up to which it is safe to emit from a partial
245/// string. If the string ends with a backslash (`\`, 0x5C), that byte is
246/// held back because it may be an artifact of the partial JSON fixer closing
247/// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
248/// The next partial will reveal the correct character.
249fn safe_emit_end(text: &str) -> usize {
250 if text.as_bytes().last() == Some(&b'\\') {
251 text.len() - 1
252 } else {
253 text.len()
254 }
255}
256
257fn safe_emit_end_for_edit_text(text: &str) -> usize {
258 let safe_end = safe_emit_end(text);
259 if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' {
260 safe_end - 1
261 } else {
262 safe_end
263 }
264}
265
266fn normalize_done_chunk(mut chunk: String) -> String {
267 if chunk.ends_with('\n') {
268 chunk.pop();
269 }
270 chunk
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_single_edit_streamed_incrementally() {
279 let mut parser = ToolEditParser::default();
280
281 // old_text arrives in chunks: "hell" → "hello w" → "hello world"
282 let events = parser.push_edits(&[PartialEdit {
283 old_text: Some("hell".into()),
284 new_text: None,
285 }]);
286 assert_eq!(
287 events.as_slice(),
288 &[ToolEditEvent::OldTextChunk {
289 edit_index: 0,
290 chunk: "hell".into(),
291 done: false,
292 }]
293 );
294
295 let events = parser.push_edits(&[PartialEdit {
296 old_text: Some("hello w".into()),
297 new_text: None,
298 }]);
299 assert_eq!(
300 events.as_slice(),
301 &[ToolEditEvent::OldTextChunk {
302 edit_index: 0,
303 chunk: "o w".into(),
304 done: false,
305 }]
306 );
307
308 // new_text appears → old_text finalizes
309 let events = parser.push_edits(&[PartialEdit {
310 old_text: Some("hello world".into()),
311 new_text: Some("good".into()),
312 }]);
313 assert_eq!(
314 events.as_slice(),
315 &[
316 ToolEditEvent::OldTextChunk {
317 edit_index: 0,
318 chunk: "orld".into(),
319 done: true,
320 },
321 ToolEditEvent::NewTextChunk {
322 edit_index: 0,
323 chunk: "good".into(),
324 done: false,
325 },
326 ]
327 );
328
329 // new_text grows
330 let events = parser.push_edits(&[PartialEdit {
331 old_text: Some("hello world".into()),
332 new_text: Some("goodbye world".into()),
333 }]);
334 assert_eq!(
335 events.as_slice(),
336 &[ToolEditEvent::NewTextChunk {
337 edit_index: 0,
338 chunk: "bye world".into(),
339 done: false,
340 }]
341 );
342
343 // Finalize
344 let events = parser.finalize_edits(&[Edit {
345 old_text: "hello world".into(),
346 new_text: "goodbye world".into(),
347 }]);
348 assert_eq!(
349 events.as_slice(),
350 &[ToolEditEvent::NewTextChunk {
351 edit_index: 0,
352 chunk: "".into(),
353 done: true,
354 }]
355 );
356 }
357
358 #[test]
359 fn test_done_chunks_strip_trailing_newline() {
360 let mut parser = ToolEditParser::default();
361
362 let events = parser.finalize_edits(&[Edit {
363 old_text: "before\n".into(),
364 new_text: "after\n".into(),
365 }]);
366 assert_eq!(
367 events.as_slice(),
368 &[
369 ToolEditEvent::OldTextChunk {
370 edit_index: 0,
371 chunk: "before".into(),
372 done: true,
373 },
374 ToolEditEvent::NewTextChunk {
375 edit_index: 0,
376 chunk: "after".into(),
377 done: true,
378 },
379 ]
380 );
381 }
382
383 #[test]
384 fn test_partial_edit_chunks_hold_back_trailing_newline() {
385 let mut parser = ToolEditParser::default();
386
387 let events = parser.push_edits(&[PartialEdit {
388 old_text: Some("before\n".into()),
389 new_text: Some("after\n".into()),
390 }]);
391 assert_eq!(
392 events.as_slice(),
393 &[
394 ToolEditEvent::OldTextChunk {
395 edit_index: 0,
396 chunk: "before".into(),
397 done: true,
398 },
399 ToolEditEvent::NewTextChunk {
400 edit_index: 0,
401 chunk: "after".into(),
402 done: false,
403 },
404 ]
405 );
406
407 let events = parser.finalize_edits(&[Edit {
408 old_text: "before\n".into(),
409 new_text: "after\n".into(),
410 }]);
411 assert_eq!(
412 events.as_slice(),
413 &[ToolEditEvent::NewTextChunk {
414 edit_index: 0,
415 chunk: "".into(),
416 done: true,
417 }]
418 );
419 }
420
421 #[test]
422 fn test_multiple_edits_sequential() {
423 let mut parser = ToolEditParser::default();
424
425 // First edit streams in
426 let events = parser.push_edits(&[PartialEdit {
427 old_text: Some("first old".into()),
428 new_text: None,
429 }]);
430 assert_eq!(
431 events.as_slice(),
432 &[ToolEditEvent::OldTextChunk {
433 edit_index: 0,
434 chunk: "first old".into(),
435 done: false,
436 }]
437 );
438
439 let events = parser.push_edits(&[PartialEdit {
440 old_text: Some("first old".into()),
441 new_text: Some("first new".into()),
442 }]);
443 assert_eq!(
444 events.as_slice(),
445 &[
446 ToolEditEvent::OldTextChunk {
447 edit_index: 0,
448 chunk: "".into(),
449 done: true,
450 },
451 ToolEditEvent::NewTextChunk {
452 edit_index: 0,
453 chunk: "first new".into(),
454 done: false,
455 },
456 ]
457 );
458
459 // Second edit appears → first edit's new_text is finalized
460 let events = parser.push_edits(&[
461 PartialEdit {
462 old_text: Some("first old".into()),
463 new_text: Some("first new".into()),
464 },
465 PartialEdit {
466 old_text: Some("second".into()),
467 new_text: None,
468 },
469 ]);
470 assert_eq!(
471 events.as_slice(),
472 &[
473 ToolEditEvent::NewTextChunk {
474 edit_index: 0,
475 chunk: "".into(),
476 done: true,
477 },
478 ToolEditEvent::OldTextChunk {
479 edit_index: 1,
480 chunk: "second".into(),
481 done: false,
482 },
483 ]
484 );
485
486 // Finalize everything
487 let events = parser.finalize_edits(&[
488 Edit {
489 old_text: "first old".into(),
490 new_text: "first new".into(),
491 },
492 Edit {
493 old_text: "second old".into(),
494 new_text: "second new".into(),
495 },
496 ]);
497 assert_eq!(
498 events.as_slice(),
499 &[
500 ToolEditEvent::OldTextChunk {
501 edit_index: 1,
502 chunk: " old".into(),
503 done: true,
504 },
505 ToolEditEvent::NewTextChunk {
506 edit_index: 1,
507 chunk: "second new".into(),
508 done: true,
509 },
510 ]
511 );
512 }
513
514 #[test]
515 fn test_content_streamed_incrementally() {
516 let mut parser = ToolEditParser::default();
517
518 let events = parser.push_content("hello");
519 assert_eq!(
520 events.as_slice(),
521 &[ToolEditEvent::ContentChunk {
522 chunk: "hello".into(),
523 }]
524 );
525
526 let events = parser.push_content("hello world");
527 assert_eq!(
528 events.as_slice(),
529 &[ToolEditEvent::ContentChunk {
530 chunk: " world".into(),
531 }]
532 );
533
534 // No change
535 let events = parser.push_content("hello world");
536 assert!(events.is_empty());
537
538 let events = parser.push_content("hello world!");
539 assert_eq!(
540 events.as_slice(),
541 &[ToolEditEvent::ContentChunk { chunk: "!".into() }]
542 );
543
544 // Finalize with no additional content
545 let events = parser.finalize_content("hello world!");
546 assert!(events.is_empty());
547 }
548
549 #[test]
550 fn test_finalize_content_with_remaining() {
551 let mut parser = ToolEditParser::default();
552
553 parser.push_content("partial");
554 let events = parser.finalize_content("partial content here");
555 assert_eq!(
556 events.as_slice(),
557 &[ToolEditEvent::ContentChunk {
558 chunk: " content here".into(),
559 }]
560 );
561 }
562
563 #[test]
564 fn test_content_trailing_backslash_held_back() {
565 let mut parser = ToolEditParser::default();
566
567 // Partial JSON fixer turns incomplete \n into \\ (literal backslash).
568 // The trailing backslash is held back.
569 let events = parser.push_content("hello,\\");
570 assert_eq!(
571 events.as_slice(),
572 &[ToolEditEvent::ContentChunk {
573 chunk: "hello,".into(),
574 }]
575 );
576
577 // Next partial corrects the escape to an actual newline.
578 // The held-back byte was wrong; the correct newline is emitted.
579 let events = parser.push_content("hello,\n");
580 assert_eq!(
581 events.as_slice(),
582 &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
583 );
584
585 // Normal growth.
586 let events = parser.push_content("hello,\nworld");
587 assert_eq!(
588 events.as_slice(),
589 &[ToolEditEvent::ContentChunk {
590 chunk: "world".into(),
591 }]
592 );
593 }
594
595 #[test]
596 fn test_content_finalize_with_trailing_backslash() {
597 let mut parser = ToolEditParser::default();
598
599 // Stream a partial with a fixer-corrupted trailing backslash.
600 // The backslash is held back.
601 parser.push_content("abc\\");
602
603 // Finalize reveals the correct character.
604 let events = parser.finalize_content("abc\n");
605 assert_eq!(
606 events.as_slice(),
607 &[ToolEditEvent::ContentChunk { chunk: "\n".into() }]
608 );
609 }
610
611 #[test]
612 fn test_no_partials_direct_finalize() {
613 let mut parser = ToolEditParser::default();
614
615 let events = parser.finalize_edits(&[Edit {
616 old_text: "old".into(),
617 new_text: "new".into(),
618 }]);
619 assert_eq!(
620 events.as_slice(),
621 &[
622 ToolEditEvent::OldTextChunk {
623 edit_index: 0,
624 chunk: "old".into(),
625 done: true,
626 },
627 ToolEditEvent::NewTextChunk {
628 edit_index: 0,
629 chunk: "new".into(),
630 done: true,
631 },
632 ]
633 );
634 }
635
636 #[test]
637 fn test_no_partials_direct_finalize_multiple() {
638 let mut parser = ToolEditParser::default();
639
640 let events = parser.finalize_edits(&[
641 Edit {
642 old_text: "first old".into(),
643 new_text: "first new".into(),
644 },
645 Edit {
646 old_text: "second old".into(),
647 new_text: "second new".into(),
648 },
649 ]);
650 assert_eq!(
651 events.as_slice(),
652 &[
653 ToolEditEvent::OldTextChunk {
654 edit_index: 0,
655 chunk: "first old".into(),
656 done: true,
657 },
658 ToolEditEvent::NewTextChunk {
659 edit_index: 0,
660 chunk: "first new".into(),
661 done: true,
662 },
663 ToolEditEvent::OldTextChunk {
664 edit_index: 1,
665 chunk: "second old".into(),
666 done: true,
667 },
668 ToolEditEvent::NewTextChunk {
669 edit_index: 1,
670 chunk: "second new".into(),
671 done: true,
672 },
673 ]
674 );
675 }
676
677 #[test]
678 fn test_old_text_no_growth() {
679 let mut parser = ToolEditParser::default();
680
681 let events = parser.push_edits(&[PartialEdit {
682 old_text: Some("same".into()),
683 new_text: None,
684 }]);
685 assert_eq!(
686 events.as_slice(),
687 &[ToolEditEvent::OldTextChunk {
688 edit_index: 0,
689 chunk: "same".into(),
690 done: false,
691 }]
692 );
693
694 // Same old_text, no new_text → no events
695 let events = parser.push_edits(&[PartialEdit {
696 old_text: Some("same".into()),
697 new_text: None,
698 }]);
699 assert!(events.is_empty());
700 }
701
702 #[test]
703 fn test_old_text_none_then_appears() {
704 let mut parser = ToolEditParser::default();
705
706 // Edit exists but old_text is None (field hasn't arrived yet)
707 let events = parser.push_edits(&[PartialEdit {
708 old_text: None,
709 new_text: None,
710 }]);
711 assert!(events.is_empty());
712
713 // old_text appears
714 let events = parser.push_edits(&[PartialEdit {
715 old_text: Some("text".into()),
716 new_text: None,
717 }]);
718 assert_eq!(
719 events.as_slice(),
720 &[ToolEditEvent::OldTextChunk {
721 edit_index: 0,
722 chunk: "text".into(),
723 done: false,
724 }]
725 );
726 }
727
728 #[test]
729 fn test_empty_old_text_with_new_text() {
730 let mut parser = ToolEditParser::default();
731
732 // old_text is empty, new_text appears immediately
733 let events = parser.push_edits(&[PartialEdit {
734 old_text: Some("".into()),
735 new_text: Some("inserted".into()),
736 }]);
737 assert_eq!(
738 events.as_slice(),
739 &[
740 ToolEditEvent::OldTextChunk {
741 edit_index: 0,
742 chunk: "".into(),
743 done: true,
744 },
745 ToolEditEvent::NewTextChunk {
746 edit_index: 0,
747 chunk: "inserted".into(),
748 done: false,
749 },
750 ]
751 );
752 }
753
754 #[test]
755 fn test_three_edits_streamed() {
756 let mut parser = ToolEditParser::default();
757
758 // Stream first edit
759 parser.push_edits(&[PartialEdit {
760 old_text: Some("a".into()),
761 new_text: Some("A".into()),
762 }]);
763
764 // Second edit appears
765 parser.push_edits(&[
766 PartialEdit {
767 old_text: Some("a".into()),
768 new_text: Some("A".into()),
769 },
770 PartialEdit {
771 old_text: Some("b".into()),
772 new_text: Some("B".into()),
773 },
774 ]);
775
776 // Third edit appears
777 let events = parser.push_edits(&[
778 PartialEdit {
779 old_text: Some("a".into()),
780 new_text: Some("A".into()),
781 },
782 PartialEdit {
783 old_text: Some("b".into()),
784 new_text: Some("B".into()),
785 },
786 PartialEdit {
787 old_text: Some("c".into()),
788 new_text: None,
789 },
790 ]);
791
792 // Should finalize edit 1 (index=1) and start edit 2 (index=2)
793 assert_eq!(
794 events.as_slice(),
795 &[
796 ToolEditEvent::NewTextChunk {
797 edit_index: 1,
798 chunk: "".into(),
799 done: true,
800 },
801 ToolEditEvent::OldTextChunk {
802 edit_index: 2,
803 chunk: "c".into(),
804 done: false,
805 },
806 ]
807 );
808
809 // Finalize
810 let events = parser.finalize_edits(&[
811 Edit {
812 old_text: "a".into(),
813 new_text: "A".into(),
814 },
815 Edit {
816 old_text: "b".into(),
817 new_text: "B".into(),
818 },
819 Edit {
820 old_text: "c".into(),
821 new_text: "C".into(),
822 },
823 ]);
824 assert_eq!(
825 events.as_slice(),
826 &[
827 ToolEditEvent::OldTextChunk {
828 edit_index: 2,
829 chunk: "".into(),
830 done: true,
831 },
832 ToolEditEvent::NewTextChunk {
833 edit_index: 2,
834 chunk: "C".into(),
835 done: true,
836 },
837 ]
838 );
839 }
840
841 #[test]
842 fn test_finalize_with_unseen_old_text() {
843 let mut parser = ToolEditParser::default();
844
845 // Only saw partial old_text, never saw new_text in partials
846 parser.push_edits(&[PartialEdit {
847 old_text: Some("partial".into()),
848 new_text: None,
849 }]);
850
851 let events = parser.finalize_edits(&[Edit {
852 old_text: "partial old text".into(),
853 new_text: "replacement".into(),
854 }]);
855 assert_eq!(
856 events.as_slice(),
857 &[
858 ToolEditEvent::OldTextChunk {
859 edit_index: 0,
860 chunk: " old text".into(),
861 done: true,
862 },
863 ToolEditEvent::NewTextChunk {
864 edit_index: 0,
865 chunk: "replacement".into(),
866 done: true,
867 },
868 ]
869 );
870 }
871
872 #[test]
873 fn test_finalize_with_partially_seen_new_text() {
874 let mut parser = ToolEditParser::default();
875
876 parser.push_edits(&[PartialEdit {
877 old_text: Some("old".into()),
878 new_text: Some("partial".into()),
879 }]);
880
881 let events = parser.finalize_edits(&[Edit {
882 old_text: "old".into(),
883 new_text: "partial new text".into(),
884 }]);
885 assert_eq!(
886 events.as_slice(),
887 &[ToolEditEvent::NewTextChunk {
888 edit_index: 0,
889 chunk: " new text".into(),
890 done: true,
891 }]
892 );
893 }
894
895 #[test]
896 fn test_repeated_pushes_with_no_change() {
897 let mut parser = ToolEditParser::default();
898
899 let events = parser.push_edits(&[PartialEdit {
900 old_text: Some("stable".into()),
901 new_text: Some("also stable".into()),
902 }]);
903 assert_eq!(events.len(), 2); // old done + new chunk
904
905 // Push the exact same data again
906 let events = parser.push_edits(&[PartialEdit {
907 old_text: Some("stable".into()),
908 new_text: Some("also stable".into()),
909 }]);
910 assert!(events.is_empty());
911
912 // And again
913 let events = parser.push_edits(&[PartialEdit {
914 old_text: Some("stable".into()),
915 new_text: Some("also stable".into()),
916 }]);
917 assert!(events.is_empty());
918 }
919
920 #[test]
921 fn test_old_text_trailing_backslash_held_back() {
922 let mut parser = ToolEditParser::default();
923
924 // Partial-json-fixer produces a literal backslash when the JSON stream
925 // cuts in the middle of an escape sequence like \n. The parser holds
926 // back the trailing backslash instead of emitting it.
927 let events = parser.push_edits(&[PartialEdit {
928 old_text: Some("hello,\\".into()), // fixer closed incomplete \n as \\
929 new_text: None,
930 }]);
931 // The trailing `\` is held back — only "hello," is emitted.
932 assert_eq!(
933 events.as_slice(),
934 &[ToolEditEvent::OldTextChunk {
935 edit_index: 0,
936 chunk: "hello,".into(),
937 done: false,
938 }]
939 );
940
941 // Next partial: the fixer corrects the escape to \n.
942 // Because edit text also holds back a trailing newline, nothing new
943 // is emitted yet.
944 let events = parser.push_edits(&[PartialEdit {
945 old_text: Some("hello,\n".into()),
946 new_text: None,
947 }]);
948 assert!(events.is_empty());
949
950 // Continue normally. The held-back newline is emitted together with the
951 // next content once it is no longer trailing.
952 let events = parser.push_edits(&[PartialEdit {
953 old_text: Some("hello,\nworld".into()),
954 new_text: None,
955 }]);
956 assert_eq!(
957 events.as_slice(),
958 &[ToolEditEvent::OldTextChunk {
959 edit_index: 0,
960 chunk: "\nworld".into(),
961 done: false,
962 }]
963 );
964 }
965
966 #[test]
967 fn test_multiline_old_and_new_text() {
968 let mut parser = ToolEditParser::default();
969
970 let events = parser.push_edits(&[PartialEdit {
971 old_text: Some("line1\nline2".into()),
972 new_text: None,
973 }]);
974 assert_eq!(
975 events.as_slice(),
976 &[ToolEditEvent::OldTextChunk {
977 edit_index: 0,
978 chunk: "line1\nline2".into(),
979 done: false,
980 }]
981 );
982
983 let events = parser.push_edits(&[PartialEdit {
984 old_text: Some("line1\nline2\nline3".into()),
985 new_text: Some("LINE1\n".into()),
986 }]);
987 assert_eq!(
988 events.as_slice(),
989 &[
990 ToolEditEvent::OldTextChunk {
991 edit_index: 0,
992 chunk: "\nline3".into(),
993 done: true,
994 },
995 ToolEditEvent::NewTextChunk {
996 edit_index: 0,
997 chunk: "LINE1".into(),
998 done: false,
999 },
1000 ]
1001 );
1002
1003 let events = parser.push_edits(&[PartialEdit {
1004 old_text: Some("line1\nline2\nline3".into()),
1005 new_text: Some("LINE1\nLINE2\nLINE3".into()),
1006 }]);
1007 assert_eq!(
1008 events.as_slice(),
1009 &[ToolEditEvent::NewTextChunk {
1010 edit_index: 0,
1011 chunk: "\nLINE2\nLINE3".into(),
1012 done: false,
1013 }]
1014 );
1015 }
1016}