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 .xcodebuildmcp/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enabledWorkflows:
sessionDefaults:
projectPath: "./openclient-llm.xcodeproj"
scheme: "openclient-llm"
simulatorName: "iPhone 17 Pro Max"
simulatorName: "iPhone 18 Pro Max"
useLatestOS: true
suppressWarnings: true
bundleId: "com.artcc.openclient-llm"
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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.7.20-build-118] - 2026-10-01

### Changed

- Agent text and vision rounds now stream reasoning and responses as they arrive, while native image-generation rounds retain their existing completion flow
- Agent streaming now groups interface updates and flushes pending content before completion, errors, or persistence checkpoints

### Fixed

- Streamed tool calls are reconstructed and validated before execution, rejecting incomplete responses, malformed fragments, and duplicate call identifiers
- SSE parsing now preserves tool arguments across data fields with or without a space, multiline events, and LF, CRLF, or CR line endings
- Provisional text and reasoning are discarded as soon as tool calls are detected, preventing failed tool rounds from being saved as assistant answers

## [1.7.15-build-117] - 2026-09-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ EOF
open openclient-llm.xcodeproj
# Or build from terminal:
xcodebuild build -project openclient-llm.xcodeproj -scheme openclient-llm \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'
-destination 'platform=iOS Simulator,name=iPhone 18 Pro Max'
```

When using OpenCode with **XcodeBuildMCP**, the project skills under `.opencode/skills/` guide builds, launches, tests,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<img src="https://img.shields.io/badge/License-AGPL%20v3.0-blue?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Platform-iOS%2026+%20|%20iPadOS%2026+%20|%20macOS%2026+-blue?style=flat-square" alt="Platform" />
<img src="https://img.shields.io/badge/Swift-6+-orange?style=flat-square&logo=swift" alt="Swift" />
<img src="https://img.shields.io/badge/Version-1.7.15-brightgreen?style=flat-square" alt="Version 1.7.15" />
<img src="https://img.shields.io/badge/Version-1.7.20-brightgreen?style=flat-square" alt="Version 1.7.20" />
</p>

OpenClient connects directly to the AI server you configure, without an OpenClient-hosted proxy or subscription.
Expand Down
9 changes: 3 additions & 6 deletions TestFlight/WhatToTest.en-US.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
Hi there! We've got some great new features for you in this update.

• OpenClient is now ready for Xcode 27 and the latest iOS 27 and macOS 27 SDKs, keeping your experience up to date across iPhone, iPad, and Mac.
• On Mac, tag filters in your conversation list and the onboarding buttons now use the same Liquid Glass style you already enjoy on iPhone and iPad.
• Announcements on the Home screen now animate into view on Mac with the same smooth transition used on iPhone and iPad.
• Your conversation list no longer shows a duplicate separator above your chats.
• The Models screen no longer repeats requests while loading, so it stays responsive.
• Image generation now works with GPT Image models through LiteLLM, without requests failing because of an unsupported setting.
• Real-time responses and reasoning in chats with tools, without waiting for the entire response to finish.
• Smoother, more reliable long responses and tool use.
• Provisional text from failed tool calls is no longer saved as an answer in your conversation history.
• Minor bug fixes and improvements for a smoother experience.

Thanks for your continued support and for helping us build the best possible LLM client together.
Expand Down
9 changes: 3 additions & 6 deletions TestFlight/WhatToTest.es-ES.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
¡Hola! Esta actualización viene cargada de novedades.

• OpenClient ya está preparado para Xcode 27 y los últimos SDK de iOS 27 y macOS 27, para ofrecerte una experiencia actualizada en iPhone, iPad y Mac.
• En Mac, los filtros de etiquetas de la lista de conversaciones y los botones del onboarding ahora usan el mismo estilo Liquid Glass que ya disfrutas en iPhone y iPad.
• Los anuncios de la pantalla de Inicio ahora aparecen con una animación fluida en Mac, con la misma transición que en iPhone e iPad.
• La lista de conversaciones ya no muestra un separador duplicado encima de tus chats.
• La pantalla de Modelos ya no repite solicitudes mientras se carga, para mantenerse ágil.
• La generación de imágenes ya funciona con modelos GPT Image a través de LiteLLM, sin que las solicitudes fallen por un ajuste no compatible.
• Respuestas y razonamiento en tiempo real en el chat con herramientas, sin esperar a que termine toda la respuesta.
• Mejoras en la fluidez y fiabilidad de las respuestas largas y del uso de herramientas.
• El texto provisional de las llamadas a herramientas fallidas ya no se guarda como una respuesta en el historial.
• Pequeñas correcciones de errores y mejoras para disfrutar de una experiencia más fluida.

Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje.
Expand Down
135 changes: 135 additions & 0 deletions openclient-llm-test/Core/Networking/APIClientStreamingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// APIClientStreamingTests.swift
// openclient-llm-test
//
// Created by Arturo Carretero Calvo on 01/10/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import XCTest
@testable import openclient_llm

@MainActor
final class APIClientStreamingTests: XCTestCase {
func test_streamRequest_multilineEvents_preservesDataAcrossSupportedLineEndings() async throws {
for endpoint in ["lf", "crlf", "cr"] {
// Given
let session = makeSession()
defer { session.invalidateAndCancel() }
let client = APIClient(session: session, serverBaseURL: "https://example.invalid", apiKey: "")

// When
var payloads: [String] = []
for try await data in client.streamRequest(endpoint: endpoint, body: ["stream": true]) {
payloads.append(try XCTUnwrap(String(bytes: data, encoding: .utf8)))
}

// Then: fields and comments are ignored, only one optional space is removed, and DONE stops delivery.
XCTAssertEqual(payloads, ["{\"text\":\n\"á🙂\"}", " leading space"])
}
}

func test_streamRequest_unterminatedEvent_doesNotDeliverPartialPayload() async throws {
// Given
let session = makeSession()
defer { session.invalidateAndCancel() }
let client = APIClient(session: session, serverBaseURL: "https://example.invalid", apiKey: "")

// When
var payloads: [String] = []
for try await data in client.streamRequest(endpoint: "unterminated", body: ["stream": true]) {
payloads.append(try XCTUnwrap(String(bytes: data, encoding: .utf8)))
}

// Then
XCTAssertEqual(payloads, ["complete"])
}

func test_streamAgentCompletion_mixedDataPrefixes_preservesToolArguments() async throws {
// Given
let session = makeSession()
defer { session.invalidateAndCancel() }
let repository = ChatRepository(
apiClient: APIClient(session: session, serverBaseURL: "https://example.invalid/tools", apiKey: ""),
attachmentRepository: MockAttachmentRepository()
)

// When
var responses: [ChatCompletionResponse] = []
for try await event in repository.streamAgentCompletion(
messages: [], model: "test", parameters: .default, tools: nil
) {
if case .completed(let response) = event { responses.append(response) }
}

// Then: losing the unspaced middle event would silently turn 10 into 1.
XCTAssertEqual(responses.count, 1)
let call = try XCTUnwrap(responses.first?.choices.first?.message.toolCalls?.first)
XCTAssertEqual(call.id, "call")
XCTAssertEqual(call.function.name, "lookup")
XCTAssertEqual(call.function.arguments, "{\"amount\":10}")
XCTAssertEqual(responses.first?.choices.first?.finishReason, "tool_calls")
}

private func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [SSEFixtureURLProtocol.self]
return URLSession(configuration: configuration)
}
}

private final class SSEFixtureURLProtocol: URLProtocol {
override static func canInit(with request: URLRequest) -> Bool { true }

override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
guard let url = request.url,
let payload = Self.payload(for: url.path),
let response = HTTPURLResponse(
url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/event-stream"]
) else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
// Include byte boundaries inside UTF-8 characters, CRLF pairs, and data field names.
for byte in payload.utf8 {
client?.urlProtocol(self, didLoad: Data([byte]))
}
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {}

private static func payload(for path: String) -> String? {
switch path {
case "/lf", "/crlf", "/cr":
let lineEnding = path == "/crlf" ? "\r\n" : path == "/cr" ? "\r" : "\n"
return [
"\u{FEFF}: keepalive", "event: message", "data: {\"text\":", "id: ignored", "data:\"á🙂\"}",
"", "data: leading space", "", "data:[DONE]", "", "data: must not be delivered", "", ""
].joined(separator: lineEnding)
case "/unterminated":
return "data: complete\n\ndata: incomplete\n"
case "/tools/chat/completions":
let first = #"{"index":0,"id":"call","type":"function","function":{"name":"lookup","#
+ #""arguments":"{\"amount\":1"}}"#
let middle = #"{"index":0,"function":{"arguments":"0"}}"#
let last = #"{"index":0,"function":{"arguments":"}"}}"#
return toolEvent(first, prefix: "data: ")
+ toolEvent(middle, prefix: "data:")
+ toolEvent(last, prefix: "data: ", finish: "tool_calls")
+ "data:[DONE]\n\n"
default:
return nil
}
}

private static func toolEvent(_ call: String, prefix: String, finish: String? = nil) -> String {
let reason = finish.map { "\"\($0)\"" } ?? "null"
return "\(prefix){\"id\":\"stream\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[\(call)]},"
+ "\"finish_reason\":\(reason)}]}\n\n"
}
}
126 changes: 126 additions & 0 deletions openclient-llm-test/Features/Chat/AgentStreamUseCaseDiscardTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// AgentStreamUseCaseDiscardTests.swift
// openclient-llm-test
//
// Created by Arturo Carretero Calvo on 01/10/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import XCTest
@testable import openclient_llm

@MainActor
final class AgentStreamUseCaseDiscardTests: XCTestCase {
func test_execute_pendingToolRound_discardsBeforeFailureAndSuppressesFurtherProgress() async throws {
// Given
let api = MockAPIClient()
let source = AsyncThrowingStream<Data, Error>.makeStream()
api.streamOverride = source.stream
let tool = DiscardCountingTool()
let discarded = expectation(description: "Tool progress discarded while the request is still pending")
let consumer = Task { () -> ([AgentEvent], Error?) in
var events: [AgentEvent] = []
do {
for try await event in makeStream(api, tool: tool) {
events.append(event)
if case .responseDiscarded = event { discarded.fulfill() }
}
return (events, nil)
} catch { return (events, error) }
}
defer { source.continuation.finish(); consumer.cancel() }

// When
source.continuation.yield(try AgentStreamingFixture.chunk(content: "Checking", reasoning: "Planning"))
source.continuation.yield(try AgentStreamingFixture.chunk(
content: "Tool-only text", reasoning: "Tool-only reasoning",
calls: [AgentStreamingFixture.call(id: "call", name: tool.name, arguments: "{")]
))
await fulfillment(of: [discarded], timeout: 2)
XCTAssertEqual(tool.executionCount, 0)
source.continuation.yield(try AgentStreamingFixture.chunk(content: "Late text", reasoning: "Late reasoning"))
source.continuation.yield(try AgentStreamingFixture.chunk(
calls: [AgentStreamingFixture.call(arguments: "}")], finish: "tool_calls"
))
source.continuation.finish(throwing: APIError.serverUnreachable)
let (events, error) = await consumer.value

// Then
XCTAssertEqual(error as? APIError, .serverUnreachable)
XCTAssertEqual(events.compactMap { if case .token(let text) = $0 { text } else { nil } }, ["Checking"])
XCTAssertEqual(events.compactMap { if case .reasoning(let text) = $0 { text } else { nil } }, ["Planning"])
assertDiscardedWithoutExecution(events, api: api, tool: tool)
}

func test_execute_invalidToolRounds_discardProgressWithoutExecutionOrRetry() async throws {
// Given
let call = AgentStreamingFixture.call(id: "call", name: "lookup", arguments: "{}")
let scenarios: [(chunks: [Data], error: APIError)] = try [
([AgentStreamingFixture.chunk(calls: [call], finish: "length")], .invalidResponse),
([AgentStreamingFixture.chunk(calls: [
AgentStreamingFixture.call(name: "lookup", arguments: "{}")
], finish: "tool_calls")], .invalidResponse),
([AgentStreamingFixture.chunk(calls: [
AgentStreamingFixture.call(index: -1, id: "call", name: "lookup", arguments: "{}")
])], .invalidResponse),
([AgentStreamingFixture.chunk(calls: [call]), Data("{invalid".utf8)], .decodingError),
([AgentStreamingFixture.chunk(calls: [call])], .invalidResponse)
]
for scenario in scenarios {
let api = MockAPIClient()
let tool = DiscardCountingTool()
api.streamChunks = [try AgentStreamingFixture.chunk(content: "Checking", reasoning: "Planning")]
+ scenario.chunks
var events: [AgentEvent] = []

// When
do {
for try await event in makeStream(api, tool: tool) { events.append(event) }
XCTFail("Expected invalid tool round")
} catch {
XCTAssertEqual(error as? APIError, scenario.error)
}

// Then
assertDiscardedWithoutExecution(events, api: api, tool: tool)
}
}

private func makeStream(_ api: MockAPIClient, tool: DiscardCountingTool) -> AsyncThrowingStream<AgentEvent, Error> {
AgentStreamUseCase(
repository: ChatRepository(apiClient: api, attachmentRepository: MockAttachmentRepository()),
chunkDelay: .zero
).execute(messages: [], model: "test", parameters: .default, toolRegistry: ToolRegistry(tools: [tool]))
}

private func assertDiscardedWithoutExecution(
_ events: [AgentEvent], api: MockAPIClient, tool: DiscardCountingTool
) {
XCTAssertEqual(events.filter { if case .responseDiscarded = $0 { true } else { false } }.count, 1)
XCTAssertFalse(events.contains {
switch $0 {
case .toolCallStarted, .transcriptAppended, .completed: true
default: false
}
})
XCTAssertEqual(tool.executionCount, 0)
XCTAssertEqual(api.streamRequestCount, 1)
}
}

@MainActor
private final class DiscardCountingTool: ChatToolProtocol {
let name = "lookup"
private(set) var executionCount = 0

var definition: ToolDefinition {
ToolDefinition(type: "function", function: .init(
name: name, description: "Test tool", parameters: .init(type: "object", properties: [:], required: [])
))
}

func execute(arguments: String) async throws -> ToolExecutionResult {
executionCount += 1
return ToolExecutionResult(text: "Result")
}
}
Loading
Loading