From 62c0927c83ae76f65176eb82720b10936399899a Mon Sep 17 00:00:00 2001 From: Timothy Zelinsky Date: Sat, 21 Mar 2026 20:08:42 +1100 Subject: [PATCH 1/2] Add auth recovery, retries, and reasoning effort UI --- .../Shared/AgentDemoRuntimeFactory.swift | 5 + .../Shared/AgentDemoView+ChatSections.swift | 60 +++++ .../Shared/AgentDemoViewModel.swift | 62 +++++ README.md | 41 +++- .../CodexKit/Auth/ChatGPTSessionManager.swift | 15 ++ Sources/CodexKit/Runtime/AgentRuntime.swift | 71 +++++- .../Runtime/CodexResponsesBackend.swift | 230 +++++++++++------ .../CodexKit/Runtime/ReasoningEffort.swift | 21 ++ .../CodexKit/Runtime/RequestRetryPolicy.swift | 53 ++++ Tests/CodexKitTests/AgentRuntimeTests.swift | 178 ++++++++++++++ .../CodexResponsesBackendTests.swift | 232 ++++++++++++++++++ Tests/CodexKitTests/TestSupport.swift | 8 + 12 files changed, 894 insertions(+), 82 deletions(-) create mode 100644 Sources/CodexKit/Runtime/ReasoningEffort.swift create mode 100644 Sources/CodexKit/Runtime/RequestRetryPolicy.swift diff --git a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoRuntimeFactory.swift b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoRuntimeFactory.swift index 02afbd8..9fc5b19 100644 --- a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoRuntimeFactory.swift +++ b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoRuntimeFactory.swift @@ -28,6 +28,7 @@ enum AgentDemoRuntimeFactory { static func makeLive( model: String = "gpt-5.4", enableWebSearch: Bool = false, + reasoningEffort: ReasoningEffort = .medium, stateURL: URL? = nil, keychainAccount: String = "live" ) -> AgentDemoViewModel { @@ -37,6 +38,7 @@ enum AgentDemoRuntimeFactory { authenticationMethod: .deviceCode, model: model, enableWebSearch: enableWebSearch, + reasoningEffort: reasoningEffort, stateURL: stateURL, keychainAccount: keychainAccount, approvalInbox: approvalInbox, @@ -46,6 +48,7 @@ enum AgentDemoRuntimeFactory { runtime: runtime, model: model, enableWebSearch: enableWebSearch, + reasoningEffort: reasoningEffort, stateURL: stateURL, keychainAccount: keychainAccount, approvalInbox: approvalInbox, @@ -61,6 +64,7 @@ enum AgentDemoRuntimeFactory { authenticationMethod: DemoAuthenticationMethod, model: String = "gpt-5.4", enableWebSearch: Bool = false, + reasoningEffort: ReasoningEffort = .medium, stateURL: URL? = nil, keychainAccount: String = "live", approvalInbox: ApprovalInbox, @@ -90,6 +94,7 @@ enum AgentDemoRuntimeFactory { backend: CodexResponsesBackend( configuration: CodexResponsesBackendConfiguration( model: model, + reasoningEffort: reasoningEffort, enableWebSearch: enableWebSearch ) ), diff --git a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoView+ChatSections.swift b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoView+ChatSections.swift index 8a04379..04f8878 100644 --- a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoView+ChatSections.swift +++ b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoView+ChatSections.swift @@ -28,6 +28,30 @@ extension AgentDemoView { ScrollView(.horizontal, showsIndicators: false) { headerActions } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Model") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(viewModel.model) + .font(.caption) + .foregroundStyle(.secondary) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(ReasoningEffort.allCases, id: \.self) { effort in + reasoningEffortButton(for: effort) + } + } + } + + Text("Thinking level for future requests.") + .font(.caption2) + .foregroundStyle(.secondary) + } } } @@ -132,6 +156,27 @@ extension AgentDemoView { } } + @ViewBuilder + func reasoningEffortButton(for effort: ReasoningEffort) -> some View { + if effort == viewModel.reasoningEffort { + Button(effort.demoTitle) { + Task { + await viewModel.updateReasoningEffort(effort) + } + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canReconfigureRuntime) + } else { + Button(effort.demoTitle) { + Task { + await viewModel.updateReasoningEffort(effort) + } + } + .buttonStyle(.bordered) + .disabled(!viewModel.canReconfigureRuntime) + } + } + @ViewBuilder var personaExamples: some View { if viewModel.session != nil { @@ -239,3 +284,18 @@ extension AgentDemoView { } } } + +private extension ReasoningEffort { + var demoTitle: String { + switch self { + case .low: + "Think Low" + case .medium: + "Think Medium" + case .high: + "Think High" + case .extraHigh: + "Think Extra High" + } + } +} diff --git a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift index ee18427..55be8a2 100644 --- a/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift +++ b/DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift @@ -61,6 +61,8 @@ final class AgentDemoViewModel: @unchecked Sendable { var cachedAIReminderBody: String? var cachedAIReminderKey: String? var cachedAIReminderGeneratedAt: Date? + var reasoningEffort: ReasoningEffort + var currentAuthenticationMethod: DemoAuthenticationMethod = .deviceCode let approvalInbox: ApprovalInbox let deviceCodePromptCoordinator: DeviceCodePromptCoordinator @@ -82,6 +84,7 @@ final class AgentDemoViewModel: @unchecked Sendable { runtime: AgentRuntime, model: String, enableWebSearch: Bool, + reasoningEffort: ReasoningEffort, stateURL: URL?, keychainAccount: String, approvalInbox: ApprovalInbox, @@ -90,6 +93,7 @@ final class AgentDemoViewModel: @unchecked Sendable { self.runtime = runtime self.model = model self.enableWebSearch = enableWebSearch + self.reasoningEffort = reasoningEffort self.stateURL = stateURL self.keychainAccount = keychainAccount self.approvalInbox = approvalInbox @@ -122,6 +126,17 @@ final class AgentDemoViewModel: @unchecked Sendable { dailyStepGoal > 0 && todayStepCount >= dailyStepGoal } + var canReconfigureRuntime: Bool { + !isAuthenticating && threads.allSatisfy { thread in + switch thread.status { + case .idle, .failed: + true + case .streaming, .waitingForApproval, .waitingForToolResult: + false + } + } + } + func restore() async { do { _ = try await runtime.restore() @@ -139,10 +154,12 @@ final class AgentDemoViewModel: @unchecked Sendable { isAuthenticating = true lastError = nil + currentAuthenticationMethod = authenticationMethod runtime = AgentDemoRuntimeFactory.makeRuntime( authenticationMethod: authenticationMethod, model: model, enableWebSearch: enableWebSearch, + reasoningEffort: reasoningEffort, stateURL: stateURL, keychainAccount: keychainAccount, approvalInbox: approvalInbox, @@ -168,6 +185,51 @@ final class AgentDemoViewModel: @unchecked Sendable { } } + func updateReasoningEffort(_ reasoningEffort: ReasoningEffort) async { + guard self.reasoningEffort != reasoningEffort else { + return + } + + guard canReconfigureRuntime else { + lastError = "Wait for the current turn to finish before switching thinking level." + return + } + + self.reasoningEffort = reasoningEffort + let preservedActiveThreadID = activeThreadID + let preservedHealthCoachThreadID = healthCoachThreadID + + runtime = AgentDemoRuntimeFactory.makeRuntime( + authenticationMethod: currentAuthenticationMethod, + model: model, + enableWebSearch: enableWebSearch, + reasoningEffort: reasoningEffort, + stateURL: stateURL, + keychainAccount: keychainAccount, + approvalInbox: approvalInbox, + deviceCodePromptCoordinator: deviceCodePromptCoordinator + ) + + do { + _ = try await runtime.restore() + await registerDemoTool() + await refreshSnapshot() + + if let preservedActiveThreadID, + threads.contains(where: { $0.id == preservedActiveThreadID }) { + activeThreadID = preservedActiveThreadID + messages = await runtime.messages(for: preservedActiveThreadID) + } + + if let preservedHealthCoachThreadID, + threads.contains(where: { $0.id == preservedHealthCoachThreadID }) { + healthCoachThreadID = preservedHealthCoachThreadID + } + } catch { + lastError = error.localizedDescription + } + } + func createThread() async { await createThreadInternal( title: nil, diff --git a/README.md b/README.md index 2e9b0f1..fcf5509 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ let runtime = try AgentRuntime(configuration: .init( backend: CodexResponsesBackend( configuration: .init( model: "gpt-5.4", + reasoningEffort: .medium, enableWebSearch: true ) ), @@ -73,7 +74,9 @@ let stream = try await runtime.sendMessage( | Threaded runtime state + restore | Yes | | Streamed assistant output | Yes | | Host-defined tools + approval flow | Yes | +| Configurable thinking level | Yes | | Web search toggle (`enableWebSearch`) | Yes | +| Built-in request retry/backoff | Yes (configurable) | | Text + image input | Yes | | Assistant image attachment rendering | Yes | | Video/audio input attachments | Not yet | @@ -115,6 +118,42 @@ The recommended production path for iOS is: For browser OAuth, `CodexKit` uses the Codex-compatible redirect `http://localhost:1455/auth/callback` internally and only runs the loopback listener during active auth. +`CodexResponsesBackend` also includes built-in retry/backoff for transient failures (`429`, `5xx`, and network-transient URL errors like `networkConnectionLost`). You can tune or disable it: + +```swift +let backend = CodexResponsesBackend( + configuration: .init( + model: "gpt-5.4", + requestRetryPolicy: .init( + maxAttempts: 3, + initialBackoff: 0.5, + maxBackoff: 4, + jitterFactor: 0.2 + ) + // or disable: + // requestRetryPolicy: .disabled + ) +) +``` + +`CodexResponsesBackendConfiguration` also lets you control the model thinking level: + +```swift +let backend = CodexResponsesBackend( + configuration: .init( + model: "gpt-5.4", + reasoningEffort: .high + ) +) +``` + +Available values: + +- `.low` +- `.medium` +- `.high` +- `.extraHigh` + ## Image Attachments `CodexKit` supports: @@ -198,7 +237,7 @@ The demo app exercises: - Use persistent runtime state (`FileRuntimeStateStore`) - Gate impactful tools with approvals - Handle auth cancellation and sign-out resets cleanly -- Add retry/backoff around network-dependent UX +- Tune retry/backoff policy for your app’s UX and latency targets - Log tool invocations and failures for supportability - Validate HealthKit/notification permission fallback states if using health features diff --git a/Sources/CodexKit/Auth/ChatGPTSessionManager.swift b/Sources/CodexKit/Auth/ChatGPTSessionManager.swift index f55b5cb..b5f60bb 100644 --- a/Sources/CodexKit/Auth/ChatGPTSessionManager.swift +++ b/Sources/CodexKit/Auth/ChatGPTSessionManager.swift @@ -58,6 +58,21 @@ public actor ChatGPTSessionManager { return session } + public func recoverUnauthorizedSession( + previousAccessToken: String? + ) async throws -> ChatGPTSession { + if let restored = try secureStore.loadSession() { + session = restored + if let previousAccessToken, + restored.accessToken != previousAccessToken, + !restored.requiresRefresh() { + return restored + } + } + + return try await refresh(reason: .unauthorized) + } + private func requireStoredSession() throws -> ChatGPTSession { guard let session else { throw AgentRuntimeError.signedOut() diff --git a/Sources/CodexKit/Runtime/AgentRuntime.swift b/Sources/CodexKit/Runtime/AgentRuntime.swift index e133c28..4af99da 100644 --- a/Sources/CodexKit/Runtime/AgentRuntime.swift +++ b/Sources/CodexKit/Runtime/AgentRuntime.swift @@ -113,7 +113,12 @@ public actor AgentRuntime { personaStack: AgentPersonaStack? = nil ) async throws -> AgentThread { let session = try await sessionManager.requireSession() - var thread = try await backend.createThread(session: session) + let creation = try await withUnauthorizedRecovery( + initialSession: session + ) { session in + try await backend.createThread(session: session) + } + var thread = creation.result if let title { thread.title = title } @@ -125,7 +130,12 @@ public actor AgentRuntime { @discardableResult public func resumeThread(id: String) async throws -> AgentThread { let session = try await sessionManager.requireSession() - let thread = try await backend.resumeThread(id: id, session: session) + let resume = try await withUnauthorizedRecovery( + initialSession: session + ) { session in + try await backend.resumeThread(id: id, session: session) + } + let thread = resume.result try await upsertThread(thread) return thread } @@ -159,7 +169,7 @@ public actor AgentRuntime { try await setThreadStatus(.streaming, for: threadID) let tools = await toolRegistry.allDefinitions() - let turnStream = try await backend.beginTurn( + let turnStart = try await beginTurnWithUnauthorizedRecovery( thread: thread, history: priorMessages, message: request, @@ -167,6 +177,8 @@ public actor AgentRuntime { tools: tools, session: session ) + let turnStream = turnStart.turnStream + let turnSession = turnStart.session return AsyncThrowingStream { continuation in continuation.yield(.messageCommitted(userMessage)) @@ -176,13 +188,39 @@ public actor AgentRuntime { await self.consumeTurnStream( turnStream, for: threadID, - session: session, + session: turnSession, continuation: continuation ) } } } + private func beginTurnWithUnauthorizedRecovery( + thread: AgentThread, + history: [AgentMessage], + message: UserMessageRequest, + instructions: String, + tools: [ToolDefinition], + session: ChatGPTSession + ) async throws -> ( + turnStream: any AgentTurnStreaming, + session: ChatGPTSession + ) { + let beginTurn = try await withUnauthorizedRecovery( + initialSession: session + ) { session in + try await backend.beginTurn( + thread: thread, + history: history, + message: message, + instructions: instructions, + tools: tools, + session: session + ) + } + return (beginTurn.result, beginTurn.session) + } + private func consumeTurnStream( _ turnStream: any AgentTurnStreaming, for threadID: String, @@ -383,4 +421,29 @@ public actor AgentRuntime { turnPersonaOverride: message.personaOverride ) } + + private static func isUnauthorizedError(_ error: Error) -> Bool { + (error as? AgentRuntimeError)?.code == AgentRuntimeError.unauthorized().code + } + + private func withUnauthorizedRecovery( + initialSession: ChatGPTSession, + operation: (ChatGPTSession) async throws -> Result + ) async throws -> ( + result: Result, + session: ChatGPTSession + ) { + do { + return (try await operation(initialSession), initialSession) + } catch { + guard Self.isUnauthorizedError(error) else { + throw error + } + + let recoveredSession = try await sessionManager.recoverUnauthorizedSession( + previousAccessToken: initialSession.accessToken + ) + return (try await operation(recoveredSession), recoveredSession) + } + } } diff --git a/Sources/CodexKit/Runtime/CodexResponsesBackend.swift b/Sources/CodexKit/Runtime/CodexResponsesBackend.swift index 5c6cbb7..bab05b6 100644 --- a/Sources/CodexKit/Runtime/CodexResponsesBackend.swift +++ b/Sources/CodexKit/Runtime/CodexResponsesBackend.swift @@ -3,30 +3,36 @@ import Foundation public struct CodexResponsesBackendConfiguration: Sendable { public let baseURL: URL public let model: String + public let reasoningEffort: ReasoningEffort public let instructions: String public let originator: String public let streamIdleTimeout: TimeInterval public let extraHeaders: [String: String] public let enableWebSearch: Bool + public let requestRetryPolicy: RequestRetryPolicy public init( baseURL: URL = URL(string: "https://chatgpt.com/backend-api/codex")!, model: String = "gpt-5", + reasoningEffort: ReasoningEffort = .medium, instructions: String = """ You are a helpful assistant embedded in an iOS app. Respond naturally, keep the user oriented, and use registered tools when they are helpful. Do not assume shell, terminal, repository, or desktop capabilities unless a host-defined tool explicitly provides them. """, originator: String = "codex_cli_rs", streamIdleTimeout: TimeInterval = 60, extraHeaders: [String: String] = [:], - enableWebSearch: Bool = false + enableWebSearch: Bool = false, + requestRetryPolicy: RequestRetryPolicy = .default ) { self.baseURL = baseURL self.model = model + self.reasoningEffort = reasoningEffort self.instructions = instructions self.originator = originator self.streamIdleTimeout = streamIdleTimeout self.extraHeaders = extraHeaders self.enableWebSearch = enableWebSearch + self.requestRetryPolicy = requestRetryPolicy } } @@ -178,93 +184,119 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { while shouldContinue { shouldContinue = false + var sawToolCall = false + let retryPolicy = configuration.requestRetryPolicy + var attempt = 1 - let request = try buildURLRequest( - configuration: configuration, - instructions: instructions, - threadID: threadID, - items: workingHistory, - tools: tools, - session: session, - encoder: encoder - ) - - let stream = try await streamEvents( - request: request, - configuration: configuration, - urlSession: urlSession, - decoder: decoder - ) + retryLoop: while true { + var emittedRetryUnsafeOutput = false - var sawToolCall = false + do { + let request = try buildURLRequest( + configuration: configuration, + instructions: instructions, + threadID: threadID, + items: workingHistory, + tools: tools, + session: session, + encoder: encoder + ) - for try await event in stream { - switch event { - case let .assistantTextDelta(delta): - continuation.yield( - .assistantMessageDelta( - threadID: threadID, - turnID: turnID, - delta: delta - ) + let stream = try await streamEvents( + request: request, + urlSession: urlSession, + decoder: decoder ) - case let .assistantMessage(messageTemplate): - let assistantText: String - if messageTemplate.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !pendingToolFallbackTexts.isEmpty { - assistantText = pendingToolFallbackTexts.joined(separator: "\n\n") - } else { - assistantText = messageTemplate.text - } + for try await event in stream { + switch event { + case let .assistantTextDelta(delta): + emittedRetryUnsafeOutput = true + continuation.yield( + .assistantMessageDelta( + threadID: threadID, + turnID: turnID, + delta: delta + ) + ) - let mergedImages = (messageTemplate.images + pendingToolImages).uniqued() - let message = AgentMessage( - threadID: threadID, - role: .assistant, - text: assistantText, - images: mergedImages - ) - workingHistory.append(.assistantMessage(message)) - continuation.yield(.assistantMessageCompleted(message)) - pendingToolImages.removeAll(keepingCapacity: true) - pendingToolFallbackTexts.removeAll(keepingCapacity: true) + case let .assistantMessage(messageTemplate): + emittedRetryUnsafeOutput = true - case let .functionCall(functionCall): - sawToolCall = true - workingHistory.append(.functionCall(functionCall)) + let assistantText: String + if messageTemplate.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !pendingToolFallbackTexts.isEmpty { + assistantText = pendingToolFallbackTexts.joined(separator: "\n\n") + } else { + assistantText = messageTemplate.text + } - let invocation = ToolInvocation( - id: functionCall.callID, - threadID: threadID, - turnID: turnID, - toolName: functionCall.name, - arguments: functionCall.arguments - ) + let mergedImages = (messageTemplate.images + pendingToolImages).uniqued() + let message = AgentMessage( + threadID: threadID, + role: .assistant, + text: assistantText, + images: mergedImages + ) + workingHistory.append(.assistantMessage(message)) + continuation.yield(.assistantMessageCompleted(message)) + pendingToolImages.removeAll(keepingCapacity: true) + pendingToolFallbackTexts.removeAll(keepingCapacity: true) + + case let .functionCall(functionCall): + emittedRetryUnsafeOutput = true + sawToolCall = true + workingHistory.append(.functionCall(functionCall)) + + let invocation = ToolInvocation( + id: functionCall.callID, + threadID: threadID, + turnID: turnID, + toolName: functionCall.name, + arguments: functionCall.arguments + ) + + continuation.yield(.toolCallRequested(invocation)) + let toolResult = try await pendingToolResults.wait(for: invocation.id) + let toolImages = await toolOutputImages(from: toolResult, urlSession: urlSession) + pendingToolImages.append(contentsOf: toolImages) + pendingToolImages = pendingToolImages.uniqued() + + if let primaryText = toolResult.primaryText? + .trimmingCharacters(in: .whitespacesAndNewlines), + !primaryText.isEmpty { + pendingToolFallbackTexts.append(primaryText) + } - continuation.yield(.toolCallRequested(invocation)) - let toolResult = try await pendingToolResults.wait(for: invocation.id) - let toolImages = await toolOutputImages(from: toolResult, urlSession: urlSession) - pendingToolImages.append(contentsOf: toolImages) - pendingToolImages = pendingToolImages.uniqued() + workingHistory.append( + .functionCallOutput( + callID: invocation.id, + output: toolOutputText(from: toolResult) + ) + ) - if let primaryText = toolResult.primaryText? - .trimmingCharacters(in: .whitespacesAndNewlines), - !primaryText.isEmpty { - pendingToolFallbackTexts.append(primaryText) + case let .completed(usage): + aggregateUsage.inputTokens += usage.inputTokens + aggregateUsage.cachedInputTokens += usage.cachedInputTokens + aggregateUsage.outputTokens += usage.outputTokens + } } - workingHistory.append( - .functionCallOutput( - callID: invocation.id, - output: toolOutputText(from: toolResult) - ) - ) + break retryLoop + } catch { + guard !emittedRetryUnsafeOutput, + attempt < retryPolicy.maxAttempts, + shouldRetry(error, policy: retryPolicy) + else { + throw error + } - case let .completed(usage): - aggregateUsage.inputTokens += usage.inputTokens - aggregateUsage.cachedInputTokens += usage.cachedInputTokens - aggregateUsage.outputTokens += usage.outputTokens + let delay = retryPolicy.delayBeforeRetry(attempt: attempt) + if delay > 0 { + let nanoseconds = UInt64((delay * 1_000_000_000).rounded()) + try await Task.sleep(nanoseconds: nanoseconds) + } + attempt += 1 } } @@ -297,6 +329,7 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { ) throws -> URLRequest { let requestBody = ResponsesRequestBody( model: configuration.model, + reasoning: .init(effort: configuration.reasoningEffort), instructions: instructions, input: items.map(\.jsonValue), tools: responsesTools( @@ -344,7 +377,6 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { private static func streamEvents( request: URLRequest, - configuration: CodexResponsesBackendConfiguration, urlSession: URLSession, decoder: JSONDecoder ) async throws -> AsyncThrowingStream { @@ -363,7 +395,7 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { throw AgentRuntimeError.unauthorized(body) } throw AgentRuntimeError( - code: "responses_request_failed", + code: "responses_http_status_\(httpResponse.statusCode)", message: "The ChatGPT responses request failed with status \(httpResponse.statusCode): \(body)" ) } @@ -425,6 +457,40 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { } } + private static func shouldRetry( + _ error: Error, + policy: RequestRetryPolicy + ) -> Bool { + if let runtimeError = error as? AgentRuntimeError { + if runtimeError.code == AgentRuntimeError.unauthorized().code { + return false + } + if let statusCode = httpStatusCode(from: runtimeError.code) { + return policy.retryableHTTPStatusCodes.contains(statusCode) + } + return false + } + + if let urlError = error as? URLError { + return policy.retryableURLErrorCodes.contains(urlError.errorCode) + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + return policy.retryableURLErrorCodes.contains(nsError.code) + } + + return false + } + + private static func httpStatusCode(from errorCode: String) -> Int? { + let prefix = "responses_http_status_" + guard errorCode.hasPrefix(prefix) else { + return nil + } + return Int(errorCode.dropFirst(prefix.count)) + } + private static func parseStreamEvent( from payload: SSEEventPayload, decoder: JSONDecoder @@ -609,6 +675,7 @@ final class CodexResponsesTurnSession: AgentTurnStreaming, @unchecked Sendable { private struct ResponsesRequestBody: Encodable { let model: String + let reasoning: ResponsesReasoningConfiguration let instructions: String let input: [JSONValue] let tools: [JSONValue] @@ -621,6 +688,7 @@ private struct ResponsesRequestBody: Encodable { enum CodingKeys: String, CodingKey { case model + case reasoning case instructions case input case tools @@ -633,6 +701,14 @@ private struct ResponsesRequestBody: Encodable { } } +private struct ResponsesReasoningConfiguration: Encodable { + let effort: String + + init(effort: ReasoningEffort) { + self.effort = effort.apiValue + } +} + private enum WorkingHistoryItem: Sendable { case visibleMessage(AgentMessage) case userMessage(AgentMessage) diff --git a/Sources/CodexKit/Runtime/ReasoningEffort.swift b/Sources/CodexKit/Runtime/ReasoningEffort.swift new file mode 100644 index 0000000..ccb8f5c --- /dev/null +++ b/Sources/CodexKit/Runtime/ReasoningEffort.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum ReasoningEffort: String, Codable, CaseIterable, Sendable { + case low + case medium + case high + case extraHigh + + var apiValue: String { + switch self { + case .low: + "low" + case .medium: + "medium" + case .high: + "high" + case .extraHigh: + "xhigh" + } + } +} diff --git a/Sources/CodexKit/Runtime/RequestRetryPolicy.swift b/Sources/CodexKit/Runtime/RequestRetryPolicy.swift new file mode 100644 index 0000000..70c1e35 --- /dev/null +++ b/Sources/CodexKit/Runtime/RequestRetryPolicy.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct RequestRetryPolicy: Sendable, Equatable { + public let maxAttempts: Int + public let initialBackoff: TimeInterval + public let maxBackoff: TimeInterval + public let jitterFactor: Double + public let retryableHTTPStatusCodes: Set + public let retryableURLErrorCodes: Set + + public init( + maxAttempts: Int = 3, + initialBackoff: TimeInterval = 0.5, + maxBackoff: TimeInterval = 4, + jitterFactor: Double = 0.2, + retryableHTTPStatusCodes: Set = [408, 409, 425, 429, 500, 502, 503, 504], + retryableURLErrorCodes: Set = [ + URLError.timedOut.rawValue, + URLError.cannotFindHost.rawValue, + URLError.cannotConnectToHost.rawValue, + URLError.networkConnectionLost.rawValue, + URLError.dnsLookupFailed.rawValue, + URLError.notConnectedToInternet.rawValue, + URLError.resourceUnavailable.rawValue, + URLError.cannotLoadFromNetwork.rawValue, + URLError.internationalRoamingOff.rawValue, + ] + ) { + self.maxAttempts = max(1, maxAttempts) + self.initialBackoff = max(0, initialBackoff) + self.maxBackoff = max(self.initialBackoff, maxBackoff) + self.jitterFactor = min(max(0, jitterFactor), 1) + self.retryableHTTPStatusCodes = retryableHTTPStatusCodes + self.retryableURLErrorCodes = retryableURLErrorCodes + } + + public static let `default` = RequestRetryPolicy() + public static let disabled = RequestRetryPolicy(maxAttempts: 1) +} + +extension RequestRetryPolicy { + func delayBeforeRetry(attempt: Int) -> TimeInterval { + let exponential = initialBackoff * pow(2, Double(max(0, attempt - 1))) + let capped = min(maxBackoff, exponential) + guard jitterFactor > 0 else { + return capped + } + + let jitterRange = capped * jitterFactor + let jittered = capped + Double.random(in: -jitterRange ... jitterRange) + return max(0, jittered) + } +} diff --git a/Tests/CodexKitTests/AgentRuntimeTests.swift b/Tests/CodexKitTests/AgentRuntimeTests.swift index 42d69d4..2547de5 100644 --- a/Tests/CodexKitTests/AgentRuntimeTests.swift +++ b/Tests/CodexKitTests/AgentRuntimeTests.swift @@ -395,6 +395,71 @@ final class AgentRuntimeTests: XCTestCase { XCTAssertEqual(messages.filter { $0.role == .assistant }.count, 1) } + func testSendMessageRetriesUnauthorizedByRefreshingSession() async throws { + let authProvider = RotatingDemoAuthProvider() + let backend = UnauthorizedThenSuccessBackend() + let runtime = try AgentRuntime(configuration: .init( + authProvider: authProvider, + secureStore: KeychainSessionSecureStore( + service: "CodexKitTests.ChatGPTSession", + account: UUID().uuidString + ), + backend: backend, + approvalPresenter: AutoApprovalPresenter(), + stateStore: InMemoryRuntimeStateStore() + )) + + _ = try await runtime.restore() + _ = try await runtime.signIn() + let thread = try await runtime.createThread() + + let stream = try await runtime.sendMessage( + UserMessageRequest(text: "Hello"), + in: thread.id + ) + for try await _ in stream {} + + let refreshCount = await authProvider.refreshCount() + XCTAssertEqual(refreshCount, 1) + + let attemptedTokens = await backend.attemptedAccessTokens() + XCTAssertEqual(attemptedTokens.count, 2) + XCTAssertEqual(attemptedTokens[0], "demo-access-token-initial") + XCTAssertEqual(attemptedTokens[1], "demo-access-token-refreshed-1") + + let messages = await runtime.messages(for: thread.id) + XCTAssertEqual(messages.filter { $0.role == .assistant }.count, 1) + } + + func testCreateThreadRetriesUnauthorizedByRefreshingSession() async throws { + let authProvider = RotatingDemoAuthProvider() + let backend = UnauthorizedOnCreateThenSuccessBackend() + let runtime = try AgentRuntime(configuration: .init( + authProvider: authProvider, + secureStore: KeychainSessionSecureStore( + service: "CodexKitTests.ChatGPTSession", + account: UUID().uuidString + ), + backend: backend, + approvalPresenter: AutoApprovalPresenter(), + stateStore: InMemoryRuntimeStateStore() + )) + + _ = try await runtime.restore() + _ = try await runtime.signIn() + + let thread = try await runtime.createThread(title: "Recovered Thread") + XCTAssertEqual(thread.title, "Recovered Thread") + + let refreshCount = await authProvider.refreshCount() + XCTAssertEqual(refreshCount, 1) + + let attemptedTokens = await backend.attemptedAccessTokens() + XCTAssertEqual(attemptedTokens.count, 2) + XCTAssertEqual(attemptedTokens[0], "demo-access-token-initial") + XCTAssertEqual(attemptedTokens[1], "demo-access-token-refreshed-1") + } + func testConfigurationRegistersInitialTools() async throws { let runtime = try AgentRuntime(configuration: .init( authProvider: DemoChatGPTAuthProvider(), @@ -442,6 +507,119 @@ final class AgentRuntimeTests: XCTestCase { } } +private actor RotatingDemoAuthProvider: ChatGPTAuthProviding { + private var refreshInvocationCount = 0 + + func signInInteractively() async throws -> ChatGPTSession { + ChatGPTSession( + accessToken: "demo-access-token-initial", + refreshToken: "demo-refresh-token", + account: ChatGPTAccount( + id: "demo-account", + email: "demo@example.com", + plan: .plus + ), + acquiredAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + isExternallyManaged: true + ) + } + + func refresh( + session: ChatGPTSession, + reason _: ChatGPTAuthRefreshReason + ) async throws -> ChatGPTSession { + refreshInvocationCount += 1 + var refreshed = session + refreshed.accessToken = "demo-access-token-refreshed-\(refreshInvocationCount)" + refreshed.acquiredAt = Date() + refreshed.expiresAt = Date().addingTimeInterval(3600) + return refreshed + } + + func signOut(session _: ChatGPTSession?) async {} + + func refreshCount() -> Int { + refreshInvocationCount + } +} + +private actor UnauthorizedThenSuccessBackend: AgentBackend { + private var didThrowUnauthorized = false + private var accessTokensByAttempt: [String] = [] + + func createThread(session _: ChatGPTSession) async throws -> AgentThread { + AgentThread(id: UUID().uuidString) + } + + func resumeThread(id: String, session _: ChatGPTSession) async throws -> AgentThread { + AgentThread(id: id) + } + + func beginTurn( + thread: AgentThread, + history _: [AgentMessage], + message: UserMessageRequest, + instructions _: String, + tools _: [ToolDefinition], + session: ChatGPTSession + ) async throws -> any AgentTurnStreaming { + accessTokensByAttempt.append(session.accessToken) + if !didThrowUnauthorized { + didThrowUnauthorized = true + throw AgentRuntimeError.unauthorized("Simulated unauthorized") + } + + return MockAgentTurnSession( + thread: thread, + message: message, + selectedTool: nil + ) + } + + func attemptedAccessTokens() -> [String] { + accessTokensByAttempt + } +} + +private actor UnauthorizedOnCreateThenSuccessBackend: AgentBackend { + private var didThrowUnauthorized = false + private var accessTokensByAttempt: [String] = [] + + func createThread(session: ChatGPTSession) async throws -> AgentThread { + accessTokensByAttempt.append(session.accessToken) + if !didThrowUnauthorized { + didThrowUnauthorized = true + throw AgentRuntimeError.unauthorized("Simulated unauthorized during createThread") + } + + return AgentThread(id: UUID().uuidString) + } + + func resumeThread(id: String, session _: ChatGPTSession) async throws -> AgentThread { + AgentThread(id: id) + } + + func beginTurn( + thread: AgentThread, + history _: [AgentMessage], + message _: UserMessageRequest, + instructions _: String, + tools _: [ToolDefinition], + session _: ChatGPTSession + ) async throws -> any AgentTurnStreaming { + MockAgentTurnSession( + thread: thread, + message: .init(text: ""), + selectedTool: nil + ) + } + + func attemptedAccessTokens() -> [String] { + accessTokensByAttempt + } +} + private actor ImageReplyAgentBackend: AgentBackend { func createThread(session _: ChatGPTSession) async throws -> AgentThread { AgentThread(id: UUID().uuidString) diff --git a/Tests/CodexKitTests/CodexResponsesBackendTests.swift b/Tests/CodexKitTests/CodexResponsesBackendTests.swift index a3bbee7..1b38749 100644 --- a/Tests/CodexKitTests/CodexResponsesBackendTests.swift +++ b/Tests/CodexKitTests/CodexResponsesBackendTests.swift @@ -43,6 +43,10 @@ final class CodexResponsesBackendTests: XCTestCase { XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-token") XCTAssertEqual(request.value(forHTTPHeaderField: "ChatGPT-Account-ID"), "workspace-123") XCTAssertEqual(request.value(forHTTPHeaderField: "originator"), "codex_cli_rs") + let body = try XCTUnwrap(requestBodyData(for: request)) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let reasoning = try XCTUnwrap(json?["reasoning"] as? [String: Any]) + XCTAssertEqual(reasoning["effort"] as? String, "medium") } ) ) @@ -80,6 +84,54 @@ final class CodexResponsesBackendTests: XCTestCase { XCTAssertEqual(summary?.usage?.outputTokens, 4) } + func testBackendEncodesConfiguredReasoningEffort() async throws { + let backend = CodexResponsesBackend( + configuration: CodexResponsesBackendConfiguration( + model: "gpt-5.4", + reasoningEffort: .extraHigh + ), + urlSession: makeTestURLSession() + ) + let session = ChatGPTSession( + accessToken: "access-token", + refreshToken: "refresh-token", + account: ChatGPTAccount(id: "workspace-123", email: "taylor@example.com", plan: .plus) + ) + + await TestURLProtocol.enqueue( + .init( + headers: ["Content-Type": "text/event-stream"], + body: Data( + """ + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Ready"}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_effort","usage":{"input_tokens":4,"input_tokens_details":{"cached_tokens":0},"output_tokens":1}}} + + """.utf8 + ), + inspect: { request in + let body = try XCTUnwrap(requestBodyData(for: request)) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let reasoning = try XCTUnwrap(json?["reasoning"] as? [String: Any]) + XCTAssertEqual(reasoning["effort"] as? String, "xhigh") + } + ) + ) + + let turnStream = try await backend.beginTurn( + thread: AgentThread(id: "thread-effort"), + history: [], + message: UserMessageRequest(text: "Think hard"), + instructions: "Resolved instructions", + tools: [], + session: session + ) + + for try await _ in turnStream.events {} + } + func testBackendContinuesTurnAfterToolOutput() async throws { let backend = CodexResponsesBackend(urlSession: makeTestURLSession()) let session = ChatGPTSession( @@ -398,4 +450,184 @@ final class CodexResponsesBackendTests: XCTestCase { XCTAssertEqual(assistantMessage?.images.first?.data, pngBytes) } + func testBackendRetriesTransientStatusCodeWithBackoffPolicy() async throws { + let backend = CodexResponsesBackend( + configuration: CodexResponsesBackendConfiguration( + requestRetryPolicy: .init( + maxAttempts: 2, + initialBackoff: 0, + maxBackoff: 0, + jitterFactor: 0 + ) + ), + urlSession: makeTestURLSession() + ) + let session = ChatGPTSession( + accessToken: "access-token", + refreshToken: "refresh-token", + account: ChatGPTAccount(id: "workspace-123", email: "taylor@example.com", plan: .plus) + ) + + await TestURLProtocol.enqueue( + .init( + statusCode: 503, + headers: ["Content-Type": "application/json"], + body: Data(#"{"error":"upstream overloaded"}"#.utf8) + ) + ) + + await TestURLProtocol.enqueue( + .init( + headers: ["Content-Type": "text/event-stream"], + body: Data( + """ + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Recovered"}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_retry","usage":{"input_tokens":5,"input_tokens_details":{"cached_tokens":0},"output_tokens":2}}} + + """.utf8 + ) + ) + ) + + let turnStream = try await backend.beginTurn( + thread: AgentThread(id: "thread-retry"), + history: [], + message: UserMessageRequest(text: "Hi"), + instructions: "Resolved instructions", + tools: [], + session: session + ) + + var assistantMessage: AgentMessage? + for try await event in turnStream.events { + if case let .assistantMessageCompleted(message) = event { + assistantMessage = message + } + } + + XCTAssertEqual(assistantMessage?.text, "Recovered") + } + + func testBackendDoesNotRetryNonRetryableStatusCode() async throws { + let backend = CodexResponsesBackend( + configuration: CodexResponsesBackendConfiguration( + requestRetryPolicy: .init( + maxAttempts: 3, + initialBackoff: 0, + maxBackoff: 0, + jitterFactor: 0 + ) + ), + urlSession: makeTestURLSession() + ) + let session = ChatGPTSession( + accessToken: "access-token", + refreshToken: "refresh-token", + account: ChatGPTAccount(id: "workspace-123", email: "taylor@example.com", plan: .plus) + ) + + await TestURLProtocol.enqueue( + .init( + statusCode: 400, + headers: ["Content-Type": "application/json"], + body: Data(#"{"error":"bad request"}"#.utf8) + ) + ) + await TestURLProtocol.enqueue( + .init( + headers: ["Content-Type": "text/event-stream"], + body: Data(), + inspect: { _ in + XCTFail("Non-retryable 400 should not trigger a retry.") + } + ) + ) + + let turnStream = try await backend.beginTurn( + thread: AgentThread(id: "thread-no-retry"), + history: [], + message: UserMessageRequest(text: "Hi"), + instructions: "Resolved instructions", + tools: [], + session: session + ) + + await XCTAssertThrowsErrorAsync(try await drainEvents(turnStream.events)) { error in + XCTAssertEqual( + error as? AgentRuntimeError, + AgentRuntimeError( + code: "responses_http_status_400", + message: "The ChatGPT responses request failed with status 400: {\"error\":\"bad request\"}" + ) + ) + } + } + + func testBackendRetriesWhenNetworkConnectionIsLostBeforeOutput() async throws { + let backend = CodexResponsesBackend( + configuration: CodexResponsesBackendConfiguration( + requestRetryPolicy: .init( + maxAttempts: 2, + initialBackoff: 0, + maxBackoff: 0, + jitterFactor: 0 + ) + ), + urlSession: makeTestURLSession() + ) + let session = ChatGPTSession( + accessToken: "access-token", + refreshToken: "refresh-token", + account: ChatGPTAccount(id: "workspace-123", email: "taylor@example.com", plan: .plus) + ) + + await TestURLProtocol.enqueue( + .init( + body: Data(), + error: URLError(.networkConnectionLost) + ) + ) + + await TestURLProtocol.enqueue( + .init( + headers: ["Content-Type": "text/event-stream"], + body: Data( + """ + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Recovered after network loss"}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_network_retry","usage":{"input_tokens":5,"input_tokens_details":{"cached_tokens":0},"output_tokens":2}}} + + """.utf8 + ) + ) + ) + + let turnStream = try await backend.beginTurn( + thread: AgentThread(id: "thread-network-retry"), + history: [], + message: UserMessageRequest(text: "Retry me"), + instructions: "Resolved instructions", + tools: [], + session: session + ) + + var assistantMessage: AgentMessage? + for try await event in turnStream.events { + if case let .assistantMessageCompleted(message) = event { + assistantMessage = message + } + } + + XCTAssertEqual(assistantMessage?.text, "Recovered after network loss") + } + +} + +private func drainEvents(_ events: AsyncThrowingStream) async throws { + for try await _ in events {} } diff --git a/Tests/CodexKitTests/TestSupport.swift b/Tests/CodexKitTests/TestSupport.swift index 49bb743..52f61db 100644 --- a/Tests/CodexKitTests/TestSupport.swift +++ b/Tests/CodexKitTests/TestSupport.swift @@ -7,17 +7,20 @@ final class TestURLProtocol: URLProtocol, @unchecked Sendable { let statusCode: Int let headers: [String: String] let body: Data + let error: Error? let inspect: @Sendable (URLRequest) throws -> Void init( statusCode: Int = 200, headers: [String: String] = [:], body: Data, + error: Error? = nil, inspect: @escaping @Sendable (URLRequest) throws -> Void = { _ in } ) { self.statusCode = statusCode self.headers = headers self.body = body + self.error = error self.inspect = inspect } } @@ -68,6 +71,11 @@ final class TestURLProtocol: URLProtocol, @unchecked Sendable { let stub = try await Self.store.dequeue() try stub.inspect(request) + if let error = stub.error { + client?.urlProtocol(self, didFailWithError: error) + return + } + let response = HTTPURLResponse( url: request.url ?? URL(string: "https://example.com")!, statusCode: stub.statusCode, From cbcde0b9df5cf916d20d2accfffc9cf63450b76b Mon Sep 17 00:00:00 2001 From: Timothy Zelinsky Date: Sat, 21 Mar 2026 20:13:57 +1100 Subject: [PATCH 2/2] Fix sendable constraint for unauthorized retry helper --- Sources/CodexKit/Runtime/AgentRuntime.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexKit/Runtime/AgentRuntime.swift b/Sources/CodexKit/Runtime/AgentRuntime.swift index 477fafa..dea445d 100644 --- a/Sources/CodexKit/Runtime/AgentRuntime.swift +++ b/Sources/CodexKit/Runtime/AgentRuntime.swift @@ -685,7 +685,7 @@ public actor AgentRuntime { (error as? AgentRuntimeError)?.code == AgentRuntimeError.unauthorized().code } - private func withUnauthorizedRecovery( + private func withUnauthorizedRecovery( initialSession: ChatGPTSession, operation: (ChatGPTSession) async throws -> Result ) async throws -> (