diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7438d4d5..cf3425e4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ permissions: env: # Bump to invalidate every cache entry without source surgery (e.g., after a # known-bad cache or an Xcode toolchain upgrade we want to flush manually). - CACHE_SALT: v2-vmlx-5b84387 + CACHE_SALT: v3-pr-cold-deriveddata # Pin Xcode so cache keys are stable across runner image bumps. When you # need to upgrade, change here AND in setup-xcode below. XCODE_VERSION: "26.4.1" diff --git a/Packages/OsaurusCore/Models/API/OpenAIAPI.swift b/Packages/OsaurusCore/Models/API/OpenAIAPI.swift index 99a63447f..85abdfb37 100644 --- a/Packages/OsaurusCore/Models/API/OpenAIAPI.swift +++ b/Packages/OsaurusCore/Models/API/OpenAIAPI.swift @@ -15,15 +15,15 @@ struct OpenAIModel: Codable, Sendable { var object: String = "model" var created: Int = 0 var owned_by: String = "osaurus" - var permission: [ModelPermission]? = nil - var root: String? = nil - var parent: String? = nil - var name: String? = nil - var model: String? = nil - var modified_at: String? = nil - var size: Int? = nil - var digest: String? = nil - var details: ModelDetails? = nil + var permission: [ModelPermission]? + var root: String? + var parent: String? + var name: String? + var model: String? + var modified_at: String? + var size: Int? + var digest: String? + var details: ModelDetails? /// Initialize from a model name (for local models) init(modelName: String) { @@ -89,7 +89,8 @@ struct OpenAIModel: Codable, Sendable { } } -/// Model permission object (OpenAI format) +// Model permission object. Permission booleans mirror provider JSON where omitted and false are not equivalent. +// swiftlint:disable discouraged_optional_boolean struct ModelPermission: Codable, Sendable { var id: String? var object: String? @@ -104,6 +105,7 @@ struct ModelPermission: Codable, Sendable { var group: String? var is_blocking: Bool? } +// swiftlint:enable discouraged_optional_boolean struct ModelDetails: Codable, Sendable { let parent_model: String? @@ -348,14 +350,14 @@ extension ChatMessage { // otherwise as string. Round-trip preserves audio/video/image parts // so a request that came in with `input_audio` or `video_url` is // re-serialized in the same shape. - if let parts = contentParts, - parts.contains(where: { + let shouldEncodeContentParts = + contentParts?.contains { switch $0 { case .imageUrl, .audioInput, .videoUrl: return true case .text: return false } - }) - { + } == true + if shouldEncodeContentParts, let parts = contentParts { try container.encode(parts, forKey: .content) } else if let content = content { // Only encode content if it's not nil (OpenAI rejects null content) @@ -482,7 +484,9 @@ struct ChatCompletionRequest: Codable, Sendable { let temperature: Float? let max_tokens: Int? /// OpenAI newer alias for max_tokens; accepted on inbound requests alongside max_tokens. - var max_completion_tokens: Int? = nil + var max_completion_tokens: Int? + // Omission carries provider-default semantics that differ from explicitly sending false. + // swiftlint:disable:next discouraged_optional_boolean let stream: Bool? let top_p: Float? let frequency_penalty: Float? @@ -496,20 +500,20 @@ struct ChatCompletionRequest: Codable, Sendable { /// Optional session identifier for chat/history grouping. Not a KV cache key — /// vmlx-swift-lm's `CacheCoordinator` is content-addressed and discovers /// reusable prefixes autonomously. - var session_id: String? = nil + var session_id: String? /// Deterministic-sampling seed (OpenAI v1.x). When set, identical /// requests should yield identical completions on the same backend. - var seed: Int? = nil + var seed: Int? /// `{"type":"json_object"}` for OpenAI JSON mode. Other shapes /// (`text`, `json_schema`) are rejected at request validation. - var response_format: ResponseFormat? = nil + var response_format: ResponseFormat? /// `{"include_usage": true}` instructs the SSE producer to emit a /// final chunk carrying `usage` (prompt/completion/total tokens). - var stream_options: StreamOptions? = nil + var stream_options: StreamOptions? /// Model-specific options from the active ModelProfile (not serialized to JSON). - var modelOptions: [String: ModelOptionValue]? = nil + var modelOptions: [String: ModelOptionValue]? /// Optional TTFT trace for diagnostic timing (not serialized to JSON). - var ttftTrace: TTFTTrace? = nil + var ttftTrace: TTFTTrace? /// Resolved max tokens, preferring max_tokens then max_completion_tokens. var resolvedMaxTokens: Int? { max_tokens ?? max_completion_tokens } @@ -554,6 +558,8 @@ struct ResponseFormat: Codable, Sendable, Equatable { /// OpenAI `stream_options` shape. Today we only honor `include_usage`. struct StreamOptions: Codable, Sendable, Equatable { + // Omission carries provider-default semantics that differ from explicitly sending false. + // swiftlint:disable:next discouraged_optional_boolean let include_usage: Bool? } @@ -579,12 +585,12 @@ struct ChatCompletionResponse: Codable, Sendable { let model: String let choices: [ChatChoice] let usage: Usage - var system_fingerprint: String? = nil + var system_fingerprint: String? /// Content hash of the system prompt + tool names used for this request. /// Informational only — clients can use it to detect when the system /// prefix changed across requests. KV reuse itself is handled /// autonomously by vmlx's `CacheCoordinator` (content-addressed). - var prefix_hash: String? = nil + var prefix_hash: String? } // MARK: - Streaming Response Structures @@ -631,12 +637,12 @@ struct ChatCompletionChunk: Codable, Sendable { let created: Int let model: String let choices: [StreamChoice] - var system_fingerprint: String? = nil + var system_fingerprint: String? /// Included only in the first chunk; see `ChatCompletionResponse.prefix_hash`. - var prefix_hash: String? = nil + var prefix_hash: String? /// Final usage chunk (OpenAI `stream_options.include_usage`). Populated /// only on the dedicated penultimate SSE chunk; nil on every other. - var usage: Usage? = nil + var usage: Usage? } // MARK: - Error Response @@ -846,13 +852,12 @@ public enum JSONValue: Codable, Sendable, Equatable { extension JSONValue { /// Convert JSONValue to Sendable-compatible value for Jinja chat templates. - /// Null values are dropped from dictionaries because Jinja's `Value(any:)` cannot - /// handle `NSNull` and throws a runtime error. JSON Schema treats a missing key - /// the same as `null`, so this is semantically lossless for tool specs. - var sendableValue: any Sendable { + /// Null values are dropped because Jinja's `Value(any:)` cannot handle + /// null/optional placeholders inside erased Swift containers. + var sendableValue: (any Sendable)? { switch self { case .null: - return NSNull() + return nil case .bool(let b): return b case .number(let n): @@ -860,12 +865,13 @@ extension JSONValue { case .string(let s): return s case .array(let arr): - return arr.map { $0.sendableValue } + return arr.compactMap { $0.sendableValue } case .object(let obj): var dict: [String: any Sendable] = [:] for (k, v) in obj { - if case .null = v { continue } - dict[k] = v.sendableValue + if let converted = v.sendableValue { + dict[k] = converted + } } return dict } @@ -903,8 +909,8 @@ extension ToolFunction { if let description { fn["description"] = description } - if let parameters { - fn["parameters"] = parameters.sendableValue + if let parameters, let converted = parameters.sendableValue { + fn["parameters"] = converted } return fn } diff --git a/Packages/OsaurusCore/Models/API/OpenResponsesAPI.swift b/Packages/OsaurusCore/Models/API/OpenResponsesAPI.swift index 19d1b8ca0..99fdf7cb9 100644 --- a/Packages/OsaurusCore/Models/API/OpenResponsesAPI.swift +++ b/Packages/OsaurusCore/Models/API/OpenResponsesAPI.swift @@ -21,8 +21,11 @@ public struct OpenResponsesRequest: Codable, Sendable { public let model: String /// Input content - can be a string or array of input items public let input: OpenResponsesInput + // Omission carries provider-default semantics that differ from explicitly sending false. + // swiftlint:disable discouraged_optional_boolean /// Whether to stream the response public let stream: Bool? + // swiftlint:enable discouraged_optional_boolean /// Available tools for the model to use public let tools: [OpenResponsesTool]? /// Tool choice configuration @@ -91,7 +94,7 @@ public enum OpenResponsesInputItem: Codable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) + let type = try container.decodeIfPresent(String.self, forKey: .type) ?? "message" switch type { case "message": @@ -132,6 +135,19 @@ public struct OpenResponsesMessageItem: Codable, Sendable { self.role = role self.content = content } + + private enum CodingKeys: String, CodingKey { + case type + case role + case content + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? "message" + self.role = try container.decode(String.self, forKey: .role) + self.content = try container.decode(OpenResponsesMessageContent.self, forKey: .content) + } } /// Message content can be string or array of content parts @@ -286,9 +302,7 @@ public enum OpenResponsesToolChoice: Codable, Sendable { public init(from decoder: Decoder) throws { // Try decoding as string first - if let container = try? decoder.singleValueContainer(), - let str = try? container.decode(String.self) - { + if let str = try? decoder.singleValueContainer().decode(String.self) { switch str { case "auto": self = .auto case "none": self = .none @@ -843,7 +857,7 @@ extension OpenResponsesRequest { } // Convert tools - var openAITools: [Tool]? = nil + var openAITools: [Tool]? if let tools = tools { openAITools = tools.map { tool in Tool( @@ -858,7 +872,7 @@ extension OpenResponsesRequest { } // Convert tool choice - var openAIToolChoice: ToolChoiceOption? = nil + var openAIToolChoice: ToolChoiceOption? if let choice = tool_choice { switch choice { case .auto: diff --git a/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift b/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift index 2512fa539..e8644f5cb 100644 --- a/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift +++ b/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift @@ -72,6 +72,11 @@ enum ChatSessionStore { print("[ChatSessionStore] Failed to open chat-history database: \(error)") return } + #if DEBUG + if RuntimeEnvironment.isUnderTests, OsaurusPaths.overrideRoot == nil { + return + } + #endif LegacySessionImporter.runIfNeeded() } diff --git a/Packages/OsaurusCore/Services/Chat/ChatEngine.swift b/Packages/OsaurusCore/Services/Chat/ChatEngine.swift index 796ac0371..dbcd07ea1 100644 --- a/Packages/OsaurusCore/Services/Chat/ChatEngine.swift +++ b/Packages/OsaurusCore/Services/Chat/ChatEngine.swift @@ -7,7 +7,7 @@ import Foundation -actor ChatEngine: Sendable, ChatEngineProtocol { +actor ChatEngine: ChatEngineProtocol { private let services: [ModelService] private let installedModelsProvider: @Sendable () -> [String] @@ -177,7 +177,8 @@ actor ChatEngine: Sendable, ChatEngineProtocol { role: "assistant", content: nil, tool_calls: toolCalls, - tool_call_id: nil + tool_call_id: nil, + reasoning_content: invocations.compactMap(\.reasoningContent).first ) let choice = ChatChoice(index: 0, message: assistant, finish_reason: "tool_calls") let usage = Usage(prompt_tokens: inputTokens, completion_tokens: 0, total_tokens: inputTokens) @@ -311,8 +312,8 @@ actor ChatEngine: Sendable, ChatEngineProtocol { var outputTokenCount = 0 var deltaCount = 0 var finishReason: InferenceLog.FinishReason = .stop - var errorMsg: String? = nil - var toolInvocation: (name: String, args: String)? = nil + var errorMsg: String? + var toolInvocation: (name: String, args: String)? var lastDeltaTime = startTime print("[Osaurus][Stream] Starting stream wrapper for model: \(model)") @@ -385,7 +386,7 @@ actor ChatEngine: Sendable, ChatEngineProtocol { // Log the completed inference (only for Chat UI - HTTP requests are logged by HTTPHandler) if source == .chatUI { let durationMs = Date().timeIntervalSince(startTime) * 1000 - var toolCalls: [ToolCallLog]? = nil + var toolCalls: [ToolCallLog]? if let (name, args) = toolInvocation { toolCalls = [ToolCallLog(name: name, arguments: args)] } diff --git a/Packages/OsaurusCore/Services/Inference/ModelService.swift b/Packages/OsaurusCore/Services/Inference/ModelService.swift index a6591b905..fce10c181 100644 --- a/Packages/OsaurusCore/Services/Inference/ModelService.swift +++ b/Packages/OsaurusCore/Services/Inference/ModelService.swift @@ -77,12 +77,22 @@ struct ServiceToolInvocation: Error, Sendable { let toolCallId: String? /// Optional thought signature for Gemini thinking-mode models (e.g. Gemini 2.5) let geminiThoughtSignature: String? + /// Provider reasoning text that must be echoed on assistant tool-call + /// messages for APIs such as DeepSeek thinking mode. + let reasoningContent: String? - init(toolName: String, jsonArguments: String, toolCallId: String? = nil, geminiThoughtSignature: String? = nil) { + init( + toolName: String, + jsonArguments: String, + toolCallId: String? = nil, + geminiThoughtSignature: String? = nil, + reasoningContent: String? = nil + ) { self.toolName = toolName self.jsonArguments = jsonArguments self.toolCallId = toolCallId self.geminiThoughtSignature = geminiThoughtSignature + self.reasoningContent = reasoningContent } } @@ -122,7 +132,7 @@ public enum StreamingToolHint: Sendable { struct Payload: Encodable { let id, name, arguments, result: String } let json = (try? JSONEncoder().encode(Payload(id: callId, name: name, arguments: arguments, result: result))) - .map { String(decoding: $0, as: UTF8.self) } ?? "{}" + .flatMap { String(bytes: $0, encoding: .utf8) } ?? "{}" return donePrefix + json } diff --git a/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift b/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift index f0dabe4ae..1d0313b9a 100644 --- a/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift +++ b/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift @@ -234,7 +234,7 @@ public actor RemoteProviderService: ToolCapableService { configuredProviderType: provider.providerType, request: request ) - let (content, _) = try parseResponse(data, providerType: responseProviderType) + let (content, _, _) = try parseResponse(data, providerType: responseProviderType) return content ?? "" } @@ -323,7 +323,7 @@ public actor RemoteProviderService: ToolCapableService { configuredProviderType: provider.providerType, request: request ) - let (content, toolCalls) = try parseResponse(data, providerType: responseProviderType) + let (content, toolCalls, reasoningContent) = try parseResponse(data, providerType: responseProviderType) // Check for tool calls if let toolCalls = toolCalls, let firstCall = toolCalls.first { @@ -331,7 +331,8 @@ public actor RemoteProviderService: ToolCapableService { toolName: firstCall.function.name, jsonArguments: firstCall.function.arguments, toolCallId: firstCall.id, - geminiThoughtSignature: firstCall.geminiThoughtSignature + geminiThoughtSignature: firstCall.geminiThoughtSignature, + reasoningContent: reasoningContent ) } @@ -669,6 +670,9 @@ public actor RemoteProviderService: ToolCapableService { /// Yielded text content. Only used when `trackContent` is `true` /// (streamWithTools, for the inline tool-call detection fallback). var accumulatedContent: String = "" + /// Reasoning content streamed before a tool call. DeepSeek requires + /// callers to echo this on the assistant tool-call message. + var accumulatedReasoningContent: String = "" let stopSequences: [String] let trackContent: Bool @@ -708,9 +712,15 @@ public actor RemoteProviderService: ToolCapableService { /// a successful call to lock into history. static func resolveAccumulatedToolCall( from accumulated: [Int: StreamingState.ToolSlot], + reasoningContent: String, finishMarker: String ) -> AccumulatedToolCallResult { - guard let (invocation, wasRepaired) = makeToolInvocation(from: accumulated) else { + guard + let (invocation, wasRepaired) = makeToolInvocation( + from: accumulated, + reasoningContent: reasoningContent + ) + else { return .none } if wasRepaired { @@ -881,6 +891,7 @@ public actor RemoteProviderService: ToolCapableService { if finishReason == "STOP" || finishReason == "MAX_TOKENS" { switch resolveAccumulatedToolCall( from: state.accumulatedToolCalls, + reasoningContent: state.accumulatedReasoningContent, finishMarker: "gemini=\(finishReason)" ) { case .none: return .finishNormal @@ -938,15 +949,15 @@ public actor RemoteProviderService: ToolCapableService { } case "message_delta": - if let deltaEvent = try? JSONDecoder().decode(MessageDeltaEvent.self, from: jsonData), - let stopReason = deltaEvent.delta.stop_reason - { + let deltaEvent = try? JSONDecoder().decode(MessageDeltaEvent.self, from: jsonData) + if let stopReason = deltaEvent?.delta.stop_reason { state.lastFinishReason = stopReason } case "message_stop": switch resolveAccumulatedToolCall( from: state.accumulatedToolCalls, + reasoningContent: state.accumulatedReasoningContent, finishMarker: "anthropic message_stop" ) { case .none: return .finishNormal @@ -982,15 +993,15 @@ public actor RemoteProviderService: ToolCapableService { } case "response.output_item.added": - if let addedEvent = try? JSONDecoder().decode(OutputItemAddedEvent.self, from: jsonData), - case .functionCall(let funcCall) = addedEvent.item - { - let idx = addedEvent.output_index - state.accumulatedToolCalls[idx] = ( - id: funcCall.call_id, name: funcCall.name, args: "", thoughtSignature: nil - ) - print("[Osaurus] Open Responses tool call detected: index=\(idx), name=\(funcCall.name)") - yield(StreamingToolHint.encode(funcCall.name)) + if let addedEvent = try? JSONDecoder().decode(OutputItemAddedEvent.self, from: jsonData) { + if case .functionCall(let funcCall) = addedEvent.item { + let idx = addedEvent.output_index + state.accumulatedToolCalls[idx] = ( + id: funcCall.call_id, name: funcCall.name, args: "", thoughtSignature: nil + ) + print("[Osaurus] Open Responses tool call detected: index=\(idx), name=\(funcCall.name)") + yield(StreamingToolHint.encode(funcCall.name)) + } } case "response.function_call_arguments.delta": @@ -1026,22 +1037,23 @@ public actor RemoteProviderService: ToolCapableService { case "response.output_item.done": // Final confirmed item — extract args from the completed function_call // when no `.delta` events landed first (common for short calls). - if let doneEvent = try? JSONDecoder().decode(OutputItemDoneEvent.self, from: jsonData), - case .functionCall(let funcCall) = doneEvent.item - { - let idx = doneEvent.output_index - var current = - state.accumulatedToolCalls[idx] ?? ( - id: funcCall.call_id, name: funcCall.name, args: "", thoughtSignature: nil - ) - if current.args.isEmpty { current.args = funcCall.arguments } - state.accumulatedToolCalls[idx] = current + if let doneEvent = try? JSONDecoder().decode(OutputItemDoneEvent.self, from: jsonData) { + if case .functionCall(let funcCall) = doneEvent.item { + let idx = doneEvent.output_index + var current = + state.accumulatedToolCalls[idx] ?? ( + id: funcCall.call_id, name: funcCall.name, args: "", thoughtSignature: nil + ) + if current.args.isEmpty { current.args = funcCall.arguments } + state.accumulatedToolCalls[idx] = current + } } case "response.completed": state.lastFinishReason = "completed" switch resolveAccumulatedToolCall( from: state.accumulatedToolCalls, + reasoningContent: state.accumulatedReasoningContent, finishMarker: "response.completed" ) { case .none: return .finishNormal @@ -1097,18 +1109,17 @@ public actor RemoteProviderService: ToolCapableService { // (DeepSeek, Qwen, Together, vLLM). Forwarded as a sentinel so the // SSE layer routes it onto `reasoning_content` and ChatView places // it in the Think panel — without ever emitting `` literals. - if state.accumulatedToolCalls.isEmpty, - let reasoning = chunk.choices.first?.delta.reasoning_content, - !reasoning.isEmpty - { + let reasoning = chunk.choices.first?.delta.reasoning_content + if let reasoning = reasoning, !reasoning.isEmpty { + state.accumulatedReasoningContent += reasoning + guard state.accumulatedToolCalls.isEmpty else { return .continue } yield(StreamingReasoningHint.encode(reasoning)) } // Only yield content if no tool calls have been detected, to avoid // function-call JSON leaking into the chat UI. - if state.accumulatedToolCalls.isEmpty, - let delta = chunk.choices.first?.delta.content, !delta.isEmpty - { + let delta = chunk.choices.first?.delta.content + if state.accumulatedToolCalls.isEmpty, let delta = delta, !delta.isEmpty { let (truncated, hitStop) = applyStopSequences(delta, stopSequences: state.stopSequences) state.recordYield(truncated) yield(truncated) @@ -1120,6 +1131,7 @@ public actor RemoteProviderService: ToolCapableService { state.lastFinishReason = finishReason switch resolveAccumulatedToolCall( from: state.accumulatedToolCalls, + reasoningContent: state.accumulatedReasoningContent, finishMarker: "finish_reason=\(finishReason)" ) { case .none: break @@ -1143,6 +1155,7 @@ public actor RemoteProviderService: ToolCapableService { ) { switch resolveAccumulatedToolCall( from: state.accumulatedToolCalls, + reasoningContent: state.accumulatedReasoningContent, finishMarker: finishMarker ) { case .ready(let invocation): @@ -1157,12 +1170,11 @@ public actor RemoteProviderService: ToolCapableService { case .none: // Llama-style fallback: search yielded text for an inline tool call. - if state.trackContent, !state.accumulatedContent.isEmpty, !tools.isEmpty, - let (name, args) = RemoteToolDetection.detectInlineToolCall( - in: state.accumulatedContent, - tools: tools - ) - { + let shouldDetectInlineCall = state.trackContent && !state.accumulatedContent.isEmpty && !tools.isEmpty + let inlineToolCall = + shouldDetectInlineCall + ? RemoteToolDetection.detectInlineToolCall(in: state.accumulatedContent, tools: tools) : nil + if let (name, args) = inlineToolCall { print("[Osaurus] Fallback: detected inline tool call '\(name)' in text") continuation.finish( throwing: ServiceToolInvocation( @@ -1339,10 +1351,8 @@ public actor RemoteProviderService: ToolCapableService { /// producers and the one-shot response parser). private static func geminiArgsJSON(from args: [String: AnyCodableValue]?) -> String { let dict = (args ?? [:]).mapValues { $0.value } - if let data = try? JSONSerialization.data(withJSONObject: dict), - let s = String(data: data, encoding: .utf8) - { - return s + if let data = try? JSONSerialization.data(withJSONObject: dict) { + return String(bytes: data, encoding: .utf8) ?? "{}" } return "{}" } @@ -1359,7 +1369,8 @@ public actor RemoteProviderService: ToolCapableService { /// malformed and had to be structurally closed — strong signal that the stream /// was truncated mid-argument, especially when no `finish_reason` was ever seen. private static func makeToolInvocation( - from accumulated: [Int: (id: String?, name: String?, args: String, thoughtSignature: String?)] + from accumulated: [Int: (id: String?, name: String?, args: String, thoughtSignature: String?)], + reasoningContent: String = "" ) -> (invocation: ServiceToolInvocation, wasRepaired: Bool)? { guard let first = accumulated.min(by: { $0.key < $1.key }), let name = first.value.name @@ -1371,7 +1382,8 @@ public actor RemoteProviderService: ToolCapableService { toolName: name, jsonArguments: validated.json, toolCallId: first.value.id, - geminiThoughtSignature: first.value.thoughtSignature + geminiThoughtSignature: first.value.thoughtSignature, + reasoningContent: reasoningContent.isEmpty ? nil : reasoningContent ), validated.wasRepaired ) @@ -1476,9 +1488,8 @@ public actor RemoteProviderService: ToolCapableService { guard !trimmed.isEmpty else { return ValidatedToolCallJSON(json: "{}", wasRepaired: false) } // Quick validation: try to parse as-is. - if let data = trimmed.data(using: .utf8), - (try? JSONSerialization.jsonObject(with: data)) != nil - { + let trimmedData = trimmed.data(using: .utf8) + if let data = trimmedData, (try? JSONSerialization.jsonObject(with: data)) != nil { return ValidatedToolCallJSON(json: trimmed, wasRepaired: false) } @@ -1548,9 +1559,8 @@ public actor RemoteProviderService: ToolCapableService { } // Verify the repair worked - if let data = repaired.data(using: .utf8), - (try? JSONSerialization.jsonObject(with: data)) != nil - { + let repairedData = repaired.data(using: .utf8) + if let data = repairedData, (try? JSONSerialization.jsonObject(with: data)) != nil { print("[Osaurus] Repaired incomplete tool call JSON (\(json.count) -> \(repaired.count) chars)") return ValidatedToolCallJSON(json: repaired, wasRepaired: true) } @@ -1904,7 +1914,7 @@ public actor RemoteProviderService: ToolCapableService { private func parseResponse( _ data: Data, providerType: RemoteProviderType - ) throws -> (content: String?, toolCalls: [ToolCall]?) { + ) throws -> (content: String?, toolCalls: [ToolCall]?, reasoningContent: String?) { switch providerType { case .anthropic: let response = try JSONDecoder().decode(AnthropicMessagesResponse.self, from: data) @@ -1928,13 +1938,14 @@ public actor RemoteProviderService: ToolCapableService { } } - return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls) + return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls, nil) case .openaiLegacy, .azureOpenAI: let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data) - let content = response.choices.first?.message.content - let toolCalls = response.choices.first?.message.tool_calls - return (content, toolCalls) + let message = response.choices.first?.message + let content = message?.content + let toolCalls = message?.tool_calls + return (content, toolCalls, message?.reasoning_content) case .openResponses, .openAICodex: let response = try JSONDecoder().decode(OpenResponsesResponse.self, from: data) @@ -1966,7 +1977,7 @@ public actor RemoteProviderService: ToolCapableService { } } - return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls) + return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls, nil) case .gemini: let response = try JSONDecoder().decode(GeminiGenerateContentResponse.self, from: data) @@ -2000,13 +2011,13 @@ public actor RemoteProviderService: ToolCapableService { } } - return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls) + return (textContent.isEmpty ? nil : textContent, toolCalls.isEmpty ? nil : toolCalls, nil) case .osaurus: // Native Osaurus agent returns OpenAI-compatible responses let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data) let content = response.choices.first?.message.content - return (content, nil) + return (content, nil, nil) } } @@ -2061,10 +2072,10 @@ public actor RemoteProviderService: ToolCapableService { } } - if let altRange = Range(match.range(at: 1), in: text), - let mimeRange = Range(match.range(at: 2), in: text), - let dataRange = Range(match.range(at: 3), in: text) - { + let altRange = Range(match.range(at: 1), in: text) + let mimeRange = Range(match.range(at: 2), in: text) + let dataRange = Range(match.range(at: 3), in: text) + if let altRange = altRange, let mimeRange = mimeRange, let dataRange = dataRange { let altText = String(text[altRange]) let sig: String? = altText.hasPrefix("image|ts:") @@ -2271,9 +2282,10 @@ struct RemoteChatRequest: Encodable { for toolCall in toolCalls { var input: [String: AnyCodableValue] = [:] - if let argsData = toolCall.function.arguments.data(using: .utf8), - let argsDict = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] - { + let argsObject = toolCall.function.arguments.data(using: .utf8).flatMap { + try? JSONSerialization.jsonObject(with: $0) + } + if let argsDict = argsObject as? [String: Any] { input = argsDict.mapValues { AnyCodableValue($0) } } @@ -2402,10 +2414,9 @@ struct RemoteChatRequest: Encodable { for part in parts { if case .imageUrl(let url, _) = part { // Parse data URLs: "data:;base64," - if url.hasPrefix("data:"), - let semicolonIdx = url.firstIndex(of: ";"), - let commaIdx = url.firstIndex(of: ",") - { + let semicolonIdx = url.firstIndex(of: ";") + let commaIdx = url.firstIndex(of: ",") + if url.hasPrefix("data:"), let semicolonIdx = semicolonIdx, let commaIdx = commaIdx { let mimeType = String(url[url.index(url.startIndex, offsetBy: 5) ..< semicolonIdx]) let base64Data = String(url[url.index(after: commaIdx)...]) userParts.append( @@ -2434,9 +2445,10 @@ struct RemoteChatRequest: Encodable { if let toolCalls = msg.tool_calls { for toolCall in toolCalls { var args: [String: AnyCodableValue] = [:] - if let argsData = toolCall.function.arguments.data(using: .utf8), - let argsDict = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] - { + let argsObject = toolCall.function.arguments.data(using: .utf8).flatMap { + try? JSONSerialization.jsonObject(with: $0) + } + if let argsDict = argsObject as? [String: Any] { args = argsDict.mapValues { AnyCodableValue($0) } } parts.append( @@ -2463,9 +2475,10 @@ struct RemoteChatRequest: Encodable { var responseData: [String: AnyCodableValue] = [:] // Try to parse the content as JSON first - if let data = content.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - { + let jsonObject = content.data(using: .utf8).flatMap { + try? JSONSerialization.jsonObject(with: $0) + } + if let json = jsonObject as? [String: Any] { responseData = json.mapValues { AnyCodableValue($0) } } else { responseData["result"] = AnyCodableValue(content) @@ -2538,9 +2551,10 @@ struct RemoteChatRequest: Encodable { }() var generationConfig: GeminiGenerationConfig? - if temperature != nil || max_completion_tokens != nil || top_p != nil || stop != nil + let hasGenerationConfig = + temperature != nil || max_completion_tokens != nil || top_p != nil || stop != nil || responseModalities != nil || imageConfig != nil - { + if hasGenerationConfig { generationConfig = GeminiGenerationConfig( temperature: temperature.map { Double($0) }, maxOutputTokens: max_completion_tokens, @@ -2786,19 +2800,21 @@ extension RemoteProviderService { req.setValue("application/json", forHTTPHeaderField: "Accept") req.timeoutInterval = min(provider.timeout, 10) for (key, value) in provider.resolvedHeaders() { req.setValue(value, forHTTPHeaderField: key) } - if let (data, response) = try? await URLSession.shared.data(for: req), - let http = response as? HTTPURLResponse, http.statusCode < 400, - let parsed = try? JSONDecoder().decode(ModelsResponse.self, from: data), - !parsed.data.isEmpty - { - return parsed.data.map { $0.id } + if let (data, response) = try? await URLSession.shared.data(for: req) { + let http = response as? HTTPURLResponse + let parsed = try? JSONDecoder().decode(ModelsResponse.self, from: data) + if (http?.statusCode ?? 500) < 400, let parsed = parsed, !parsed.data.isEmpty { + return parsed.data.map { $0.id } + } } } // Fallback: fetch the agent's configured default_model - guard let agentId = provider.remoteAgentId, - let url = provider.url(for: "/agents/\(agentId.uuidString)") - else { + guard let agentId = provider.remoteAgentId else { + return ["default"] + } + let agentPath = "/agents/\(agentId.uuidString)" + guard let url = provider.url(for: agentPath) else { return ["default"] } var req = URLRequest(url: url) diff --git a/Packages/OsaurusCore/Storage/ChatHistoryDatabase.swift b/Packages/OsaurusCore/Storage/ChatHistoryDatabase.swift index 75fe037ca..565695942 100644 --- a/Packages/OsaurusCore/Storage/ChatHistoryDatabase.swift +++ b/Packages/OsaurusCore/Storage/ChatHistoryDatabase.swift @@ -53,6 +53,13 @@ public final class ChatHistoryDatabase: @unchecked Sendable { // MARK: - Lifecycle public func open() throws { + #if DEBUG + if RuntimeEnvironment.isUnderTests, OsaurusPaths.overrideRoot == nil { + try openInMemory() + return + } + #endif + // Defensive gate: production flow already awaits the // migrator in `AppDelegate.applicationDidFinishLaunching`, // but tests + future headless entry points may call @@ -436,10 +443,8 @@ public final class ChatHistoryDatabase: @unchecked Sendable { // Best-effort GC. We re-check each hash against the surviving // rows; anything still referenced stays. - for hash in ownedRefs { - if !isBlobReferenced(hash) { - AttachmentBlobStore.delete(hash) - } + for hash in ownedRefs where !isBlobReferenced(hash) { + AttachmentBlobStore.delete(hash) } } diff --git a/Packages/OsaurusCore/Tests/Chat/ChatViewSandboxTests.swift b/Packages/OsaurusCore/Tests/Chat/ChatViewSandboxTests.swift index c45006399..b50dd0d10 100644 --- a/Packages/OsaurusCore/Tests/Chat/ChatViewSandboxTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ChatViewSandboxTests.swift @@ -52,6 +52,8 @@ struct ChatViewSandboxTests { await SandboxTestLock.runWithStoragePaths { let manager = AgentManager.shared let originalActiveAgentId = manager.activeAgentId + ToolRegistry.shared.unregisterAllSandboxTools() + let inactiveAgent = Agent( name: "Chat Estimate Off", agentAddress: "test-chat-estimate-off" @@ -78,13 +80,10 @@ struct ChatViewSandboxTests { let inactiveBreakdown = inactiveSession.estimatedContextBreakdown let sandboxBreakdown = sandboxSession.estimatedContextBreakdown - let inactiveContextTokens = inactiveBreakdown.context.reduce(0) { $0 + $1.tokens } - let sandboxContextTokens = sandboxBreakdown.context.reduce(0) { $0 + $1.tokens } - #expect(sandboxContextTokens > inactiveContextTokens) + #expect(inactiveBreakdown.context.contains { $0.id == "sandbox" } == false) + #expect(sandboxBreakdown.context.contains { $0.id == "sandbox" }) let sandboxToolTokens = sandboxBreakdown.context.first { $0.id == "tools" }?.tokens ?? 0 - let inactiveToolTokens = inactiveBreakdown.context.first { $0.id == "tools" }?.tokens ?? 0 - #expect(sandboxToolTokens > inactiveToolTokens) #expect(sandboxToolTokens >= ToolRegistry.shared.estimatedTokens(for: "sandbox_exec")) ToolRegistry.shared.unregisterAllSandboxTools() diff --git a/Packages/OsaurusCore/Tests/Provider/RemoteChatRequestEncodingTests.swift b/Packages/OsaurusCore/Tests/Provider/RemoteChatRequestEncodingTests.swift index 375eee0b9..79a481523 100644 --- a/Packages/OsaurusCore/Tests/Provider/RemoteChatRequestEncodingTests.swift +++ b/Packages/OsaurusCore/Tests/Provider/RemoteChatRequestEncodingTests.swift @@ -60,6 +60,42 @@ struct RemoteChatRequestEncodingTests { #expect(payload["max_completion_tokens"] == nil) } + @Test func encode_assistantToolCall_preservesReasoningContent() throws { + let call = ToolCall( + id: "call_123", + type: "function", + function: ToolCallFunction(name: "search", arguments: "{\"q\":\"DeepSeek\"}") + ) + let assistant = ChatMessage( + role: "assistant", + content: nil, + tool_calls: [call], + tool_call_id: nil, + reasoning_content: "I should search first." + ) + let request = RemoteChatRequest( + model: "deepseek-v4-pro", + messages: [assistant], + temperature: 0.7, + max_completion_tokens: 512, + stream: false, + top_p: nil, + frequency_penalty: nil, + presence_penalty: nil, + stop: nil, + tools: nil, + tool_choice: nil, + reasoning_effort: nil, + reasoning: nil, + modelOptions: [:], + veniceParameters: nil + ) + + let payload = try Self.encodeAsDictionary(request) + let messages = try #require(payload["messages"] as? [[String: Any]]) + #expect(messages.first?["reasoning_content"] as? String == "I should search first.") + } + @Test func openResponsesRequest_defaultSingleUserMessage_usesTextShorthand() throws { let request = Self.makeRequest(model: "gpt-5.2", maxTokens: 1024) let responsesRequest = request.toOpenResponsesRequest() @@ -76,6 +112,28 @@ struct RemoteChatRequestEncodingTests { #expect(payload["input"] is [[String: Any]]) } + @Test func openResponsesRequest_decodesOpenAIStyleMessageItemWithoutType() throws { + let data = #""" + { + "model": "foundation", + "input": [ + { + "role": "user", + "content": "Hello!" + } + ], + "stream": false + } + """#.data(using: .utf8)! + + let request = try JSONDecoder().decode(OpenResponsesRequest.self, from: data) + let chatRequest = request.toChatCompletionRequest() + + #expect(chatRequest.messages.count == 1) + #expect(chatRequest.messages[0].role == "user") + #expect(chatRequest.messages[0].content == "Hello!") + } + @Test func codexRequest_removesMaxOutputTokens() throws { let request = Self.makeRequest(model: "gpt-5.2", maxTokens: 1024) let payload = try Self.decodeAsDictionary(request.toCodexOpenResponsesRequest().toCodexOAuthPayloadData()) diff --git a/Packages/OsaurusCore/Tests/Tool/ToolSerializationStabilityTests.swift b/Packages/OsaurusCore/Tests/Tool/ToolSerializationStabilityTests.swift index 550f1a4f7..8cbacf4e4 100644 --- a/Packages/OsaurusCore/Tests/Tool/ToolSerializationStabilityTests.swift +++ b/Packages/OsaurusCore/Tests/Tool/ToolSerializationStabilityTests.swift @@ -43,4 +43,32 @@ struct ToolSerializationStabilityTests { let bData = try JSONSerialization.data(withJSONObject: b, options: [.sortedKeys]) #expect(aData == bData) } + + @Test + func toTokenizerToolSpec_dropsNullsBeforeJinjaConversion() throws { + let tool = Tool( + type: "function", + function: ToolFunction( + name: "schema_probe", + description: nil, + parameters: .object([ + "type": .string("object"), + "properties": .object([ + "value": .object([ + "type": .array([.string("string"), .null]), + "description": .null, + ]) + ]), + ]) + ) + ) + + let spec = tool.toTokenizerToolSpec() + let data = try JSONSerialization.data(withJSONObject: spec, options: [.sortedKeys]) + let json = String(decoding: data, as: UTF8.self) + + #expect(!json.contains("null")) + #expect(json.contains("\"type\":[\"string\"]")) + #expect(!json.contains("description")) + } } diff --git a/Packages/OsaurusCore/Utils/OsaurusPaths.swift b/Packages/OsaurusCore/Utils/OsaurusPaths.swift index e0edf8cac..e6d7b23ea 100644 --- a/Packages/OsaurusCore/Utils/OsaurusPaths.swift +++ b/Packages/OsaurusCore/Utils/OsaurusPaths.swift @@ -13,7 +13,7 @@ import Foundation public enum OsaurusPaths { /// Optional root directory override for tests /// Note: nonisolated(unsafe) since this is only set during test setup before any concurrent access - public nonisolated(unsafe) static var overrideRoot: URL? + nonisolated(unsafe) public static var overrideRoot: URL? // MARK: - Root Directory @@ -164,14 +164,12 @@ public enum OsaurusPaths { public static func volumeFreeBytes(forPath path: String) -> Int64? { let url = URL(fileURLWithPath: path) let keys: Set = [.volumeAvailableCapacityForImportantUsageKey] - if let values = try? url.resourceValues(forKeys: keys), - let capacity = values.volumeAvailableCapacityForImportantUsage - { + let values = try? url.resourceValues(forKeys: keys) + if let capacity = values?.volumeAvailableCapacityForImportantUsage { return capacity } - if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path), - let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value - { + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path) + if let free = (attrs?[.systemFreeSize] as? NSNumber)?.int64Value { return free } return nil @@ -183,14 +181,12 @@ public enum OsaurusPaths { public static func volumeTotalBytes(forPath path: String) -> Int64? { let url = URL(fileURLWithPath: path) let keys: Set = [.volumeTotalCapacityKey] - if let values = try? url.resourceValues(forKeys: keys), - let capacity = values.volumeTotalCapacity - { + let values = try? url.resourceValues(forKeys: keys) + if let capacity = values?.volumeTotalCapacity { return Int64(capacity) } - if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path), - let total = (attrs[.systemSize] as? NSNumber)?.int64Value - { + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path) + if let total = (attrs?[.systemSize] as? NSNumber)?.int64Value { return total } return nil @@ -434,6 +430,34 @@ public enum OsaurusPaths { return total } + /// Finder-style free capacity for the volume containing `url`. + /// Modern APFS reports purgeable/important-usage capacity through URL + /// resource keys; legacy filesystem attributes can return zero for some + /// sandboxed container paths. + public static func volumeFreeBytes(containing url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + if let capacity = values?.volumeAvailableCapacityForImportantUsage { + return capacity + } + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: url.path) + if let free = (attrs?[.systemFreeSize] as? NSNumber)?.int64Value { + return free + } + return nil + } + + public static func volumeTotalBytes(containing url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [.volumeTotalCapacityKey]) + if let capacity = values?.volumeTotalCapacity { + return Int64(capacity) + } + let attrs = try? FileManager.default.attributesOfFileSystem(forPath: url.path) + if let total = (attrs?[.systemSize] as? NSNumber)?.int64Value { + return total + } + return nil + } + // MARK: - Migration /// Recursively copy the contents of `src` into `dest` (never deletes from `src`). diff --git a/Packages/OsaurusCore/Views/Chat/ChatView.swift b/Packages/OsaurusCore/Views/Chat/ChatView.swift index 26a8dc071..61d034c10 100644 --- a/Packages/OsaurusCore/Views/Chat/ChatView.swift +++ b/Packages/OsaurusCore/Views/Chat/ChatView.swift @@ -50,7 +50,7 @@ final class ChatSession: ObservableObject { let expandedBlocksStore = ExpandedBlocksStore() @Published var input: String = "" @Published var pendingAttachments: [Attachment] = [] - @Published var selectedModel: String? = nil + @Published var selectedModel: String? @Published var pickerItems: [ModelPickerItem] = [] @Published var activeModelOptions: [String: ModelOptionValue] = [:] @Published var hasAnyModel: Bool = false @@ -678,9 +678,7 @@ final class ChatSession: ObservableObject { // fall back to the agent's preferred model. `isLoadingModel` // suppresses the auto-persist sink so a load doesn't look like // the user just picked a model. - if let savedModel = data.selectedModel, - pickerItems.contains(where: { $0.id == savedModel }) - { + if let savedModel = data.selectedModel, pickerItems.contains(where: { $0.id == savedModel }) { isLoadingModel = true selectedModel = savedModel isLoadingModel = false @@ -797,9 +795,8 @@ final class ChatSession: ObservableObject { // marker-only string from before the envelope migration) are // accepted too so plugin authors who emit raw markers keep working. let markerText: String - if let payload = ToolEnvelope.successPayload(toolResult) as? [String: Any], - let text = payload["text"] as? String - { + let payload = ToolEnvelope.successPayload(toolResult) as? [String: Any] + if let text = payload?["text"] as? String { markerText = text } else { markerText = toolResult @@ -959,12 +956,11 @@ final class ChatSession: ObservableObject { } private func trimTrailingEmptyAssistantTurn() { - if let lastTurn = turns.last, - lastTurn.role == .assistant, - lastTurn.contentIsEmpty, - lastTurn.toolCalls == nil, - !lastTurn.hasThinking - { + if let lastTurn = turns.last { + let isTrailingEmptyAssistant = + lastTurn.role == .assistant && lastTurn.contentIsEmpty && lastTurn.toolCalls == nil + && !lastTurn.hasThinking + guard isTrailingEmptyAssistant else { return } turns.removeLast() } } @@ -1746,6 +1742,9 @@ final class ChatSession: ObservableObject { function: ToolCallFunction(name: inv.toolName, arguments: inv.jsonArguments), geminiThoughtSignature: inv.geminiThoughtSignature ) + if let reasoning = inv.reasoningContent { + assistantTurn.thinking = reasoning + } assistantTurn.pendingToolName = nil assistantTurn.clearPendingToolArgs() if assistantTurn.toolCalls == nil { assistantTurn.toolCalls = [] } @@ -1837,9 +1836,8 @@ final class ChatSession: ObservableObject { // proper summary. } if inv.toolName == "clarify" { - if !ToolEnvelope.isError(resultText), - let payload = Self.parseClarifyPayload(from: inv.jsonArguments) - { + let clarifyPayload = Self.parseClarifyPayload(from: inv.jsonArguments) + if !ToolEnvelope.isError(resultText), let payload = clarifyPayload { // Build a ClarifyPromptState bound to // `self.send(...)` so the user's answer // dispatches as the next user turn @@ -1867,10 +1865,10 @@ final class ChatSession: ObservableObject { // Hot-load tools injected by capabilities_load or sandbox_plugin_register. // Skipped in manual mode — the user's explicit tool set is fixed. - if !isManualTools, - inv.toolName == "capabilities_load" - || inv.toolName == "sandbox_plugin_register" - { + let canHotLoadTools = + !isManualTools + && (inv.toolName == "capabilities_load" || inv.toolName == "sandbox_plugin_register") + if canHotLoadTools { let newTools = await CapabilityLoadBuffer.shared.drain() for tool in newTools where !toolSpecs.contains(where: { $0.function.name == tool.function.name }) { @@ -1902,9 +1900,8 @@ final class ChatSession: ObservableObject { } } - if inv.toolName == "sandbox_secret_set", - let prompt = SecretPromptParser.parse(resultText) - { + let secretPrompt = SecretPromptParser.parse(resultText) + if inv.toolName == "sandbox_secret_set", let prompt = secretPrompt { let stored: Bool = await withCheckedContinuation { continuation in let promptState = SecretPromptState( key: prompt.key, @@ -2040,13 +2037,13 @@ struct ChatView: View { @State private var editText: String = "" @State private var userImagePreview: NSImage? // Bonjour agent connection - @State private var pendingDiscoveredAgent: DiscoveredAgent? = nil + @State private var pendingDiscoveredAgent: DiscoveredAgent? // Minimap @State private var activeMinimapTurnId: UUID? @State private var scrollToTurnId: UUID? @State private var scrollToTurnTrigger: Int = 0 // What's New modal - @State private var pendingWhatsNew: WhatsNewRelease? = nil + @State private var pendingWhatsNew: WhatsNewRelease? /// Convenience accessor for the window's theme private var theme: ThemeProtocol { windowState.theme } @@ -2116,6 +2113,8 @@ struct ChatView: View { } var body: some View { + // ViewBuilder treats plain assignment as a view expression; the local binding keeps tracing side-effect-only. + // swiftlint:disable:next redundant_discardable_let let _ = ChatPerfTrace.shared.count("body.ChatView") chatModeContent .themedAlertScope(.chat(windowState.windowId)) @@ -2759,11 +2758,13 @@ private struct IsolatedThreadView: View { let onConfirmEdit: (() -> Void)? let onCancelEdit: (() -> Void)? let onUserImagePreview: ((String) -> Void)? - var onVisibleTopUserTurnChanged: ((UUID?) -> Void)? = nil - var scrollToTurnId: UUID? = nil + var onVisibleTopUserTurnChanged: ((UUID?) -> Void)? + var scrollToTurnId: UUID? var scrollToTurnTrigger: Int = 0 var body: some View { + // ViewBuilder treats plain assignment as a view expression; the local binding keeps tracing side-effect-only. + // swiftlint:disable:next redundant_discardable_let let _ = ChatPerfTrace.shared.count("body.IsolatedThreadView") MessageThreadView( blocks: store.blocks, @@ -2811,10 +2812,9 @@ extension ChatView { } } } - if let url = sharedArtifactImageURL(artifactId: attachmentId), - let data = try? Data(contentsOf: url), - let img = NSImage(data: data) - { + let sharedURL = sharedArtifactImageURL(artifactId: attachmentId) + let sharedData = sharedURL.flatMap { try? Data(contentsOf: $0) } + if let data = sharedData, let img = NSImage(data: data) { userImagePreview = img } } @@ -3030,7 +3030,7 @@ private struct PairingSheet: View { let onCancel: () -> Void @State private var isPairing = false - @State private var errorMessage: String? = nil + @State private var errorMessage: String? @Environment(\.theme) private var theme var body: some View { @@ -3136,17 +3136,17 @@ private enum PairingClient { let context = LAContext() context.touchIDAuthenticationAllowableReuseDuration = 300 - var masterKey = try MasterKey.getPrivateKey(context: context) + var pairingPrivateKey = try MasterKey.getPrivateKey(context: context) defer { - masterKey.withUnsafeMutableBytes { ptr in + pairingPrivateKey.withUnsafeMutableBytes { ptr in if let base = ptr.baseAddress { memset(base, 0, ptr.count) } } } - let connectorAddress = try PairingKey.deriveAddress(masterKey: masterKey) + let connectorAddress = try PairingKey.deriveAddress(masterKey: pairingPrivateKey) let nonce = UUID().uuidString - let signature = try PairingKey.sign(payload: Data(nonce.utf8), masterKey: masterKey) + let signature = try PairingKey.sign(payload: Data(nonce.utf8), masterKey: pairingPrivateKey) let hexSig = "0x" + signature.hexEncodedString let rawHost = agent.host ?? ""