import CopilotForXcodeKit import Foundation import CodableWrappers import LanguageServerProtocol public protocol ConversationServiceType { func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws -> ConversationCreateResponse? func deleteTurn(with conversationId: String, turnId: String, workspace: WorkspaceInfo) async throws func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? func modes(workspace: WorkspaceInfo) async throws -> [ConversationMode]? func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspace: WorkspaceInfo) async throws func reviewChanges( workspace: WorkspaceInfo, changes: [ReviewChangesParams.Change] ) async throws -> CodeReviewResult? func generateThinkingTitle(workspace: WorkspaceInfo, params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public protocol ConversationServiceProvider { func createConversation(_ request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? func createTurn(with conversationId: String, request: ConversationRequest, workspaceURL: URL?) async throws -> ConversationCreateResponse? func deleteTurn(with conversationId: String, turnId: String, workspaceURL: URL?) async throws func stopReceivingMessage(_ workDoneToken: String, workspaceURL: URL?) async throws func rateConversation(turnId: String, rating: ConversationRating, workspaceURL: URL?) async throws func copyCode(_ request: CopyCodeRequest, workspaceURL: URL?) async throws func templates() async throws -> [ChatTemplate]? func modes() async throws -> [ConversationMode]? func models() async throws -> [CopilotModel]? func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws func agents() async throws -> [ChatAgent]? func notifyChangeTextDocument(fileURL: URL, content: String, version: Int, workspaceURL: URL?) async throws func reviewChanges(_ changes: [ReviewChangesParams.Change]) async throws -> CodeReviewResult? func generateThinkingTitle(_ params: GenerateThinkingTitleParams) async throws -> GenerateThinkingTitleResponse? } public struct ConversationFileReference: Hashable, Codable, Equatable { public let url: URL public let relativePath: String? public let fileName: String? public var isCurrentEditor: Bool = false public var selection: LSPRange? public init( url: URL, relativePath: String? = nil, fileName: String? = nil, isCurrentEditor: Bool = false, selection: LSPRange? = nil ) { self.url = url self.relativePath = relativePath self.fileName = fileName self.isCurrentEditor = isCurrentEditor self.selection = selection } public func hash(into hasher: inout Hasher) { hasher.combine(url) hasher.combine(isCurrentEditor) hasher.combine(selection) } public static func == (lhs: ConversationFileReference, rhs: ConversationFileReference) -> Bool { return lhs.url == rhs.url && lhs.isCurrentEditor == rhs.isCurrentEditor } } public struct ConversationDirectoryReference: Hashable, Codable { public let url: URL // The project URL that this directory belongs to. // When directly dragging a directory into the chat, this can be nil. public let projectURL: URL? public var depth: Int { guard let projectURL else { return -1 } let directoryPathComponents = url.pathComponents let projectPathComponents = projectURL.pathComponents if directoryPathComponents.count <= projectPathComponents.count { return 0 } return directoryPathComponents.count - projectPathComponents.count } public var relativePath: String { guard let projectURL else { return url.path } return url.path.replacingOccurrences(of: projectURL.path, with: "") } public var displayName: String { url.lastPathComponent } public init(url: URL, projectURL: URL? = nil) { self.url = url self.projectURL = projectURL } } extension ConversationDirectoryReference: Equatable { public static func == (lhs: ConversationDirectoryReference, rhs: ConversationDirectoryReference) -> Bool { lhs.url.path == rhs.url.path && lhs.projectURL == rhs.projectURL } } public enum ConversationAttachedReference: Hashable, Codable, Equatable { case file(ConversationFileReference) case directory(ConversationDirectoryReference) public var url: URL { switch self { case .directory(let ref): return ref.url case .file(let ref): return ref.url } } public var isDirectory: Bool { switch self { case .directory: true case .file: false } } public var relativePath: String { switch self { case .directory(let dir): dir.relativePath case .file(let file): file.relativePath ?? file.url.lastPathComponent } } public var displayName: String { switch self { case .directory(let dir): dir.displayName case .file(let file): file.fileName ?? file.url.lastPathComponent } } } public enum ImageReferenceSource: String, Codable { case file = "file" case pasted = "pasted" case screenshot = "screenshot" } public struct ImageReference: Equatable, Codable, Hashable { public var data: Data public var fileUrl: URL? public var source: ImageReferenceSource public init(data: Data, source: ImageReferenceSource) { self.data = data self.source = source } public init(data: Data, fileUrl: URL) { self.data = data self.fileUrl = fileUrl self.source = .file } public func dataURL(imageType: String = "") -> String { let base64String = data.base64EncodedString() var type = imageType if let url = fileUrl, imageType.isEmpty { type = url.pathExtension } let mimeType: String switch type { case "png": mimeType = "image/png" case "jpeg", "jpg": mimeType = "image/jpeg" case "bmp": mimeType = "image/bmp" case "gif": mimeType = "image/gif" case "webp": mimeType = "image/webp" case "tiff", "tif": mimeType = "image/tiff" default: mimeType = "image/png" } return "data:\(mimeType);base64,\(base64String)" } } public enum MessageContentType: String, Codable { case text = "text" case imageUrl = "image_url" } public enum ImageDetail: String, Codable { case low = "low" case high = "high" } public struct ChatCompletionImageURL: Codable,Equatable { let url: String let detail: ImageDetail? public init(url: String, detail: ImageDetail? = nil) { self.url = url self.detail = detail } } public struct ChatCompletionContentPartText: Codable, Equatable { public let type: MessageContentType public let text: String public init(text: String) { self.type = .text self.text = text } } public struct ChatCompletionContentPartImage: Codable, Equatable { public let type: MessageContentType public let imageUrl: ChatCompletionImageURL public init(imageUrl: ChatCompletionImageURL) { self.type = .imageUrl self.imageUrl = imageUrl } public init(url: String, detail: ImageDetail? = nil) { self.type = .imageUrl self.imageUrl = ChatCompletionImageURL(url: url, detail: detail) } } public enum ChatCompletionContentPart: Codable, Equatable { case text(ChatCompletionContentPartText) case imageUrl(ChatCompletionContentPartImage) private enum CodingKeys: String, CodingKey { case type } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(MessageContentType.self, forKey: .type) switch type { case .text: self = .text(try ChatCompletionContentPartText(from: decoder)) case .imageUrl: self = .imageUrl(try ChatCompletionContentPartImage(from: decoder)) } } public func encode(to encoder: Encoder) throws { switch self { case .text(let content): try content.encode(to: encoder) case .imageUrl(let content): try content.encode(to: encoder) } } } public enum MessageContent: Codable, Equatable { case string(String) case messageContentArray([ChatCompletionContentPart]) public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let stringValue = try? container.decode(String.self) { self = .string(stringValue) } else if let arrayValue = try? container.decode([ChatCompletionContentPart].self) { self = .messageContentArray(arrayValue) } else { throw DecodingError.typeMismatch(MessageContent.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected String or Array of MessageContent")) } } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let value): try container.encode(value) case .messageContentArray(let value): try container.encode(value) } } } public struct TurnSchema: Codable { public var request: MessageContent public var response: String? public var agentSlug: String? public var turnId: String? public init(request: String, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { self.request = .string(request) self.response = response self.agentSlug = agentSlug self.turnId = turnId } public init( request: [ChatCompletionContentPart], response: String? = nil, agentSlug: String? = nil, turnId: String? = nil ) { self.request = .messageContentArray(request) self.response = response self.agentSlug = agentSlug self.turnId = turnId } public init(request: MessageContent, response: String? = nil, agentSlug: String? = nil, turnId: String? = nil) { self.request = request self.response = response self.agentSlug = agentSlug self.turnId = turnId } } public struct ConversationRequest { public var workDoneToken: String public var content: String public var contentImages: [ChatCompletionContentPartImage] = [] public var workspaceFolder: String public var activeDoc: Doc? public var skills: [String] public var ignoredSkills: [String]? public var references: [ConversationAttachedReference]? public var model: String? public var modelProviderName: String? public var turns: [TurnSchema] public var agentMode: Bool = false public var customChatModeId: String? = nil public var userLanguage: String? = nil public var turnId: String? = nil public init( workDoneToken: String, content: String, contentImages: [ChatCompletionContentPartImage] = [], workspaceFolder: String, activeDoc: Doc? = nil, skills: [String], ignoredSkills: [String]? = nil, references: [ConversationAttachedReference]? = nil, model: String? = nil, modelProviderName: String? = nil, turns: [TurnSchema] = [], agentMode: Bool = false, customChatModeId: String? = nil, userLanguage: String?, turnId: String? = nil ) { self.workDoneToken = workDoneToken self.content = content self.contentImages = contentImages self.workspaceFolder = workspaceFolder self.activeDoc = activeDoc self.skills = skills self.ignoredSkills = ignoredSkills self.references = references self.model = model self.modelProviderName = modelProviderName self.turns = turns self.agentMode = agentMode self.customChatModeId = customChatModeId self.userLanguage = userLanguage self.turnId = turnId } } public struct CopyCodeRequest { public var turnId: String public var codeBlockIndex: Int public var copyType: CopyKind public var copiedCharacters: Int public var totalCharacters: Int public var copiedText: String init(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) { self.turnId = turnId self.codeBlockIndex = codeBlockIndex self.copyType = copyType self.copiedCharacters = copiedCharacters self.totalCharacters = totalCharacters self.copiedText = copiedText } } public enum ConversationRating: Int, Codable { case unrated = 0 case helpful = 1 case unhelpful = -1 } public enum CopyKind: Int, Codable { case keyboard = 1 case toolbar = 2 } public struct ConversationFollowUp: Codable, Equatable { public var message: String public var id: String public var type: String public init(message: String, id: String, type: String) { self.message = message self.id = id self.type = type } } public struct ConversationProgressStep: Codable, Equatable, Identifiable { public enum StepStatus: String, Codable { case running, completed, failed, cancelled } public struct StepError: Codable, Equatable { public let message: String } public let id: String public let title: String public let description: String? public var status: StepStatus public let error: StepError? public init(id: String, title: String, description: String?, status: StepStatus, error: StepError?) { self.id = id self.title = title self.description = description self.status = status self.error = error } } public struct Thinking: Codable, Equatable { public let id: String public let text: [String]? public let encrypted: String? public init(id: String, text: [String]?, encrypted: String?) { self.id = id self.text = text self.encrypted = encrypted } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) text = try container.decodeStringOrArray(forKey: .text) } } /// Internal, message-level thinking state. /// /// Distinct from the wire/server `Thinking` payload above: that type carries deltas /// streamed from the LSP, while `MessageThinking` is the accumulated UI state stored on /// a `ChatMessage` (or `AgentRound`) and persisted across sessions. `isComplete` is a /// UI/state flag the server never sends — it's set when a thinking block ends. public struct MessageThinking: Codable, Equatable { /// Stable client-generated key for this entry. Survives server delta `id` churn (e.g. /// CodeX models emit a new `id` per delta) and is what the seal/title-attach code paths /// look up. Persisted; older saved messages without it get a fresh UUID on decode. public var clientEntryId: UUID public var id: String public var text: [String]? public var encrypted: String? public var title: String? public var isComplete: Bool public init( clientEntryId: UUID = UUID(), id: String, text: [String]?, encrypted: String?, title: String? = nil, isComplete: Bool = false ) { self.clientEntryId = clientEntryId self.id = id self.text = text self.encrypted = encrypted self.title = title self.isComplete = isComplete } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) clientEntryId = try container.decodeIfPresent(UUID.self, forKey: .clientEntryId) ?? UUID() id = try container.decode(String.self, forKey: .id) encrypted = try container.decodeIfPresent(String.self, forKey: .encrypted) title = try container.decodeIfPresent(String.self, forKey: .title) isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false text = try container.decodeStringOrArray(forKey: .text) } public init(from server: Thinking, clientEntryId: UUID = UUID(), isComplete: Bool = false) { self.clientEntryId = clientEntryId self.id = server.id self.text = server.text self.encrypted = server.encrypted self.title = nil self.isComplete = isComplete } /// Parses thinking text into title-paired sections. /// /// Each "title-only" line (`**Title**` on its own) starts a new section. All lines that /// follow up to the next title (or end of text) become that section's body. Lines before /// any title go into a leading section with `title == nil`. public static func parseSections(from raw: String) -> [ThinkingSection] { if raw.isEmpty { return [] } var sections: [ThinkingSection] = [] var currentTitle: String? = nil var currentBody: [String] = [] func flush() { let body = currentBody.joined().trimmingCharacters(in: .whitespacesAndNewlines) if currentTitle != nil || !body.isEmpty { sections.append(ThinkingSection(title: currentTitle, body: body)) } } for line in raw.components(separatedBy: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("**"), trimmed.hasSuffix("**"), trimmed.count > 4 { let inner = String(trimmed.dropFirst(2).dropLast(2)) if !inner.isEmpty, !inner.contains("*") { flush() currentTitle = inner currentBody = [] continue } } currentBody.append(line + "\n") } flush() return sections } } public struct ThinkingSection: Equatable { public let title: String? public let body: String public init(title: String?, body: String) { self.title = title self.body = body } } public extension KeyedDecodingContainer { /// Decodes a value that the wire format may emit as either a single `String` or `[String]`, /// normalizing to `[String]?`. Returns `nil` if the key is absent. func decodeStringOrArray(forKey key: Key) throws -> [String]? { if let single = try? decode(String.self, forKey: key) { return [single] } return try decodeIfPresent([String].self, forKey: key) } } public struct ContextSizeInfo: Codable, Equatable { public let totalTokenLimit: Int public let systemPromptTokens: Int public let toolDefinitionTokens: Int public let userMessagesTokens: Int public let assistantMessagesTokens: Int public let attachedFilesTokens: Int public let toolResultsTokens: Int public let totalUsedTokens: Int public let utilizationPercentage: Double } public struct DidChangeWatchedFilesEvent: Codable { public var workspaceUri: String public var changes: [FileEvent] public init(workspaceUri: String, changes: [FileEvent]) { self.workspaceUri = workspaceUri self.changes = changes } }