[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 {