Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## [1.6.35-build-88] - 2026-08-27
## [1.6.35-build-90] - 2026-08-28

### Added

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// AgentStreamUseCasePresentationTests.swift
// openclient-llm
//
// Created by Arturo Carretero Calvo on 27/08/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import XCTest
@testable import openclient_llm

@MainActor
extension AgentStreamUseCaseTests {
func test_execute_responseWithReasoning_emitsReasoningBeforeContentWithoutLosingText() async throws {
// Given
mockRepository.agentCompletionResult = .success(
makePresentationResponse(content: "Final answer", reasoning: "Think first")
)

// When
var reasoning = ""
var content = ""
var didReceiveContent = false
var reasoningArrivedAfterContent = false
let stream = sut.execute(
messages: [ChatMessage(role: .user, content: "Hi")],
model: "gpt-4",
parameters: .default,
toolRegistry: toolRegistry
)
for try await event in stream {
switch event {
case .reasoning(let text):
reasoning += text
reasoningArrivedAfterContent = reasoningArrivedAfterContent || didReceiveContent
case .token(let text):
content += text
didReceiveContent = true
default:
break
}
}

// Then
XCTAssertEqual(reasoning, "Think first")
XCTAssertEqual(content, "Final answer")
XCTAssertFalse(reasoningArrivedAfterContent)
}

func test_execute_extremelyLongFinalResponse_limitsPresentationUpdates() async throws {
// Given
let content = String(repeating: "a", count: 100_000)
mockRepository.agentCompletionResult = .success(makePresentationResponse(content: content))

// When
var tokens: [String] = []
let stream = sut.execute(
messages: [ChatMessage(role: .user, content: "Hi")],
model: "gpt-4",
parameters: .default,
toolRegistry: toolRegistry
)
for try await event in stream {
if case .token(let text) = event { tokens.append(text) }
}

// Then
XCTAssertEqual(tokens.joined(), content)
XCTAssertLessThanOrEqual(tokens.count, 300)
}

func test_execute_whitespaceOnlyResponse_retriesWithoutEmittingBlankContent() async throws {
// Given
let repository = RecordingAgentRepository(responses: [
makePresentationResponse(content: " \n\t "),
makePresentationResponse(content: "Final answer")
])
let whitespaceSUT = AgentStreamUseCase(repository: repository, chunkDelay: .zero)

// When
var tokens: [String] = []
let stream = whitespaceSUT.execute(
messages: [ChatMessage(role: .user, content: "Hi")],
model: "gpt-4",
parameters: .default,
toolRegistry: ToolRegistry(tools: [GetCurrentDatetimeTool()])
)
for try await event in stream {
if case .token(let text) = event { tokens.append(text) }
}

// Then
XCTAssertEqual(tokens.joined(), "Final answer")
XCTAssertEqual(repository.requests.count, 2)
XCTAssertFalse(repository.toolRequests[0]?.isEmpty ?? true)
XCTAssertNil(repository.toolRequests[1])
}
}

private extension AgentStreamUseCaseTests {
func makePresentationResponse(content: String, reasoning: String? = nil) -> ChatCompletionResponse {
let message = ChatCompletionResponse.Message(
role: "assistant", content: content, reasoningContent: reasoning, images: nil, toolCalls: nil
)
return ChatCompletionResponse(
id: "presentation-response",
choices: [ChatCompletionResponse.Choice(message: message, finishReason: "stop")],
usage: nil
)
}
}
24 changes: 14 additions & 10 deletions openclient-llm-test/Features/Chat/AgentStreamUseCaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ final class AgentStreamUseCaseTests: XCTestCase {

// Then — content emitted as chunked tokens (simulated typewriter streaming)
XCTAssertEqual(tokens.joined(), "Hello world")
XCTAssertEqual(tokens, ["Hell", "o wo", "rld"])
}

