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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ 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-90] - 2026-08-28

### Added

- App icon selector in Settings with 20 designs on iOS and iPadOS
- Native continued-processing Live Activity for model responses on iOS and iPadOS, with progress for reasoning, tool use, image generation, and saving

### Fixed

- Chat context usage now retains LiteLLM's reported prompt-token count and estimates only the newly generated response, falling back to local estimation when usage is unavailable
- Automatic chat context compaction now persists summaries before the first overflowing request and rolls back pending summaries when saving, cancellation, or background expiration interrupts the operation
- Agent-mode context estimates now include the actual agent system instructions
- Long-chat scrolling no longer refreshes the full chat scene as visible messages change, improving manual navigation responsiveness
- Manual scrolling during response streaming now keeps message presentation stable until scrolling ends, then displays buffered updates together

### Security

- Compacted conversation summaries are now size-bounded and isolated as untrusted data before being reused in model prompts

## [1.6.30-build-85] - 2026-08-23

### Added
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.6.30-brightgreen?style=flat-square" alt="Version 1.6.30" />
<img src="https://img.shields.io/badge/Version-1.6.35-brightgreen?style=flat-square" alt="Version 1.6.35" />
</p>

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

***1.6.30:
***1.6.35:

• Fixed an issue that could cause the app to freeze when manually scrolling while a model was responding, or after several responses.
• On macOS, add OpenClient widgets to the desktop or Notification Center and test New Chat, Search, Quick Actions, Continue Chat, Recent, Pinned, and Tagged Conversations.
• On macOS, add the New Chat control and verify that it opens the app directly in a new conversation.
• Choose from 20 app icons in Settings on iPhone and iPad.
• Long chats now preserve earlier context before the next model response, with safer handling when requests are cancelled or interrupted.
• Scroll through long conversations and verify that scrolling remains smooth while the floating date indicator updates.
• While a model is responding, scroll through the conversation and verify that the visible history stays stable, then catches up when scrolling stops.
• Start a model response, leave OpenClient, and verify that its Live Activity shows progress through reasoning, tool use, image generation, and saving.
• Minor bug fixes and improvements for a smoother experience.

***Recent Updates:

• Fixed an issue that could cause the app to freeze when manually scrolling while a model was responding, or after several responses.
• On macOS, add OpenClient widgets to the desktop or Notification Center and test New Chat, Search, Quick Actions, Continue Chat, Recent, Pinned, and Tagged Conversations.
• On macOS, add the New Chat control and verify that it opens the app directly in a new conversation.
• Chat messages now show their time, with a floating date indicator when scrolling through conversation history.
• Test the new per-tool MCP permissions and approval flow: set tools to Always Allow, Ask, or Deny, then review requested MCP calls before execution.
• You can now support OpenClient with optional monthly or annual subscriptions, alongside one-time coffee purchases. No features are locked.
Expand Down
338 changes: 338 additions & 0 deletions openclient-llm-test/Core/Managers/BackgroundTaskManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
//
// BackgroundTaskManagerTests.swift
// openclient-llm
//
// Created by Arturo Carretero Calvo on 27/08/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import UIKit
import XCTest
@testable import openclient_llm

@MainActor
final class BackgroundTaskManagerTests: XCTestCase {
func test_beginTask_registrationSucceeds_registersAndSubmitsSameIdentifier() {
// Given
let system = MockBackgroundTaskSystem()
let sut = makeSUT(system: system)

// When
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// Then
XCTAssertEqual(system.registeredIdentifiers, ["test.streaming.response"])
XCTAssertEqual(system.submittedIdentifiers, ["test.streaming.response"])
XCTAssertEqual(system.submittedTitles, ["Title"])
XCTAssertEqual(system.submittedSubtitles, ["Preparing"])
XCTAssertTrue(system.legacyTaskNames.isEmpty)
}

func test_beginTask_registrationFails_startsLegacyFallback() {
// Given
let system = MockBackgroundTaskSystem()
system.registrationResult = false
let sut = makeSUT(system: system)

// When
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// Then
XCTAssertEqual(system.cancelledIdentifiers, ["test.streaming.response"])
XCTAssertEqual(system.legacyTaskNames, ["LLMStreaming"])
XCTAssertTrue(system.submittedIdentifiers.isEmpty)
}

func test_beginTask_submissionFails_startsLegacyFallback() {
// Given
let system = MockBackgroundTaskSystem()
system.submissionError = TestError.submissionFailed
let sut = makeSUT(system: system)

// When
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// Then
XCTAssertEqual(system.cancelledIdentifiers, ["test.streaming.response"])
XCTAssertEqual(system.legacyTaskNames, ["LLMStreaming"])
}

func test_endTask_legacyFallback_endsOnce() {
// Given
let system = MockBackgroundTaskSystem()
system.registrationResult = false
let sut = makeSUT(system: system)
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// When
sut.endTask(success: true)
sut.endTask(success: false)

// Then
XCTAssertEqual(system.endedLegacyIdentifiers, [system.legacyIdentifier])
}

func test_endTask_launchedTask_completesOnceWithProgress() {
// Given
let system = MockBackgroundTaskSystem()
let task = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})
system.launch(task)

