返回 CodeWhale
protocol_recovery.rs
根目录 / crates / tui / tests / integration / protocol_recovery.rs
1 //! Protocol-recovery contract tests.
2 //!
3 //! These tests exist to keep the engine hostile to fake tool-call wrappers
4 //! (XML/Replit/markdown pseudo-calls in assistant text). Their job is to make
5 //! sure that:
6 //!
7 //! 1. The known wrapper markers are still present in `core/engine.rs` so the
8 //! streaming filter has something to scrub.
9 //! 2. The legacy text-based `tool_parser` flags fake wrappers for
10 //! stripping/status bookkeeping but does NOT treat the newer
11 //! `<function_calls>` wrapper as a real tool call — only the legacy
12 //! `[TOOL_CALL]` and `<invoke>` shapes ever produced structured calls, and
13 //! nothing should silently re-enable text-based execution.
14 //! 3. The closing-marker list stays the same length as the start-marker list,
15 //! so filter logic cannot get stuck in tool-call mode forever.
16 //!
17 //! The point is that protocol drift in the model output should be visible (we
18 //! still strip it and emit a status notice), not silently turned into tool
19 //! execution.
20
21 use std::fs;
22
23 // `engine.rs` was decomposed into submodules under `core/engine/`. The
24 // protocol-scrubbing strings the tests below assert on are now spread
25 // across `engine.rs` and several `engine/*.rs` files. We compile-time
26 // include each so a contributor moving a marker into a sibling submodule
27 // does not silently break these regression checks.
28 const ENGINE_SOURCES: &[&str] = &[
29 include_str!("../../src/core/engine.rs"),
30 include_str!("../../src/core/engine/streaming.rs"),
31 include_str!("../../src/core/engine/turn_loop.rs"),
32 include_str!("../../src/core/engine/dispatch.rs"),
33 include_str!("../../src/core/engine/tool_setup.rs"),
34 include_str!("../../src/core/engine/tool_execution.rs"),
35 include_str!("../../src/core/engine/tool_catalog.rs"),
36 include_str!("../../src/core/engine/context.rs"),
37 include_str!("../../src/core/engine/approval.rs"),
38 include_str!("../../src/core/engine/lsp_hooks.rs"),
39 ];
40
41 fn any_engine_source_contains(needle: &str) -> bool {
42 ENGINE_SOURCES.iter().any(|src| src.contains(needle))
43 }
44
45 const EXPECTED_START_MARKERS: &[&str] = &[
46 "[TOOL_CALL]",
47 "<codewhale:tool_call",
48 "<tool_call",
49 "<invoke ",
50 "<function_calls>",
51 ];
52
53 const EXPECTED_END_MARKERS: &[&str] = &[
54 "[/TOOL_CALL]",
55 "</codewhale:tool_call>",
56 "</tool_call>",
57 "</invoke>",
58 "</function_calls>",
59 ];
60
61 #[test]
62 fn engine_keeps_known_fake_wrapper_start_markers() {
63 for marker in EXPECTED_START_MARKERS {
64 let needle = format!("\"{marker}\"");
65 assert!(
66 any_engine_source_contains(&needle),
67 "no engine source file still mentions start marker `{marker}` — \
68 protocol scrubbing may have regressed. Searched for {needle:?} \
69 across engine.rs and engine/* submodules."
70 );
71 }
72 }
73
74 #[test]
75 fn engine_keeps_known_fake_wrapper_end_markers() {
76 for marker in EXPECTED_END_MARKERS {
77 let needle = format!("\"{marker}\"");
78 assert!(
79 any_engine_source_contains(&needle),
80 "no engine source file still mentions end marker `{marker}` — \
81 protocol scrubbing may have regressed. Searched for {needle:?} \
82 across engine.rs and engine/* submodules."
83 );
84 }
85 }
86
87 #[test]
88 fn engine_marker_counts_stay_paired() {
89 // A future contributor could quietly drop a closing marker and leave the
90 // filter able to enter tool-call mode without ever leaving it. Lock the
91 // count to whatever the constants currently declare.
92 assert_eq!(EXPECTED_START_MARKERS.len(), EXPECTED_END_MARKERS.len());
93 assert!(any_engine_source_contains("TOOL_CALL_START_MARKERS"));
94 assert!(any_engine_source_contains("TOOL_CALL_END_MARKERS"));
95 }
96
97 #[test]
98 fn engine_emits_compact_fake_wrapper_notice() {
99 assert!(
100 any_engine_source_contains("FAKE_WRAPPER_NOTICE"),
101 "no engine source file references the protocol-recovery notice constant"
102 );
103 assert!(
104 any_engine_source_contains("API tool channel"),
105 "the protocol-recovery notice should mention the API tool channel"
106 );
107 }
108
109 #[test]
110 fn legacy_parser_extracts_bracket_tool_call() {
111 let result = crate::tool_parser::parse_tool_calls(
112 "intro [TOOL_CALL]\n{\"tool\":\"x\",\"args\":{}}\n[/TOOL_CALL]",
113 );
114 assert_eq!(result.tool_calls.len(), 1);
115 assert_eq!(result.tool_calls[0].name, "x");
116 assert_eq!(result.clean_text, "intro");
117 }
118
119 #[test]
120 fn legacy_parser_extracts_invoke_block() {
121 let result = crate::tool_parser::parse_tool_calls(
122 "before <invoke name=\"do_thing\"><parameter name=\"k\">v</parameter></invoke> after",
123 );
124 assert_eq!(result.tool_calls.len(), 1);
125 assert_eq!(result.tool_calls[0].name, "do_thing");
126 }
127
128 #[test]
129 fn legacy_parser_does_not_execute_function_calls_wrapper() {
130 // The newer `<function_calls>` wrapper is the kind of forged shape that
131 // shows up in non-DeepSeek tool-call leakage. The legacy text parser must
132 // NOT turn it into a structured tool call (the engine's filter still
133 // strips it from visible text and the model is expected to use the API
134 // tool channel instead).
135 let raw = "narrative <function_calls>\n{\"name\":\"x\",\"input\":{}}\n</function_calls> end";
136 let result = crate::tool_parser::parse_tool_calls(raw);
137 assert!(
138 result.tool_calls.is_empty(),
139 "function_calls wrapper must not be parsed as a real tool call: {:?}",
140 result.tool_calls
141 );
142 }
143
144 #[test]
145 fn legacy_parser_marker_helper_flags_fake_wrappers_without_enabling_execution() {
146 // `has_tool_call_markers` now also flags forged wrappers so the engine can
147 // scrub them from visible text and keep reasoning-placeholder bookkeeping.
148 // The parser still must not turn those wrappers into executable calls.
149 assert!(crate::tool_parser::has_tool_call_markers(
150 "noise [TOOL_CALL]x[/TOOL_CALL]"
151 ));
152 assert!(crate::tool_parser::has_tool_call_markers(
153 "noise <invoke name=\"x\"></invoke>"
154 ));
155 assert!(crate::tool_parser::has_tool_call_markers(
156 "noise <function_calls>{}</function_calls>"
157 ));
158 assert!(
159 crate::tool_parser::parse_tool_calls("noise <function_calls>{}</function_calls>")
160 .tool_calls
161 .is_empty()
162 );
163 }
164
165 #[test]
166 fn engine_source_file_still_exists_and_is_non_trivial() {
167 // Sanity check so the `include_str!` above is meaningful — if the engine
168 // module ever moves, this test must be updated alongside it.
169 let metadata = fs::metadata("src/core/engine.rs").expect("engine.rs must exist next to tests");
170 assert!(
171 metadata.len() > 10_000,
172 "engine.rs is unexpectedly small ({} bytes); did the file move?",
173 metadata.len()
174 );
175 }
176
176 lines RUST