func test_execute_longFinalResponse_emitsBoundedChunksWithoutLosingContent() async throws {
// Given
let content = String(repeating: "🙂", count: 65) + String(repeating: "a", count: 256)
let content = String(repeating: "👨‍👩‍👧‍👦", count: 65) + String(repeating: "e\u{301}", count: 256)
mockRepository.agentCompletionResult = .success(makeStopResponse(content: content))

// When
Expand All @@ -74,11 +75,11 @@ final class AgentStreamUseCaseTests: XCTestCase {

// Then
XCTAssertEqual(tokens.joined(), content)
XCTAssertEqual(tokens.count, 11)
XCTAssertTrue(tokens.allSatisfy { $0.count <= 32 })
XCTAssertEqual(tokens.count, 81)
XCTAssertTrue(tokens.allSatisfy { $0.count <= 4 })
}

func test_execute_veryLongFinalResponse_limitsAdaptiveChunkCount() async throws {
func test_execute_veryLongFinalResponse_capsAdaptiveChunkSize() async throws {
// Given
let content = String(repeating: "a", count: 10_000)
mockRepository.agentCompletionResult = .success(makeStopResponse(content: content))
Expand All @@ -97,7 +98,8 @@ final class AgentStreamUseCaseTests: XCTestCase {

// Then
XCTAssertEqual(tokens.joined(), content)
XCTAssertLessThanOrEqual(tokens.count, 100)
XCTAssertEqual(tokens.count, 209)
XCTAssertTrue(tokens.allSatisfy { $0.count <= 48 })
}

// MARK: - Tests — Tool call round
Expand Down Expand Up @@ -150,23 +152,25 @@ final class AgentStreamUseCaseTests: XCTestCase {
// without tools, which forces the model to give a natural response.
let firstResponse = makeStopResponse(content: "{}")
let secondResponse = makeStopResponse(content: "Hola! Estoy bien, gracias.")
let seqRepo = makeSequentialRepo(responses: [firstResponse, secondResponse])
let seqRepo = RecordingAgentRepository(responses: [firstResponse, secondResponse])
let seqSut = AgentStreamUseCase(repository: seqRepo)

var tokens: [String] = []
let stream = seqSut.execute(
messages: [ChatMessage(role: .user, content: "Hola! Que tal?")],
model: "ollama/qwen3:14b",
parameters: .default,
toolRegistry: ToolRegistry(tools: [])
toolRegistry: ToolRegistry(tools: [GetCurrentDatetimeTool()])
)
for try await event in stream {
if case .token(let text) = event { tokens.append(text) }
}

XCTAssertFalse(tokens.contains("{}"), "Raw '{}' must never reach the UI")
XCTAssertEqual(tokens.joined(), "Hola! Estoy bien, gracias.")
XCTAssertEqual(seqRepo.callIndex, 2, "Should have made a second request without tools")
XCTAssertEqual(seqRepo.requests.count, 2, "Should have made a second request without tools")
XCTAssertFalse(seqRepo.toolRequests[0]?.isEmpty ?? true)
XCTAssertNil(seqRepo.toolRequests[1])
}

func test_execute_toolCallsWithStopFinishReason_executesToolsInsteadOfEmittingContent() async throws {
Expand Down Expand Up @@ -364,9 +368,9 @@ final class AgentStreamUseCaseTests: XCTestCase {
// MARK: - Helpers

private extension AgentStreamUseCaseTests {
func makeStopResponse(content: String) -> ChatCompletionResponse {
func makeStopResponse(content: String, reasoning: String? = nil) -> ChatCompletionResponse {
let message = ChatCompletionResponse.Message(
role: "assistant", content: content, reasoningContent: nil, images: nil, toolCalls: nil
role: "assistant", content: content, reasoningContent: reasoning, images: nil, toolCalls: nil
)
return ChatCompletionResponse(
id: "resp-stop",
Expand Down
46 changes: 46 additions & 0 deletions openclient-llm-test/Features/Chat/ChatViewModelTests+Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,52 @@ extension ChatViewModelTests {
XCTAssertEqual(lastAssistant?.content, "Agent answer")
}

func test_sendMessage_agentToken_publishesDirectlyWithoutServerStreamingBuffer() async throws {
// Given
let mockAgent = MockAgentStreamUseCase()
mockAgent.events = [.token("Agent")]
mockAgent.waitsForCancellation = true
let modelWithFunctionCalling = LLMModel(id: "gpt-4", capabilities: [.functionCalling])
mockFetchModels.result = .success([modelWithFunctionCalling])
let sutWithAgent = ChatViewModel(
fetchModelsUseCase: mockFetchModels,
streamMessageUseCase: mockStreamMessage,
agentStreamUseCase: mockAgent,
webSearchUseCase: mockWebSearch,
saveConversationUseCase: mockSaveConversation,
exportConversationUseCase: mockExportConversation,
branchConversationUseCase: mockBranchConversation,
getChatPreferencesUseCase: mockGetChatPreferences,
fetchMCPToolsUseCase: MockFetchMCPToolsUseCase(),
getConversationStartersUseCase: mockGetConversationStarters,
streamingBackgroundUseCase: MockStreamingBackgroundUseCase(),
notifyStreamingCompletedUseCase: MockNotifyStreamingCompletedUseCase()
)
sutWithAgent.send(.viewAppeared)
await waitUntil {
if case .loaded = sutWithAgent.state { return true }
return false
}

// When
sutWithAgent.send(.inputChanged("Tell me a story"))
sutWithAgent.send(.sendTapped)
await waitUntil {
guard case .loaded(let loadedState) = sutWithAgent.state else { return false }
return loadedState.messages.last?.content == "Agent"
}

// Then
guard case .loaded(let loadedState) = sutWithAgent.state else {
XCTFail("Expected loaded state")
return
}
XCTAssertEqual(loadedState.streamingRevision, 1)
XCTAssertTrue(sutWithAgent.streamingUpdateBuffer.updates.isEmpty)
XCTAssertNil(sutWithAgent.streamingUpdateBuffer.flushTask)
sutWithAgent.send(.stopStreamingTapped)
}

func test_sendMessage_noCapabilitiesModel_andWebSearch_usesRegularStreaming() async throws {
// Given — model without any capabilities (no functionCalling)
let mockAgent = MockAgentStreamUseCase()
Expand Down
3 changes: 3 additions & 0 deletions openclient-llm-test/Mocks/MockAgentStreamUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ final class MockAgentStreamUseCase: AgentStreamUseCaseProtocol, @unchecked Senda
let error = error
return AsyncThrowingStream { continuation in
if waitsForCancellation {
for event in events {
continuation.yield(event)
}
activeContinuation = continuation
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,53 @@
import Foundation

nonisolated extension AgentStreamUseCase {
func handleFinalChoice(
_ choice: ChatCompletionResponse.Choice,
continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation,
delay: Duration
) async throws -> Bool {
let content = choice.message.content ?? ""
let reasoning = choice.message.reasoningContent
guard hasPresentableFinalContent(choice) else { return true }
if let reasoning, !reasoning.isEmpty {
try await yieldChunked(
reasoning,
as: { .reasoning($0) },
continuation: continuation,
delay: delay
)
}
if !content.isEmpty {
try await yieldChunked(
content,
as: { .token($0) },
continuation: continuation,
delay: delay
)
}
return false
}

func hasPresentableFinalContent(_ choice: ChatCompletionResponse.Choice) -> Bool {
let content = choice.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let reasoning = choice.message.reasoningContent?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return content != "{}" && (!content.isEmpty || !reasoning.isEmpty)
}

func yieldChunked(
_ text: String,
as event: @Sendable (String) -> AgentEvent,
continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation,
delay: Duration
) async throws {
let maximumChunkCount = 100
let chunkSize = max(32, (text.count + maximumChunkCount - 1) / maximumChunkCount)
let targetChunkCount = 100
let maximumChunkCount = 300
let minimumChunkSize = 4
let preferredMaximumChunkSize = 48
let adaptiveChunkSize = text.count / targetChunkCount + (text.count % targetChunkCount == 0 ? 0 : 1)
let preferredChunkSize = min(preferredMaximumChunkSize, max(minimumChunkSize, adaptiveChunkSize))
let minimumSizeForChunkLimit = text.count / maximumChunkCount + (text.count % maximumChunkCount == 0 ? 0 : 1)
let chunkSize = max(preferredChunkSize, minimumSizeForChunkLimit)
var index = text.startIndex
while index < text.endIndex {
try Task.checkCancellation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ struct AgentStreamUseCase: AgentStreamUseCaseProtocol {
init(
repository: ChatRepositoryProtocol = ChatRepository(),
timeout: Duration = .seconds(300),
chunkDelay: Duration = .milliseconds(10)
chunkDelay: Duration = .milliseconds(20)
) {
self.repository = repository
self.timeout = timeout
Expand Down Expand Up @@ -175,8 +175,12 @@ private extension AgentStreamUseCase {
let response = try await request(context: context, messages: requestMessages, tools: tools)
aggregateUsage = emitUsage(response.usage, aggregate: aggregateUsage, continuation: context.continuation)
guard let choice = response.choices.first else { throw AgentStreamError.invalidResponse }
let toolCalls = choice.message.toolCalls ?? []
if toolCalls.isEmpty, hasPresentableFinalContent(choice) {
await context.timeoutController.pause()
}

if let toolCalls = choice.message.toolCalls, !toolCalls.isEmpty {
if !toolCalls.isEmpty {
guard !forceFinalResponse else { throw AgentStreamError.iterationLimitReached }
forceFinalResponse = try await completeToolRound(
choice: choice,
Expand All @@ -185,7 +189,11 @@ private extension AgentStreamUseCase {
toolCallCount: &toolCallCount,
context: AgentRoundContext(requestMessages: requestMessages, loop: context)
)
} else if try await handleFinalChoice(choice, continuation: context.continuation) {
} else if try await handleFinalChoice(
choice,
continuation: context.continuation,
delay: chunkDelay
) {
guard !forceFinalResponse else { throw AgentStreamError.invalidResponse }
forceFinalResponse = true
} else {
Expand Down Expand Up @@ -405,32 +413,6 @@ private extension AgentStreamUseCase {
)
}

func handleFinalChoice(
_ choice: ChatCompletionResponse.Choice,
continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
) async throws -> Bool {
let content = choice.message.content ?? ""
let reasoning = choice.message.reasoningContent
if content.trimmingCharacters(in: .whitespacesAndNewlines) == "{}" { return true }
if let reasoning, !reasoning.isEmpty {
try await yieldChunked(
reasoning,
as: { .reasoning($0) },
continuation: continuation,
delay: chunkDelay
)
}
if !content.isEmpty {
try await yieldChunked(
content,
as: { .token($0) },
continuation: continuation,
delay: chunkDelay
)
}
return content.isEmpty && (reasoning?.isEmpty ?? true)
}

func emitUsage(
_ usage: ChatCompletionResponse.Usage?,
aggregate: TokenUsage,
Expand Down
Loading