18
//! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
19
//! on-device models. A fix in any single upstream leaves the others exposed.
20
//!
21
-//! Only well-formed shapes are recovered. A block that doesn't match is left
22
-//! in the text untouched, deliberately. Reissuing a `run_command` is cheap,
23
-//! but guessing wrong at a half-parsed `edit_file` would write the wrong
24
-//! change to a file, so a malformed block stays visible rather than being
25
-//! silently misinterpreted.
21
+//! Only well-formed shapes are recovered. A block that doesn't match is never
22
+//! guessed at: reissuing a `run_command` is cheap, but guessing wrong at a
23
+//! half-parsed `edit_file` would write the wrong change to a file. It isn't
24
+//! shown either. A tool-call block that fails to parse, or that is still open
25
+//! when the reply ends, is reported as [`ScanEvent::Malformed`] and kept out of
26
+//! both the UI and the conversation history. Left in the history, the model
27
+//! reads back a call it "made" with no result attached, and later turns start
28
+//! inventing `<function_results>` blocks of their own. The backend tells the
29
+//! model the call didn't run and asks for it again instead.
30
31
use std::collections::HashMap;
32
57
pub arguments: String,
58
}
59
56
-/// Scan a complete text blob for inline tool-call blocks. Returns the text
57
-/// with recovered blocks removed, plus the calls recovered from them. Blocks
58
-/// that don't parse cleanly, and unterminated trailing tags, are left in the
59
-/// returned text untouched.
60
-pub fn extract(text: &str, tools: &[ToolSpec]) -> (String, Vec<Recovered>) {
60
+/// What [`extract`] found in a complete text blob.
61
+#[derive(Debug, Default, PartialEq, Eq)]
62
+pub struct Extracted {
63
+ /// The text with every tool-call block removed, recovered or not.
64
+ pub text: String,
65
+ /// Calls recovered from well-formed blocks, in order.
66
+ pub calls: Vec<Recovered>,
67
+ /// Tool-call blocks that were dropped because they couldn't be parsed.
68
+ pub malformed: usize,
69
+}
70
+
71
+/// Scan a complete text blob for inline tool-call blocks. Recovered blocks
72
+/// become calls; blocks that don't parse cleanly, including an unterminated
73
+/// trailing one, are dropped and counted in [`Extracted::malformed`].
74
+pub fn extract(text: &str, tools: &[ToolSpec]) -> Extracted {
75
let mut scanner = StreamScanner::new(tools);
62
- let mut out = String::with_capacity(text.len());
63
- let mut calls = Vec::new();
76
+ let mut extracted = Extracted {
77
+ text: String::with_capacity(text.len()),
78
+ ..Extracted::default()
79
+ };
80
65
- for event in scanner.push(text) {
81
+ for event in scanner.push(text).into_iter().chain(scanner.finish()) {
82
match event {
67
- ScanEvent::Text(text) => out.push_str(&text),
68
- ScanEvent::ToolCall(call) => calls.push(call),
83
+ ScanEvent::Text(text) => extracted.text.push_str(&text),
84
+ ScanEvent::ToolCall(call) => extracted.calls.push(call),
85
+ ScanEvent::Malformed(_) => extracted.malformed += 1,
86
}
87
}
71
- if let Some(rest) = scanner.take_pending() {
72
- out.push_str(&rest);
73
- }
88
75
- (out, calls)
89
+ extracted
90
}
91
92
/// Parse one legacy XML block's inner text: an offered tool name, then zero or
328
Text(String),
329
/// A tool call recovered from a completed block.
330
ToolCall(Recovered),
331
+ /// A tool-call block that couldn't be parsed, or never closed. Carries the
332
+ /// raw block for logging; it must not be shown or executed.
333
+ Malformed(String),
334
}
335
336
/// Incremental scanner for a live delta stream.
359
self.drain()
360
}
361
345
- /// Take whatever is still held back, e.g. a `<tool_call` or Kimi marker
346
- /// prefix that never closed. Text we never saw the end of can't be
347
- /// interpreted safely, so the honest thing is to hand it back as-is.
348
- pub fn take_pending(&mut self) -> Option<String> {
362
+ /// Call once the stream has ended to flush whatever is still held back.
363
+ /// A tool-call block that opened but never closed is [`ScanEvent::Malformed`]:
364
+ /// it can't be executed safely, and it isn't prose either. Anything else,
365
+ /// such as a partial marker prefix or an unclosed Kimi response wrapper, is
366
+ /// handed back as text.
367
+ pub fn finish(&mut self) -> Option<ScanEvent> {
368
if self.pending.is_empty() {
350
- None
351
- } else {
352
- Some(std::mem::take(&mut self.pending))
369
+ return None;
370
}
371
+ let rest = std::mem::take(&mut self.pending);
372
+ Some(
373
+ if rest.starts_with(XML_OPEN_TAG) || rest.starts_with(K3_TOOLS_OPEN) {
374
+ ScanEvent::Malformed(rest)
375
+ } else {
376
+ ScanEvent::Text(rest)
377
+ },
378
+ )
379
}
380
381
fn drain(&mut self) -> Vec<ScanEvent> {
441
let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
442
Some(match parse_xml_block(inner, self.tools) {
443
Some(call) => ScanEvent::ToolCall(call),
419
- None => ScanEvent::Text(block),
444
+ None => ScanEvent::Malformed(block),
445
})
446
}
447
456
457
Some(match parse_k3_tools_block(inner, self.tools) {
458
Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
434
- None => vec![ScanEvent::Text(block)],
459
+ None => vec![ScanEvent::Malformed(block)],
460
})
461
}
462
498
fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
499
let mut scanner = StreamScanner::new(tools);
500
let mut events = scanner.push(text);
476
- if let Some(rest) = scanner.take_pending() {
477
- events.push(ScanEvent::Text(rest));
478
- }
501
+ events.extend(scanner.finish());
502
events
503
}
504
570
#[test]
571
fn an_integer_argument_is_recovered_as_a_json_number() {
572
let tools = vec![command_output_spec()];
550
- let (text, calls) = extract(
573
+ let Extracted { text, calls, .. } = extract(
574
"Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
575
&tools,
576
);
585
#[test]
586
fn recovers_multiple_string_arguments_in_order() {
587
let tools = vec![run_command_spec()];
565
- let (text, calls) = extract(
588
+ let Extracted { text, calls, .. } = extract(
589
"<tool_call>run_command<arg_key>command</arg_key><arg_value>cargo test --locked 2>&1 | tail -20</arg_value><arg_key>cwd</arg_key><arg_value>/Users/x/sigit</arg_value><arg_key>run_in_background</arg_key><arg_value>true</arg_value></tool_call>",
590
&tools,
591
);
599
#[test]
600
fn recovers_glm_call_with_a_malformed_first_argument_marker() {
601
let tools = vec![run_command_spec()];
579
- let (text, calls) = extract(
602
+ let Extracted { text, calls, .. } = extract(
603
"<tool_call>run_command<tool_call>command</arg_key><arg_value>pwd</arg_value><arg_key>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>",
604
&tools,
605
);
615
#[test]
616
fn recovers_glm_call_with_check_status_before_real_arguments() {
617
let tools = vec![command_output_spec()];
595
- let (text, calls) = extract(
618
+ let Extracted { text, calls, .. } = extract(
619
"polling <tool_call>command_output CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>",
620
&tools,
621
);
630
}
631
632
#[test]
610
- fn malformed_check_status_preamble_is_left_as_text() {
633
+ fn malformed_check_status_preamble_is_dropped() {
634
let text = "<tool_call>command_output CheckStatus=keep going</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
612
- let (out, calls) = extract(text, &[command_output_spec()]);
613
- assert_eq!(out, text);
635
+ let Extracted {
636
+ text: out,
637
+ calls,
638
+ malformed,
639
+ } = extract(text, &[command_output_spec()]);
640
+ assert_eq!(out, "");
641
+ assert_eq!(malformed, 1);
642
assert!(calls.is_empty());
643
}
644
645
#[test]
618
- fn check_status_after_a_real_argument_is_left_as_text() {
646
+ fn check_status_after_a_real_argument_is_dropped() {
647
let text = "<tool_call>command_output<arg_key>task_id</arg_key><arg_value>1</arg_value> CheckStatus=true_or_poll_again_with_different_params</arg_value></tool_call>";
620
- let (out, calls) = extract(text, &[command_output_spec()]);
621
- assert_eq!(out, text);
648
+ let Extracted {
649
+ text: out,
650
+ calls,
651
+ malformed,
652
+ } = extract(text, &[command_output_spec()]);
653
+ assert_eq!(out, "");
654
+ assert_eq!(malformed, 1);
655
assert!(calls.is_empty());
656
}
657
658
#[test]
626
- fn check_status_for_an_unknown_tool_is_left_as_text() {
659
+ fn check_status_for_an_unknown_tool_is_dropped() {
660
let text = "<tool_call>not_offered CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
628
- let (out, calls) = extract(text, &[command_output_spec()]);
629
- assert_eq!(out, text);
661
+ let Extracted {
662
+ text: out,
663
+ calls,
664
+ malformed,
665
+ } = extract(text, &[command_output_spec()]);
666
+ assert_eq!(out, "");
667
+ assert_eq!(malformed, 1);
668
assert!(calls.is_empty());
669
}
670
671
#[test]
634
- fn unknown_glm_tool_is_left_as_text() {
672
+ fn unknown_glm_tool_is_dropped() {
673
let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
636
- let (out, calls) = extract(text, &[run_command_spec()]);
637
- assert_eq!(out, text);
674
+ let Extracted {
675
+ text: out,
676
+ calls,
677
+ malformed,
678
+ } = extract(text, &[run_command_spec()]);
679
+ assert_eq!(out, "");
680
+ assert_eq!(malformed, 1);
681
assert!(calls.is_empty());
682
}
683
684
#[test]
685
fn malformed_glm_marker_is_accepted_only_for_the_first_argument() {
686
let text = "<tool_call>run_command<arg_key>command</arg_key><arg_value>pwd</arg_value><tool_call>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>";
644
- let (out, calls) = extract(text, &[run_command_spec()]);
645
- assert_eq!(out, text);
687
+ let Extracted {
688
+ text: out,
689
+ calls,
690
+ malformed,
691
+ } = extract(text, &[run_command_spec()]);
692
+ assert_eq!(out, "");
693
+ assert_eq!(malformed, 1);
694
assert!(calls.is_empty());
695
}
696
697
#[test]
650
- fn a_malformed_block_is_left_alone_rather_than_guessed_at() {
698
+ fn a_malformed_block_is_dropped_rather_than_guessed_at() {
699
let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
652
- let (out, calls) = extract(text, &[run_command_spec()]);
653
- assert_eq!(out, text);
700
+ let Extracted {
701
+ text: out,
702
+ calls,
703
+ malformed,
704
+ } = extract(text, &[run_command_spec()]);
705
+ assert_eq!(out, "");
706
+ assert_eq!(malformed, 1);
707
assert!(calls.is_empty());
708
}
709
710
#[test]
658
- fn an_unterminated_tag_is_left_alone() {
711
+ fn an_unterminated_tag_is_dropped() {
712
let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
660
- let (out, calls) = extract(text, &[run_command_spec()]);
661
- assert_eq!(out, text);
713
+ let Extracted {
714
+ text: out,
715
+ calls,
716
+ malformed,
717
+ } = extract(text, &[run_command_spec()]);
718
+ assert_eq!(out, "working on it ");
719
+ assert_eq!(malformed, 1);
720
assert!(calls.is_empty());
721
}
722
723
#[test]
724
fn ordinary_text_passes_through_untouched() {
667
- let (out, calls) = extract("if x < y then a<b, nothing to recover", &[]);
725
+ let Extracted {
726
+ text: out, calls, ..
727
+ } = extract("if x < y then a<b, nothing to recover", &[]);
728
assert_eq!(out, "if x < y then a<b, nothing to recover");
729
assert!(calls.is_empty());
730
}
732
#[test]
733
fn kimi_k3_run_command_block_is_recovered_and_removed() {
734
let tools = vec![run_command_spec()];
675
- let (text, calls) = extract(
735
+ let Extracted { text, calls, .. } = extract(
736
"Working <|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>sleep 180; echo waited<|close|>argument<|sep|><|open|>argument key=\"cwd\" type=\"string\"<|sep|>/Users/setoelkahfi/Repositories/onde-ed<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
737
&tools,
738
);
747
#[test]
748
fn kimi_k3_non_string_arguments_are_json_decoded() {
749
let tools = vec![command_output_spec()];
690
- let (_, calls) = extract(
750
+ let Extracted { calls, .. } = extract(
751
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
752
&tools,
753
);
759
#[test]
760
fn kimi_k3_missing_argument_type_uses_the_tool_schema() {
761
let tools = vec![command_output_spec()];
702
- let (_, calls) = extract(
762
+ let Extracted { calls, .. } = extract(
763
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
764
&tools,
765
);
771
#[test]
772
fn kimi_k3_multiple_calls_are_recovered_in_order() {
773
let tools = vec![command_output_spec()];
714
- let (_, calls) = extract(
774
+ let Extracted { calls, .. } = extract(
775
"<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>1<|close|>argument<|sep|><|close|>call<|sep|><|open|>call tool=\"command_output\" index=\"2\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
776
&tools,
777
);
784
785
#[test]
786
fn kimi_k3_response_is_unwrapped_and_think_is_hidden() {
727
- let (text, calls) = extract(
787
+ let Extracted { text, calls, .. } = extract(
788
"a<|open|>think<|sep|>private<|close|>think<|sep|>b<|open|>response<|sep|>visible<|close|>response<|sep|>c",
789
&[],
790
);
795
#[test]
796
fn kimi_k3_response_content_is_scanned_for_nested_tool_calls() {
797
let tools = vec![command_output_spec()];
738
- let (text, calls) = extract(
798
+ let Extracted { text, calls, .. } = extract(
799
"<|open|>response<|sep|>before <|open|>think<|sep|>private<|close|>think<|sep|><|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after<|close|>response<|sep|>",
800
&tools,
801
);
813
}
814
815
#[test]
756
- fn malformed_kimi_k3_tools_block_is_left_alone() {
816
+ fn unterminated_kimi_k3_tools_block_is_dropped() {
817
let text = "<|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi<|close|>argument<|sep|>";
758
- let (out, calls) = extract(text, &[run_command_spec()]);
759
- assert_eq!(out, text);
818
+ let Extracted {
819
+ text: out,
820
+ calls,
821
+ malformed,
822
+ } = extract(text, &[run_command_spec()]);
823
+ assert_eq!(out, "");
824
+ assert_eq!(malformed, 1);
825
assert!(calls.is_empty());
826
}
827
828
#[test]
764
- fn unknown_kimi_k3_tool_block_is_left_alone() {
829
+ fn unknown_kimi_k3_tool_block_is_dropped() {
830
let text = "<|open|>tools<|sep|><|open|>call tool=\"delete_everything\" index=\"1\"<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
766
- let (out, calls) = extract(text, &[run_command_spec()]);
767
- assert_eq!(out, text);
831
+ let Extracted {
832
+ text: out,
833
+ calls,
834
+ malformed,
835
+ } = extract(text, &[run_command_spec()]);
836
+ assert_eq!(out, "");
837
+ assert_eq!(malformed, 1);
838
assert!(calls.is_empty());
839
}
840
841
#[test]
772
- fn invalid_kimi_k3_non_string_argument_is_left_alone() {
842
+ fn invalid_kimi_k3_non_string_argument_is_dropped() {
843
let text = "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>not-json<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
774
- let (out, calls) = extract(text, &[command_output_spec()]);
775
- assert_eq!(out, text);
844
+ let Extracted {
845
+ text: out,
846
+ calls,
847
+ malformed,
848
+ } = extract(text, &[command_output_spec()]);
849
+ assert_eq!(out, "");
850
+ assert_eq!(malformed, 1);
851
assert!(calls.is_empty());
852
}
853
870
match event {
871
ScanEvent::Text(t) => text.push_str(&t),
872
ScanEvent::ToolCall(c) => calls.push(c),
873
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
874
}
875
}
876
}
801
- if let Some(rest) = scanner.take_pending() {
877
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
878
text.push_str(&rest);
879
}
880
900
match event {
901
ScanEvent::Text(value) => text.push_str(&value),
902
ScanEvent::ToolCall(call) => calls.push(call),
903
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
904
}
905
}
906
}
830
- if let Some(rest) = scanner.take_pending() {
907
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
908
text.push_str(&rest);
909
}
910
932
match event {
933
ScanEvent::Text(value) => text.push_str(&value),
934
ScanEvent::ToolCall(call) => calls.push(call),
935
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
936
}
937
}
938
}
861
- if let Some(rest) = scanner.take_pending() {
939
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
940
text.push_str(&rest);
941
}
942
964
match event {
965
ScanEvent::Text(t) => text.push_str(&t),
966
ScanEvent::ToolCall(c) => calls.push(c),
967
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
968
}
969
}
970
}
892
- if let Some(rest) = scanner.take_pending() {
971
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
972
text.push_str(&rest);
973
}
974
992
match event {
993
ScanEvent::Text(chunk) => text.push_str(&chunk),
994
ScanEvent::ToolCall(call) => calls.push(call),
995
+ ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
996
}
997
}
998
}
919
- if let Some(rest) = scanner.take_pending() {
999
+ if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1000
text.push_str(&rest);
1001
}
1002
1008
#[test]
1009
fn scanner_recovers_tools_after_an_unclosed_k3_think_wrapper() {
1010
let tools = vec![command_output_spec()];
931
- let (text, calls) = extract(
1011
+ let Extracted { text, calls, .. } = extract(
1012
"<|open|>think<|sep|>broken <|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1013
&tools,
1014
);
1025
events,
1026
vec![ScanEvent::Text("a normal streamed answer".to_string())]
1027
);
948
- assert!(scanner.take_pending().is_none());
1028
+ assert!(scanner.finish().is_none());
1029
}
1030
1031
#[test]
1039
scanner.push("box for details"),
1040
vec![ScanEvent::Text("<toolbox for details".to_string())]
1041
);
962
- assert!(scanner.take_pending().is_none());
1042
+ assert!(scanner.finish().is_none());
1043
}
1044
1045
#[test]
966
- fn scanner_hands_back_an_unclosed_tag_at_stream_end() {
1046
+ fn scanner_reports_an_unclosed_tag_at_stream_end_as_malformed() {
1047
let tools = vec![run_command_spec()];
1048
let mut scanner = StreamScanner::new(&tools);
1049
let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
1050
assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1051
assert_eq!(
972
- scanner.take_pending().as_deref(),
973
- Some("<tool_call>run_command<arg_key>cmd")
1052
+ scanner.finish(),
1053
+ Some(ScanEvent::Malformed(
1054
+ "<tool_call>run_command<arg_key>cmd".to_string()
1055
+ ))
1056
+ );
1057
+ }
1058
+
1059
+ #[test]
1060
+ fn scanner_hands_back_a_partial_marker_prefix_at_stream_end_as_text() {
1061
+ let mut scanner = StreamScanner::new(&[]);
1062
+ assert_eq!(
1063
+ scanner.push("see <tool_"),
1064
+ vec![ScanEvent::Text("see ".to_string())]
1065
+ );
1066
+ assert_eq!(
1067
+ scanner.finish(),
1068
+ Some(ScanEvent::Text("<tool_".to_string()))
1069
);
1070
}
1071
+
1072
+ /// Issue #105: a GLM-family reply that opened a call, then wrote an
1073
+ /// invented invoke/result transcript instead of closing it. None of that
1074
+ /// may reach the editor, and it must not count as a recovered call.
1075
+ #[test]
1076
+ fn a_hallucinated_invoke_transcript_is_dropped() {
1077
+ let tools = vec![command_output_spec()];
1078
+ let reply = "Checking.<tool_call>command_output\n\n<invokeID>reply_A</invokeID>\n\
1079
+ <parameter_name>task_id</parameter_name>\n<parameter>6</parameter>\n\
1080
+ </invoke>\n\n<function_results>PyPI and NuGet are queued.\n\
1081
+ <tool_call>command_output<arg_key>task_id</arg_key><arg_value>5</task_id>\n\
1082
+ </invoke>\n</function_results>";
1083
+ let Extracted {
1084
+ text,
1085
+ calls,
1086
+ malformed,
1087
+ } = extract(reply, &tools);
1088
+ assert_eq!(text, "Checking.");
1089
+ assert!(calls.is_empty());
1090
+ assert_eq!(malformed, 1);
1091
+ }
1092
+
1093
+ /// Issue #97: the well-formed blocks from that thread are recovered as
1094
+ /// real calls, multi-line argument values included.
1095
+ #[test]
1096
+ fn well_formed_blocks_from_issue_97_are_recovered() {
1097
+ let tools = vec![
1098
+ run_command_spec(),
1099
+ spec(
1100
+ "edit_file",
1101
+ serde_json::json!({
1102
+ "type": "object",
1103
+ "properties": {
1104
+ "path": { "type": "string" },
1105
+ "old_text": { "type": "string" },
1106
+ "new_text": { "type": "string" }
1107
+ }
1108
+ }),
1109
+ ),
1110
+ ];
1111
+ let reply = "Now wire `body_path` into the step:<tool_call>edit_file<arg_key>new_text</arg_key><arg_value> - name: Release\n with:\n body_path: release_notes.md</arg_value><arg_key>old_text</arg_key><arg_value> - name: Release\n with:</arg_value><arg_key>path</arg_key><arg_value>/repo/.github/workflows/release-github.yml</arg_value></tool_call>";
1112
+ let Extracted {
1113
+ text,
1114
+ calls,
1115
+ malformed,
1116
+ } = extract(reply, &tools);
1117
+ assert_eq!(text, "Now wire `body_path` into the step:");
1118
+ assert_eq!(malformed, 0);
1119
+ assert_eq!(calls.len(), 1);
1120
+ let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1121
+ assert_eq!(
1122
+ args["new_text"],
1123
+ " - name: Release\n with:\n body_path: release_notes.md"
1124
+ );
1125
+ assert_eq!(args["path"], "/repo/.github/workflows/release-github.yml");
1126
+
1127
+ let Extracted { calls, .. } = extract(
1128
+ "<tool_call>run_command<arg_key>command</arg_key><arg_value>sleep 90</arg_value><arg_key>cwd</arg_key><arg_value>/repo</arg_value></tool_call>",
1129
+ &tools,
1130
+ );
1131
+ assert_eq!(calls.len(), 1);
1132
+ assert_eq!(calls[0].name, "run_command");
1133
+ }
1134
}