diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs
index aec5c9d..8111ffc 100644
--- a/crates/llm/src/lib.rs
+++ b/crates/llm/src/lib.rs
@@ -631,10 +631,27 @@ fn json_envelope_complete(text: &str) -> bool {
}
fn extract_json_envelope(text: &str) -> Option<&str> {
- let start = text
+ // Phase B.9 audit residual (2026-05-14): strip the leading
+ // `…` reasoning block before scanning. Qwen-style
+ // models emit non-empty reasoning when thinking mode is on, and
+ // the reasoning can contain JSON-looking literals (e.g.
+ // "the answer should be {\"x\":1}") or unbalanced braces ("I wonder
+ // about {..."). The naive "find the first '{' or '['" extractor
+ // would then either return the wrong envelope or pollute the
+ // brace-stack and return None. We split on the FIRST `` —
+ // anything before it is reasoning, anything after is the answer
+ // proper. Falls back to the whole text when no `` is
+ // present (covers non-reasoning models and the empty-thinking
+ // case already covered by `extract_json_envelope_skips_qwen_thinking_prefix`).
+ let scan_region = text
+ .split_once("")
+ .map(|(_, rest)| rest)
+ .unwrap_or(text);
+
+ let start = scan_region
.char_indices()
.find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?;
- let mut chars = text[start..].char_indices();
+ let mut chars = scan_region[start..].char_indices();
let (_, first) = chars.next()?;
let mut stack = vec![match first {
@@ -667,7 +684,7 @@ fn extract_json_envelope(text: &str) -> Option<&str> {
}
if stack.is_empty() {
let end = start + offset + ch.len_utf8();
- return Some(&text[start..end]);
+ return Some(&scan_region[start..end]);
}
}
_ => {}
@@ -812,6 +829,45 @@ mod tests {
);
}
+ /// Phase B.9 audit regression (2026-05-14). The original
+ /// `extract_json_envelope_skips_qwen_thinking_prefix` test only
+ /// covered an EMPTY `` block. Qwen-style reasoning
+ /// is typically non-empty and can contain JSON-looking literals
+ /// (the model thinking out loud about what shape it should emit).
+ /// The naive "first '{' wins" extractor mis-identified the
+ /// reasoning's literal as the answer envelope and returned it,
+ /// skipping the actual answer that followed ``.
+ ///
+ /// Post-fix the extractor strips the leading `…`
+ /// block before scanning, so the reasoning's literal cannot
+ /// poison the result.
+ #[test]
+ fn extract_json_envelope_skips_thinking_block_with_json_looking_content() {
+ let raw = "The answer should look like {\"topic\":\"reasoning-example\",\"intent\":\"capture\"} \
+ based on the schema.{\"topic\":\"real-answer\",\"intent\":\"planning\"}";
+ assert_eq!(
+ extract_json_envelope(raw),
+ Some("{\"topic\":\"real-answer\",\"intent\":\"planning\"}"),
+ );
+ }
+
+ /// Phase B.9 audit regression (2026-05-14). If the reasoning block
+ /// contains UNBALANCED braces (e.g. the model writes "I wonder
+ /// about {..." inside `…`), the pre-strip extractor
+ /// would start its stack on that unbalanced `{`, never find a
+ /// matching `}`, and continue past `` polluting the stack
+ /// with the real answer's braces — ultimately returning None and
+ /// losing the answer entirely. Stripping the reasoning block first
+ /// makes both cases moot.
+ #[test]
+ fn extract_json_envelope_survives_unbalanced_braces_in_thinking() {
+ let raw = "I wonder about {something unfinished here{\"topic\":\"recovery\",\"intent\":\"capture\"}";
+ assert_eq!(
+ extract_json_envelope(raw),
+ Some("{\"topic\":\"recovery\",\"intent\":\"capture\"}"),
+ );
+ }
+
#[test]
fn prompt_preflight_rejects_oversized_prompt_tokens() {
let err = preflight_context_window(7_105, 1_024).unwrap_err();