Route model reasoning to ACP thought chunks

Emit assistant reasoning as ACP AgentThoughtChunk so clients render a collapsed "thinking" section, closing the final ACP v1 conformance gap (#144). Two reasoning paths reach the client: - TokenChunk::Reasoning, from a dedicated reasoning_content/reasoning delta field, routed straight to thought. - Inline <think> blocks in TokenChunk::Visible, split downstream by each surface's strip_think_blocks. backend.rs gains the TokenChunk enum (Visible/Reasoning), a TokenSink over it, StreamDelta.reasoning_content (aliased "reasoning"), and a consume_stream that emits Reasoning before the content scanner. main.rs routes both kinds via send_agent_thought and RevealedText; headless.rs emits a reasoning_delta JSONL event (dropped in plain mode); chat.rs wraps Reasoning in <think> markers for the TUI's own stripper. Co-Authored-By: siGit Code v1.6.4-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 8, 2026 at 11:34 UTC 2fd1e015c2820ad8591595ebddcdd17d608ad51e
4 files changed +235 -77
src/backend.rs
+49 -10
@@ -310,14 +310,35 @@ pub fn message_image_count(message: &serde_json::Value) -> usize {
310 .unwrap_or(0)
311 }
312
313 -/// A sink for streaming assistant text deltas to the UI as they are produced.
313 +/// One streamed fragment, tagged by whether it is visible prose or the model's
314 +/// reasoning.
315 +///
316 +/// Reasoning reaches a backend two ways: inline `<think>…</think>` tags inside
317 +/// the ordinary content stream (local models, and some OpenAI-compatible
318 +/// endpoints), or a separate `reasoning_content` delta field (DeepSeek-style
319 +/// endpoints). Inline tags can't be classified at the source — a tag may span
320 +/// chunk boundaries — so they travel as [`TokenChunk::Visible`] and are split
321 +/// out downstream by `StreamedReply`. The `reasoning_content` field *is*
322 +/// unambiguous at the source, so the backend tags it [`TokenChunk::Reasoning`]
323 +/// directly.
324 +#[derive(Debug, Clone, PartialEq, Eq)]
325 +pub enum TokenChunk {
326 + /// Content-stream text. May still contain inline `<think>` tags that a
327 + /// downstream consumer splits into reasoning and prose.
328 + Visible(String),
329 + /// Text from a dedicated reasoning channel, already known to be reasoning.
330 + Reasoning(String),
331 +}
332 +
333 +/// A sink for streaming assistant fragments to the UI as they are produced.
334 ///
335 /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
316 -/// text fragment through it as the model emits it; the returned [`TurnResult`]
317 -/// still carries the fully assembled text (and any tool calls). When the sink is
318 -/// `None`, the backend runs in non-streaming mode. Unbounded so the inference
319 -/// task never blocks on a slow consumer.
320 -pub type TokenSink = tokio::sync::mpsc::UnboundedSender<String>;
336 +/// fragment through it as the model emits it, tagged [`TokenChunk::Visible`] or
337 +/// [`TokenChunk::Reasoning`]; the returned [`TurnResult`] still carries the
338 +/// fully assembled (visible) text and any tool calls. When the sink is `None`,
339 +/// the backend runs in non-streaming mode. Unbounded so the inference task
340 +/// never blocks on a slow consumer.
341 +pub type TokenSink = tokio::sync::mpsc::UnboundedSender<TokenChunk>;
342
343 /// An image attached to a user message: base64 data and its media type, as an
344 /// ACP client sends it.
@@ -781,7 +802,9 @@ async fn drain_onde_stream(
802 text.push_str(&chunk.delta);
803 // The receiver is the UI; if it's gone the turn is being cancelled,
804 // so stop assembling rather than spinning the model to completion.
784 - if sink.send(chunk.delta).is_err() {
805 + // onde reports reasoning as inline `<think>` tags in `delta`, so it
806 + // is visible content that a downstream consumer splits out.
807 + if sink.send(TokenChunk::Visible(chunk.delta)).is_err() {
808 break;
809 }
810 }
@@ -917,7 +940,7 @@ impl OpenAiBackend {
940 {
941 // The retry's text follows what already streamed; keep it from
942 // running on into the previous sentence.
920 - let _ = sink.send("\n\n".to_string());
943 + let _ = sink.send(TokenChunk::Visible("\n\n".to_string()));
944 }
945 let (retry, retry_malformed) = self.request(tools, allow_tool_calls, sink).await?;
946 if retry_malformed > 0 && retry.tool_calls.is_empty() {
@@ -1292,6 +1315,17 @@ impl OpenAiBackend {
1315 if let Some(reason) = choice.finish_reason.as_deref() {
1316 finish = FinishReason::from_wire(Some(reason));
1317 }
1318 + // Reasoning streamed in its own field is tagged at the source;
1319 + // it bypasses the inline-tag scanner and is never kept in the
1320 + // assistant message text.
1321 + if let Some(reasoning) = choice.delta.reasoning_content
1322 + && !reasoning.is_empty()
1323 + && sink.send(TokenChunk::Reasoning(reasoning)).is_err()
1324 + {
1325 + // Consumer dropped (turn cancelled).
1326 + done = true;
1327 + break;
1328 + }
1329 if let Some(content) = choice.delta.content
1330 && !content.is_empty()
1331 {
@@ -1300,7 +1334,7 @@ impl OpenAiBackend {
1334 match event {
1335 crate::inline_tool_calls::ScanEvent::Text(chunk) => {
1336 text.push_str(&chunk);
1303 - if sink.send(chunk).is_err() {
1337 + if sink.send(TokenChunk::Visible(chunk)).is_err() {
1338 // Consumer dropped (turn cancelled).
1339 cancelled = true;
1340 break;
@@ -1361,7 +1395,7 @@ impl OpenAiBackend {
1395 match scanner.finish() {
1396 Some(crate::inline_tool_calls::ScanEvent::Text(leftover)) => {
1397 text.push_str(&leftover);
1364 - let _ = sink.send(leftover);
1398 + let _ = sink.send(TokenChunk::Visible(leftover));
1399 }
1400 Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
1401 log_malformed_block(&block);
@@ -1882,6 +1916,11 @@ struct StreamChoice {
1916 struct StreamDelta {
1917 #[serde(default)]
1918 content: Option<String>,
1919 + // Some endpoints stream model reasoning in a dedicated field rather than
1920 + // inline `<think>` tags. It is unambiguously reasoning at the source, so it
1921 + // travels as `TokenChunk::Reasoning` and never enters the content scanner.
1922 + #[serde(default, alias = "reasoning")]
1923 + reasoning_content: Option<String>,
1924 #[serde(default)]
1925 tool_calls: Option<Vec<StreamToolCallDelta>>,
1926 }
src/chat.rs
+16 -5
@@ -513,7 +513,9 @@ mod tui {
513 use onde::inference::{ChatEngine, SamplingConfig};
514
515 use super::{RepoItem, Tab};
516 - use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
516 + use crate::backend::{
517 + InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk, ToolResult, ToolSpec,
518 + };
519 use crate::models::{
520 InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
521 };
@@ -3167,12 +3169,21 @@ mod tui {
3169 vec![]
3170 };
3171
3170 - // Bridge the backend's token sink (plain strings) onto the UI update
3171 - // channel as `Delta` messages. The forwarder lives for the whole turn.
3172 - let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
3172 + // Bridge the backend's token sink onto the UI update channel as `Delta`
3173 + // messages. The forwarder lives for the whole turn. The TUI's
3174 + // `push_stream_delta` runs `strip_think_blocks` on its own buffer, so
3175 + // `Visible` chunks are forwarded raw (any inline `<think>` markers kept
3176 + // intact) while `Reasoning` chunks — which come from a dedicated field
3177 + // and carry no markers — are wrapped in `<think>…</think>` so the same
3178 + // splitter treats them as reasoning rather than visible reply text.
3179 + let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<TokenChunk>();
3180 let forward_tx = tx.clone();
3181 let forwarder = tokio::spawn(async move {
3175 - while let Some(piece) = delta_rx.recv().await {
3182 + while let Some(chunk) = delta_rx.recv().await {
3183 + let piece = match chunk {
3184 + TokenChunk::Visible(text) => text,
3185 + TokenChunk::Reasoning(text) => format!("<think>{text}</think>"),
3186 + };
3187 if forward_tx
3188 .send(InferenceUpdate::Delta(piece))
3189 .await
src/headless.rs
+49 -22
@@ -23,7 +23,7 @@ use std::path::PathBuf;
23 use std::sync::Arc;
24
25 use crate::backend::{
26 - self, InferenceBackend, OpenAiBackend, ToolResult as BackendToolResult, TurnResult,
26 + self, InferenceBackend, OpenAiBackend, TokenChunk, ToolResult as BackendToolResult, TurnResult,
27 };
28 use crate::{permissions, provider, session_store, settings, tools};
29
@@ -435,7 +435,7 @@ async fn run_prompt(
435 // Token sink: assistant text streams through this while a turn runs; the
436 // drain loop forwards the visible portion to stdout live. In quiet mode no
437 // sink is passed and only the final message is printed.
438 - let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
438 + let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
439 let sink_opt = if config.quiet { None } else { Some(&sink) };
440 let mut reply = crate::StreamedReply::default();
441
@@ -607,7 +607,7 @@ async fn run_prompt(
607 /// arrive — the stdio counterpart of `SiGitAgent::drain_turn`.
608 async fn drain_to_stdout<F>(
609 fut: F,
610 - sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
610 + sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TokenChunk>,
611 reply: &mut crate::StreamedReply,
612 config: &HeadlessConfig,
613 ) -> Result<TurnResult, backend::BackendError>
@@ -618,35 +618,62 @@ where
618 let result = loop {
619 tokio::select! {
620 done = &mut fut => break done,
621 - Some(piece) = sink_rx.recv() => {
622 - emit_visible_chunk(&piece, reply, config);
621 + Some(chunk) = sink_rx.recv() => {
622 + emit_token_chunk(chunk, reply, config);
623 }
624 }
625 };
626 // Flush tokens that landed between the last poll and the future resolving.
627 - while let Ok(piece) = sink_rx.try_recv() {
628 - emit_visible_chunk(&piece, reply, config);
627 + while let Ok(chunk) = sink_rx.try_recv() {
628 + emit_token_chunk(chunk, reply, config);
629 }
630 result
631 }
632
633 /// Fold a streamed fragment into `reply` and print whatever it newly reveals —
634 -/// the stdio counterpart of `SiGitAgent::emit_visible_chunk`.
635 -fn emit_visible_chunk(piece: &str, reply: &mut crate::StreamedReply, config: &HeadlessConfig) {
636 - if let Some(extra) = reply.push(piece) {
637 - if config.output == OutputFormat::Jsonl {
638 - emit_event(
639 - config,
640 - serde_json::json!({
641 - "type": "assistant_delta",
642 - "session_id": config.session_id,
643 - "text": extra,
644 - }),
645 - );
646 - } else {
647 - print!("{extra}");
634 +/// the stdio counterpart of `SiGitAgent::emit_token_chunk`. Reasoning (from a
635 +/// dedicated field or an inline `<think>` block) never joins the stdout answer
636 +/// stream; under `--output jsonl` it surfaces as a separate `reasoning_delta`
637 +/// event, and is dropped in plain mode.
638 +fn emit_token_chunk(chunk: TokenChunk, reply: &mut crate::StreamedReply, config: &HeadlessConfig) {
639 + match chunk {
640 + TokenChunk::Reasoning(text) => emit_reasoning_delta(&text, config),
641 + TokenChunk::Visible(piece) => {
642 + let revealed = reply.push(&piece);
643 + if let Some(reasoning) = revealed.reasoning {
644 + emit_reasoning_delta(&reasoning, config);
645 + }
646 + if let Some(extra) = revealed.visible {
647 + if config.output == OutputFormat::Jsonl {
648 + emit_event(
649 + config,
650 + serde_json::json!({
651 + "type": "assistant_delta",
652 + "session_id": config.session_id,
653 + "text": extra,
654 + }),
655 + );
656 + } else {
657 + print!("{extra}");
658 + }
659 + let _ = std::io::stdout().flush();
660 + }
661 }
649 - let _ = std::io::stdout().flush();
662 + }
663 +}
664 +
665 +/// Emit model reasoning as a structured event under `--output jsonl`. In plain
666 +/// mode reasoning is not part of the answer, so it is dropped.
667 +fn emit_reasoning_delta(text: &str, config: &HeadlessConfig) {
668 + if config.output == OutputFormat::Jsonl {
669 + emit_event(
670 + config,
671 + serde_json::json!({
672 + "type": "reasoning_delta",
673 + "session_id": config.session_id,
674 + "text": text,
675 + }),
676 + );
677 }
678 }
679
src/main.rs
+121 -40
@@ -94,8 +94,8 @@ use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}
94 use onde::inference::{ChatEngine, GgufModelConfig};
95
96 use crate::backend::{
97 - ImageInput, InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult,
98 - ToolSpec, TurnResult,
97 + ImageInput, InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk,
98 + ToolResult as BackendToolResult, ToolSpec, TurnResult,
99 };
100 use std::path::PathBuf;
101 use std::sync::atomic::{AtomicBool, Ordering};
@@ -355,12 +355,23 @@ struct StreamedReply {
355 assembled: String,
356 /// The visible text already on the wire.
357 sent: String,
358 + /// The inline-`<think>` reasoning text already on the wire.
359 + sent_think: String,
360 /// Whether any chunk has been sent, i.e. the client already has a reply.
361 streamed_any: bool,
362 /// Whether the next visible text should open a new paragraph.
363 paragraph_pending: bool,
364 }
365
366 +/// What a streamed fragment newly revealed once `<think>` blocks were split:
367 +/// visible reply text and/or inline reasoning text, either of which may be
368 +/// absent for a given chunk.
369 +#[derive(Default)]
370 +struct RevealedText {
371 + visible: Option<String>,
372 + reasoning: Option<String>,
373 +}
374 +
375 impl StreamedReply {
376 /// Record that a tool round has interrupted the prose, so the next chunk
377 /// starts a paragraph. A round that opened with tool calls and no text has
@@ -369,10 +380,11 @@ impl StreamedReply {
380 self.paragraph_pending = self.streamed_any;
381 }
382
372 - /// Fold one streamed fragment into the reply and return the visible text it
373 - /// newly reveals, or `None` when it reveals nothing to show (it landed
374 - /// inside a `<think>` block, say).
375 - fn push(&mut self, piece: &str) -> Option<String> {
383 + /// Fold one streamed fragment into the reply and return the text it newly
384 + /// reveals: visible reply text (sent as an agent message) and/or inline
385 + /// `<think>` reasoning (sent as an agent thought). Either may be absent when
386 + /// the fragment reveals nothing of that kind.
387 + fn push(&mut self, piece: &str) -> RevealedText {
388 if self.paragraph_pending {
389 // A tool round interrupted the prose. Clients concatenate
390 // consecutive agent-message chunks into one block, so the new round
@@ -382,7 +394,7 @@ impl StreamedReply {
394 // way to the break.
395 let piece = piece.trim_start();
396 if piece.is_empty() {
385 - return None;
397 + return RevealedText::default();
398 }
399 self.assembled.push_str(PARAGRAPH_BREAK);
400 self.assembled.push_str(piece);
@@ -391,22 +403,35 @@ impl StreamedReply {
403 self.assembled.push_str(piece);
404 }
405
394 - let (_think, visible) = chat::strip_think_blocks(&self.assembled);
406 + let (think, visible) = chat::strip_think_blocks(&self.assembled);
407 + let mut revealed = RevealedText::default();
408 +
409 match visible.strip_prefix(self.sent.as_str()) {
410 Some(extra) if !extra.is_empty() => {
397 - let extra = extra.to_string();
411 + revealed.visible = Some(extra.to_string());
412 self.sent = visible;
413 self.streamed_any = true;
400 - Some(extra)
414 }
415 // No new visible text, or the visible prefix changed retroactively
416 // (rare, e.g. a late-closing think tag): just resync without
417 // resending what's already on the wire.
418 _ => {
419 self.sent = visible;
407 - None
420 }
421 }
422 +
423 + match think.strip_prefix(self.sent_think.as_str()) {
424 + Some(extra) if !extra.is_empty() => {
425 + revealed.reasoning = Some(extra.to_string());
426 + self.sent_think = think;
427 + }
428 + // No new reasoning, or it changed retroactively: resync quietly.
429 + _ => {
430 + self.sent_think = think;
431 + }
432 + }
433 +
434 + revealed
435 }
436 }
437
@@ -1400,6 +1425,20 @@ impl SiGitAgent {
1425 ))
1426 }
1427
1428 + /// Stream a fragment of the model's reasoning as a thought chunk. Clients
1429 + /// render these in a collapsed "thinking" section, separate from the reply.
1430 + fn send_agent_thought(
1431 + &self,
1432 + cx: &ConnectionTo<Client>,
1433 + session_id: SessionId,
1434 + text: impl Into<String>,
1435 + ) -> agent_client_protocol::Result<()> {
1436 + cx.send_notification(SessionNotification::new(
1437 + session_id,
1438 + SessionUpdate::AgentThoughtChunk(ContentChunk::new(ContentBlock::from(text.into()))),
1439 + ))
1440 + }
1441 +
1442 fn send_system_status(
1443 &self,
1444 cx: &ConnectionTo<Client>,
@@ -1430,7 +1469,7 @@ impl SiGitAgent {
1469 cx: &ConnectionTo<Client>,
1470 session_id: &SessionId,
1471 fut: F,
1433 - sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1472 + sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TokenChunk>,
1473 reply: &mut StreamedReply,
1474 cancellation: &PromptCancellation,
1475 ) -> Result<TurnResult, DrainTurnError>
@@ -1450,30 +1489,45 @@ impl SiGitAgent {
1489 break Err(DrainTurnError::Cancelled);
1490 }
1491 }
1453 - Some(piece) = sink_rx.recv() => {
1454 - self.emit_visible_chunk(cx, session_id, &piece, reply);
1492 + Some(chunk) = sink_rx.recv() => {
1493 + self.emit_token_chunk(cx, session_id, chunk, reply);
1494 }
1495 }
1496 };
1497 // Flush tokens that landed between the last poll and the future resolving.
1459 - while let Ok(piece) = sink_rx.try_recv() {
1460 - self.emit_visible_chunk(cx, session_id, &piece, reply);
1498 + while let Ok(chunk) = sink_rx.try_recv() {
1499 + self.emit_token_chunk(cx, session_id, chunk, reply);
1500 }
1501 result
1502 }
1503
1465 - /// Fold a streamed fragment into `reply` and send whatever it newly reveals
1466 - /// as an agent-message chunk.
1467 - fn emit_visible_chunk(
1504 + /// Route one streamed token chunk. A `Reasoning` chunk comes from a
1505 + /// dedicated reasoning field and is unambiguous, so it goes straight to the
1506 + /// client as a thought. A `Visible` chunk is folded into `reply`, which may
1507 + /// reveal visible reply text (sent as an agent message) and/or inline
1508 + /// `<think>` reasoning (sent as a thought).
1509 + fn emit_token_chunk(
1510 &self,
1511 cx: &ConnectionTo<Client>,
1512 session_id: &SessionId,
1471 - piece: &str,
1513 + chunk: TokenChunk,
1514 reply: &mut StreamedReply,
1515 ) {
1474 - if let Some(extra) = reply.push(piece) {
1475 - self.send_assistant_message(cx, session_id.clone(), extra)
1476 - .ok();
1516 + match chunk {
1517 + TokenChunk::Reasoning(text) => {
1518 + self.send_agent_thought(cx, session_id.clone(), text).ok();
1519 + }
1520 + TokenChunk::Visible(piece) => {
1521 + let revealed = reply.push(&piece);
1522 + if let Some(reasoning) = revealed.reasoning {
1523 + self.send_agent_thought(cx, session_id.clone(), reasoning)
1524 + .ok();
1525 + }
1526 + if let Some(visible) = revealed.visible {
1527 + self.send_assistant_message(cx, session_id.clone(), visible)
1528 + .ok();
1529 + }
1530 + }
1531 }
1532 }
1533
@@ -3003,10 +3057,11 @@ impl SiGitAgent {
3057
3058 // Token sink: backends stream assistant text through this while a turn
3059 // runs. We forward the visible portion to the editor as agent-message
3006 - // chunks live (see `drain_turn` / `emit_visible_chunk`). The sink stays
3007 - // alive for the whole prompt so `recv()` only ends when a turn future
3008 - // resolves, never because every sender was dropped.
3009 - let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
3060 + // chunks live, and the reasoning portion as thought chunks (see
3061 + // `drain_turn` / `emit_token_chunk`). The sink stays alive for the whole
3062 + // prompt so `recv()` only ends when a turn future resolves, never
3063 + // because every sender was dropped.
3064 + let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<TokenChunk>();
3065 let mut reply = StreamedReply::default();
3066 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
3067
@@ -6757,8 +6812,8 @@ mod tests {
6812 #[test]
6813 fn streamed_reply_sends_each_fragment_as_it_is_revealed() {
6814 let mut reply = StreamedReply::default();
6760 - assert_eq!(reply.push("Hello"), Some("Hello".to_string()));
6761 - assert_eq!(reply.push(", world"), Some(", world".to_string()));
6815 + assert_eq!(reply.push("Hello").visible, Some("Hello".to_string()));
6816 + assert_eq!(reply.push(", world").visible, Some(", world".to_string()));
6817 assert!(reply.streamed_any);
6818 }
6819
@@ -6771,12 +6826,15 @@ mod tests {
6826 reply.interrupt();
6827
6828 assert_eq!(
6774 - reply.push("Let me broaden the search:"),
6829 + reply.push("Let me broaden the search:").visible,
6830 Some("\n\nLet me broaden the search:".to_string()),
6831 "the new round must not run onto the end of the last sentence"
6832 );
6833 // Only the seam gets a break; the rest of the round streams as usual.
6779 - assert_eq!(reply.push(" Found them."), Some(" Found them.".to_string()));
6834 + assert_eq!(
6835 + reply.push(" Found them.").visible,
6836 + Some(" Found them.".to_string())
6837 + );
6838 }
6839
6840 #[test]
@@ -6787,9 +6845,9 @@ mod tests {
6845
6846 // A fragment of pure whitespace reveals nothing and must not consume
6847 // the pending break.
6790 - assert_eq!(reply.push("\n"), None);
6848 + assert_eq!(reply.push("\n").visible, None);
6849 assert_eq!(
6792 - reply.push(" Done."),
6850 + reply.push(" Done.").visible,
6851 Some("\n\nDone.".to_string()),
6852 "the break replaces the model's leading whitespace"
6853 );
@@ -6802,26 +6860,49 @@ mod tests {
6860 let mut reply = StreamedReply::default();
6861 reply.interrupt();
6862
6805 - assert_eq!(reply.push("Found them."), Some("Found them.".to_string()));
6863 + assert_eq!(
6864 + reply.push("Found them.").visible,
6865 + Some("Found them.".to_string())
6866 + );
6867 }
6868
6869 #[test]
6809 - fn streamed_reply_hides_reasoning_and_still_breaks_the_paragraph() {
6870 + fn streamed_reply_keeps_reasoning_out_of_the_reply_and_still_breaks_the_paragraph() {
6871 let mut reply = StreamedReply::default();
6872 reply.push("Looking now.");
6873 reply.interrupt();
6874
6814 - // A round that opens with reasoning reveals nothing until the visible
6815 - // text arrives — and that text still starts the new paragraph.
6816 - assert_eq!(reply.push("<think>weigh"), None);
6817 - assert_eq!(reply.push(" options</think>"), None);
6875 + // A round that opens with reasoning reveals no *visible* text until the
6876 + // reply arrives — and that text still starts the new paragraph.
6877 + assert_eq!(reply.push("<think>weigh").visible, None);
6878 + assert_eq!(reply.push(" options</think>").visible, None);
6879 assert_eq!(
6819 - reply.push("Here it is."),
6880 + reply.push("Here it is.").visible,
6881 Some("\n\nHere it is.".to_string()),
6882 "the break waits for the reasoning to end and rides out with the text"
6883 );
6884 }
6885
6886 + #[test]
6887 + fn streamed_reply_surfaces_inline_think_reasoning_incrementally() {
6888 + let mut reply = StreamedReply::default();
6889 +
6890 + // Inline `<think>` content is routed as reasoning, not reply text, and
6891 + // only the newly-revealed suffix is emitted each time.
6892 + let r = reply.push("<think>weigh");
6893 + assert_eq!(r.visible, None);
6894 + assert_eq!(r.reasoning, Some("weigh".to_string()));
6895 +
6896 + let r = reply.push(" options</think>");
6897 + assert_eq!(r.visible, None);
6898 + assert_eq!(r.reasoning, Some(" options".to_string()));
6899 +
6900 + // The visible answer carries no reasoning.
6901 + let r = reply.push("Here it is.");
6902 + assert_eq!(r.visible, Some("Here it is.".to_string()));
6903 + assert_eq!(r.reasoning, None);
6904 + }
6905 +
6906 #[test]
6907 fn explicit_acp_flag_is_accepted_without_other_arguments() {
6908 assert_eq!(parse_explicit_acp(&["--acp".to_string()]), Ok(true));