// When
sut.updateTask(completedUnitCount: 65, subtitle: "Using tools")
sut.endTask(success: true)
sut.endTask(success: false)

// Then
XCTAssertEqual(task.titles, ["Title", "Title"])
XCTAssertEqual(task.subtitles, ["Preparing", "Using tools"])
XCTAssertEqual(task.progress.totalUnitCount, 100)
XCTAssertEqual(task.progress.completedUnitCount, 100)
XCTAssertEqual(task.completionResults, [true])
}

func test_beginTask_activeSession_doesNotReplaceTask() {
// Given
let system = MockBackgroundTaskSystem()
let task = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
sut.beginTask(title: "First", subtitle: "Preparing", expirationHandler: {})
system.launch(task)

// When
sut.beginTask(title: "Second", subtitle: "Preparing", expirationHandler: {})
sut.endTask(success: true)

// Then
XCTAssertEqual(system.registeredIdentifiers, ["test.streaming.response"])
XCTAssertEqual(task.completionResults, [true])
}

func test_endTask_beforeLaunch_lateTaskCompletesImmediately() {
// Given
let system = MockBackgroundTaskSystem()
let task = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// When
sut.endTask(success: true)
system.launch(task)

// Then
XCTAssertEqual(system.cancelledIdentifiers, ["test.streaming.response"])
XCTAssertEqual(task.completionResults, [true])
XCTAssertNil(task.expirationHandler)
}

func test_expiration_runningTask_invokesHandlerAndCompletesFailureOnce() {
// Given
let system = MockBackgroundTaskSystem()
let task = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
let probe = ExpirationProbe()
sut.beginTask(title: "Title", subtitle: "Preparing") {
probe.callCount += 1
}
system.launch(task)

// When
let expirationHandler = task.expirationHandler
expirationHandler?()
expirationHandler?()
sut.endTask(success: true)

// Then
XCTAssertEqual(probe.callCount, 1)
XCTAssertEqual(task.completionResults, [false])
}

func test_expiration_afterCompletion_doesNotInvokeHandler() {
// Given
let system = MockBackgroundTaskSystem()
let task = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
let probe = ExpirationProbe()
sut.beginTask(title: "Title", subtitle: "Preparing") {
probe.callCount += 1
}
system.launch(task)
let expirationHandler = task.expirationHandler

// When
sut.endTask(success: true)
expirationHandler?()

// Then
XCTAssertEqual(probe.callCount, 0)
XCTAssertEqual(task.completionResults, [true])
}

func test_expiration_legacyFallback_cleansUpSynchronouslyAndAllowsNextTask() {
// Given
let system = MockBackgroundTaskSystem()
system.registrationResult = false
var identifiers = ["test.streaming.first", "test.streaming.second"].makeIterator()
let sut = BackgroundTaskManager(system: system) { identifiers.next() ?? "unexpected" }
let probe = ExpirationProbe()
sut.beginTask(title: "First", subtitle: "Preparing") {
probe.callCount += 1
}

// When
system.expireLegacy()
sut.beginTask(title: "Second", subtitle: "Preparing", expirationHandler: {})

// Then
XCTAssertEqual(probe.callCount, 1)
XCTAssertEqual(system.endedLegacyIdentifiers, [system.legacyIdentifier])
XCTAssertEqual(system.legacyTaskNames, ["LLMStreaming", "LLMStreaming"])
}

