From d2ab61eae35baa6ae4fc35da846b4dc10736bb29 Mon Sep 17 00:00:00 2001 From: Yurii Chukhlib Date: Tue, 11 Aug 2026 13:41:32 +0200 Subject: [PATCH] fix(ai): keep response content when a stray follows a real think block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stripThinkingTags ran its orphan pass unconditionally. For models that emit a proper block and then leak a stray later in the answer, everything between the real close and the stray close was reclassified as thinking and dropped from the visible response. Only apply the orphan separator when the original text had no opening tag (the Nemotron case it is meant for); otherwise leave any remaining to the existing stray-tag stripping. Adds regression tests covering the stray-close case plus the preserved Nemotron, long-form, and plain-content paths. --- Fluid.xcodeproj/project.pbxproj | 4 ++ Sources/Fluid/Services/LLMClient.swift | 18 +++++- .../StripThinkingTagsTests.swift | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index f281dd4c..807b588f 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; }; D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */; }; + F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD00000000000000000001 /* StripThinkingTagsTests.swift */; }; C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; }; DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; }; 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; }; @@ -57,6 +58,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = ""; }; D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerTurnMergingTests.swift; sourceTree = ""; }; + F1BD00000000000000000001 /* StripThinkingTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripThinkingTagsTests.swift; sourceTree = ""; }; C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = ""; }; DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -138,6 +140,7 @@ 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A62300000000000000000001 /* AudioBufferConverterTests.swift */, D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */, + F1BD00000000000000000001 /* StripThinkingTagsTests.swift */, C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */, DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */, @@ -300,6 +303,7 @@ 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */, D1A600000000000000000202 /* SpeakerTurnMergingTests.swift in Sources */, + F1BD00000000000000000002 /* StripThinkingTagsTests.swift in Sources */, C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */, DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */, 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */, diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index a6603b8f..03982e9d 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -897,6 +897,13 @@ final class LLMClient { var workingText = text var thinking = "" + // Models that emit a proper block may still leak a stray + // later in their answer. Detect up front whether an opening tag was + // ever present so the orphan pass below is only applied to the no-opening-tag + // case it is meant for (e.g. Nemotron's "thoughtsresponse"). + let hasOpeningThinkTag = text.range(of: "") != nil + || text.range(of: "") != nil + // First, handle proper ... pairs if let regex = try? NSRegularExpression(pattern: Self.thinkingTagPattern, options: []) { let range = NSRange(workingText.startIndex..., in: workingText) @@ -912,8 +919,15 @@ final class LLMClient { } // Second, handle orphan closing tags (content before without opening tag) - // This handles cases like "We have a request...Hello!" - if let orphanRegex = try? NSRegularExpression(pattern: Self.orphanThinkingPattern, options: []) { + // This handles cases like "We have a request...Hello!" (Nemotron-style output + // that begins with thinking and uses as the separator, with no opening tag). + // Skip this when an opening tag was present: there the thinking section was already + // removed above, so any remaining is stray markup and is stripped below + // rather than reclassifying the preceding answer text as thinking (which dropped the + // text between the real close and the stray close from the visible response). + if !hasOpeningThinkTag, + let orphanRegex = try? NSRegularExpression(pattern: Self.orphanThinkingPattern, options: []) + { let range = NSRange(workingText.startIndex..., in: workingText) let matches = orphanRegex.matches(in: workingText, options: [], range: range) diff --git a/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift b/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift new file mode 100644 index 00000000..04fd50bd --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/StripThinkingTagsTests.swift @@ -0,0 +1,61 @@ +@testable import FluidVoice_Debug +import XCTest + +// Regression tests for `LLMClient.stripThinkingTags`. +// +// Models that emit a proper block can still leak a stray +// later in their answer. The orphan pass (meant for Nemotron's no-opening-tag +// "thoughtsresponse" output) used to reclassify every character between the +// real close and the stray close as thinking, dropping it from the visible response. + +@MainActor +final class StripThinkingTagsTests: XCTestCase { + private let client = LLMClient.shared + + /// A stray closing tag after a real block must not eat the + /// answer text that precedes it. Previously "The literal " was moved into + /// thinking and removed from the response. + func testStrayCloseAfterRealThinkBlockKeepsContent() { + let result = self.client.stripThinkingTags( + "reasoning hereThe literal tag is stray." + ) + + XCTAssertEqual(result.thinking, "reasoning here", "only the real think block is thinking") + XCTAssertTrue(result.content.contains("The literal"), "content before the stray close must stay visible") + XCTAssertTrue(result.content.contains("tag is stray"), "content after the stray close must stay visible") + } + + /// Nemotron-style output has no opening tag and uses as the separator: + /// everything before it is thinking, everything after is content. This must keep + /// working (the orphan pass is still applied when no opening tag was present). + func testOrphanCloseWithoutOpeningTagStillSplits() { + let result = self.client.stripThinkingTags("reasoning hereHello world.") + + XCTAssertEqual(result.thinking, "reasoning here") + XCTAssertEqual(result.content, "Hello world.") + } + + /// The `` variant of the Nemotron separator is handled the same way. + func testOrphanCloseWithoutOpeningTagSupportsLongFormTag() { + let result = self.client.stripThinkingTags("thoughtsresponse") + + XCTAssertEqual(result.thinking, "thoughts") + XCTAssertEqual(result.content, "response") + } + + /// A normal, well-formed block is unchanged. + func testProperThinkBlockIsUnchanged() { + let result = self.client.stripThinkingTags("reasoning hereHello world.") + + XCTAssertEqual(result.thinking, "reasoning here") + XCTAssertEqual(result.content, "Hello world.") + } + + /// Plain content with no thinking tags at all is returned verbatim. + func testPlainContentWithNoTagsIsUnchanged() { + let result = self.client.stripThinkingTags("Just a normal response with no tags at all.") + + XCTAssertEqual(result.thinking, "") + XCTAssertEqual(result.content, "Just a normal response with no tags at all.") + } +}