tool_edit_parser.rs

   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}