diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index d72a6a01..2746f0a0 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3697882ED70F9C005874CE /* DynamicNotchKit */; }; 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + 9E1636273C4735E04F0940B0 /* LLMClientThinkingTagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FD426ADEB23850ABB2FFEA /* LLMClientThinkingTagTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; @@ -33,6 +34,7 @@ 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + 18FD426ADEB23850ABB2FFEA /* LLMClientThinkingTagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientThinkingTagTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; @@ -104,6 +106,7 @@ 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + 18FD426ADEB23850ABB2FFEA /* LLMClientThinkingTagTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -258,6 +261,7 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + 9E1636273C4735E04F0940B0 /* LLMClientThinkingTagTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index ae907b29..5dd6f7e9 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -795,7 +795,7 @@ final class LLMClient { // MARK: - Parse Non-Streaming Message - private func parseResponsesResponse(_ json: [String: Any]) throws -> Response { + func parseResponsesResponse(_ json: [String: Any]) throws -> Response { guard let output = json["output"] as? [[String: Any]] else { throw LLMError.invalidResponse } @@ -838,12 +838,12 @@ final class LLMClient { return Response( thinking: thinking.isEmpty ? nil : thinking, - content: cleanedContent.isEmpty ? rawContent.trimmingCharacters(in: .whitespacesAndNewlines) : cleanedContent, + content: cleanedContent, toolCalls: parsedToolCalls ) } - private func parseMessageResponse(_ message: [String: Any]) -> Response { + func parseMessageResponse(_ message: [String: Any]) -> Response { // Extract content let rawContent = message["content"] as? String ?? "" @@ -878,7 +878,7 @@ final class LLMClient { return Response( thinking: finalThinking.isEmpty ? nil : finalThinking, - content: cleanedContent.isEmpty ? rawContent : cleanedContent, + content: cleanedContent, toolCalls: parsedToolCalls ) } diff --git a/Tests/FluidDictationIntegrationTests/LLMClientThinkingTagTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientThinkingTagTests.swift new file mode 100644 index 00000000..44bd7442 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/LLMClientThinkingTagTests.swift @@ -0,0 +1,65 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +@MainActor +final class LLMClientThinkingTagTests: XCTestCase { + private let client = LLMClient.shared + + // MARK: - Chat-completions message parser + + func testMessageResponseDropsContentThatIsAllThinking() { + let message: [String: Any] = ["content": "reasoning"] + + let response = self.client.parseMessageResponse(message) + + XCTAssertTrue(response.content.isEmpty, "All-thinking replies must not leak raw content downstream") + XCTAssertEqual(response.thinking, "reasoning") + } + + func testMessageResponseKeepsContentAfterThinking() { + let message: [String: Any] = ["content": "reasoningHello there"] + + let response = self.client.parseMessageResponse(message) + + XCTAssertEqual(response.content, "Hello there") + XCTAssertEqual(response.thinking, "reasoning") + } + + func testMessageResponseLeavesPlainContentUnchanged() { + let message: [String: Any] = ["content": "Hello there"] + + let response = self.client.parseMessageResponse(message) + + XCTAssertEqual(response.content, "Hello there") + XCTAssertNil(response.thinking) + } + + // MARK: - Responses API parser + + func testResponsesResponseDropsContentThatIsAllThinking() throws { + let json = self.responsesPayload(text: "reasoning") + + let response = try self.client.parseResponsesResponse(json) + + XCTAssertTrue(response.content.isEmpty, "All-thinking replies must not leak raw content downstream") + XCTAssertEqual(response.thinking, "reasoning") + } + + func testResponsesResponseKeepsContentAfterThinking() throws { + let json = self.responsesPayload(text: "reasoningHello there") + + let response = try self.client.parseResponsesResponse(json) + + XCTAssertEqual(response.content, "Hello there") + XCTAssertEqual(response.thinking, "reasoning") + } + + // MARK: - Helpers + + private func responsesPayload(text: String) -> [String: Any] { + let part: [String: Any] = ["type": "output_text", "text": text] + let message: [String: Any] = ["type": "message", "content": [part]] + return ["output": [message]] + } +}