func test_oldLateLaunch_newSessionActive_doesNotCompleteNewTask() {
// Given
let system = MockBackgroundTaskSystem()
var identifiers = ["test.streaming.first", "test.streaming.second"].makeIterator()
let sut = BackgroundTaskManager(system: system) { identifiers.next() ?? "unexpected" }
sut.beginTask(title: "First", subtitle: "Preparing", expirationHandler: {})
let firstLaunch = system.launchHandlers["test.streaming.first"]
sut.endTask(success: true)
sut.beginTask(title: "Second", subtitle: "Preparing", expirationHandler: {})
let oldTask = MockContinuedProcessingTask()
let newTask = MockContinuedProcessingTask()

// When
firstLaunch?(oldTask)
system.launch(newTask, identifier: "test.streaming.second")
sut.endTask(success: true)

// Then
XCTAssertEqual(oldTask.completionResults, [true])
XCTAssertEqual(newTask.completionResults, [true])
}

func test_duplicateLaunch_continuedTaskActive_rejectsDuplicate() {
// Given
let system = MockBackgroundTaskSystem()
let activeTask = MockContinuedProcessingTask()
let duplicateTask = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})
system.launch(activeTask)

// When
system.launch(duplicateTask)
sut.endTask(success: true)

// Then
XCTAssertEqual(duplicateTask.completionResults, [false])
XCTAssertEqual(activeTask.completionResults, [true])
}

func test_lateLaunch_legacyFallbackActive_rejectsContinuedTask() {
// Given
let system = MockBackgroundTaskSystem()
system.submissionError = TestError.submissionFailed
let continuedTask = MockContinuedProcessingTask()
let sut = makeSUT(system: system)
sut.beginTask(title: "Title", subtitle: "Preparing", expirationHandler: {})

// When
system.launch(continuedTask)
sut.endTask(success: true)

// Then
XCTAssertEqual(continuedTask.completionResults, [false])
XCTAssertEqual(system.endedLegacyIdentifiers, [system.legacyIdentifier])
}

private func makeSUT(system: MockBackgroundTaskSystem) -> BackgroundTaskManager {
BackgroundTaskManager(system: system, identifierProvider: { "test.streaming.response" })
}
}

@MainActor
private final class MockBackgroundTaskSystem: BackgroundTaskSystemProtocol {
var registrationResult = true
var submissionError: Error?
var legacyIdentifier = UIBackgroundTaskIdentifier(rawValue: 1)
private(set) var registeredIdentifiers: [String] = []
private(set) var submittedIdentifiers: [String] = []
private(set) var submittedTitles: [String] = []
private(set) var submittedSubtitles: [String] = []
private(set) var cancelledIdentifiers: [String] = []
private(set) var legacyTaskNames: [String] = []
private(set) var endedLegacyIdentifiers: [UIBackgroundTaskIdentifier] = []
private(set) var launchHandlers: [String: @MainActor (ContinuedProcessingTaskProtocol) -> Void] = [:]
private var legacyExpirationHandler: (@MainActor @Sendable () -> Void)?

func registerContinuedTask(
identifier: String,
launchHandler: @escaping @MainActor (ContinuedProcessingTaskProtocol) -> Void
) -> Bool {
registeredIdentifiers.append(identifier)
launchHandlers[identifier] = launchHandler
return registrationResult
}

func submitContinuedTask(identifier: String, title: String, subtitle: String) throws {
if let submissionError { throw submissionError }
submittedIdentifiers.append(identifier)
submittedTitles.append(title)
submittedSubtitles.append(subtitle)
}

func cancelContinuedTask(identifier: String) {
cancelledIdentifiers.append(identifier)
}

func beginLegacyTask(
name: String,
expirationHandler: @escaping @MainActor @Sendable () -> Void
) -> UIBackgroundTaskIdentifier {
legacyTaskNames.append(name)
legacyExpirationHandler = expirationHandler
return legacyIdentifier
}

func endLegacyTask(_ identifier: UIBackgroundTaskIdentifier) {
endedLegacyIdentifiers.append(identifier)
}

func launch(_ task: ContinuedProcessingTaskProtocol, identifier: String = "test.streaming.response") {
launchHandlers[identifier]?(task)
}

func expireLegacy() {
legacyExpirationHandler?()
}
}

@MainActor
private final class MockContinuedProcessingTask: ContinuedProcessingTaskProtocol {
var expirationHandler: (() -> Void)?
let progress = Progress(totalUnitCount: 0)
private(set) var titles: [String] = []
private(set) var subtitles: [String] = []
private(set) var completionResults: [Bool] = []

func updateTitle(_ title: String, subtitle: String) {
titles.append(title)
subtitles.append(subtitle)
}

func setTaskCompleted(success: Bool) {
completionResults.append(success)
}
}

private enum TestError: Error {
case submissionFailed
}

@MainActor
private final class ExpirationProbe {
var callCount = 0
}
Loading