返回 CodeWhale
stream_decoder.rs
根目录 / crates / tui / src / client / chat / tests / stream_decoder.rs
1 //! Drive `parse_sse_chunk` (the in-place SSE event extractor) over canned
2 //! chunk sequences. The full `handle_chat_completion_stream` path needs a
3 //! live `reqwest::Response` so it isn't unit-testable without a mock HTTP
4 //! harness (issue #69 tracks that). For #103 we exercise the chunk decoder
5 //! directly to verify each "class of stream failure" the engine relies on.
6 use super::*;
7 use crate::client::wire::{InvalidSseUtf8, SseLineDecoder};
8 use codewhale_models::{ContentBlockStart, Delta, StreamEvent};
9
10 /// Decode a raw SSE-data JSON chunk into our internal events, mirroring
11 /// the per-event call shape used by `handle_chat_completion_stream`.
12 fn decode_chunk(json_text: &str) -> Vec<StreamEvent> {
13 decode_chunk_with_reasoning(json_text, true)
14 }
15
16 fn decode_chunk_with_reasoning(json_text: &str, is_reasoning_model: bool) -> Vec<StreamEvent> {
17 let chunk: Value = serde_json::from_str(json_text).expect("valid SSE JSON");
18 let mut content_index = 0u32;
19 let mut text_started = false;
20 let mut thinking_started = false;
21 let mut tool_indices = std::collections::HashMap::new();
22 let mut reasoning_detail_buffers = std::collections::HashMap::new();
23 parse_sse_chunk(
24 &chunk,
25 &mut content_index,
26 &mut text_started,
27 &mut thinking_started,
28 &mut tool_indices,
29 &mut reasoning_detail_buffers,
30 is_reasoning_model,
31 )
32 }
33
34 fn decode_chunks_with_style(
35 chunks: &[&str],
36 reasoning_stream_style: ReasoningStreamStyle,
37 ) -> Vec<StreamEvent> {
38 let mut content_index = 0u32;
39 let mut text_started = false;
40 let mut thinking_started = false;
41 let mut tool_indices = std::collections::HashMap::new();
42 let mut reasoning_detail_buffers = std::collections::HashMap::new();
43 let mut inline_reasoning_tags = InlineReasoningTagState::default();
44 let mut events = Vec::new();
45
46 for chunk in chunks {
47 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
48 events.extend(parse_sse_chunk_with_reasoning_style(
49 &value,
50 &mut content_index,
51 &mut text_started,
52 &mut thinking_started,
53 &mut tool_indices,
54 &mut reasoning_detail_buffers,
55 &mut inline_reasoning_tags,
56 reasoning_stream_style,
57 ));
58 }
59 events
60 }
61
62 /// Drive the Chat Completions SSE path with raw byte chunks so tests can
63 /// split a multi-byte UTF-8 character across HTTP/2-style DATA boundaries.
64 fn decode_sse_byte_chunks(chunks: &[&[u8]]) -> Result<Vec<StreamEvent>, InvalidSseUtf8> {
65 struct FrameState {
66 line_buf: String,
67 content_index: u32,
68 text_started: bool,
69 thinking_started: bool,
70 tool_indices: std::collections::HashMap<u32, u32>,
71 reasoning_detail_buffers: std::collections::HashMap<u32, String>,
72 inline_reasoning_tags: InlineReasoningTagState,
73 events: Vec<StreamEvent>,
74 }
75
76 impl FrameState {
77 fn new() -> Self {
78 Self {
79 line_buf: String::new(),
80 content_index: 0,
81 text_started: false,
82 thinking_started: false,
83 tool_indices: std::collections::HashMap::new(),
84 reasoning_detail_buffers: std::collections::HashMap::new(),
85 inline_reasoning_tags: InlineReasoningTagState::default(),
86 events: Vec::new(),
87 }
88 }
89
90 fn handle_line(&mut self, line: &str) -> bool {
91 if line.is_empty() {
92 return matches!(self.flush_frame(), SseDataFrame::Done);
93 }
94 if let Some(data) = extract_sse_data_value(line) {
95 if !self.line_buf.is_empty() {
96 self.line_buf.push('\n');
97 }
98 self.line_buf.push_str(data);
99 }
100 false
101 }
102
103 fn flush_frame(&mut self) -> SseDataFrame {
104 if self.line_buf.is_empty() {
105 return SseDataFrame::Events(Vec::new());
106 }
107 let data = std::mem::take(&mut self.line_buf);
108 match parse_sse_data_frame(
109 &data,
110 &mut self.content_index,
111 &mut self.text_started,
112 &mut self.thinking_started,
113 &mut self.tool_indices,
114 &mut self.reasoning_detail_buffers,
115 &mut self.inline_reasoning_tags,
116 ReasoningStreamStyle::SeparateField,
117 ) {
118 SseDataFrame::Done => SseDataFrame::Done,
119 SseDataFrame::Events(frame_events) => {
120 self.events.extend(frame_events);
121 SseDataFrame::Events(Vec::new())
122 }
123 }
124 }
125 }
126
127 let mut decoder = SseLineDecoder::new();
128 let mut state = FrameState::new();
129 for chunk in chunks {
130 for line in decoder.push(chunk)? {
131 if state.handle_line(&line) {
132 return Ok(state.events);
133 }
134 }
135 }
136 if let Some(line) = decoder.finish()?
137 && state.handle_line(&line)
138 {
139 return Ok(state.events);
140 }
141 state.flush_frame();
142 Ok(state.events)
143 }
144
145 fn cjk_content_sse(text: &str) -> Vec<u8> {
146 let payload = serde_json::json!({
147 "choices": [{ "delta": { "content": text } }]
148 });
149 format!("data: {payload}\n\n").into_bytes()
150 }
151
152 fn mid_char_split(bytes: &[u8], ch: char) -> usize {
153 let needle = ch.to_string();
154 let start = bytes
155 .windows(needle.len())
156 .position(|window| window == needle.as_bytes())
157 .unwrap_or_else(|| panic!("{ch:?} present in SSE frame"));
158 start + 1
159 }
160
161 fn text_delta_text(events: &[StreamEvent]) -> String {
162 events
163 .iter()
164 .filter_map(|event| match event {
165 StreamEvent::ContentBlockDelta {
166 delta: Delta::TextDelta { text },
167 ..
168 } => Some(text.as_str()),
169 _ => None,
170 })
171 .collect()
172 }
173
174 fn thinking_delta_text(events: &[StreamEvent]) -> String {
175 events
176 .iter()
177 .filter_map(|event| match event {
178 StreamEvent::ContentBlockDelta {
179 delta: Delta::ThinkingDelta { thinking },
180 ..
181 } => Some(thinking.as_str()),
182 _ => None,
183 })
184 .collect()
185 }
186
187 #[test]
188 fn decoder_reassembles_cjk_split_across_byte_chunks() {
189 let frame = cjk_content_sse("你好世界");
190 let split = mid_char_split(&frame, '好');
191 let events = decode_sse_byte_chunks(&[&frame[..split], &frame[split..]]).expect("valid utf-8");
192 let text = text_delta_text(&events);
193 assert_eq!(text, "你好世界");
194 assert!(
195 !text.contains('\u{FFFD}'),
196 "HTTP/2 mid-character split must not substitute U+FFFD; got {text:?}"
197 );
198 }
199
200 #[test]
201 fn decoder_reassembles_emoji_and_cjk_fed_one_byte_at_a_time() {
202 let frame = cjk_content_sse("你好🌊世界");
203 let chunks: Vec<&[u8]> = frame.chunks(1).collect();
204 let events = decode_sse_byte_chunks(&chunks).expect("valid utf-8");
205 let text = text_delta_text(&events);
206 assert_eq!(text, "你好🌊世界");
207 assert!(
208 !text.contains('\u{FFFD}'),
209 "byte-at-a-time feed garbled: {text:?}"
210 );
211 }
212
213 #[test]
214 fn decoder_rejects_invalid_sse_bytes_without_replacement() {
215 let mut frame = cjk_content_sse("ok");
216 // Bare 0xFF is never valid UTF-8. Insert it inside the first SSE line.
217 let newline = frame.iter().position(|&b| b == b'\n').expect("SSE line");
218 frame.insert(newline, 0xFF);
219 let result = decode_sse_byte_chunks(&[&frame]);
220 let err = result.expect_err("invalid SSE bytes must fail closed");
221 assert!(
222 !err.to_string().contains('\u{FFFD}'),
223 "error path must not substitute U+FFFD: {err}"
224 );
225
226 // Unterminated tail of continuation bytes: fail on flush, no replacement.
227 let result = decode_sse_byte_chunks(&[&[0x80, 0xBF]]);
228 let err = result.expect_err("invalid unterminated flush must fail closed");
229 assert!(!err.to_string().contains('\u{FFFD}'));
230 }
231
232 #[test]
233 fn decoder_emits_text_delta_for_content_chunk() {
234 // The "happy" first chunk: a normal content delta. The engine treats
235 // this as `any_content_received = true` and would NOT transparently
236 // retry on a subsequent error.
237 let events = decode_chunk(r#"{"choices":[{"delta":{"content":"hello"}}]}"#);
238 assert!(
239 matches!(
240 events.first(),
241 Some(StreamEvent::ContentBlockStart {
242 content_block: ContentBlockStart::Text { .. },
243 ..
244 })
245 ),
246 "first event should open a text block; got {events:?}"
247 );
248 assert!(
249 events
250 .iter()
251 .any(|e| matches!(e, StreamEvent::ContentBlockDelta {
252 delta: Delta::TextDelta { text },
253 ..
254 } if text == "hello")),
255 "should yield a TextDelta carrying 'hello'; got {events:?}"
256 );
257 }
258
259 #[test]
260 fn decoder_emits_thinking_delta_for_reasoning_chunk() {
261 // V4 thinking models surface reasoning_content first — the engine
262 // also counts these as content received (so a subsequent stream error
263 // surfaces rather than retrying transparently).
264 let events = decode_chunk(r#"{"choices":[{"delta":{"reasoning_content":"plan..."}}]}"#);
265 assert!(
266 matches!(
267 events.first(),
268 Some(StreamEvent::ContentBlockStart {
269 content_block: ContentBlockStart::Thinking { .. },
270 ..
271 })
272 ),
273 "first event should open a thinking block; got {events:?}"
274 );
275 assert!(
276 events
277 .iter()
278 .any(|e| matches!(e, StreamEvent::ContentBlockDelta {
279 delta: Delta::ThinkingDelta { thinking },
280 ..
281 } if thinking == "plan...")),
282 "should yield a ThinkingDelta carrying 'plan...'; got {events:?}"
283 );
284 }
285
286 #[test]
287 fn decoder_streams_moonshot_multi_chunk_reasoning_as_thinking() {
288 // #3016: recorded shape from Moonshot's native endpoint — kimi-k2.6
289 // streams `reasoning_content` deltas before the answer text. The
290 // thinking deltas must accumulate into ONE thinking block and the
291 // answer must arrive as text, not be glued into the trace.
292 let chunks = [
293 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me check"}}]}"#,
294 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"reasoning_content":" the config."}}]}"#,
295 r#"{"id":"cmpl-kimi","model":"kimi-k2.6","choices":[{"index":0,"delta":{"content":"The answer is 42."}}]}"#,
296 ];
297
298 let is_reasoning =
299 is_reasoning_model_for_stream(crate::config::ApiProvider::Moonshot, "kimi-k2.6");
300 let mut content_index = 0u32;
301 let mut text_started = false;
302 let mut thinking_started = false;
303 let mut tool_indices = std::collections::HashMap::new();
304 let mut reasoning_detail_buffers = std::collections::HashMap::new();
305 let mut events = Vec::new();
306 for chunk in chunks {
307 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
308 events.extend(parse_sse_chunk(
309 &value,
310 &mut content_index,
311 &mut text_started,
312 &mut thinking_started,
313 &mut tool_indices,
314 &mut reasoning_detail_buffers,
315 is_reasoning,
316 ));
317 }
318
319 let thinking: String = events
320 .iter()
321 .filter_map(|event| match event {
322 StreamEvent::ContentBlockDelta {
323 delta: Delta::ThinkingDelta { thinking },
324 ..
325 } => Some(thinking.as_str()),
326 _ => None,
327 })
328 .collect();
329 assert_eq!(thinking, "Let me check the config.");
330
331 let thinking_starts = events
332 .iter()
333 .filter(|event| {
334 matches!(
335 event,
336 StreamEvent::ContentBlockStart {
337 content_block: ContentBlockStart::Thinking { .. },
338 ..
339 }
340 )
341 })
342 .count();
343 assert_eq!(thinking_starts, 1, "one thinking block: {events:?}");
344
345 let text: String = events
346 .iter()
347 .filter_map(|event| match event {
348 StreamEvent::ContentBlockDelta {
349 delta: Delta::TextDelta { text },
350 ..
351 } => Some(text.as_str()),
352 _ => None,
353 })
354 .collect();
355 assert_eq!(text, "The answer is 42.");
356 }
357
358 #[test]
359 fn decoder_accepts_openrouter_reasoning_delta_with_extra_fields() {
360 let events = decode_chunk(
361 r#"{"id":"or-1","choices":[{"delta":{"reasoning":"openrouter thought","reasoning_details":[{"type":"summary","text":"extra"}],"native_finish_reason":null}}],"usage":{"completion_tokens_details":{"reasoning_tokens":3}}}"#,
362 );
363
364 assert!(
365 events.iter().any(|e| matches!(
366 e,
367 StreamEvent::ContentBlockDelta {
368 delta: Delta::ThinkingDelta { thinking },
369 ..
370 } if thinking == "openrouter thought"
371 )),
372 "OpenRouter-style reasoning deltas with extra fields should not crash decoding; got {events:?}"
373 );
374 }
375
376 #[test]
377 fn decoder_streams_minimax_reasoning_details_as_incremental_thinking() {
378 // MiniMax's reasoning_split stream reports reasoning_details text as
379 // a cumulative buffer. Emit only the suffix so the Thinking cell does
380 // not duplicate earlier reasoning chunks.
381 let chunks = [
382 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"text","text":"Inspect"}]}}]}"#,
383 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"text","text":"Inspect config"}]}}]}"#,
384 r#"{"id":"minimax-1","choices":[{"index":0,"delta":{"content":"Done."}}]}"#,
385 ];
386
387 let is_reasoning = is_reasoning_model_for_stream(ApiProvider::Minimax, "MiniMax-M3");
388 let mut content_index = 0u32;
389 let mut text_started = false;
390 let mut thinking_started = false;
391 let mut tool_indices = std::collections::HashMap::new();
392 let mut reasoning_detail_buffers = std::collections::HashMap::new();
393 let mut events = Vec::new();
394 for chunk in chunks {
395 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
396 events.extend(parse_sse_chunk(
397 &value,
398 &mut content_index,
399 &mut text_started,
400 &mut thinking_started,
401 &mut tool_indices,
402 &mut reasoning_detail_buffers,
403 is_reasoning,
404 ));
405 }
406
407 let thinking: String = events
408 .iter()
409 .filter_map(|event| match event {
410 StreamEvent::ContentBlockDelta {
411 delta: Delta::ThinkingDelta { thinking },
412 ..
413 } => Some(thinking.as_str()),
414 _ => None,
415 })
416 .collect();
417 assert_eq!(thinking, "Inspect config");
418
419 assert!(!events.iter().any(|event| matches!(
420 event,
421 StreamEvent::ContentBlockDelta {
422 delta: Delta::TextDelta { text },
423 ..
424 } if text == "Inspect" || text == "Inspect config"
425 )));
426 }
427
428 #[test]
429 fn modelstudio_streams_reasoning_content_as_thinking() {
430 // Recorded-style DashScope OpenAI-compatible frames (shape lifted from
431 // Model Studio's deep-thinking docs): reasoning streams in
432 // `delta.reasoning_content`, the answer in `delta.content`, and a
433 // trailing usage-only chunk closes the stream.
434 let chunks = [
435 r#"{"choices":[{"delta":{"content":null,"role":"assistant","reasoning_content":""},"index":0,"logprobs":null,"finish_reason":null}],"object":"chat.completion.chunk","usage":null,"model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
436 r#"{"choices":[{"delta":{"reasoning_content":"Let me think"},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
437 r#"{"choices":[{"delta":{"reasoning_content":" about this."},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
438 r#"{"choices":[{"delta":{"content":"The answer."},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
439 r#"{"choices":[{"finish_reason":"stop","delta":{"content":"","reasoning_content":null},"index":0}],"object":"chat.completion.chunk","model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
440 r#"{"choices":[],"object":"chat.completion.chunk","usage":{"prompt_tokens":10,"completion_tokens":30,"total_tokens":40,"completion_tokens_details":{"reasoning_tokens":20}},"model":"qwen3.8-max","id":"chatcmpl-ms-1"}"#,
441 ];
442
443 // Both OpenAI-dialect plans classify their reasoning catalog.
444 for (provider, base_url, model) in [
445 (
446 ApiProvider::ModelstudioTokenPlan,
447 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
448 "qwen3.8-max",
449 ),
450 (
451 ApiProvider::ModelstudioCodingPlan,
452 crate::config::DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
453 "qwen3.7-plus",
454 ),
455 ] {
456 let style = reasoning_stream_style_for_route(provider, base_url, model, None);
457 assert_eq!(style, ReasoningStreamStyle::SeparateField, "{provider:?}");
458
459 let mut content_index = 0u32;
460 let mut text_started = false;
461 let mut thinking_started = false;
462 let mut tool_indices = std::collections::HashMap::new();
463 let mut reasoning_detail_buffers = std::collections::HashMap::new();
464 let mut inline_reasoning_tags = InlineReasoningTagState::default();
465 let mut events = Vec::new();
466 for chunk in chunks {
467 let value: Value = serde_json::from_str(chunk).expect("valid SSE JSON");
468 events.extend(parse_sse_chunk_with_reasoning_style(
469 &value,
470 &mut content_index,
471 &mut text_started,
472 &mut thinking_started,
473 &mut tool_indices,
474 &mut reasoning_detail_buffers,
475 &mut inline_reasoning_tags,
476 style,
477 ));
478 }
479
480 let thinking: String = events
481 .iter()
482 .filter_map(|event| match event {
483 StreamEvent::ContentBlockDelta {
484 delta: Delta::ThinkingDelta { thinking },
485 ..
486 } => Some(thinking.as_str()),
487 _ => None,
488 })
489 .collect();
490 assert_eq!(thinking, "Let me think about this.", "{provider:?}");
491
492 let text: String = events
493 .iter()
494 .filter_map(|event| match event {
495 StreamEvent::ContentBlockDelta {
496 delta: Delta::TextDelta { text },
497 ..
498 } => Some(text.as_str()),
499 _ => None,
500 })
501 .collect();
502 assert_eq!(text, "The answer.", "{provider:?}");
503
504 // The trailing usage chunk still surfaces token accounting.
505 assert!(
506 events.iter().any(|event| matches!(
507 event,
508 StreamEvent::MessageDelta { usage: Some(usage), .. }
509 if usage.output_tokens == 30
510 )),
511 "{provider:?}: {events:?}"
512 );
513 }
514
515 // A non-reasoning model id on the same route keeps the old
516 // pass-through semantics (no fabricated Thinking surface).
517 let style = reasoning_stream_style_for_route(
518 ApiProvider::ModelstudioTokenPlan,
519 crate::config::DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
520 "qwen3.8-max-lite-unknown",
521 None,
522 );
523 assert_eq!(style, ReasoningStreamStyle::None);
524 }
525
526 #[test]
527 fn decoder_does_not_render_reasoning_as_text_for_known_provider_models() {
528 let mut content_index = 0u32;
529 let mut text_started = false;
530 let mut thinking_started = false;
531 let mut tool_indices = std::collections::HashMap::new();
532 let mut reasoning_detail_buffers = std::collections::HashMap::new();
533 let is_reasoning_model =
534 is_reasoning_model_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro");
535 let events = parse_sse_chunk(
536 &serde_json::json!({
537 "choices": [{
538 "delta": {
539 "reasoning_content": "private plan"
540 }
541 }]
542 }),
543 &mut content_index,
544 &mut text_started,
545 &mut thinking_started,
546 &mut tool_indices,
547 &mut reasoning_detail_buffers,
548 is_reasoning_model,
549 );
550
551 assert!(events.iter().any(|event| matches!(
552 event,
553 StreamEvent::ContentBlockDelta {
554 delta: Delta::ThinkingDelta { thinking },
555 ..
556 } if thinking == "private plan"
557 )));
558 assert!(!events.iter().any(|event| matches!(
559 event,
560 StreamEvent::ContentBlockDelta {
561 delta: Delta::TextDelta { text },
562 ..
563 } if text == "private plan"
564 )));
565 }
566
567 #[test]
568 fn decoder_treats_reasoning_content_as_text_when_provider_does_not_support_reasoning() {
569 let events = decode_chunk_with_reasoning(
570 r#"{"choices":[{"delta":{"reasoning_content":"hello"}}]}"#,
571 false,
572 );
573
574 assert!(
575 matches!(
576 events.first(),
577 Some(StreamEvent::ContentBlockStart {
578 content_block: ContentBlockStart::Text { .. },
579 ..
580 })
581 ),
582 "first event should open a text block; got {events:?}"
583 );
584 assert!(
585 events.iter().any(|e| matches!(
586 e,
587 StreamEvent::ContentBlockDelta {
588 delta: Delta::TextDelta { text },
589 ..
590 } if text == "hello"
591 )),
592 "should yield a TextDelta carrying 'hello'; got {events:?}"
593 );
594 assert!(
595 !events.iter().any(|e| matches!(
596 e,
597 StreamEvent::ContentBlockDelta {
598 delta: Delta::ThinkingDelta { .. },
599 ..
600 }
601 )),
602 "should not emit thinking deltas for generic providers; got {events:?}"
603 );
604 }
605
606 #[test]
607 fn reasoning_style_separate_field_routes_reasoning_to_thinking() {
608 let events = decode_chunks_with_style(
609 &[
610 r#"{"choices":[{"delta":{"reasoning_content":"private plan"}}]}"#,
611 r#"{"choices":[{"delta":{"content":"Public answer."}}]}"#,
612 ],
613 ReasoningStreamStyle::SeparateField,
614 );
615
616 assert_eq!(thinking_delta_text(&events), "private plan");
617 assert_eq!(text_delta_text(&events), "Public answer.");
618 }
619
620 #[test]
621 fn exact_kimi_code_k3_streams_reasoning_content_as_thinking() {
622 let style = reasoning_stream_style_for_route(
623 ApiProvider::Moonshot,
624 crate::config::DEFAULT_KIMI_CODE_BASE_URL,
625 crate::config::KIMI_CODE_K3_MODEL,
626 None,
627 );
628 assert_eq!(style, ReasoningStreamStyle::SeparateField);
629
630 let events = decode_chunks_with_style(
631 &[r#"{"choices":[{"delta":{"reasoning_content":"private K3 plan"}}]}"#],
632 style,
633 );
634 assert_eq!(thinking_delta_text(&events), "private K3 plan");
635 assert_eq!(text_delta_text(&events), "");
636
637 let generic_style = reasoning_stream_style_for_route(
638 ApiProvider::Moonshot,
639 crate::config::DEFAULT_MOONSHOT_BASE_URL,
640 crate::config::KIMI_CODE_K3_MODEL,
641 None,
642 );
643 assert_eq!(generic_style, ReasoningStreamStyle::None);
644 }
645
646 #[test]
647 fn reasoning_style_inline_tags_routes_think_blocks_to_thinking() {
648 let events = decode_chunks_with_style(
649 &[
650 r#"{"choices":[{"delta":{"content":"Before <thi"}}]}"#,
651 r#"{"choices":[{"delta":{"content":"nk>private plan</thi"}}]}"#,
652 r#"{"choices":[{"delta":{"content":"nk> after."}}]}"#,
653 ],
654 ReasoningStreamStyle::InlineTags,
655 );
656
657 assert_eq!(thinking_delta_text(&events), "private plan");
658 assert_eq!(text_delta_text(&events), "Before after.");
659 assert!(
660 !text_delta_text(&events).contains("<think>"),
661 "inline reasoning tags must not leak into visible text: {events:?}"
662 );
663 }
664
665 #[test]
666 fn reasoning_style_inline_tags_flushes_unclosed_think_at_stream_end() {
667 let events = decode_chunks_with_style(
668 &[
669 r#"{"choices":[{"delta":{"content":"Before <think>partial reasoning"}}]}"#,
670 r#"{"choices":[{"finish_reason":"stop"}]}"#,
671 ],
672 ReasoningStreamStyle::InlineTags,
673 );
674
675 assert_eq!(thinking_delta_text(&events), "partial reasoning");
676 assert_eq!(text_delta_text(&events), "Before ");
677 }
678
679 #[test]
680 fn reasoning_style_inline_tags_ignores_separate_reasoning_field() {
681 let events = decode_chunks_with_style(
682 &[
683 r#"{"choices":[{"delta":{"reasoning_content":"metadata","content":"<think>tagged</think> answer"}}]}"#,
684 ],
685 ReasoningStreamStyle::InlineTags,
686 );
687
688 assert_eq!(thinking_delta_text(&events), "tagged");
689 assert_eq!(text_delta_text(&events), " answer");
690 }
691
692 #[test]
693 fn reasoning_style_none_keeps_inline_tags_visible_text() {
694 let events = decode_chunks_with_style(
695 &[r#"{"choices":[{"delta":{"content":"<think>visible</think> answer"}}]}"#],
696 ReasoningStreamStyle::None,
697 );
698
699 assert_eq!(thinking_delta_text(&events), "");
700 assert_eq!(text_delta_text(&events), "<think>visible</think> answer");
701 }
702
703 #[test]
704 fn configured_reasoning_style_overrides_route_default() {
705 assert_eq!(
706 reasoning_stream_style_for_stream(ApiProvider::Openai, "custom-minimax", None),
707 ReasoningStreamStyle::None
708 );
709 assert_eq!(
710 reasoning_stream_style_for_stream(
711 ApiProvider::Openai,
712 "custom-minimax",
713 Some("inline-tags")
714 ),
715 ReasoningStreamStyle::InlineTags
716 );
717 assert_eq!(
718 reasoning_stream_style_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro", None),
719 ReasoningStreamStyle::SeparateField
720 );
721 assert_eq!(
722 reasoning_stream_style_for_stream(ApiProvider::XiaomiMimo, "mimo-v2.5-pro", Some("none")),
723 ReasoningStreamStyle::None
724 );
725 }
726
727 #[test]
728 fn decoder_yields_no_events_for_keepalive_chunk() {
729 // DeepSeek often sends `{"choices":[]}` keepalive chunks before
730 // emitting real content. The engine MUST treat a stream error after
731 // these as "no content received" and be eligible for transparent
732 // retry — assert here that the decoder yields no payload events.
733 let events = decode_chunk(r#"{"choices":[]}"#);
734 assert!(
735 events.is_empty(),
736 "empty-choices chunk must produce no events; got {events:?}"
737 );
738 }
739
740 #[test]
741 fn decoder_treats_done_frame_as_terminal() {
742 let mut content_index = 0u32;
743 let mut text_started = false;
744 let mut thinking_started = false;
745 let mut tool_indices = std::collections::HashMap::new();
746 let mut reasoning_detail_buffers = std::collections::HashMap::new();
747 let mut inline_reasoning_tags = InlineReasoningTagState::default();
748
749 let outcome = parse_sse_data_frame(
750 " [DONE] ",
751 &mut content_index,
752 &mut text_started,
753 &mut thinking_started,
754 &mut tool_indices,
755 &mut reasoning_detail_buffers,
756 &mut inline_reasoning_tags,
757 ReasoningStreamStyle::SeparateField,
758 );
759
760 assert!(
761 matches!(outcome, SseDataFrame::Done),
762 "`data: [DONE]` must terminate the stream instead of waiting for the HTTP connection to close"
763 );
764 assert_eq!(content_index, 0);
765 assert!(!text_started);
766 assert!(!thinking_started);
767 assert!(tool_indices.is_empty());
768 }
769
770 #[test]
771 fn decoder_emits_tool_use_block_for_tool_call_delta() {
772 // Tool-call deltas are content too — once one arrives, transparent
773 // retry must be off (the model has committed to a tool invocation
774 // path that DeepSeek has billed for).
775 let events = decode_chunk(
776 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"grep_files","arguments":"{\"pattern\":\"foo\"}"}}]}}]}"#,
777 );
778 assert!(
779 events.iter().any(|e| matches!(
780 e,
781 StreamEvent::ContentBlockStart {
782 content_block: ContentBlockStart::ToolUse { name, ..},
783 ..
784 } if name == "grep_files"
785 )),
786 "should open a ToolUse block for grep_files; got {events:?}"
787 );
788 assert!(
789 events.iter().any(|e| matches!(
790 e,
791 StreamEvent::ContentBlockDelta {
792 delta: Delta::InputJsonDelta { partial_json },
793 ..
794 } if partial_json.contains("\"pattern\"")
795 )),
796 "should yield InputJsonDelta carrying the tool args; got {events:?}"
797 );
798 }
799
800 #[test]
801 fn decoder_uses_fallback_name_for_empty_streaming_tool_name() {
802 let events = decode_chunk(
803 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_empty","function":{"name":"","arguments":"{}"}}]}}]}"#,
804 );
805
806 assert!(
807 events.iter().any(|event| matches!(
808 event,
809 StreamEvent::ContentBlockStart {
810 content_block: ContentBlockStart::ToolUse { name, ..},
811 ..
812 } if name == "unknown_tool"
813 )),
814 "empty upstream tool names should render as unknown_tool; got {events:?}"
815 );
816 }
817
818 #[test]
819 fn non_streaming_response_uses_fallback_name_for_missing_tool_name() {
820 let payload: Value = serde_json::from_str(
821 r#"{
822 "id": "chatcmpl_1",
823 "model": "deepseek-v4-pro",
824 "choices": [{
825 "message": {
826 "role": "assistant",
827 "tool_calls": [{
828 "id": "call_missing",
829 "function": { "arguments": "{}" }
830 }]
831 },
832 "finish_reason": "tool_calls"
833 }]
834 }"#,
835 )
836 .expect("valid response");
837
838 let parsed = parse_chat_message(&payload).expect("message parses");
839 let tool_name = parsed.content.iter().find_map(|block| match block {
840 ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
841 _ => None,
842 });
843
844 assert_eq!(tool_name, Some("unknown_tool"));
845 }
846
847 /// Regression for the parallel-tool-calls-without-id collision (audit
848 /// Finding 8): when the upstream chunk omits the `id` field, the
849 /// fallback used to be the literal string `"tool_call"` for every
850 /// parallel call, so two tool calls in one delta ended up sharing an
851 /// id. Downstream routing then matched the first call's tool_result
852 /// twice and the second call hung. The fallback is now indexed by the
853 /// content-block position, keeping each call unique within the
854 /// response.
855 #[test]
856 fn decoder_assigns_unique_fallback_ids_to_parallel_tool_calls_missing_id() {
857 let events = decode_chunk(
858 r#"{"choices":[{"delta":{"tool_calls":[
859 {"index":0,"function":{"name":"grep_files","arguments":"{\"pattern\":\"a\"}"}},
860 {"index":1,"function":{"name":"read_file","arguments":"{\"path\":\"x\"}"}}
861 ]}}]}"#,
862 );
863
864 let ids: Vec<&str> = events
865 .iter()
866 .filter_map(|e| match e {
867 StreamEvent::ContentBlockStart {
868 content_block: ContentBlockStart::ToolUse { id, .. },
869 ..
870 } => Some(id.as_str()),
871 _ => None,
872 })
873 .collect();
874
875 assert_eq!(
876 ids.len(),
877 2,
878 "expected two tool-use blocks for parallel tool calls; got {events:?}"
879 );
880 assert_ne!(
881 ids[0], ids[1],
882 "parallel tool calls without upstream `id` must get distinct fallback ids; got {ids:?}"
883 );
884 }
885
886 #[test]
887 fn decoder_preserves_upstream_tool_call_id_when_present() {
888 // Counter-test to the fallback regression: when the upstream chunk
889 // does include `id`, we forward it verbatim — we shouldn't quietly
890 // rewrite ids the API gave us just because we have a fallback path.
891 let events = decode_chunk(
892 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"grep_files","arguments":"{}"}}]}}]}"#,
893 );
894 let id = events
895 .iter()
896 .find_map(|e| match e {
897 StreamEvent::ContentBlockStart {
898 content_block: ContentBlockStart::ToolUse { id, .. },
899 ..
900 } => Some(id.as_str()),
901 _ => None,
902 })
903 .expect("tool-use block present");
904 assert_eq!(id, "call_xyz");
905 }
906
907 #[test]
908 fn request_builder_preserves_internal_system_messages() {
909 let messages = vec![Message {
910 role: Role::System,
911 content: vec![ContentBlock::Text {
912 text: "internal runtime event".to_string(),
913 cache_control: None,
914 }],
915 }];
916
917 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
918
919 assert_eq!(built.len(), 1);
920 assert_eq!(built[0]["role"], "system");
921 assert_eq!(built[0]["content"], "internal runtime event");
922 }
923
924 fn tool_use_message(id: &str, name: &str, input: Value) -> Message {
925 Message {
926 role: Role::Assistant,
927 content: vec![ContentBlock::ToolUse {
928 id: id.to_string(),
929 name: name.to_string(),
930 input,
931 caller: None,
932 thought_signature: None,
933 }],
934 }
935 }
936
937 fn tool_result_message(id: &str, content: &str) -> Message {
938 Message {
939 role: Role::User,
940 content: vec![ContentBlock::ToolResult {
941 tool_use_id: id.to_string(),
942 content: content.to_string(),
943 is_error: None,
944 content_blocks: None,
945 }],
946 }
947 }
948
949 fn user_message_with_turn_meta(turn_meta: &str, task: &str) -> Message {
950 Message {
951 role: Role::User,
952 content: vec![
953 ContentBlock::Text {
954 text: turn_meta.to_string(),
955 cache_control: None,
956 },
957 ContentBlock::Text {
958 text: task.to_string(),
959 cache_control: None,
960 },
961 ],
962 }
963 }
964
965 fn user_message_with_tail_turn_meta(task: &str, turn_meta: &str) -> Message {
966 Message {
967 role: Role::User,
968 content: vec![
969 ContentBlock::Text {
970 text: task.to_string(),
971 cache_control: None,
972 },
973 ContentBlock::Text {
974 text: turn_meta.to_string(),
975 cache_control: None,
976 },
977 ],
978 }
979 }
980
981 fn tool_message_content(messages: &[Value], index: usize) -> &str {
982 messages
983 .iter()
984 .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool"))
985 .nth(index)
986 .and_then(|message| message.get("content").and_then(Value::as_str))
987 .expect("tool message content")
988 }
989
990 fn user_message_content(messages: &[Value], index: usize) -> &str {
991 messages
992 .iter()
993 .filter(|message| message.get("role").and_then(Value::as_str) == Some("user"))
994 .nth(index)
995 .and_then(|message| message.get("content").and_then(Value::as_str))
996 .expect("user message content")
997 }
998
999 fn with_tool_result_sha_spillover_root<T>(f: impl FnOnce() -> T) -> T {
1000 let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
1001 .lock()
1002 .unwrap_or_else(|err| err.into_inner());
1003 let tmp = tempfile::tempdir().expect("tempdir");
1004 let prior = crate::tools::truncate::set_test_spillover_root(Some(
1005 tmp.path().join(".deepseek").join("tool_outputs"),
1006 ));
1007 struct Restore(Option<std::path::PathBuf>);
1008 impl Drop for Restore {
1009 fn drop(&mut self) {
1010 crate::tools::truncate::set_test_spillover_root(self.0.take());
1011 }
1012 }
1013 let _restore = Restore(prior);
1014 f()
1015 }
1016
1017 #[test]
1018 fn request_builder_deduplicates_consecutive_identical_turn_meta_for_wire() {
1019 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
1020 let messages = vec![
1021 user_message_with_turn_meta(turn_meta, "first task"),
1022 Message {
1023 role: Role::Assistant,
1024 content: vec![ContentBlock::Text {
1025 text: "first answer".to_string(),
1026 cache_control: None,
1027 }],
1028 },
1029 user_message_with_turn_meta(turn_meta, "second task"),
1030 ];
1031
1032 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1033 let first = user_message_content(&built, 0);
1034 let second = user_message_content(&built, 1);
1035 let expected_ref = "<turn_meta_unchanged />";
1036
1037 assert!(first.starts_with(turn_meta), "got: {first}");
1038 assert!(second.starts_with(expected_ref), "got: {second}");
1039 assert!(second.ends_with("second task"), "got: {second}");
1040 assert_eq!(
1041 second,
1042 format!("{expected_ref}\nsecond task"),
1043 "ref text must stay stable"
1044 );
1045 }
1046
1047 #[test]
1048 fn request_builder_keeps_tail_turn_meta_after_user_text_for_wire() {
1049 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
1050 let messages = vec![
1051 user_message_with_tail_turn_meta("first task", turn_meta),
1052 Message {
1053 role: Role::Assistant,
1054 content: vec![ContentBlock::Text {
1055 text: "first answer".to_string(),
1056 cache_control: None,
1057 }],
1058 },
1059 user_message_with_tail_turn_meta("second task", turn_meta),
1060 ];
1061
1062 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1063 let first = user_message_content(&built, 0);
1064 let second = user_message_content(&built, 1);
1065 let expected_ref = "<turn_meta_unchanged />";
1066
1067 assert_eq!(first, format!("first task\n{turn_meta}"));
1068 assert_eq!(second, format!("second task\n{expected_ref}"));
1069 }
1070
1071 #[test]
1072 fn request_builder_keeps_changed_turn_meta_full_and_updates_recent_hash() {
1073 let first_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
1074 let second_meta =
1075 "<turn_meta>\nCurrent local date: 2026-05-09\nWorking set: src/lib.rs\n</turn_meta>";
1076 let messages = vec![
1077 user_message_with_turn_meta(first_meta, "first task"),
1078 user_message_with_turn_meta(second_meta, "second task"),
1079 ];
1080
1081 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1082 let first = user_message_content(&built, 0);
1083 let second = user_message_content(&built, 1);
1084
1085 assert!(first.starts_with(first_meta), "got: {first}");
1086 assert!(second.starts_with(second_meta), "got: {second}");
1087 assert!(!second.contains("<TURN_META_REF"), "got: {second}");
1088 }
1089
1090 #[test]
1091 fn turn_meta_dedup_is_wire_only_and_does_not_mutate_session_message() {
1092 let turn_meta = "<turn_meta>\nCurrent local date: 2026-05-09\n</turn_meta>";
1093 let messages = vec![
1094 user_message_with_turn_meta(turn_meta, "first task"),
1095 user_message_with_turn_meta(turn_meta, "second task"),
1096 ];
1097
1098 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1099 assert!(
1100 user_message_content(&built, 1).starts_with("<turn_meta_unchanged />"),
1101 "got: {}",
1102 user_message_content(&built, 1)
1103 );
1104
1105 match &messages[1].content[0] {
1106 ContentBlock::Text { text, .. } => assert_eq!(text, turn_meta),
1107 other => panic!("expected text block, got {other:?}"),
1108 }
1109 }
1110
1111 #[test]
1112 fn cache_inspect_reports_turn_meta_dedup_metadata() {
1113 let turn_meta = format!(
1114 "<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
1115 "Working set: src/lib.rs\n".repeat(20)
1116 );
1117 let request = MessageRequest {
1118 model: "deepseek-v4-flash".to_string(),
1119 messages: vec![
1120 user_message_with_turn_meta(&turn_meta, "first task"),
1121 user_message_with_turn_meta(&turn_meta, "second task"),
1122 ],
1123 max_tokens: 0,
1124 system: None,
1125 tools: None,
1126 tool_choice: None,
1127 metadata: None,
1128 thinking: None,
1129 reasoning_effort: None,
1130 stream: None,
1131 temperature: None,
1132 top_p: None,
1133 };
1134
1135 let inspection = inspect_prompt_for_request(&request);
1136 let turn_meta_layers: Vec<_> = inspection
1137 .layers
1138 .iter()
1139 .filter_map(|layer| layer.turn_meta.as_ref())
1140 .collect();
1141
1142 assert_eq!(turn_meta_layers.len(), 2);
1143 assert_eq!(
1144 turn_meta_layers[0].original_chars,
1145 turn_meta.chars().count()
1146 );
1147 assert_eq!(turn_meta_layers[0].sent_chars, turn_meta.chars().count());
1148 assert!(!turn_meta_layers[0].deduplicated);
1149 assert_eq!(turn_meta_layers[0].sha256, sha256_hex(turn_meta.as_bytes()));
1150 assert_eq!(
1151 turn_meta_layers[1].original_chars,
1152 turn_meta.chars().count()
1153 );
1154 assert!(turn_meta_layers[1].sent_chars < turn_meta_layers[1].original_chars);
1155 assert!(turn_meta_layers[1].deduplicated);
1156 assert_eq!(turn_meta_layers[1].sha256, turn_meta_layers[0].sha256);
1157 }
1158
1159 #[test]
1160 fn request_builder_truncates_large_tool_result_for_wire() {
1161 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
1162 let messages = vec![
1163 tool_use_message(
1164 "tool-long",
1165 "shell_command",
1166 json!({"command": "cargo test"}),
1167 ),
1168 tool_result_message("tool-long", &long_output),
1169 ];
1170
1171 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1172 let sent = tool_message_content(&built, 0);
1173
1174 assert!(sent.contains("[TOOL_RESULT_TRUNCATED]"), "got: {sent}");
1175 assert!(sent.contains("tool_name: shell_command"), "got: {sent}");
1176 assert!(sent.contains("command_or_query: cargo test"), "got: {sent}");
1177 assert!(sent.contains("original_chars: 14000"), "got: {sent}");
1178 assert!(sent.contains("sha256:"), "got: {sent}");
1179 assert!(
1180 sent.contains("exact_detail: unavailable; no session-owned artifact was recorded"),
1181 "got: {sent}"
1182 );
1183 assert!(!sent.contains("retrieve_tool_result"), "got: {sent}");
1184 assert!(sent.contains(&"A".repeat(4_000)), "got: {sent}");
1185 assert!(sent.contains(&"Z".repeat(4_000)), "got: {sent}");
1186 assert!(
1187 sent.contains("truncated 6000 chars from middle"),
1188 "got: {sent}"
1189 );
1190 assert_ne!(sent, long_output);
1191 }
1192
1193 #[test]
1194 fn request_builder_keeps_unowned_extreme_tool_output_bounded_without_false_hint() {
1195 with_tool_result_sha_spillover_root(|| {
1196 let huge_output = format!(
1197 "{}{}{}",
1198 "DIFF_HEAD\n".repeat(10_000),
1199 "MIDDLE_POISON\n".repeat(10_000),
1200 "DIFF_TAIL\n".repeat(10_000)
1201 );
1202 let sha = sha256_hex(huge_output.as_bytes());
1203 let messages = vec![
1204 tool_use_message("tool-huge", "exec_shell", json!({"command": "git diff"})),
1205 tool_result_message("tool-huge", &huge_output),
1206 ];
1207
1208 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1209 let sent = tool_message_content(&built, 0);
1210
1211 assert!(sent.contains("[TOOL_RESULT_TRUNCATED]"), "got: {sent}");
1212 assert!(sent.contains("tool_name: exec_shell"), "got: {sent}");
1213 assert!(sent.contains("command_or_query: git diff"), "got: {sent}");
1214 assert!(sent.contains(&format!("sha256: {sha}")), "got: {sent}");
1215 assert!(sent.contains("exact_detail: unavailable"), "got: {sent}");
1216 assert!(!sent.contains("retrieve_tool_result"), "got: {sent}");
1217 assert!(
1218 sent.chars().count() <= TOOL_RESULT_SENT_CHAR_BUDGET,
1219 "truncated result should stay bounded, sent {} chars",
1220 sent.chars().count()
1221 );
1222 assert!(
1223 !sent.contains("MIDDLE_POISON"),
1224 "omitted middle should not be sent to the next model turn"
1225 );
1226 assert_ne!(sent, huge_output);
1227 });
1228 }
1229
1230 #[test]
1231 fn request_builder_does_not_dedup_short_tool_results_for_wire() {
1232 let output = "same tool output";
1233 let messages = vec![
1234 tool_use_message("tool-1", "read_file", json!({"path": "README.md"})),
1235 tool_result_message("tool-1", output),
1236 tool_use_message("tool-2", "read_file", json!({"path": "README.md"})),
1237 tool_result_message("tool-2", output),
1238 ];
1239
1240 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1241 let first = tool_message_content(&built, 0);
1242 let second = tool_message_content(&built, 1);
1243
1244 assert_eq!(first, output);
1245 assert_eq!(second, output);
1246 assert!(!second.contains("<TOOL_RESULT_REF"), "got: {second}");
1247 }
1248
1249 #[test]
1250 fn request_builder_deduplicates_medium_identical_tool_results_to_earlier_message() {
1251 with_tool_result_sha_spillover_root(|| {
1252 // 2,000 chars is intentionally above TOOL_RESULT_DEDUP_MIN_CHARS
1253 // (1,024) but below TOOL_RESULT_SENT_CHAR_BUDGET (12,000). This
1254 // verifies the cache-saving path for repeated medium outputs that
1255 // do not otherwise need truncation.
1256 let output = "A".repeat(2_000);
1257 let messages = vec![
1258 tool_use_message("tool-1", "read_file", json!({"path": "README.md"})),
1259 tool_result_message("tool-1", &output),
1260 tool_use_message("tool-2", "read_file", json!({"path": "README.md"})),
1261 tool_result_message("tool-2", &output),
1262 ];
1263
1264 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1265 let first = tool_message_content(&built, 0);
1266 let second = tool_message_content(&built, 1);
1267
1268 assert_eq!(first, output);
1269 assert!(!first.contains("[TOOL_RESULT_TRUNCATED]"), "got: {first}");
1270 assert!(
1271 second.starts_with("<TOOL_RESULT_REF sha=\""),
1272 "got: {second}"
1273 );
1274 assert!(
1275 second.contains("original_message=\"Message #1\""),
1276 "got: {second}"
1277 );
1278 assert!(second.contains("chars=\"2000\""), "got: {second}");
1279 assert!(
1280 second.contains("source: full content appears in Message #1 earlier in this request"),
1281 "got: {second}"
1282 );
1283 assert!(!second.contains("retrieve_tool_result"), "got: {second}");
1284 });
1285 }
1286
1287 #[test]
1288 fn request_builder_never_dedups_large_identical_write_file_confirmations() {
1289 with_tool_result_sha_spillover_root(|| {
1290 // A `write_file` result embeds the unified diff + summary; it is a
1291 // confirmation, not retrievable data. Two identical >1024-char
1292 // write_file results must BOTH stay inline — collapsing the second
1293 // to a SHA ref makes the model lose write-success context and
1294 // report the file as missing (#1695).
1295 let output = "A".repeat(2_000);
1296 let messages = vec![
1297 tool_use_message("tool-1", "write_file", json!({"path": "big.txt"})),
1298 tool_result_message("tool-1", &output),
1299 tool_use_message("tool-2", "write_file", json!({"path": "big.txt"})),
1300 tool_result_message("tool-2", &output),
1301 ];
1302
1303 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1304 let first = tool_message_content(&built, 0);
1305 let second = tool_message_content(&built, 1);
1306
1307 assert_eq!(first, output);
1308 assert_eq!(second, output);
1309 assert!(!second.contains("<TOOL_RESULT_REF"), "got: {second}");
1310
1311 // Non-mutation tools still dedup: an identical medium read_file
1312 // result points back to the first full message in this request.
1313 let read_messages = vec![
1314 tool_use_message("read-1", "read_file", json!({"path": "README.md"})),
1315 tool_result_message("read-1", &output),
1316 tool_use_message("read-2", "read_file", json!({"path": "README.md"})),
1317 tool_result_message("read-2", &output),
1318 ];
1319 let read_built = build_chat_messages(None, &read_messages, "deepseek-v4-flash");
1320 let read_first = tool_message_content(&read_built, 0);
1321 let read_second = tool_message_content(&read_built, 1);
1322 assert_eq!(read_first, output);
1323 assert!(
1324 read_second.starts_with("<TOOL_RESULT_REF sha=\""),
1325 "got: {read_second}"
1326 );
1327 assert!(read_second.contains("source: full content appears in Message #1"));
1328 assert!(!read_second.contains("retrieve_tool_result"));
1329 });
1330 }
1331
1332 #[test]
1333 fn large_unowned_results_stay_bounded_without_false_retrieval_handles() {
1334 // The adaptive router normally replaces a large result with a
1335 // session-owned artifact receipt before this provider-wire fallback.
1336 // If legacy/raw history reaches here, it may be excerpted but must not
1337 // advertise the process-wide SHA store as retrievable.
1338 let big_diff = "D".repeat(20_000);
1339 let sha = sha256_hex(big_diff.as_bytes());
1340
1341 let messages = vec![
1342 tool_use_message("w-1", "write_file", json!({"path": "huge.rs"})),
1343 tool_result_message("w-1", &big_diff),
1344 tool_use_message("w-2", "write_file", json!({"path": "huge.rs"})),
1345 tool_result_message("w-2", &big_diff),
1346 ];
1347 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1348 let first = tool_message_content(&built, 0);
1349 let second = tool_message_content(&built, 1);
1350
1351 // Mutation confirmations are independently excerpted, never deduped.
1352 assert!(
1353 first.contains("[TOOL_RESULT_TRUNCATED]"),
1354 "first should be truncated, got: {first}"
1355 );
1356 assert!(
1357 !first.contains("<TOOL_RESULT_REF"),
1358 "first must not be a dedup ref, got: {first}"
1359 );
1360 assert!(
1361 !second.contains("<TOOL_RESULT_REF"),
1362 "second identical write_file must stay inline (#1695), got: {second}"
1363 );
1364 assert!(
1365 second.contains("[TOOL_RESULT_TRUNCATED]"),
1366 "second should also be inline-truncated, got: {second}"
1367 );
1368 assert!(
1369 first.contains(&format!("sha256: {sha}")),
1370 "truncation block should retain an integrity digest, got: {first}"
1371 );
1372 assert!(first.contains("exact_detail: unavailable"));
1373 assert!(!first.contains("retrieve_tool_result"));
1374
1375 // A huge non-mutation result cannot refer to an earlier *full* message,
1376 // because both wire messages are excerpts. It therefore stays a
1377 // truthful bounded excerpt too.
1378 let read_messages = vec![
1379 tool_use_message("r-1", "read_file", json!({"path": "huge.rs"})),
1380 tool_result_message("r-1", &big_diff),
1381 tool_use_message("r-2", "read_file", json!({"path": "huge.rs"})),
1382 tool_result_message("r-2", &big_diff),
1383 ];
1384 let read_built = build_chat_messages(None, &read_messages, "deepseek-v4-flash");
1385 let read_second = tool_message_content(&read_built, 1);
1386 assert!(read_second.contains("[TOOL_RESULT_TRUNCATED]"));
1387 assert!(!read_second.contains("<TOOL_RESULT_REF"));
1388 assert!(!read_second.contains("retrieve_tool_result"));
1389 }
1390
1391 #[test]
1392 fn tool_result_budget_is_wire_only_and_does_not_mutate_session_message() {
1393 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
1394 let messages = vec![
1395 tool_use_message(
1396 "tool-long",
1397 "shell_command",
1398 json!({"command": "cargo test"}),
1399 ),
1400 tool_result_message("tool-long", &long_output),
1401 ];
1402
1403 let built = build_chat_messages(None, &messages, "deepseek-v4-flash");
1404 let sent = tool_message_content(&built, 0);
1405 assert_ne!(sent, long_output);
1406
1407 match &messages[1].content[0] {
1408 ContentBlock::ToolResult { content, .. } => assert_eq!(content, &long_output),
1409 other => panic!("expected tool result, got {other:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn cache_inspect_reports_bounded_unowned_tool_result_metadata() {
1415 let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
1416 let request = MessageRequest {
1417 model: "deepseek-v4-flash".to_string(),
1418 messages: vec![
1419 tool_use_message("tool-1", "shell_command", json!({"command": "cargo test"})),
1420 tool_result_message("tool-1", &long_output),
1421 tool_use_message("tool-2", "shell_command", json!({"command": "cargo test"})),
1422 tool_result_message("tool-2", &long_output),
1423 ],
1424 max_tokens: 0,
1425 system: None,
1426 tools: None,
1427 tool_choice: None,
1428 metadata: None,
1429 thinking: None,
1430 reasoning_effort: None,
1431 stream: None,
1432 temperature: None,
1433 top_p: None,
1434 };
1435
1436 let inspection = inspect_prompt_for_request(&request);
1437 let tool_layers: Vec<_> = inspection
1438 .layers
1439 .iter()
1440 .filter_map(|layer| layer.tool_result.as_ref())
1441 .collect();
1442
1443 assert_eq!(tool_layers.len(), 2);
1444 for layer in tool_layers {
1445 assert_eq!(layer.original_chars, 14_000);
1446 assert!(layer.sent_chars < layer.original_chars);
1447 assert!(layer.truncated);
1448 assert!(!layer.deduplicated);
1449 }
1450 }
1451
1452 #[test]
1453 fn mistral_stream_blocks_are_decoded_only_by_the_mistral_style() {
1454 let chunk = r#"{
1455 "choices": [{
1456 "index": 0,
1457 "delta": {"content": [
1458 {"type": "thinking", "thinking": [
1459 {"type": "text", "text": "private trace"}
1460 ], "closed": true},
1461 {"type": "text", "text": "public answer"}
1462 ]},
1463 "finish_reason": null
1464 }]
1465 }"#;
1466
1467 let mistral = decode_chunks_with_style(&[chunk], ReasoningStreamStyle::MistralBlocks);
1468 assert!(mistral.iter().any(|event| matches!(
1469 event,
1470 StreamEvent::ContentBlockDelta {
1471 delta: Delta::ThinkingDelta { thinking },
1472 ..
1473 } if thinking == "private trace"
1474 )));
1475 assert!(mistral.iter().any(|event| matches!(
1476 event,
1477 StreamEvent::ContentBlockDelta {
1478 delta: Delta::TextDelta { text },
1479 ..
1480 } if text == "public answer"
1481 )));
1482
1483 let generic = decode_chunks_with_style(&[chunk], ReasoningStreamStyle::None);
1484 assert!(!generic.iter().any(|event| matches!(
1485 event,
1486 StreamEvent::ContentBlockDelta {
1487 delta: Delta::ThinkingDelta { .. },
1488 ..
1489 }
1490 )));
1491 assert!(!generic.iter().any(|event| matches!(
1492 event,
1493 StreamEvent::ContentBlockDelta {
1494 delta: Delta::TextDelta { .. },
1495 ..
1496 }
1497 )));
1498 }
1499
1500 #[test]
1501 fn deepseek_flash_v41_classifies_reasoning_through_the_catalog() {
1502 // #6044: V4.1's official id dropped the version number, so the literal
1503 // `deepseek-v4` arms cannot see it. The catalog owns the capability and
1504 // every classifier — stream style, wire replay, prompt inspection — must
1505 // read it there instead of relying on another classifier's fallback.
1506 let base_url = "https://api.deepseek.com";
1507 assert!(
1508 requires_reasoning_content("deepseek-flash"),
1509 "the name gate must recognize the official V4.1 id through the catalog"
1510 );
1511 assert!(
1512 should_replay_reasoning_content("deepseek-flash", None),
1513 "prompt inspection must agree with the wire request"
1514 );
1515 assert!(should_replay_reasoning_content_for_provider_on_route(
1516 ApiProvider::Deepseek,
1517 base_url,
1518 "deepseek-flash",
1519 None,
1520 ));
1521
1522 let style =
1523 reasoning_stream_style_for_route(ApiProvider::Deepseek, base_url, "deepseek-flash", None);
1524 assert_eq!(style, ReasoningStreamStyle::SeparateField);
1525 let events = decode_chunks_with_style(
1526 &[r#"{"choices":[{"delta":{"reasoning_content":"private flash plan"}}]}"#],
1527 style,
1528 );
1529 assert_eq!(thinking_delta_text(&events), "private flash plan");
1530 assert_eq!(
1531 text_delta_text(&events),
1532 "",
1533 "reasoning must never leak into visible prose"
1534 );
1535
1536 // A non-reasoning DeepSeek name stays literal: the prefix alone is not
1537 // evidence, the catalog entry is.
1538 assert!(!requires_reasoning_content("deepseek-coder"));
1539 }
1540
1540 lines RUST