feat(helexa-acp): route Qwen3 inline <think> blocks to reasoning
Some checks failed
build-prerelease / Build cortex binary (push) Blocked by required conditions
CI / Test (push) Waiting to run
CI / Format (push) Successful in 26s
build-prerelease / Resolve version stamps (push) Successful in 30s
CI / Clippy (push) Successful in 2m40s
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
build-prerelease / Build neuron-blackwell (push) Has been cancelled
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled

Qwen3 emits chain-of-thought as literal <think>...</think> tags
inside delta.content rather than via the separate reasoning_content
field — so without parsing the markers, the thinking shows up in
the message pane as ordinary text. Add a small ThinkParser in
qwen3.rs (same chunk-boundary discipline as ToolCallParser) and
stage it after the tool-call parser in decode_stream: text events
from the tool-call parser are fed in and split into TextDelta /
ReasoningDelta. Zed now renders thinking in its dedicated thought
UI; visible answer text stays in the message pane.

The parking-lot entry from the plan is now closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:30:25 +03:00
parent 5a0861d639
commit 1c16732668
2 changed files with 332 additions and 2 deletions

View File

@@ -296,6 +296,49 @@ mod tests {
assert_eq!(events.len(), 4);
}
#[tokio::test]
async fn decodes_qwen3_inline_think_block_to_reasoning_deltas() {
// Qwen3-shaped output: a `<think>…</think>` block lives
// inside `delta.content`. The decoder should route bytes
// inside the block to ReasoningDelta and the surrounding
// content to TextDelta. Marker boundaries split across
// chunks to exercise the parser's prefix-hold logic.
let sse = fake_sse(vec![
r#"{"choices":[{"delta":{"content":"<thi"}}]}"#,
r#"{"choices":[{"delta":{"content":"nk>internal reasoning</thi"}}]}"#,
r#"{"choices":[{"delta":{"content":"nk>visible answer"}}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#,
"[DONE]",
]);
let events: Vec<_> = decode_stream(sse, CancellationToken::new())
.collect::<Vec<_>>()
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let text: String = events
.iter()
.filter_map(|e| match e {
CompletionEvent::TextDelta(t) => Some(t.as_str()),
_ => None,
})
.collect();
let reasoning: String = events
.iter()
.filter_map(|e| match e {
CompletionEvent::ReasoningDelta(r) => Some(r.as_str()),
_ => None,
})
.collect();
assert_eq!(text, "visible answer");
assert_eq!(reasoning, "internal reasoning");
assert!(matches!(
events.last(),
Some(CompletionEvent::Finish { reason }) if reason.as_deref() == Some("stop")
));
}
#[tokio::test]
async fn decodes_qwen3_inline_tool_call_from_content_stream() {
// Qwen3-shaped output: `<tool_call>{…}</tool_call>` inside
@@ -638,6 +681,11 @@ where
// structured tool-call events, holding back only the suffix
// bytes that could be the start of a marker.
let mut qwen_parser = crate::qwen3::ToolCallParser::new();
// Same shape, second stage: take the plain-text events out
// of the tool-call parser and split off `<think>…</think>`
// blocks into ReasoningDelta so Zed can render them in its
// dedicated thought UI rather than the message pane.
let mut think_parser = crate::qwen3::ThinkParser::new();
let mut sse = Box::pin(sse);
loop {
@@ -678,7 +726,21 @@ where
for ev in qwen_parser.feed(&text) {
match ev {
crate::qwen3::ParserEvent::Text(t) if !t.is_empty() => {
yield Ok(CompletionEvent::TextDelta(t));
for tev in think_parser.feed(&t) {
match tev {
crate::qwen3::ThinkEvent::Text(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
}
crate::qwen3::ParserEvent::Text(_) => {}
crate::qwen3::ParserEvent::Start { index, name } => {
@@ -747,7 +809,21 @@ where
for ev in qwen_parser.finish() {
match ev {
crate::qwen3::ParserEvent::Text(t) if !t.is_empty() => {
yield Ok(CompletionEvent::TextDelta(t));
for tev in think_parser.feed(&t) {
match tev {
crate::qwen3::ThinkEvent::Text(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s)
if !s.is_empty() =>
{
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
}
crate::qwen3::ParserEvent::Text(_) => {}
crate::qwen3::ParserEvent::Start { index, name } => {
@@ -768,6 +844,21 @@ where
}
}
}
// Flush the think parser too — any
// unclosed <think> at stream end becomes
// a final ReasoningDelta rather than
// being lost.
for tev in think_parser.finish() {
match tev {
crate::qwen3::ThinkEvent::Text(s) if !s.is_empty() => {
yield Ok(CompletionEvent::TextDelta(s));
}
crate::qwen3::ThinkEvent::Reasoning(s) if !s.is_empty() => {
yield Ok(CompletionEvent::ReasoningDelta(s));
}
_ => {}
}
}
yield Ok(CompletionEvent::Finish { reason: Some(reason) });
}
}