[Bugfix][Rust Frontend] Fix adjacent DeepSeek V4 user content rendering (#53281)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: reidliu41 <reid201711@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Reid committed Sep 1, 2026 at 08:23 UTC d4329ba53df94aeedf9a82566703108f443e0b7d
2 files changed +153 -63
rust/src/chat/src/renderer/deepseek_v4/encoding.rs
+67 -63
@@ -72,8 +72,9 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
72 render_index += 1;
73 }
74
75 + let mut last_tool_call_order = HashMap::new();
76 for (message_index, message) in request.messages.iter().enumerate() {
76 - if is_following_tool_response(request.messages.as_slice(), message_index) {
77 + if is_following_user_content(request.messages.as_slice(), message_index) {
78 continue;
79 }
80
@@ -93,7 +94,14 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
94 ChatMessage::Developer { content, tools } => {
95 render_developer_message(&mut out, content, tools.as_deref().unwrap_or(&[]))?;
96 }
96 - ChatMessage::User { content } => render_user_message(&mut out, content)?,
97 + ChatMessage::User { .. } | ChatMessage::ToolResponse { .. } => {
98 + render_user_content_block(
99 + &mut out,
100 + request.messages.as_slice(),
101 + message_index,
102 + &last_tool_call_order,
103 + )?;
104 + }
105 ChatMessage::Assistant { content } => {
106 // Mirror Python: thinking block (reasoning + </think>) is
107 // emitted whenever thinking is active and reasoning isn't
@@ -104,9 +112,16 @@ pub(super) fn render_request(request: &ChatRequest) -> Result<String> {
112 let append_eos = !(message_index + 1 == request.messages.len()
113 && request.chat_options.continue_final_message());
114 render_assistant_message(&mut out, emit_thinking_block, append_eos, content)?;
107 - }
108 - ChatMessage::ToolResponse { .. } => {
109 - render_tool_response_block(&mut out, request.messages.as_slice(), message_index)?;
115 +
116 + if content.has_tool_calls() {
117 + last_tool_call_order.clear();
118 + last_tool_call_order.extend(
119 + content
120 + .tool_calls()
121 + .enumerate()
122 + .map(|(index, tool_call)| (tool_call.id.clone(), index)),
123 + );
124 + }
125 }
126 }
127
@@ -191,7 +206,7 @@ fn find_last_user_render_index(messages: &[ChatMessage], synthetic_tool_system:
206 let mut last_user_index = -1;
207
208 for (message_index, message) in messages.iter().enumerate() {
194 - if is_following_tool_response(messages, message_index) {
209 + if is_following_user_content(messages, message_index) {
210 continue;
211 }
212
@@ -204,14 +219,20 @@ fn find_last_user_render_index(messages: &[ChatMessage], synthetic_tool_system:
219 last_user_index
220 }
221
207 -/// Return whether this tool message is already covered by a previous tool run.
208 -fn is_following_tool_response(messages: &[ChatMessage], message_index: usize) -> bool {
209 - matches!(messages[message_index], ChatMessage::ToolResponse { .. })
222 +/// Return whether this message is already covered by a previous user-content
223 +/// entry.
224 +fn is_following_user_content(messages: &[ChatMessage], message_index: usize) -> bool {
225 + is_user_content_entry(&messages[message_index])
226 && message_index > 0
211 - && matches!(
212 - messages[message_index - 1],
213 - ChatMessage::ToolResponse { .. }
214 - )
227 + && is_user_content_entry(&messages[message_index - 1])
228 +}
229 +
230 +/// Return whether one message contributes content to a V4 user turn.
231 +fn is_user_content_entry(message: &ChatMessage) -> bool {
232 + matches!(
233 + message,
234 + ChatMessage::User { .. } | ChatMessage::ToolResponse { .. }
235 + )
236 }
237
238 /// Return whether one rendered entry should be treated as user-like.
@@ -226,10 +247,8 @@ fn is_user_like_entry(message: &ChatMessage) -> bool {
247 /// entry.
248 fn next_rendered_entry_is_assistant_or_end(messages: &[ChatMessage], message_index: usize) -> bool {
249 let mut next_index = message_index + 1;
229 - if matches!(messages[message_index], ChatMessage::ToolResponse { .. }) {
230 - while next_index < messages.len()
231 - && matches!(messages[next_index], ChatMessage::ToolResponse { .. })
232 - {
250 + if is_user_content_entry(&messages[message_index]) {
251 + while next_index < messages.len() && is_user_content_entry(&messages[next_index]) {
252 next_index += 1;
253 }
254 }
@@ -329,47 +348,49 @@ fn render_developer_message(
348 Ok(())
349 }
350
332 -/// Render one plain user turn.
333 -fn render_user_message(out: &mut String, content: &ChatContent) -> Result<()> {
334 - out.push_str(USER_SP_TOKEN);
335 - write_chat_content(out, content)?;
336 - Ok(())
337 -}
338 -
339 -/// Render a contiguous tool-response run as one synthetic user turn.
340 -fn render_tool_response_block(
351 +/// Render contiguous user and tool-response messages as one V4 user turn.
352 +fn render_user_content_block(
353 out: &mut String,
354 messages: &[ChatMessage],
355 message_index: usize,
356 + tool_call_order: &HashMap<String, usize>,
357 ) -> Result<()> {
345 - let (block_start, block_end) = tool_response_block_bounds(messages, message_index);
346 - let sorted_indices = sorted_tool_response_indices(messages, block_start, block_end);
358 + let (block_start, block_end) = user_content_block_bounds(messages, message_index);
359 + let mut sorted_tool_indices =
360 + sorted_tool_response_indices(messages, block_start, block_end, tool_call_order).into_iter();
361
362 out.push_str(USER_SP_TOKEN);
349 - for (offset, message_index) in sorted_indices.iter().enumerate() {
363 + for (offset, message_index) in (block_start..block_end).enumerate() {
364 if offset > 0 {
365 out.push_str("\n\n");
366 }
353 - let ChatMessage::ToolResponse { content, .. } = &messages[*message_index] else {
354 - unreachable!("tool response block should only contain tool messages");
355 - };
356 - write_tool_result(out, content)?;
367 + match &messages[message_index] {
368 + ChatMessage::User { content } => write_chat_content(out, content)?,
369 + ChatMessage::ToolResponse { .. } => {
370 + let sorted_index = sorted_tool_indices
371 + .next()
372 + .expect("tool response block should include this tool message");
373 + let ChatMessage::ToolResponse { content, .. } = &messages[sorted_index] else {
374 + unreachable!("sorted tool response index should reference a tool message");
375 + };
376 + write_tool_result(out, content)?;
377 + }
378 + _ => unreachable!("user content block should only contain user content messages"),
379 + }
380 }
381
382 Ok(())
383 }
384
362 -/// Return the contiguous tool-response block containing `actual_index`.
363 -fn tool_response_block_bounds(messages: &[ChatMessage], actual_index: usize) -> (usize, usize) {
385 +/// Return the contiguous user-content block containing `actual_index`.
386 +fn user_content_block_bounds(messages: &[ChatMessage], actual_index: usize) -> (usize, usize) {
387 let mut block_start = actual_index;
365 - while block_start > 0 && matches!(messages[block_start - 1], ChatMessage::ToolResponse { .. }) {
388 + while block_start > 0 && is_user_content_entry(&messages[block_start - 1]) {
389 block_start -= 1;
390 }
391
392 let mut block_end = actual_index + 1;
370 - while block_end < messages.len()
371 - && matches!(messages[block_end], ChatMessage::ToolResponse { .. })
372 - {
393 + while block_end < messages.len() && is_user_content_entry(&messages[block_end]) {
394 block_end += 1;
395 }
396
@@ -380,12 +401,15 @@ fn sorted_tool_response_indices(
401 messages: &[ChatMessage],
402 block_start: usize,
403 block_end: usize,
404 + tool_call_order: &HashMap<String, usize>,
405 ) -> Vec<usize> {
384 - let Some(tool_call_order) = last_tool_call_order_before(messages, block_start) else {
385 - return (block_start..block_end).collect();
386 - };
406 + let mut indices = (block_start..block_end)
407 + .filter(|index| matches!(messages[*index], ChatMessage::ToolResponse { .. }))
408 + .collect::<Vec<_>>();
409 + if indices.len() <= 1 || tool_call_order.is_empty() {
410 + return indices;
411 + }
412
388 - let mut indices = (block_start..block_end).collect::<Vec<_>>();
413 indices.sort_by_key(|index| {
414 let ChatMessage::ToolResponse { tool_call_id, .. } = &messages[*index] else {
415 unreachable!("tool response block should only contain tool messages");
@@ -395,26 +419,6 @@ fn sorted_tool_response_indices(
419 indices
420 }
421
398 -fn last_tool_call_order_before(
399 - messages: &[ChatMessage],
400 - message_index: usize,
401 -) -> Option<HashMap<&str, usize>> {
402 - let mut tool_call_order = None;
403 - for message in &messages[..message_index] {
404 - if let ChatMessage::Assistant { content } = message {
405 - let order = content
406 - .tool_calls()
407 - .enumerate()
408 - .map(|(index, tool_call)| (tool_call.id.as_str(), index))
409 - .collect::<HashMap<_, _>>();
410 - if !order.is_empty() {
411 - tool_call_order = Some(order);
412 - }
413 - }
414 - }
415 - tool_call_order
416 -}
417 -
422 /// Render one tool response payload inside a V4 `<tool_result>` block.
423 fn write_tool_result(out: &mut String, content: &ChatContent) -> Result<()> {
424 out.push_str("<tool_result>");
rust/src/chat/src/renderer/deepseek_v4/tests.rs
+86
@@ -323,6 +323,92 @@ fn tool_results_are_sorted_by_previous_assistant_tool_call_order() {
323 .assert_eq(&rendered);
324 }
325
326 +#[test]
327 +fn tool_response_and_following_user_share_one_turn() {
328 + let mut request = ChatRequest {
329 + messages: vec![
330 + ChatMessage::tool_response("result", "call_1"),
331 + ChatMessage::user("follow-up"),
332 + ],
333 + ..ChatRequest::for_test()
334 + };
335 + request
336 + .chat_options
337 + .template_kwargs
338 + .insert("thinking".to_string(), Value::Bool(false));
339 +
340 + let rendered = render_request(&request);
341 +
342 + expect![
343 + "<|begin▁of▁sentence|><|User|><tool_result>result</tool_result>\n\nfollow-up<|Assistant|></think>"
344 + ]
345 + .assert_eq(&rendered);
346 +}
347 +
348 +#[test]
349 +fn consecutive_users_share_one_turn() {
350 + let mut request = ChatRequest {
351 + messages: vec![ChatMessage::user("part 1"), ChatMessage::user("part 2")],
352 + ..ChatRequest::for_test()
353 + };
354 + request
355 + .chat_options
356 + .template_kwargs
357 + .insert("thinking".to_string(), Value::Bool(false));
358 +
359 + let rendered = render_request(&request);
360 +
361 + expect!["<|begin▁of▁sentence|><|User|>part 1\n\npart 2<|Assistant|></think>"]
362 + .assert_eq(&rendered);
363 +}
364 +
365 +#[test]
366 +fn mixed_user_content_keeps_text_position_when_sorting_tool_results() {
367 + let mut request = ChatRequest {
368 + messages: vec![
369 + ChatMessage::assistant_blocks(vec![
370 + AssistantContentBlock::ToolCall(AssistantToolCall {
371 + id: "second".to_string(),
372 + name: "second_tool".to_string(),
373 + arguments: "{}".to_string(),
374 + }),
375 + AssistantContentBlock::ToolCall(AssistantToolCall {
376 + id: "first".to_string(),
377 + name: "first_tool".to_string(),
378 + arguments: "{}".to_string(),
379 + }),
380 + ]),
381 + ChatMessage::tool_response("first result", "first"),
382 + ChatMessage::user("follow-up"),
383 + ChatMessage::tool_response("second result", "second"),
384 + ],
385 + ..ChatRequest::for_test()
386 + };
387 + request
388 + .chat_options
389 + .template_kwargs
390 + .insert("thinking".to_string(), Value::Bool(false));
391 +
392 + let rendered = render_request(&request);
393 +
394 + expect![[r#"
395 + <|begin▁of▁sentence|>
396 +
397 + <|DSML|tool_calls>
398 + <|DSML|invoke name="second_tool">
399 +
400 + </|DSML|invoke>
401 + <|DSML|invoke name="first_tool">
402 +
403 + </|DSML|invoke>
404 + </|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>second result</tool_result>
405 +
406 + follow-up
407 +
408 + <tool_result>first result</tool_result><|Assistant|></think>"#]]
409 + .assert_eq(&rendered);
410 +}
411 +
412 #[test]
413 fn drop_thinking_false_keeps_prior_assistant_reasoning() {
414 let mut request = ChatRequest {