import AgentKit import Foundation import TermindDomain /// A command the model proposed. It is *never* executed on the model's say-so — /// `risk` is computed locally by `CommandRiskClassifier` and the user always /// clicks. nonisolated public struct SuggestedCommand: Identifiable, Hashable, Sendable { public var id: UUID = UUID() public var command: String public var explanation: String = "" public var createdAt: Date = .now public var risk: RiskAssessment { CommandRiskClassifier.assess(command) } public init(id: UUID = UUID(), command: String, explanation: String = "", createdAt: Date = .now) { self.id = id self.command = command self.explanation = explanation self.createdAt = createdAt } } /// One user-facing piece of context attached to an LLM conversation. /// /// Output, selections, and file contents must be added explicitly. The pinned /// host and directory pills are also chips, but they are built from the /// surface's `SessionContext` rather than held here — see that type. Shell /// Integration's narrow OS/kernel metadata is never a chip at all; it is /// transient, token-sanitized, and disclosed by the setting that enables it. /// /// `content` is the payload, not decoration. A chip carrying only a label while /// its data travels by some other route — as the SSH-failure attachment did, by /// pasting the log into the draft — discloses nothing. nonisolated public struct ContextChip: Identifiable, Hashable, Sendable { /// `CaseIterable` so `blockName` can be read backwards: a composed message /// carries these tags into the transcript, and rendering one as an /// attachment row means recognising the tag it came from. public nonisolated enum Kind: Hashable, Sendable, CaseIterable { case host, directory, output, selection, file, image, pastedText public var symbol: String { switch self { case .host: "server.rack" case .directory: "folder" case .output: "text.alignleft" case .selection: "text.cursor" case .file: "doc" case .image: "photo" case .pastedText: "doc.on.clipboard" } } /// Names the block this chip becomes inside the sent message. public var blockName: String { switch self { case .host: "host" case .directory: "directory" case .output: "output" case .selection: "selection" case .file: "file" case .image: "image" case .pastedText: "pasted_text" } } } public var id: UUID = UUID() public var kind: Kind public var label: String public var content: String = "" public var image: AgentImageAttachment? = nil /// Original non-image bytes used only to reopen a sent attachment. The /// model still receives `content`, which is the validated extraction. public var sourceAttachment: AgentContextAttachment? = nil public var contextAttachment: AgentContextAttachment? { guard image == nil, let attachmentKind = kind.agentAttachmentKind else { return nil } if let sourceAttachment { return AgentContextAttachment( id: id, kind: attachmentKind, label: label, contentTypeIdentifier: sourceAttachment.contentTypeIdentifier, suggestedFilename: sourceAttachment.suggestedFilename, data: sourceAttachment.data, isOriginalFile: sourceAttachment.isOriginalFile ) } return AgentContextAttachment( id: id, kind: attachmentKind, label: label, contentTypeIdentifier: "public.plain-text", suggestedFilename: kind.fallbackFilename(for: label), data: Data(content.utf8), isOriginalFile: false ) } /// Derived, not declared. A hand-passed count is a number that was true /// once, and the estimate exists to warn about payload size. /// /// Shares `AgentContextEstimator`'s divisor rather than repeating `/ 4`, so /// a chip and the context gauge can never quote different sizes for the /// same attachment. public var approximateTokens: Int { if let image { let tilesWide = max(1, Int(ceil(Double(image.pixelWidth) / 512))) let tilesHigh = max(1, Int(ceil(Double(image.pixelHeight) / 512))) return 85 + 170 * tilesWide * tilesHigh } return max(1, AgentContextEstimator.tokens(inCharacters: content.count)) } /// Recovers the text the user actually typed from a persisted model-facing /// turn. Attachment payloads remain in that turn for replay, but projecting /// a 200,000-character PDF extraction into Markdown makes opening history /// unnecessarily expensive and is not useful conversation UI. public static func visiblePrompt(in composedText: String) -> String { guard let boundary = payloadBoundary(in: composedText) else { return composedText } return String(composedText[.. [ContextChipReference] { let boundary = payloadBoundary(in: composedText) var matches: [(String.Index, ContextChipReference)] = [] var unusedAttachments = contextAttachments if let boundary { let suffix = composedText[boundary...] for kind in Kind.allCases where kind != .image { let prefix = "<\(kind.blockName) label=\"" var cursor = suffix.startIndex while let opening = suffix.range(of: prefix, range: cursor..", range: labelStart.. String.Index? { Kind.allCases.compactMap { text.range(of: "\n\n<\($0.blockName) label=\"")?.lowerBound }.min() } public init( id: UUID = UUID(), kind: Kind, label: String, content: String = "", image: AgentImageAttachment? = nil, sourceAttachment: AgentContextAttachment? = nil ) { self.id = id self.kind = kind self.label = label self.content = content self.image = image self.sourceAttachment = sourceAttachment } } /// The non-payload part of a Context Chip kept by the display projection. /// /// A sent PDF can contain 200,000 characters; the bubble needs only these few /// bytes to show what accompanied the prompt. nonisolated public struct ContextChipReference: Identifiable, Hashable, Sendable { public var id: UUID = UUID() public var kind: ContextChip.Kind public var label: String public var attachment: AgentContextAttachment? = nil public init(id: UUID = UUID(), kind: ContextChip.Kind, label: String, attachment: AgentContextAttachment? = nil) { self.id = id self.kind = kind self.label = label self.attachment = attachment } } extension ContextChip.Kind { fileprivate nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? { switch self { case .file: .file case .output: .output case .selection: .selection case .pastedText: .pastedText case .host: .host case .directory: .directory case .image: nil } } fileprivate nonisolated func fallbackAttachment( label: String, content: String ) -> AgentContextAttachment? { guard let kind = agentAttachmentKind else { return nil } var text = content if text.hasPrefix("\n") { text.removeFirst() } if text.hasSuffix("\n") { text.removeLast() } return AgentContextAttachment( kind: kind, label: label, contentTypeIdentifier: "public.plain-text", suggestedFilename: fallbackFilename(for: label), data: Data(text.utf8), isOriginalFile: false ) } fileprivate nonisolated func fallbackFilename(for label: String) -> String { if self == .file { let stem = (label as NSString).deletingPathExtension return "\(stem.isEmpty ? label : stem) extracted.txt" } return (label as NSString).pathExtension.isEmpty ? "\(label).txt" : label } } nonisolated public struct PendingContextAttachment: Identifiable, Hashable, Sendable { public var id: UUID = UUID() public var label: String public init(id: UUID = UUID(), label: String) { self.id = id self.label = label } } nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { public nonisolated enum Role: Hashable, Sendable { case user, assistant } public var id: UUID = UUID() public var role: Role public var text: String public var reasoning: [AgentReasoningBlock] = [] public var contextChips: [ContextChipReference] = [] public var images: [AgentImageAttachment] = [] public var commands: [SuggestedCommand] = [] /// Provider retry state is live presentation, never conversation history. public var retryProgress: AgentModelRetryProgress? /// True while tokens are still arriving, so the view can show a caret. public var isStreaming: Bool = false /// Orders this message against the tool cards, which are a separate list. /// Restored conversations carry their stored time so history does not /// re-sort itself behind every card the moment a window opens. public var createdAt: Date = .now public init( id: UUID = UUID(), role: Role, text: String, reasoning: [AgentReasoningBlock] = [], contextChips: [ContextChipReference] = [], images: [AgentImageAttachment] = [], commands: [SuggestedCommand] = [], retryProgress: AgentModelRetryProgress? = nil, isStreaming: Bool = false, createdAt: Date = .now ) { self.id = id self.role = role self.text = text self.reasoning = reasoning self.contextChips = contextChips self.images = images self.commands = commands self.retryProgress = retryProgress self.isStreaming = isStreaming self.createdAt = createdAt } } nonisolated public struct AgentToolCard: Identifiable, Hashable, Sendable { public nonisolated enum State: String, Hashable, Sendable { case proposed, waitingForApproval, waitingForUser, reviewing, running, completed, denied, failed case interrupted public var isTerminal: Bool { switch self { case .completed, .denied, .failed, .interrupted: true case .proposed, .waitingForApproval, .waitingForUser, .reviewing, .running: false } } } public var id: UUID { invocation.id } public var invocation: AgentToolInvocation public var descriptor: AgentToolDescriptor public var state: State = .proposed public var result: AgentToolResult? public var review: GuardianDecision? /// `terminal_suggest` is a user-facing artifact, like `present_plan`, not /// work detail. Once the tool has completed, promote its validated input to /// the transcript and remove the protocol row from Worked. public var terminalSuggestion: SuggestedCommand? { guard descriptor.name == "terminal_suggest", state == .completed, result?.isError == false, let arguments = invocation.call.arguments.objectValue, let command = arguments["command"]?.stringValue, !command.isEmpty else { return nil } return SuggestedCommand( id: id, command: command, explanation: arguments["explanation"]?.stringValue ?? "", createdAt: invocation.createdAt ) } public init( invocation: AgentToolInvocation, descriptor: AgentToolDescriptor, state: State = .proposed, result: AgentToolResult? = nil, review: GuardianDecision? = nil ) { self.invocation = invocation self.descriptor = descriptor self.state = state self.result = result self.review = review } } /// One message the user sent while a run was in flight, waiting on the next /// model boundary. /// /// `draft` and `attachments` are kept beside the composed message because the /// queue is a promise that can be broken — a stopped run hands these back to the /// composer, and handing back `message.text` would return the user their own /// attachment blocks to edit. nonisolated public struct PendingSteeringMessage: Identifiable, Hashable, Sendable { /// The message's own id, so delivery is matched on identity rather than on /// comparing text with text. public var id: AgentTranscriptMessage.ID { message.id } public var draft: String public var attachments: [ContextChip] = [] public var message: AgentTranscriptMessage public init(draft: String, attachments: [ContextChip] = [], message: AgentTranscriptMessage) { self.draft = draft self.attachments = attachments self.message = message } } nonisolated public struct AgentRunFailureNotice: Identifiable, Hashable, Sendable { public var runID: UUID public var reason: String public var createdAt: Date public var id: UUID { AgentTranscriptMessage.toolResultID( runID: runID, callID: "__termind_agent_failure_notice__" ) } public init(runID: UUID, reason: String, createdAt: Date) { self.runID = runID self.reason = reason self.createdAt = createdAt } } /// One row of a conversation, whichever of the two lists it came from. nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { case message(ChatMessage) case tool(AgentToolCard) case work(AgentWorkGroup) case command(SuggestedCommand) case plan(AgentPlanCard) case compaction(AgentCompactionMark) case compactionActivity(AgentCompactionActivity) case failure(AgentRunFailureNotice) public var id: UUID { switch self { case .message(let message): message.id case .tool(let card): card.id case .work(let group): group.id case .command(let command): command.id case .plan(let card): card.id case .compaction(let mark): mark.id case .compactionActivity(let activity): activity.id case .failure(let failure): failure.id } } /// The run's clock, not the UI's — the same stamp `timeline` merges the two /// existing lists on. See `AgentEvent.messageStarted`. public var orderedAt: Date { switch self { case .message(let message): message.createdAt case .tool(let card): card.invocation.createdAt case .work(let group): group.orderedAt case .command(let command): command.createdAt case .plan(let card): card.createdAt case .compaction(let mark): mark.createdAt case .compactionActivity(let activity): activity.createdAt case .failure(let failure): failure.createdAt } } /// Breaks a tie at the same instant. A compaction mark is anchored to the /// last message it summarized, so it belongs *after* that message rather /// than in a position `sorted` is free to pick either way — Swift's sort is /// not stable, so equal timestamps otherwise swap between redraws. public var sortRank: Int { switch self { case .work: -1 case .compaction, .compactionActivity: 1 case .failure: 2 default: 0 } } } nonisolated public struct AgentToolBatch: Identifiable, Hashable, Sendable { public var id: UUID { cards.first?.id ?? Self.emptyID } public var cards: [AgentToolCard] /// Batches are run-scoped rather than provider-turn-scoped. A reasoning /// message or another model round trip must not split one visual activity /// into a succession of structurally different rows. public var runID: UUID /// The latest provider-authored reasoning that can temporarily occupy the /// batch title. The renderer keeps the Markdown and derives plain text only /// for accessibility. public var reasoning: AgentToolReasoning? private static let emptyID = UUID( uuidString: "00000000-0000-0000-0000-000000000000" )! public init(cards: [AgentToolCard], runID: UUID, reasoning: AgentToolReasoning? = nil) { self.cards = cards self.runID = runID self.reasoning = reasoning } } nonisolated public struct AgentToolReasoning: Hashable, Sendable { public var sourceMessageID: UUID public var markdown: String /// Content is part of the revision: every streamed reasoning update earns /// its own complete display window, while recycling the same row does not. public var revision: String { sourceMessageID.uuidString + "\u{0}" + markdown } public init(sourceMessageID: UUID, markdown: String) { self.sourceMessageID = sourceMessageID self.markdown = markdown } } nonisolated public enum AgentWorkItem: Identifiable, Hashable, Sendable { case reasoning(AgentReasoningBlock) case message(ChatMessage) case tool(AgentToolCard) /// One or more run-scoped tool calls, regardless of their current result. /// The view shows activity until every member is terminal, then summarizes. case tools(AgentToolBatch) public var id: UUID { switch self { case .reasoning(let block): block.id case .message(let message): message.id case .tool(let card): card.id case .tools(let batch): batch.id } } public var orderedAt: Date { switch self { case .reasoning(let block): block.createdAt case .message(let message): message.createdAt case .tool(let card): card.invocation.createdAt case .tools(let batch): batch.cards.first?.invocation.createdAt ?? .distantPast } } } nonisolated public struct AgentWorkGroup: Identifiable, Hashable, Sendable { public var id: UUID { run.id } public var run: AgentRunSummary public var items: [AgentWorkItem] public var orderedAt: Date public var anchorMessageID: UUID? public var appearsAfterAnswer: Bool /// The provider summary currently receiving deltas. The view scans this /// text instead of showing a second generic Working indicator outside. public var activeReasoningBlockID: AgentReasoningBlock.ID? public var retryProgress: AgentModelRetryProgress? public init( run: AgentRunSummary, items: [AgentWorkItem], orderedAt: Date, anchorMessageID: UUID? = nil, appearsAfterAnswer: Bool, activeReasoningBlockID: AgentReasoningBlock.ID? = nil, retryProgress: AgentModelRetryProgress? = nil ) { self.run = run self.items = items self.orderedAt = orderedAt self.anchorMessageID = anchorMessageID self.appearsAfterAnswer = appearsAfterAnswer self.activeReasoningBlockID = activeReasoningBlockID self.retryProgress = retryProgress } } nonisolated public enum AgentWorkPresentation { public static func reconnectingTitle( _ progress: AgentModelRetryProgress, locale: Locale = .current ) -> String { AppLocalization.string( "Reconnecting… \(progress.attempt)/\(progress.maximumAttempts)", locale: locale ) } public static func isExpandedByDefault( for state: AgentRunState, appearsAfterAnswer: Bool = false, isCompact _: Bool = false ) -> Bool { // Compact and full Assistant surfaces follow the same disclosure // default. Surface-local presentation state still lets a reader fold // either one independently. state != .completed || appearsAfterAnswer } /// A compact, stable duration for the work disclosure. Seconds are floored /// so a completed run never appears to gain time after its finish event. public static func duration( from startedAt: Date, to finishedAt: Date, locale: Locale = .current ) -> String { let seconds = max(0, Int(finishedAt.timeIntervalSince(startedAt))) let hours = seconds / 3_600 let minutes = (seconds % 3_600) / 60 let remainingSeconds = seconds % 60 if hours > 0 { return minutes > 0 ? AppLocalization.string("\(hours)h \(minutes)m", locale: locale) : AppLocalization.string("\(hours)h", locale: locale) } if minutes > 0 { return remainingSeconds > 0 ? AppLocalization.string("\(minutes)m \(remainingSeconds)s", locale: locale) : AppLocalization.string("\(minutes)m", locale: locale) } return AppLocalization.string("\(remainingSeconds)s", locale: locale) } public static func title( for state: AgentRunState, from startedAt: Date, to finishedAt: Date, locale: Locale = .current ) -> String { let elapsed = duration(from: startedAt, to: finishedAt, locale: locale) switch state { case .idle, .running, .waitingForUser, .waitingForApproval, .reviewing: return AppLocalization.string("Working for \(elapsed)", locale: locale) case .cancelled: return AppLocalization.string("You stopped after \(elapsed)", locale: locale) case .completed, .failed, .interrupted: return AppLocalization.string("Worked for \(elapsed)", locale: locale) } } } nonisolated public struct AgentCompactionActivity: Identifiable, Hashable, Sendable { public nonisolated enum State: Hashable, Sendable { case running case failed(String) } public var id: UUID = UUID() public var state: State public var createdAt: Date = .now public init(id: UUID = UUID(), state: State, createdAt: Date = .now) { self.id = id self.state = state self.createdAt = createdAt } } /// The line in the transcript where history stopped being sent. /// /// The messages above it are *not* deleted. Compaction shapes what is sent, and /// the codebase's rule is that transforms never shape what is kept — see /// `AgentContextPipeline`. So the conversation stays scrollable and this says /// where the model's memory of it now begins. nonisolated public struct AgentCompactionMark: Identifiable, Hashable, Sendable { public var id: UUID = UUID() public var summarizedTokens: Int public var keptTokens: Int public var createdAt: Date = .now public init( id: UUID = UUID(), summarizedTokens: Int, keptTokens: Int, createdAt: Date = .now ) { self.id = id self.summarizedTokens = summarizedTokens self.keptTokens = keptTokens self.createdAt = createdAt } public init(_ record: AgentCompactionRecord) { self.init( id: record.id, summarizedTokens: record.summarizedTokens, keptTokens: record.keptTokens, createdAt: record.displayAnchor ) } } /// A presented plan and what became of it. /// /// A row of the transcript rather than a panel above the composer: it is part of /// what happened, in the order it happened, and it stays readable after it has /// been answered. Nothing is suspended waiting on it — see `AgentPlanModeHook`. nonisolated public struct AgentPlanCard: Identifiable, Hashable, Sendable { public var id: UUID { plan.id } public var plan: AgentPlan public var outcome: AgentPlan.Outcome = .pending /// Ordered against messages and tool cards by the same clock they use. public var createdAt: Date { plan.createdAt } public init(plan: AgentPlan, outcome: AgentPlan.Outcome = .pending) { self.plan = plan self.outcome = outcome } } /// One AI conversation, wherever it is being shown. /// /// It deliberately knows nothing about a session. Where the conversation is /// *about* a host and a directory, that comes from the surface showing it — /// `WindowSidebarState.sessionContext` — because a conversation that owned its /// own session context carried it into the Assistant destination, which has no session /// to describe. @Observable public final class ConversationModel: Identifiable { public let id: UUID public var createdAt: Date public var updatedAt: Date public var customTitle: String? public internal(set) var isHistoryChanging = false public var isPinned = false public var sortIndex: Double public var archivedAt: Date? public var messages: [ChatMessage] = [] public let composer = ConversationComposerState() public var attachmentLoader = ContextAttachmentLoading.unavailable private var attachmentImportTasks: [PendingContextAttachment.ID: Task] = [:] /// The last state the run reported, before anything outstanding in the UI /// is taken into account. private var reportedRunState: AgentRunState = .idle /// What the transcript shows, ranked from the reported state and actual /// outstanding work. There is deliberately no second stored run state. public var runState: AgentRunState { if reportedRunState.isTerminal || reportedRunState == .idle { reportedRunState } else if !pendingUserInputs.isEmpty || !pendingSSHAuthentications.isEmpty || !pendingSSHHostKeys.isEmpty { .waitingForUser } else if !pendingApprovals.isEmpty { .waitingForApproval } else if !reviewing.isEmpty { .reviewing } else { reportedRunState } } public var isStreaming: Bool { runState != .idle && !runState.isTerminal } public var toolCards: [AgentToolCard] = [] /// Tool proposals are durable individual events, but publishing each one to /// the transcript would briefly draw a single row and then replace it with /// a batch. Hold the UI projection until every call from the source message /// is known; state/result events update the staged card in place. private var stagedToolCards: [UUID: AgentToolCard] = [:] private var stagedToolOrder: [UUID] = [] private var activeTurnMessageID: UUID? public private(set) var runSummaries: [UUID: AgentRunSummary] = [:] public private(set) var messageRunIDs: [UUID: UUID] = [:] private var activeRunID: UUID? /// Reasoning is live presentation for one provider round trip, not run /// history. The provider copy remains on the transcript for replay, while /// this id alone decides whether it is currently visible. private var activeReasoningMessageID: UUID? /// Questions waiting on the user, oldest first. /// /// A queue rather than a slot because tools no longer run one at a time. A /// turn where two calls both need approval used to overwrite the first /// request with the second, and the continuation the first was suspended on /// was never resumed — the run simply stopped, with no error and nothing on /// screen to answer. /// /// Only the head is presented. Showing three cards at once would ask /// someone to authorize three things while reading one. public private(set) var pendingApprovals: [AgentApprovalRequest] = [] public private(set) var pendingUserInputs: [AgentUserInputRequest] = [] public private(set) var pendingSSHAuthentications: [AgentSSHAuthenticationRequest] = [] public private(set) var pendingSSHHostKeys: [AgentSSHHostKeyRequest] = [] /// What the user typed at a busy agent, oldest first, none of it delivered /// yet. See `submitSteeringDraft`. public private(set) var pendingSteering: [PendingSteeringMessage] = [] /// Reviews in flight. Counted rather than flagged: they run concurrently, /// so the last one to finish must not be able to announce that none are. private var reviewing: Set = [] public var pendingApproval: AgentApprovalRequest? { pendingApprovals.first } public var pendingUserInput: AgentUserInputRequest? { pendingUserInputs.first } public var pendingSSHAuthentication: AgentSSHAuthenticationRequest? { pendingSSHAuthentications.first } public var pendingSSHHostKey: AgentSSHHostKeyRequest? { pendingSSHHostKeys.first } public enum PendingUserInteraction { case questions(AgentUserInputRequest) case sshAuthentication(AgentSSHAuthenticationRequest) case sshHostKey(AgentSSHHostKeyRequest) public var createdAt: Date { switch self { case .questions(let request): request.createdAt case .sshAuthentication(let request): request.createdAt case .sshHostKey(let request): request.createdAt } } } public var pendingUserInteraction: PendingUserInteraction? { [ pendingUserInput.map(PendingUserInteraction.questions), pendingSSHAuthentication.map(PendingUserInteraction.sshAuthentication), pendingSSHHostKey.map(PendingUserInteraction.sshHostKey), ] .compactMap { $0 } .min { $0.createdAt < $1.createdAt } } /// What has been filled in so far, keyed by question. One request can now /// carry several questions, so the drafts are per question rather than one /// pair of fields shared by whichever one happened to be on screen. public private(set) var userInputDrafts: [String: AgentUserInputDraft] = [:] public private(set) var currentUserInputQuestionIndex = 0 public private(set) var skippedUserInputQuestionIDs: Set = [] /// Kept out of `userInputDrafts` on purpose: a secret does not belong in /// the same dictionary as text that is echoed back to the model. public var secretInputDraft = "" /// The model-facing history, which is not what `messages` holds. /// /// `messages` is a display projection: it has no tool calls and no tool /// results, because those are drawn as cards. Replaying it as the next run's /// prior context loses every tool the agent already ran, and turns a /// tool-only assistant turn into an empty message — which Anthropic and /// Google both reject. So the authoritative transcript is kept beside it and /// rebuilt from the same events. public private(set) var transcript: [AgentTranscriptMessage] = [] private let contextEstimateTracker: AgentContextEstimateTracker public private(set) var contextEstimate: AgentContextEstimate private var contextEstimatePublishTask: Task? private var contextEstimateNeedsPublish = false private let contextEstimatePublishInterval: Duration public private(set) var contextEstimatePublishCount = 0 private final class AgentRunOwner { enum Phase { case queued case starting case started case cancelledBeforeStart(AgentRunSnapshot) case finished } let generation: UInt64 let runID: UUID let startedAt: Date let runtime: AgentRuntime let request: AgentRunRequest let repository: any AgentRunPersisting var phase: Phase = .queued var cancellationRequested = false var prestartPersistence: Task? var consumer: Task? init( generation: UInt64, runtime: AgentRuntime, request: AgentRunRequest, repository: any AgentRunPersisting ) { self.generation = generation runID = UUID() startedAt = .now self.runtime = runtime self.request = request self.repository = repository } deinit { consumer?.cancel() let runtime = runtime Task { await runtime.cancel() } } } private var nextRunGeneration: UInt64 = 0 private var activeRunOwner: AgentRunOwner? private var runOwners: [UInt64: AgentRunOwner] = [:] private var agentPreparationID: UUID? private var cancelAgentPreparation: (() -> Task)? private var approvalWaiters: [UUID: CheckedContinuation] = [:] private var inputWaiters: [UUID: CheckedContinuation] = [:] private var userInputTimeoutTask: Task? private(set) var userInputDeadline: ContinuousClock.Instant? @ObservationIgnored var questionClockNow: () -> ContinuousClock.Instant = { .now } @ObservationIgnored var questionClockSleep: (ContinuousClock.Instant) async throws -> Void = { try await Task.sleep(until: $0, clock: .continuous) } private var sshAuthenticationWaiters: [UUID: CheckedContinuation] = [:] private var sshHostKeyWaiters: [UUID: CheckedContinuation] = [:] /// Whether the agent may only look. /// /// Per conversation and deliberately not remembered app-wide the way /// `lastSelectedPermissionMode` is. Permission mode is a standing preference /// about how much you trust the agent; this is a posture for one task, and a /// mode silently inherited by tomorrow's conversation is a mode nobody asked /// for. public var isPlanning = false /// Plans presented in this conversation, oldest first. public private(set) var plans: [AgentPlanCard] = [] /// Where history stopped being sent, oldest first. Display only — durable /// `compactionRecords` are authoritative across relaunches; their synthetic /// transcript turns exist only in the model-facing projection. public private(set) var compactions: [AgentCompactionMark] = [] public private(set) var compactionRecords: [AgentCompactionRecord] = [] /// Ephemeral UI state. Successful compactions become boundary marks; a /// failed attempt stays readable for this process without entering the /// model transcript or persistence schema. public private(set) var compactionActivity: AgentCompactionActivity? /// True while a compaction is in flight, so the composer can say so and not /// start a second one. public private(set) var isCompacting = false /// A submitted user turn whose immutable run assembly is still waiting for /// compaction or memory. This is deliberately separate from `isStreaming`: /// there is no runtime to accept steering until preparation completes. public private(set) var isPreparingAgentRun = false /// True only after a captured configuration failure has committed to its /// terminal snapshot save. Ordinary prepared edits remain supersedable, /// while history mutations must not overtake this durable boundary. public private(set) var isPersistingAgentPreparationFailure = false public var isAgentRunActive: Bool { isStreaming || isPreparingAgentRun } /// The current task list, whole. Replaced rather than patched — see /// `AgentTask`. public private(set) var tasks: [AgentTask] = [] public var completedTaskCount: Int { tasks.filter { $0.status == .completed }.count } /// How many characters of tool schema the last run sent. /// /// Recorded when a run starts rather than derived on demand: the registry is /// assembled per run from Vault hosts, MCP servers and the search plan, and /// a composer redrawing on every keystroke must not rebuild it. Zero until /// the first run, which is honest — nothing has been sent yet. public private(set) var toolSchemaCharacters = 0 public func noteToolSchemas(_ descriptors: [AgentToolDescriptor]) { toolSchemaCharacters = descriptors.reduce(0) { total, descriptor in total + AgentContextEstimator.characterUnits(in: descriptor.name) + AgentContextEstimator.characterUnits(in: descriptor.summary) + AgentContextEstimator.characterUnits( in: descriptor.inputSchema.encodedString ) } contextEstimateTracker.setToolSchemaCharacters(toolSchemaCharacters) publishContextEstimateImmediately() } public var contextEstimateFullRebuildCount: Int { contextEstimateTracker.fullRebuildCount } private var attachedContextCharacters: Int { composer.attachments.reduce(0) { $0 + ($1.image == nil ? AgentContextEstimator.characterUnits(in: $1.content) : $1.approximateTokens * AgentContextEstimator.charactersPerToken) } } private func refreshAttachedContextEstimate() { contextEstimateTracker.setAttachedCharacters(attachedContextCharacters) publishContextEstimateImmediately() } private func publishContextEstimateImmediately() { contextEstimatePublishTask?.cancel() contextEstimatePublishTask = nil contextEstimateNeedsPublish = false let value = contextEstimateTracker.snapshot() if contextEstimate != value { contextEstimate = value contextEstimatePublishCount += 1 } } private func scheduleContextEstimatePublish() { contextEstimateNeedsPublish = true guard contextEstimatePublishTask == nil else { return } contextEstimatePublishTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(for: contextEstimatePublishInterval) guard !Task.isCancelled else { return } contextEstimatePublishTask = nil guard contextEstimateNeedsPublish else { return } contextEstimateNeedsPublish = false let value = contextEstimateTracker.snapshot() if contextEstimate != value { contextEstimate = value contextEstimatePublishCount += 1 } } } /// What the most recent turn cost, as its provider counted it. /// /// The newest report wins rather than accumulating: a turn's input already /// counts the whole conversation behind it, so this is the size of the /// context, not a running total of what has been spent on it. /// /// `nil` until a turn reports one. A conversation restored from disk starts /// here too — usage is not persisted, so a reopened chat shows an estimate /// until it next runs. See `AgentContextEstimator`. public private(set) var usage: AgentTokenUsage? /// Per-conversation reasoning state, normalized against the resolved model /// by the composer before a request is made. public var reasoningEffort: ReasoningEffort = .medium /// An explicit choice for this conversation. `nil` follows the app-wide /// last selection through `AIConfigurationModel.resolve(conversation:)`, the same shape /// used by `model` below. public var permissionMode: AgentPermissionMode? /// The model this conversation was told to use, if it was told. /// /// `nil` is the normal state and is not a missing value — it means "whatever /// the Chat role resolves to", which `AIConfigurationModel.resolve(_:)` answers. Keeping /// it unset rather than assigning a default on creation is what lets a /// change in Settings move every conversation that never chose, and lets one /// that did keep its choice. /// /// A ref rather than a name: two providers can both offer `gpt-5`, and which /// key pays for it is the question. The store is not held here — views /// resolve through `AppModel`, so a conversation stays a value that knows /// nothing about where models come from. public var model: AIModelRef? /// The generated summary, once there has been something to summarize. /// `nil` — not "", which is what an unset stored row decodes to — is what /// makes `derivedTitle` fall back rather than show an empty row. public var title: String? /// Fired when a run reaches a terminal state, so `AppModel` can title the /// conversation without the conversation knowing what a title costs. public var runDidFinish: (() -> Void)? public init( id: UUID = UUID(), messages: [ChatMessage] = [], transcript: [AgentTranscriptMessage] = [], runState: AgentRunState = .idle, messageRunIDs: [UUID: UUID] = [:], runs: [AgentRunSummary] = [], compactionRecords: [AgentCompactionRecord] = [], createdAt: Date = .now, updatedAt: Date = .now, sortIndex: Double? = nil, contextEstimatePublishInterval: Duration = .milliseconds(250) ) { let contextEstimateTracker = AgentContextEstimateTracker( systemPrompt: TermindAgentPolicy.systemPrompt, toolSchemaCharacters: 0, transcript: transcript, attachedCharacters: 0, measured: nil ) self.id = id self.createdAt = createdAt self.updatedAt = updatedAt self.sortIndex = sortIndex ?? Self.defaultSortIndex(for: updatedAt) self.messages = messages self.transcript = transcript self.messageRunIDs = messageRunIDs self.compactionRecords = compactionRecords compactions = compactionRecords.map(AgentCompactionMark.init) runSummaries = Dictionary(uniqueKeysWithValues: runs.map { ($0.id, $0) }) reportedRunState = runState self.contextEstimatePublishInterval = contextEstimatePublishInterval self.contextEstimateTracker = contextEstimateTracker contextEstimate = contextEstimateTracker.snapshot() contextEstimatePublishCount = 1 } /// The generated summary if there is one, and the opening line until then. /// /// The fallback is not a placeholder to be replaced on refresh — a title is /// generated once, so this is what a conversation shows when the summary /// never ran: no model configured, the request failed, or the first reply /// has not arrived yet. public var derivedTitle: String { if let customTitle, !customTitle.isEmpty { return customTitle } if let title, !title.isEmpty { return title } guard let first = messages.first(where: { $0.role == .user }) else { return String(localized: "New Chat") } if first.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let attachment = first.contextChips.first?.label ?? first.images.first?.label { return attachment } let line = first.text.split(separator: "\n").first.map(String.init) ?? first.text return line.count > 40 ? String(line.prefix(40)) + "…" : line } /// User-authored content only. Surface-owned session context is supplied /// separately and must never make an otherwise empty composer sendable. public var canSendDraft: Bool { !composer.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !composer.attachments.isEmpty } public var isEmpty: Bool { timeline.isEmpty } public var isArchived: Bool { archivedAt != nil } public var metadata: StoredAgentConversationMetadata { StoredAgentConversationMetadata( customTitle: customTitle ?? "", isPinned: isPinned, sortIndex: sortIndex, archivedAt: archivedAt, createdAt: createdAt, updatedAt: updatedAt ) } public nonisolated static func defaultSortIndex(for date: Date) -> Double { -date.timeIntervalSinceReferenceDate } public func canEditUserMessage(_ id: UUID) -> Bool { guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging, !isPersistingAgentPreparationFailure else { return false } return transcript.contains { $0.id == id && $0.role == .user && !$0.isCompaction } } public func canForkAssistantMessage(_ id: UUID) -> Bool { guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging, !isPersistingAgentPreparationFailure else { return false } return transcript.contains { $0.id == id && $0.role == .assistant } } public func editedUserMessage(_ id: UUID, authoredText: String) -> AgentTranscriptMessage? { guard canEditUserMessage(id), let target = transcript.firstIndex(where: { $0.id == id }) else { return nil } let original = transcript[target] let visible = original.authoredText ?? ContextChip.visiblePrompt(in: original.text) let suffix = original.text.hasPrefix(visible) ? String(original.text.dropFirst(visible.count)) : "" return AgentTranscriptMessage( role: .user, text: authoredText + suffix, authoredText: authoredText, images: original.images, contextAttachments: original.contextAttachments, createdAt: .now ) } public func canApplyUserMessageEdit( _ id: UUID, replacement: AgentTranscriptMessage ) -> Bool { guard canEditUserMessage(id), replacement.role == .user, !replacement.isCompaction, replacement.toolCalls.isEmpty, replacement.toolCallID == nil, replacement.toolName == nil, !replacement.isError, replacement.id != id, !transcript.contains(where: { $0.id == replacement.id }) else { return false } return true } public func editUserMessage( _ id: UUID, replacement: AgentTranscriptMessage ) -> ComposedUserTurn? { guard canApplyUserMessageEdit(id, replacement: replacement) else { return nil } return applyPersistedUserMessageEdit(id, replacement: replacement) } func applyPersistedUserMessageEdit( _ id: UUID, replacement: AgentTranscriptMessage ) -> ComposedUserTurn? { guard replacement.role == .user, !replacement.isCompaction, replacement.toolCalls.isEmpty, replacement.toolCallID == nil, replacement.toolName == nil, !replacement.isError, replacement.id != id, !transcript.contains(where: { $0.id == replacement.id }), let target = transcript.firstIndex(where: { $0.id == id }) else { return nil } let original = transcript[target] guard original.role == .user, !original.isCompaction else { return nil } let prefix = Array(transcript[.. targetSummary.startedAt ? summary.id : nil } return summary.id.uuidString > targetSummary.id.uuidString ? summary.id : nil }) } let hasEarlierVisibleUser = visibleTarget.map { index in messages[..= original.createdAt } for messageID in removedMessageIDs { messageRunIDs[messageID] = nil } for runID in removedRunIDs { guard prefixRunIDs.contains(runID) else { runSummaries[runID] = nil continue } let lastPrefixDate = visiblePrefix.last(where: { messageRunIDs[$0.id] == runID })?.createdAt ?? prefix.last(where: { messageRunIDs[$0.id] == runID && $0.role != .tool })?.createdAt ?? prefix.last(where: { messageRunIDs[$0.id] == runID })?.createdAt ?? original.createdAt let startedAt = runSummaries[runID]?.startedAt ?? lastPrefixDate runSummaries[runID] = AgentRunSummary( id: runID, state: .completed, startedAt: startedAt, finishedAt: lastPrefixDate) } messageRunIDs[replacement.id] = replacement.id runSummaries[replacement.id] = AgentRunSummary( id: replacement.id, state: .completed, startedAt: stagingStartedAt, finishedAt: stagingStartedAt) plans.removeAll { $0.createdAt >= original.createdAt } compactionRecords.removeAll { record in guard let boundary = record.compactedThroughMessageID else { return false } return removedMessageIDs.contains(boundary) } compactions = compactionRecords.map(AgentCompactionMark.init) tasks.removeAll() usage = nil contextEstimateTracker.setMeasured(nil) publishContextEstimateImmediately() reportedRunState = .idle updatedAt = replacement.createdAt if !hasEarlierVisibleUser { title = nil } return ComposedUserTurn( id: replacement.id, text: replacement.text, authoredText: replacement.authoredText ?? "", images: replacement.images, contextAttachments: replacement.contextAttachments ) } public func editUserMessage(_ id: UUID, authoredText: String) -> ComposedUserTurn? { guard let replacement = editedUserMessage(id, authoredText: authoredText) else { return nil } return editUserMessage(id, replacement: replacement) } public func forked( throughAssistantMessage id: UUID, title forkTitle: String? = nil ) -> ConversationModel? { guard canForkAssistantMessage(id), let selected = transcript.firstIndex(where: { $0.id == id }) else { return nil } var end = selected let requiredCalls = Set(transcript[selected].toolCalls.map(\.id)) if !requiredCalls.isEmpty { var cursor = transcript.index(after: selected) while cursor < transcript.endIndex, transcript[cursor].role == .tool { if let callID = transcript[cursor].toolCallID, requiredCalls.contains(callID) { end = cursor } cursor = transcript.index(after: cursor) } } let source = Array(transcript[...end]) var messageIDMap: [UUID: UUID] = [:] let cloned = source.map { message -> AgentTranscriptMessage in var copy = message let newID = UUID() messageIDMap[message.id] = newID copy.id = newID return copy } let visibleMessages = cloned.compactMap { message -> ChatMessage? in guard !message.isCompaction else { return nil } switch message.role { case .user: return ChatMessage( id: message.id, role: .user, text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text), contextChips: ContextChip.visibleReferences( in: message.text, images: message.images, contextAttachments: message.contextAttachments ), images: message.images, createdAt: message.createdAt ) case .assistant: guard !message.text.isEmpty || !message.reasoning.isEmpty else { return nil } return ChatMessage( id: message.id, role: .assistant, text: message.text, reasoning: message.reasoning, createdAt: message.createdAt ) case .tool: return nil } } let cutoff = transcript[end].createdAt let includedMessageIDs = Set(source.map(\.id)) let includedCards = toolCards.filter { card in if let sourceMessageID = card.invocation.sourceMessageID { return includedMessageIDs.contains(sourceMessageID) } return card.invocation.createdAt <= cutoff } let originalRunIDs = Set( source.compactMap { messageRunIDs[$0.id] } + includedCards.map(\.invocation.runID) ) let runIDMap = Dictionary(uniqueKeysWithValues: originalRunIDs.map { ($0, UUID()) }) let clonedMessageRunIDs: [UUID: UUID] = Dictionary( uniqueKeysWithValues: source.compactMap { message -> (UUID, UUID)? in guard let oldRunID = messageRunIDs[message.id], let newRunID = runIDMap[oldRunID], let newMessageID = messageIDMap[message.id] else { return nil } return (newMessageID, newRunID) } ) let clonedRuns = originalRunIDs.compactMap { oldRunID -> AgentRunSummary? in guard var run = runSummaries[oldRunID], let newID = runIDMap[oldRunID] else { return nil } run.id = newID return run } let clonedCompactions = compactionRecords.compactMap { record -> AgentCompactionRecord? in guard let summaryMessageID = messageIDMap[record.summaryMessageID] else { return nil } var copy = record copy.id = UUID() copy.summaryMessageID = summaryMessageID // The fork stores only its compact model history, so its summary is // the base context rather than a boundary into raw source rows. copy.compactedThroughMessageID = nil return copy } let fork = ConversationModel( messages: visibleMessages, transcript: cloned, runState: .completed, messageRunIDs: clonedMessageRunIDs, runs: clonedRuns, compactionRecords: clonedCompactions, createdAt: .now, updatedAt: .now ) let resolvedTitle = forkTitle ?? Self.numberedForkTitle(base: derivedTitle, index: 1) fork.customTitle = resolvedTitle fork.model = model fork.permissionMode = permissionMode fork.reasoningEffort = reasoningEffort fork.isPlanning = isPlanning fork.toolCards = includedCards.map { card in var copy = card copy.invocation.id = UUID() copy.invocation.runID = runIDMap[card.invocation.runID] ?? UUID() copy.invocation.sourceMessageID = card.invocation.sourceMessageID.flatMap { messageIDMap[$0] } return copy } return fork } /// Keeps the disambiguating number visible even when the source title is /// already at the persistence limit. A plain `prefix(80)` would trim the /// suffix off and leave the fork indistinguishable from its source. public nonisolated static func numberedForkTitle(base: String, index: Int) -> String { let suffix = " (\(index))" return String(base.prefix(max(0, 80 - suffix.count))) + suffix } /// One collapsible work group per run, keyed by source-message ownership. public var workGroups: [AgentWorkGroup] { let visibleByID = Dictionary(uniqueKeysWithValues: messages.map { ($0.id, $0) }) let cardsByRun = Dictionary( grouping: toolCards.filter { $0.terminalSuggestion == nil }, by: { $0.invocation.runID } ) let toolProcessMessageIDs = Set( transcript.compactMap { message -> UUID? in message.role == .assistant && !message.toolCalls.isEmpty ? message.id : nil }) var processRunIDs: [UUID: UUID] = [:] for message in transcript where message.role == .assistant && !message.toolCalls.isEmpty { if let runID = messageRunIDs[message.id] { processRunIDs[message.id] = runID } } // Reasoning is visible only for the model turn currently in flight. // Completed provider summaries remain model-facing data, not UI // history, and therefore do not resurrect after restore. if let activeReasoningMessageID, let runID = messageRunIDs[activeReasoningMessageID] { processRunIDs[activeReasoningMessageID] = runID } // Put the generic activity text in the same work slot that reasoning // will occupy. Previously the empty assistant row lived outside the // work group, so its replacement jumped below a newly inserted header // and divider as soon as the first reasoning delta arrived. if let activeRunID { for message in messages where message.role == .assistant && message.isStreaming && messageRunIDs[message.id] == activeRunID { processRunIDs[message.id] = activeRunID } } let stoppedRunIDs = runSummaries.values.compactMap { run in run.state == .cancelled ? run.id : nil } let runIDs = Set(cardsByRun.keys) .union(processRunIDs.values) .union(stoppedRunIDs) return runIDs.compactMap { runID in let cards = cardsByRun[runID] ?? [] let processMessages = processRunIDs.compactMap { messageID, owner -> ChatMessage? in guard owner == runID else { return nil } return visibleByID[messageID] } let workHasNonReasoningOutput = !cards.isEmpty || processMessages.contains { !$0.text.isEmpty } let hasVisibleReasoning = processMessages.contains { message in message.id == activeReasoningMessageID && !message.reasoning.isEmpty } let visibleProcess = processMessages.filter { message in let livePlaceholder = message.isStreaming && message.text.isEmpty && message.reasoning.isEmpty let toolProcess = toolProcessMessageIDs.contains(message.id) && (!message.text.isEmpty || (message.isStreaming && message.reasoning.isEmpty)) // Working belongs to the Work as a whole. Reasoning does not // consume this slot: a later empty turn may still be replaced // by its next reasoning block. Any non-reasoning output does. return (livePlaceholder && !hasVisibleReasoning && !workHasNonReasoningOutput) || toolProcess } let groupableCards = cards.filter { $0.descriptor.presentation?.groupsWithAdjacentTools != false } let hasUnfinishedGroupableTool = groupableCards.contains { !$0.state.isTerminal } let latestToolReasoning = processMessages .sorted { $0.createdAt < $1.createdAt } .reversed() .compactMap { message -> AgentToolReasoning? in guard let markdown = message.reasoning.last?.text, !markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } return AgentToolReasoning( sourceMessageID: message.id, markdown: markdown ) } .first // Once there is a tool surface to carry it, reasoning becomes a // transient title instead of a separate structural row. Before the // atomic batch is published it remains visible on its own. let reasoningItems = !hasUnfinishedGroupableTool ? processMessages.flatMap { message -> [AgentWorkItem] in guard message.id == activeReasoningMessageID else { return [] } return message.reasoning.map(AgentWorkItem.reasoning) } : [] let activeReasoningBlockID = processMessages.first(where: { $0.id == activeReasoningMessageID && $0.isStreaming && $0.text.isEmpty })?.reasoning.last?.id let retryProgress = processMessages.compactMap(\.retryProgress).last var items = reasoningItems + visibleProcess.map(AgentWorkItem.message) + cards.map(AgentWorkItem.tool) items.sort { if $0.orderedAt != $1.orderedAt { return $0.orderedAt < $1.orderedAt } switch ($0, $1) { case (.reasoning, .message), (.reasoning, .tool), (.reasoning, .tools): return true case (.message, .reasoning), (.tool, .reasoning), (.tools, .reasoning): return false case (.message, .tool), (.message, .tools): return true case (.tool, .message), (.tools, .message): return false default: return $0.id.uuidString < $1.id.uuidString } } items = Self.groupRunTools( in: items, runID: runID, reasoning: hasUnfinishedGroupableTool ? latestToolReasoning : nil ) let stored = runSummaries[runID] // A user stop is useful transcript state even when it interrupted a // direct response before any tool or process item was produced. guard !items.isEmpty || stored?.state == .cancelled else { return nil } // The work row follows the clock of the first thing it actually // presents. A stopped direct answer has no work item, so its stop // event is the row's place in the timeline. let orderedAt = items.first?.orderedAt ?? stored?.finishedAt ?? stored?.startedAt ?? .now let anchorID = messages.first(where: { $0.createdAt >= orderedAt })?.id let answerAt = messages.last(where: { $0.role == .assistant && !$0.text.isEmpty && messageRunIDs[$0.id] == runID && processRunIDs[$0.id] == nil && $0.createdAt < orderedAt })?.createdAt let fallbackState: AgentRunState = cards.allSatisfy { $0.state == .completed } ? .completed : .interrupted var run = stored ?? AgentRunSummary( id: runID, state: fallbackState, startedAt: items.first?.orderedAt ?? orderedAt, finishedAt: items.last?.orderedAt ) if activeRunID == runID { run.state = runState } return AgentWorkGroup( run: run, items: items, orderedAt: orderedAt, anchorMessageID: anchorID, appearsAfterAnswer: answerAt != nil, activeReasoningBlockID: activeReasoningBlockID, retryProgress: retryProgress ) }.sorted { $0.orderedAt < $1.orderedAt } } /// Produces one stable batch for every grouping-enabled tool in the run. /// Reasoning, provider-turn boundaries and ordinary process messages remain /// in chronological order but no longer flush the tool collection. Tools /// that explicitly opt out keep their direct row. private static func groupRunTools( in items: [AgentWorkItem], runID: UUID, reasoning: AgentToolReasoning? ) -> [AgentWorkItem] { let batched = items.compactMap { item -> AgentToolCard? in guard case .tool(let card) = item, card.descriptor.presentation?.groupsWithAdjacentTools != false else { return nil } return card } guard !batched.isEmpty else { return items } var grouped: [AgentWorkItem] = [] var insertedBatch = false for item in items { if case .tool(let card) = item { if card.descriptor.presentation?.groupsWithAdjacentTools == false { grouped.append(.tool(card)) } else if !insertedBatch { grouped.append( .tools( AgentToolBatch( cards: batched, runID: runID, reasoning: reasoning ))) insertedBatch = true } } else { grouped.append(item) } } return grouped } /// Messages and run-scoped work in the order the reader experienced them. public var timeline: [TimelineItem] { var items: [TimelineItem] = [] let groups = workGroups let groupedProcessIDs: Set = Set( groups.flatMap { group -> [UUID] in group.items.compactMap { item -> UUID? in guard case .message(let message) = item else { return nil } return message.id } }) var emittedGroups = Set() for message in messages { for group in groups where group.anchorMessageID == message.id { items.append(.work(group)) emittedGroups.insert(group.id) } if groupedProcessIDs.contains(message.id) { continue } // A turn that only called tools has no text to show, and an empty // bubble reads as the model having said nothing. While it is still // streaming it stays only until this turn receives reasoning; that // live text then owns the activity treatment until turnFinished. guard message.role != .assistant || !message.text.isEmpty || (message.isStreaming && message.reasoning.isEmpty) else { continue } items.append(.message(message)) } items += groups.filter { !emittedGroups.contains($0.id) }.map(TimelineItem.work) let commands = toolCards.compactMap(\.terminalSuggestion) let failures = runSummaries.values.compactMap { run -> AgentRunFailureNotice? in guard run.state == .failed, let reason = run.failure, !reason.isEmpty else { return nil } return AgentRunFailureNotice( runID: run.id, reason: reason, createdAt: run.finishedAt ?? run.startedAt ) } guard !commands.isEmpty || !plans.isEmpty || !compactions.isEmpty || !failures.isEmpty || compactionActivity != nil else { return items } // Merged by time rather than woven into the loop above. A plan ends its // run and a compaction happens between runs, so there are never many of // either, and two more cursors inside that loop would be two more things // to keep right for no gain. var extra = commands.map(TimelineItem.command) + plans.map(TimelineItem.plan) + compactions.map(TimelineItem.compaction) + failures.map(TimelineItem.failure) if let compactionActivity { extra.append(.compactionActivity(compactionActivity)) } return (items + extra).sorted { $0.orderedAt == $1.orderedAt ? $0.sortRank < $1.sortRank : $0.orderedAt < $1.orderedAt } } // MARK: - Compaction /// What a compaction would summarize, or `nil` when there is not enough /// history to be worth it. public func compactionSpan( keepRecentTokens: Int = AgentCompaction.defaultKeepRecentTokens ) -> (summarize: [AgentTranscriptMessage], cut: Int)? { guard let cut = AgentCompaction.cutIndex( in: transcript, keepRecentTokens: keepRecentTokens ) else { return nil } return (Array(transcript[.. AgentCompactionRecord? { guard cut > 0, cut <= transcript.count else { return nil } let summarized = transcript[.. 0 && cut <= transcript.count) let replacement = record.summaryMessage let removedIDs = transcript[.. AgentCompactionRecord? { guard let record = prepareCompaction(summary: summary, cut: cut) else { return nil } applyCompaction(record, cut: cut) return record } /// Records what the user decided about a plan. /// /// Implementing turns plan mode off, because that is what the decision /// means: the next run is the one that acts. Keeping the mode on after /// "Implement" would refuse every tool the plan asked for. public func resolvePlan(_ id: AgentPlan.ID, as outcome: AgentPlan.Outcome) { guard let index = plans.firstIndex(where: { $0.id == id }) else { return } plans[index].outcome = outcome if outcome == .implemented { isPlanning = false } } /// What starting the implementation run should send. /// /// The plan itself is already in `transcript` — `present_plan` returned it — /// so this is a pointer, not a copy. Naming the scratch path lets the model /// re-read the exact text rather than work from what it remembers writing. public static func implementationPrompt(for plan: AgentPlan) -> String { String( localized: """ Implement the plan you presented ("\(plan.title)", drafted at \ \(plan.scratchPath)). Follow it; if something you find contradicts it, say so \ before departing from it. """ ) } public func attach(_ chip: ContextChip) { guard !composer.attachments.contains(where: { if let image = chip.image, let existing = $0.image { return existing.data == image.data } return $0.kind == chip.kind && $0.content == chip.content }) else { return } composer.attachments.append(chip) refreshAttachedContextEstimate() } public func detach(_ id: ContextChip.ID) { composer.attachments.removeAll { $0.id == id } refreshAttachedContextEstimate() } public func beginAttachmentImport(label: String) -> PendingContextAttachment? { guard composer.attachments.count + composer.pendingAttachments.count < ContextAttachmentLoading.maximumAttachments else { composer.attachmentImportError = String( localized: "A message can include at most 20 attachments." ) return nil } let pending = PendingContextAttachment(label: label) composer.pendingAttachments.append(pending) return pending } public func importAttachmentFiles(_ urls: [URL]) { for url in urls.prefix(ContextAttachmentLoading.maximumAttachments) { guard let pending = beginAttachmentImport(label: url.lastPathComponent) else { break } let task = Task { [weak self] in guard let self else { return } do { let chip = try await attachmentLoader.file(url) finishAttachmentImport(pending.id, with: chip) } catch is CancellationError { cancelAttachmentImport(pending.id) } catch { failAttachmentImport( pending.id, message: "\(url.lastPathComponent): \(error.localizedDescription)" ) } attachmentImportTasks[pending.id] = nil } attachmentImportTasks[pending.id] = task } } public func importAttachmentImages(_ images: [Data], label: String) { for data in images.prefix(ContextAttachmentLoading.maximumAttachments) { guard let pending = beginAttachmentImport(label: label) else { break } let task = Task { [weak self] in guard let self else { return } do { let chip = try await attachmentLoader.image(data, label) finishAttachmentImport(pending.id, with: chip) } catch is CancellationError { cancelAttachmentImport(pending.id) } catch { failAttachmentImport(pending.id, message: error.localizedDescription) } attachmentImportTasks[pending.id] = nil } attachmentImportTasks[pending.id] = task } } public func finishAttachmentImport(_ id: PendingContextAttachment.ID, with chip: ContextChip) { guard composer.pendingAttachments.contains(where: { $0.id == id }) else { return } composer.pendingAttachments.removeAll { $0.id == id } attach(chip) } public func failAttachmentImport(_ id: PendingContextAttachment.ID, message: String) { guard composer.pendingAttachments.contains(where: { $0.id == id }) else { return } composer.pendingAttachments.removeAll { $0.id == id } composer.attachmentImportError = [composer.attachmentImportError, message] .compactMap { $0 }.joined(separator: "\n") } public func cancelAttachmentImport(_ id: PendingContextAttachment.ID) { attachmentImportTasks[id]?.cancel() attachmentImportTasks[id] = nil composer.pendingAttachments.removeAll { $0.id == id } } /// Records a local user turn. Agent-backed composers use AppModel's runtime /// path; the sidebar promotion flow uses this before starting that run. public func sendDraft() { let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) guard canSendDraft else { return } appendUserTurn(text) composer.draft = "" } /// Adds one user turn to both the visible transcript and the model-facing /// one, folding attachments into only the latter, and hands back the text /// that actually went. /// /// The caller must start its run with the *returned* string. The run request /// carries the prompt separately and `AgentRuntime` appends it to its own /// snapshot, so passing the pre-fold text there would send the model a turn /// that does not match the one in `transcript` — and `priorMessages` must /// stop short of it either way, see `pendingPriorMessages`. /// /// Attachments are consumed, not kept: they belong to this message, they are /// in the transcript from here on, and the pill going away is how the user /// sees that the payload was sent. @discardableResult public func appendUserTurn(_ text: String) -> String { appendUserTurnContent(text).text } public struct ComposedUserTurn: Sendable { public var id: UUID public var text: String public var authoredText: String public var images: [AgentImageAttachment] public var contextAttachments: [AgentContextAttachment] public init( id: UUID, text: String, authoredText: String, images: [AgentImageAttachment], contextAttachments: [AgentContextAttachment] ) { self.id = id self.text = text self.authoredText = authoredText self.images = images self.contextAttachments = contextAttachments } } @discardableResult public func appendUserTurnContent(_ text: String, contextSnapshot: AgentTurnContextSnapshot? = nil) -> ComposedUserTurn { appendUserTurnContent(composeUserTurn(text), contextSnapshot: contextSnapshot) } @discardableResult public func appendUserTurnContent( _ composed: ComposedUserTurn, contextSnapshot: AgentTurnContextSnapshot? = nil ) -> ComposedUserTurn { let messageID = composed.id let contextChips = composer.attachments.map { ContextChipReference( id: $0.id, kind: $0.kind, label: $0.label, attachment: $0.contextAttachment ) } composer.attachments.removeAll() composer.armedSkillName = nil refreshAttachedContextEstimate() messages.append( ChatMessage( id: messageID, role: .user, text: composed.authoredText, contextChips: contextChips, images: composed.images ) ) transcript.append( AgentTranscriptMessage( id: messageID, role: .user, text: composed.text, authoredText: composed.authoredText, images: composed.images, contextAttachments: composed.contextAttachments, contextSnapshot: contextSnapshot ) ) if let message = transcript.last { contextEstimateTracker.upsert(message) publishContextEstimateImmediately() } updatedAt = .now return composed } /// Renders attachments into the model-facing message body. The visible /// `ChatMessage` deliberately keeps only the prompt: extracted documents /// and pasted text can be hundreds of thousands of characters and do not /// belong in a Markdown bubble. public func composeUserTurn(_ text: String) -> ComposedUserTurn { let skillDirective = composer.armedSkillName.map { name in """ The user asked for this skill by name. Load it with skill_read_file before answering, and \ follow it. """ } guard !composer.attachments.isEmpty || skillDirective != nil else { return ComposedUserTurn( id: UUID(), text: text, authoredText: text, images: [], contextAttachments: [] ) } let blocks = composer.attachments.compactMap { chip -> String? in guard chip.image == nil else { return nil } // A label can be a filename, and a filename can contain a quote — // which would close the attribute and leave the block malformed. let label = chip.label.replacingOccurrences(of: "\"", with: "'") return """ <\(chip.kind.blockName) label="\(label)"> \(chip.content) """ } // The directive rides `text` and not `authoredText`, exactly as the // attachment blocks do. What the user typed is what the bubble shows, // what message editing reopens, and what the title generator reads; the // model gets the instruction as well. return ComposedUserTurn( id: UUID(), text: ([text] + blocks + [skillDirective].compactMap { $0 }) .joined(separator: "\n\n"), authoredText: text, images: composer.attachments.compactMap(\.image), contextAttachments: composer.attachments.compactMap(\.contextAttachment) ) } /// The history to replay for a run whose prompt is the last user turn. public var pendingPriorMessages: [AgentTranscriptMessage] { guard let last = transcript.lastIndex(where: { $0.role == .user }), last == transcript.index(before: transcript.endIndex) else { return transcript } return Array(transcript[.. Task ) -> Bool { guard agentPreparationID == nil else { return false } agentPreparationID = id cancelAgentPreparation = cancellation isPreparingAgentRun = true return true } func supersedeAgentPreparation( id: UUID, cancellation: @escaping @MainActor () -> Task ) { agentPreparationID = id cancelAgentPreparation = cancellation isPreparingAgentRun = true } func finishAgentPreparation(id: UUID) { guard agentPreparationID == id else { return } agentPreparationID = nil cancelAgentPreparation = nil isPreparingAgentRun = false isPersistingAgentPreparationFailure = false } func beginAgentPreparationFailurePersistence(id: UUID) { guard agentPreparationID == id else { return } isPersistingAgentPreparationFailure = true } @discardableResult func cancelPendingAgentPreparation() -> Task? { cancelAgentPreparation?() } func cancelPendingAgentPreparationAndWait() async { await cancelPendingAgentPreparation()?.value } public func start( runtime: AgentRuntime, request: AgentRunRequest, repository: any AgentRunPersisting ) { if let displaced = activeRunOwner { requestCancellation(of: displaced) stopPresentation(owner: displaced) } activeContextSnapshot = request.contextSnapshot if let index = transcript.firstIndex(where: { $0.id == request.promptID }) { transcript[index].contextSnapshot = request.contextSnapshot contextEstimateTracker.upsert(transcript[index]) } reportedRunState = .running nextRunGeneration &+= 1 let owner = AgentRunOwner( generation: nextRunGeneration, runtime: runtime, request: request, repository: repository ) activeRunOwner = owner runOwners[owner.generation] = owner let generation = owner.generation owner.consumer = Task { [weak self, weak owner] in guard let phase = owner?.phase else { return } switch phase { case .cancelledBeforeStart: await owner?.prestartPersistence?.value self?.finishRunOwner(generation: generation) return case .queued: owner?.phase = .starting case .starting, .started, .finished: return } let events = await runtime.start(request) owner?.phase = .started if Task.isCancelled || owner == nil || owner?.cancellationRequested == true { await runtime.cancel() } for await event in events { guard let self, let owner, self.activeRunOwner === owner else { continue } self.handle(event) } self?.finishRunOwner(generation: generation) } } private func finishRunOwner(generation: UInt64) { guard let owner = runOwners[generation] else { return } owner.phase = .finished owner.prestartPersistence = nil owner.consumer = nil runOwners[generation] = nil if activeRunOwner === owner { activeRunOwner = nil } } private func cancelledSnapshot(for owner: AgentRunOwner, finishedAt: Date) -> AgentRunSnapshot { let request = owner.request var messages = AgentOrphanedToolResultRepair.apply( AgentUnansweredToolCallRepair.apply( AgentSecretLifecycle.sanitizeHistoricalMessages(request.priorMessages) )) let prompt = AgentTranscriptMessage( id: request.promptID, role: .user, text: request.prompt, authoredText: request.authoredPrompt, images: request.promptImages, contextAttachments: request.promptContextAttachments, contextSnapshot: request.contextSnapshot, createdAt: transcript.first(where: { $0.id == request.promptID })?.createdAt ?? owner.startedAt ) if let index = messages.firstIndex(where: { $0.id == prompt.id }) { if messages[index].contextSnapshot == nil { messages[index].contextSnapshot = prompt.contextSnapshot } } else { messages.append(prompt) } return AgentRunSnapshot( id: owner.runID, conversationID: request.conversationID, state: .cancelled, permissionMode: request.permissionMode, messages: messages, startedAt: owner.startedAt, finishedAt: finishedAt ) } private func requestCancellation(of owner: AgentRunOwner) { guard !owner.cancellationRequested else { return } owner.cancellationRequested = true switch owner.phase { case .queued: let snapshot = cancelledSnapshot(for: owner, finishedAt: .now) owner.phase = .cancelledBeforeStart(snapshot) let repository = owner.repository owner.prestartPersistence = Task { try? await repository.save(snapshot) } if activeRunOwner === owner { projectCancelledRun(snapshot) } case .starting, .started: Task { await owner.runtime.cancel() } case .cancelledBeforeStart, .finished: break } } private func projectCancelledRun(_ snapshot: AgentRunSnapshot) { guard snapshot.conversationID == id, let prompt = snapshot.messages.last(where: { $0.role == .user }) else { return } var summary = AgentRunSummary( id: snapshot.id, state: .cancelled, startedAt: snapshot.startedAt, finishedAt: snapshot.finishedAt ) if messageRunIDs[prompt.id] == nil || adoptStagedReplacementRun(promptID: prompt.id, summary: &summary) { messageRunIDs[prompt.id] = snapshot.id } runSummaries[snapshot.id] = summary reportedRunState = .cancelled updatedAt = snapshot.finishedAt ?? .now } /// Records a request that could not construct a runtime, such as a missing /// model or credential, using the same durable run failure UI as a provider /// error that arrived after dispatch. public func recordFailedRun(_ snapshot: AgentRunSnapshot) { guard snapshot.conversationID == id, snapshot.state == .failed, let failure = snapshot.failure else { return } var summary = AgentRunSummary( id: snapshot.id, state: .failed, startedAt: snapshot.startedAt, finishedAt: snapshot.finishedAt, failure: failure ) if let prompt = snapshot.messages.last(where: { $0.role == .user }), messageRunIDs[prompt.id] == nil || adoptStagedReplacementRun(promptID: prompt.id, summary: &summary) { messageRunIDs[prompt.id] = snapshot.id } runSummaries[snapshot.id] = summary reportedRunState = .failed updatedAt = snapshot.finishedAt ?? .now runDidFinish?() } public func adoptStagedReplacementRun(_ snapshot: AgentRunSnapshot) { guard snapshot.conversationID == id, let prompt = snapshot.messages.last(where: { $0.role == .user }) else { return } var summary = AgentRunSummary( id: snapshot.id, state: snapshot.state, startedAt: snapshot.startedAt, finishedAt: snapshot.finishedAt, failure: snapshot.failure) adoptStagedReplacementRun( promptID: prompt.id, summary: &summary) } @discardableResult private func adoptStagedReplacementRun( promptID: UUID, summary: inout AgentRunSummary ) -> Bool { guard messageRunIDs[promptID] == promptID, let staging = runSummaries[promptID], staging.state == .completed, staging.failure == nil, messageRunIDs.filter({ $0.value == promptID }).count == 1, !toolCards.contains(where: { $0.invocation.runID == promptID }) else { return false } if summary.startedAt < staging.startedAt { summary.startedAt = staging.startedAt } if let finishedAt = summary.finishedAt, finishedAt < summary.startedAt { summary.finishedAt = summary.startedAt } runSummaries[promptID] = nil messageRunIDs[promptID] = summary.id runSummaries[summary.id] = summary return true } /// Retains the ordered event consumer until the cancellation snapshot is durable. public func stopAndWait() async { let preparation = cancelPendingAgentPreparation() let owners = Array(runOwners.values) let activeOwner = activeRunOwner if let activeOwner { requestCancellation(of: activeOwner) } stopPresentation(owner: activeOwner) await preparation?.value for owner in owners { await owner.consumer?.value } } public func stop() { cancelPendingAgentPreparation() let owner = activeRunOwner if let owner { requestCancellation(of: owner) } stopPresentation(owner: owner) } private func stopPresentation(owner: AgentRunOwner?) { let stoppedRunID = activeRunID if let owner, activeRunOwner === owner { activeRunOwner = nil } settleStreamingPresentation(for: activeRunID) publishAllStagedTools() if let stoppedRunID { retireStoppedRunInteractions(runID: stoppedRunID) } for index in toolCards.indices where toolCards[index].invocation.runID == stoppedRunID && !toolCards[index].state.isTerminal { toolCards[index].state = .interrupted } if !reportedRunState.isTerminal { reportedRunState = .cancelled // Runtime terminal persistence can finish after the presentation // stops. Freeze the visible elapsed time without cancelling its // event drain, which is the durable join boundary. if let activeRunID, var summary = runSummaries[activeRunID] { summary.state = .cancelled summary.finishedAt = .now runSummaries[activeRunID] = summary self.activeRunID = nil } } returnAllSteeringToDraft() } private func retireStoppedRunInteractions(runID: UUID) { let approvalIDs = pendingApprovals.compactMap { request in request.invocation.runID == runID ? request.id : nil } for requestID in approvalIDs { guard let index = pendingApprovals.firstIndex(where: { $0.id == requestID }) else { continue } let request = pendingApprovals.remove(at: index) updateToolCard(request.invocation.id) { card in if !card.state.isTerminal { card.state = .interrupted } } } let reviewIDs = reviewing.filter { invocationID in toolCards.first(where: { $0.id == invocationID })?.invocation.runID == runID || stagedToolCards[invocationID]?.invocation.runID == runID } reviewing.subtract(reviewIDs) for invocationID in reviewIDs { updateToolCard(invocationID) { card in if !card.state.isTerminal { card.state = .interrupted } } } } /// Queues the draft for the running agent instead of starting a second run. /// /// Nothing reaches `messages` or `transcript` here. A bubble posted at the /// keystroke sits above the assistant text that actually preceded it, and /// `transcript` is replayed as the next run's history — so the model would /// later be shown a conversation that never happened. The bubble appears /// when the runtime reports the message delivered, which is where the model /// really saw it. public func submitSteeringDraft(context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false) { let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) guard canSendDraft, let owner = activeRunOwner else { return } let runtime = owner.runtime let composed = composeUserTurn(text) var snapshot = activeContextSnapshot?.withSessionContext(context.modelContext) if snapshot != nil { var availability = snapshot?.toolAvailability ?? [:] availability["terminal_suggest"] = terminalSuggestionsAvailable snapshot?.toolAvailability = availability } let pending = PendingSteeringMessage( draft: text, attachments: composer.attachments, message: AgentTranscriptMessage( role: .user, text: composed.text, authoredText: text, images: composed.images, contextAttachments: composed.contextAttachments, contextSnapshot: snapshot ) ) pendingSteering.append(pending) composer.attachments.removeAll() refreshAttachedContextEstimate() composer.draft = "" Task { [weak self] in guard await runtime.steer(pending.message) == false else { return } guard self?.activeRunOwner === owner else { return } // The run ended between the keystroke and this hop. Everything // still queued is undeliverable, not just this one — and returning // the whole queue at once is also what keeps the words in the order // they were typed, which racing hops resolving one at a time would // not. self?.returnAllSteeringToDraft() } } /// Hands one queued message back to the composer. /// /// Called by the ✕ on a queued row, and by every path that ends a run with /// something still undelivered. Dropping the text instead would be the /// second time the same words disappeared without being sent. public func returnSteeringToDraft(_ id: AgentTranscriptMessage.ID) { guard let index = pendingSteering.firstIndex(where: { $0.id == id }) else { return } let pending = pendingSteering.remove(at: index) composer.draft = [pending.draft, composer.draft] .filter { !$0.isEmpty }.joined(separator: "\n\n") for chip in pending.attachments { attach(chip) } } /// Reversed because each entry is put back *above* the draft, so walking /// the queue backwards is what leaves it in the order it was typed. private func returnAllSteeringToDraft() { for pending in pendingSteering.reversed() { returnSteeringToDraft(pending.id) } } /// Owns the approval queue. /// /// `AgentApprovalBroker` both publishes `approvalRequested` and calls this, /// and the two used to write the same slot, which made the duplication /// invisible. Against a queue it would not be: `handle` deliberately only /// moves the card's state, and enqueueing happens here — on the path that /// holds the continuation and can therefore actually answer. public func requestApproval(_ request: AgentApprovalRequest) async -> AgentApprovalDecision { await withTaskCancellationHandler { await withCheckedContinuation { continuation in guard !Task.isCancelled else { continuation.resume(returning: Self.cancelledApprovalDecision) return } // The approval callback can arrive before the channel drains // toolProposed. Stage the same identity now so a direct user // response has a card to update. stageTool(request.invocation, descriptor: request.descriptor) // Inserted by when the call was planned, not by when it got // here. Parallel calls race to this queue, while planning is // sequential and supplies the stable user-facing order. let index = pendingApprovals.firstIndex { $0.invocation.createdAt > request.invocation.createdAt } pendingApprovals.insert(request, at: index ?? pendingApprovals.endIndex) setToolState(request.invocation.id, .waitingForApproval) approvalWaiters[request.id] = continuation } } onCancel: { Task { @MainActor [weak self] in self?.cancelApproval(request.id) } } } private static var cancelledApprovalDecision: AgentApprovalDecision { .deny(String(localized: "The user denied this action.")) } private func cancelApproval(_ requestID: UUID) { guard let index = pendingApprovals.firstIndex(where: { $0.id == requestID }) else { approvalWaiters.removeValue(forKey: requestID)?.resume( returning: Self.cancelledApprovalDecision) return } let request = pendingApprovals.remove(at: index) updateToolCard(request.invocation.id) { card in if !card.state.isTerminal { card.state = .interrupted } } approvalWaiters.removeValue(forKey: request.id)?.resume( returning: Self.cancelledApprovalDecision) } public func respondToApproval(_ requestID: UUID, allow: Bool) { guard pendingApprovals.first?.id == requestID else { return } let request = pendingApprovals.removeFirst() let decision: AgentApprovalDecision = allow ? .allow : .deny(String(localized: "The user denied this action.")) if !allow { setToolState(request.invocation.id, .denied) } approvalWaiters.removeValue(forKey: request.id)?.resume(returning: decision) } public func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse { await withTaskCancellationHandler { await withCheckedContinuation { continuation in inputWaiters[request.id] = continuation present(request) } } onCancel: { Task { @MainActor [weak self] in self?.cancelUserInput(request.id) } } } public func requestSSHAuthentication( _ request: AgentSSHAuthenticationRequest ) async -> AgentSSHAuthenticationResponse { await withTaskCancellationHandler { await withCheckedContinuation { guard !Task.isCancelled else { $0.resume(returning: .cancelled) return } sshAuthenticationWaiters[request.id] = $0 let index = pendingSSHAuthentications.firstIndex { $0.createdAt > request.createdAt } pendingSSHAuthentications.insert( request, at: index ?? pendingSSHAuthentications.endIndex ) setToolState(request.invocationID, .waitingForUser) if request.keyboardInteractive?.prompts.isEmpty == true { Task { [weak self] in await Task.yield() self?.respondToSSHAuthentication(.keyboardInteractive([]), requestID: request.id) } } } } onCancel: { Task { @MainActor [weak self] in self?.cancelSSHAuthentication(request.id) } } } public func respondToSSHAuthentication(_ response: AgentSSHAuthenticationResponse, requestID: UUID? = nil) { guard let index = requestID.map({ id in pendingSSHAuthentications.firstIndex { $0.id == id } }) ?? (pendingSSHAuthentications.isEmpty ? nil : 0) else { return } let request = pendingSSHAuthentications.remove(at: index) setToolState(request.invocationID, .running) sshAuthenticationWaiters.removeValue(forKey: request.id)?.resume(returning: response) } public func requestSSHHostKeyConfirmation( _ request: AgentSSHHostKeyRequest ) async -> AgentSSHHostKeyResponse { await withTaskCancellationHandler { await withCheckedContinuation { sshHostKeyWaiters[request.id] = $0 let index = pendingSSHHostKeys.firstIndex { $0.createdAt > request.createdAt } pendingSSHHostKeys.insert( request, at: index ?? pendingSSHHostKeys.endIndex ) setToolState(request.invocationID, .waitingForUser) } } onCancel: { Task { @MainActor [weak self] in self?.cancelSSHHostKey(request.id) } } } public func respondToSSHHostKey(_ response: AgentSSHHostKeyResponse) { guard !pendingSSHHostKeys.isEmpty else { return } let request = pendingSSHHostKeys.removeFirst() setToolState(request.invocationID, .running) sshHostKeyWaiters.removeValue(forKey: request.id)?.resume(returning: response) } private func cancelSSHAuthentication(_ id: UUID) { guard let index = pendingSSHAuthentications.firstIndex(where: { $0.id == id }) else { return } pendingSSHAuthentications.remove(at: index) sshAuthenticationWaiters.removeValue(forKey: id)?.resume(returning: .cancelled) } private func cancelSSHHostKey(_ id: UUID) { guard let index = pendingSSHHostKeys.firstIndex(where: { $0.id == id }) else { return } pendingSSHHostKeys.remove(at: index) sshHostKeyWaiters.removeValue(forKey: id)?.resume(returning: .deny) } /// Puts a question on screen. The queue path uses it, and so does any /// caller that wants the card without a run behind it. public func present(_ request: AgentUserInputRequest) { let previousHead = pendingUserInputs.first?.id let index = pendingUserInputs.firstIndex { $0.createdAt > request.createdAt } pendingUserInputs.insert(request, at: index ?? pendingUserInputs.endIndex) // Only the head is being answered, so only the head's drafts exist. A // queued question gets a blank sheet when it reaches the front. if pendingUserInputs.first?.id != previousHead { clearUserInputDrafts() scheduleUserInputTimeout() } } public func userInputDraft(for questionID: String) -> AgentUserInputDraft { userInputDrafts[questionID] ?? AgentUserInputDraft() } /// Whether this one question has been answered. Skipping is allowed, so /// this drives a count rather than a validation error. public func isUserInputAnswered(_ question: AgentUserQuestion) -> Bool { switch question.kind { case .secret: return !secretInputDraft.isEmpty default: let draft = userInputDraft(for: question.id) return !draft.selection.isEmpty || !draft.trimmedText.isEmpty } } public var answeredUserInputCount: Int { pendingUserInput?.questions.filter(isUserInputAnswered).count ?? 0 } public var currentUserInputQuestion: AgentUserQuestion? { guard let questions = pendingUserInput?.questions, questions.indices.contains(currentUserInputQuestionIndex) else { return nil } return questions[currentUserInputQuestionIndex] } public var canAdvanceUserInput: Bool { guard let question = currentUserInputQuestion else { return false } return isUserInputAnswered(question) || skippedUserInputQuestionIDs.contains(question.id) } public var canSubmitUserInput: Bool { guard let request = pendingUserInput else { return false } return currentUserInputQuestionIndex == request.questions.count - 1 && canAdvanceUserInput } public var canSkipUserInputQuestion: Bool { guard let question = currentUserInputQuestion else { return false } if case .secret = question.kind { return false } return true } public func toggleUserInputOption(_ optionID: String, in questionID: String) { guard case .choice(let choice)? = question(questionID)?.kind else { return } var draft = userInputDraft(for: questionID) if choice.allowsMultipleAnswers { if draft.selection.contains(optionID) { draft.selection.remove(optionID) } else { draft.selection.insert(optionID) } } else { draft.selection = draft.selection.contains(optionID) ? [] : [optionID] // One answer means one answer: a half-typed "something else" left // sitting under a chosen radio would be a second, contradicting one. draft.text = "" } userInputDrafts[questionID] = draft skippedUserInputQuestionIDs.remove(questionID) } /// Handles an option click from the question card. A radio choice is a /// complete answer, so it advances immediately; checkboxes remain a draft /// until the user explicitly confirms the set. public func answerUserInputOption(_ optionID: String, in questionID: String) { guard case .choice(let choice)? = question(questionID)?.kind else { return } guard !choice.allowsMultipleAnswers else { toggleUserInputOption(optionID, in: questionID) return } var draft = userInputDraft(for: questionID) draft.selection = [optionID] draft.text = "" userInputDrafts[questionID] = draft skippedUserInputQuestionIDs.remove(questionID) advanceUserInputQuestion() } /// The other half of that rule — typing clears the radio it would otherwise /// silently disagree with. Left alone in a multi-answer question, where the /// two are additive rather than exclusive. public func setUserInputCustomAnswer(_ text: String, for questionID: String) { var draft = userInputDraft(for: questionID) draft.text = text if case .choice(let choice)? = question(questionID)?.kind, !choice.allowsMultipleAnswers, !text.isEmpty { draft.selection.removeAll() } userInputDrafts[questionID] = draft if !draft.selection.isEmpty || !draft.trimmedText.isEmpty { skippedUserInputQuestionIDs.remove(questionID) } } public func advanceUserInputQuestion() { guard let request = pendingUserInput, canAdvanceUserInput else { return } if currentUserInputQuestionIndex == request.questions.count - 1 { submitUserInput() } else { currentUserInputQuestionIndex += 1 } } public func previousUserInputQuestion() { guard currentUserInputQuestionIndex > 0 else { return } currentUserInputQuestionIndex -= 1 } public func skipUserInputQuestion() { guard let question = currentUserInputQuestion, canSkipUserInputQuestion else { return } userInputDrafts[question.id] = AgentUserInputDraft() skippedUserInputQuestionIDs.insert(question.id) advanceUserInputQuestion() } public func submitUserInput() { guard let request = pendingUserInput, canSubmitUserInput else { return } let answers = request.questions.compactMap { question -> AgentUserInputAnswer? in guard isUserInputAnswered(question) else { return nil } let draft = userInputDraft(for: question.id) switch question.kind { case .secret: var bytes = Data(secretInputDraft.utf8) secretInputDraft = "" return AgentUserInputAnswer( questionID: question.id, value: .secret(AgentSecret(consumingUTF8: &bytes)) ) case .text: return AgentUserInputAnswer(questionID: question.id, value: .text(draft.text)) case .choice(let choice): let custom = draft.trimmedText return AgentUserInputAnswer( questionID: question.id, // Ordered by the question, not by whatever order the user // clicked in — the model wrote the list and reads the // answer against it. value: .choice( selectedIDs: choice.options.map(\.id).filter(draft.selection.contains), custom: custom.isEmpty ? nil : custom ) ) } } advanceUserInput() inputWaiters.removeValue(forKey: request.id)?.resume(returning: .answered(answers)) } public func cancelUserInput() { guard let request = pendingUserInput else { return } cancelUserInput(request.id) } private func question(_ id: String) -> AgentUserQuestion? { pendingUserInput?.questions.first { $0.id == id } } /// Retires the answered question and hands a clean sheet to the next one. private func advanceUserInput() { userInputTimeoutTask?.cancel() userInputTimeoutTask = nil if !pendingUserInputs.isEmpty { pendingUserInputs.removeFirst() } clearUserInputDrafts() scheduleUserInputTimeout() } /// Removes one request by identity. Cancellation may target a queued call, /// while timeout only targets the visible head; neither is allowed to /// retire whichever request happens to be current by the time it arrives. private func cancelUserInput(_ id: UUID) { guard let index = pendingUserInputs.firstIndex(where: { $0.id == id }) else { return } let wasCurrent = index == pendingUserInputs.startIndex pendingUserInputs.remove(at: index) if wasCurrent { userInputTimeoutTask?.cancel() userInputTimeoutTask = nil clearUserInputDrafts() scheduleUserInputTimeout() } inputWaiters.removeValue(forKey: id)?.resume(returning: .cancelled) } private func scheduleUserInputTimeout() { userInputTimeoutTask?.cancel() guard let request = pendingUserInput else { userInputTimeoutTask = nil userInputDeadline = nil return } let deadline = questionClockNow().advanced(by: request.timeout) userInputDeadline = deadline let sleep = questionClockSleep userInputTimeoutTask = Task { [weak self] in do { try await sleep(deadline) } catch { return } self?.timeOutUserInput(request.id) } } private func timeOutUserInput(_ id: UUID) { guard pendingUserInput?.id == id else { return } advanceUserInput() inputWaiters.removeValue(forKey: id)?.resume(returning: .timedOut) } private func clearUserInputDrafts() { secretInputDraft = "" userInputDrafts = [:] currentUserInputQuestionIndex = 0 skippedUserInputQuestionIDs = [] } private func applyMessageDelta(_ text: String, to id: AgentTranscriptMessage.ID) { guard !text.isEmpty else { return } contextEstimateTracker.appendStreamingText(text, to: id) scheduleContextEstimatePublish() if let index = messages.firstIndex(where: { $0.id == id }) { messages[index].text += text if !messages[index].isStreaming { messages[index].isStreaming = true } } else { messages.append( ChatMessage(id: id, role: .assistant, text: text, isStreaming: true) ) } } private func applyReasoningDelta(_ text: String, to id: AgentTranscriptMessage.ID) { guard !text.isEmpty, let index = messages.firstIndex(where: { $0.id == id }) else { return } if messages[index].reasoning.isEmpty { messages[index].reasoning.append( AgentReasoningBlock(text: text, createdAt: messages[index].createdAt) ) } else { let last = messages[index].reasoning.index( before: messages[index].reasoning.endIndex ) messages[index].reasoning[last].text += text } if !messages[index].isStreaming { messages[index].isStreaming = true } } /// Ends presentation that only makes sense while a provider stream is /// live. A cancelled turn never emits `messageFinished`, so without this /// explicit cleanup its empty placeholder keeps drawing Working forever /// (and a partial answer keeps its streaming caret) after the run ends. /// /// There can only be one active run in a conversation. `nil` is therefore /// the safe fallback for a terminal event whose run boundary was missed: /// settle every orphaned streaming message rather than leave the UI live. private func settleStreamingPresentation(for runID: UUID?) { if activeReasoningMessageID != nil { activeReasoningMessageID = nil } for index in messages.indices where messages[index].isStreaming { guard runID == nil || messageRunIDs[messages[index].id] == runID else { continue } messages[index].isStreaming = false messages[index].retryProgress = nil } contextEstimateTracker.clearStreamingText() publishContextEstimateImmediately() } public func handle(_ event: AgentEvent) { switch event { case .runState(let state): if state.isTerminal { publishAllStagedTools() } reportedRunState = state if state.isTerminal { settleStreamingPresentation(for: activeRunID) } if state.isTerminal { returnAllSteeringToDraft() runDidFinish?() } // Run and turn boundaries are for readers that group by turn. The // conversation draws a flat timeline, so it has nothing to do with // them — but they are listed rather than swept into a `default` so // adding an event stays a compile error here. case .runStarted(let run): var adoptedRun = run activeRunID = adoptedRun.id if let prompt = transcript.last, prompt.role == .user, messageRunIDs[prompt.id] == nil || adoptStagedReplacementRun(promptID: prompt.id, summary: &adoptedRun) { messageRunIDs[prompt.id] = adoptedRun.id } runSummaries[adoptedRun.id] = adoptedRun case .runFinished(let run): var run = run if let current = runSummaries[run.id], run.startedAt < current.startedAt { run.startedAt = current.startedAt } if let finishedAt = run.finishedAt, finishedAt < run.startedAt { run.finishedAt = run.startedAt } publishAllStagedTools() settleStreamingPresentation(for: run.id) runSummaries[run.id] = run if activeRunID == run.id { activeRunID = nil } case .turnStarted: activeReasoningMessageID = nil case .turnFinished: activeReasoningMessageID = nil activeTurnMessageID = nil case .messageStarted(let started): activeReasoningMessageID = nil activeTurnMessageID = started.id if let activeRunID { messageRunIDs[started.id] = activeRunID } if let index = messages.firstIndex(where: { $0.id == started.id }) { messages[index].isStreaming = true } else { messages.append( ChatMessage( id: started.id, role: .assistant, text: "", isStreaming: true, // The run's clock, not the UI's. See `AgentEvent.messageStarted`. createdAt: started.createdAt )) } case .modelRetryScheduled(let progress): activeReasoningMessageID = nil if let index = messages.firstIndex(where: { $0.id == progress.messageID }) { messages[index].text = "" messages[index].reasoning = [] messages[index].commands = [] messages[index].retryProgress = progress messages[index].isStreaming = true } else { messages.append( ChatMessage( id: progress.messageID, role: .assistant, text: "", retryProgress: progress, isStreaming: true )) } messageRunIDs[progress.messageID] = progress.runID contextEstimateTracker.clearStreamingText() publishContextEstimateImmediately() case .modelRetryRecovered(let progress): if let index = messages.firstIndex(where: { $0.id == progress.messageID }), messages[index].retryProgress == progress { messages[index].retryProgress = nil } case .messageDelta(let id, let text): if let activeRunID, messageRunIDs[id] != activeRunID { messageRunIDs[id] = activeRunID } applyMessageDelta(text, to: id) case .reasoningDelta(let id, let text): if activeReasoningMessageID != id { activeReasoningMessageID = id } if let activeRunID, messageRunIDs[id] != activeRunID { messageRunIDs[id] = activeRunID } applyReasoningDelta(text, to: id) // A queued turn has entered the model's context, so now it is part of // the conversation — in both lists, at the position the run put it, and // no longer in the queue the composer is drawing. case .steeringDelivered(let message): activeContextSnapshot = message.contextSnapshot if let activeRunID { messageRunIDs[message.id] = activeRunID } let pending = pendingSteering.first(where: { $0.id == message.id }) let visibleText = pending?.draft ?? ContextChip.visiblePrompt(in: message.text) let contextChips = pending?.attachments.map { ContextChipReference( id: $0.id, kind: $0.kind, label: $0.label, attachment: $0.contextAttachment ) } ?? ContextChip.visibleReferences( in: message.text, images: message.images, contextAttachments: message.contextAttachments ) pendingSteering.removeAll { $0.id == message.id } messages.append( ChatMessage( id: message.id, role: .user, text: visibleText, contextChips: contextChips, images: message.images, createdAt: message.createdAt )) transcript.append(message) contextEstimateTracker.upsert(message) publishContextEstimateImmediately() case .messageFinished(let message): if let activeRunID { messageRunIDs[message.id] = activeRunID } if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index].text = message.text messages[index].reasoning = message.reasoning messages[index].retryProgress = nil messages[index].isStreaming = false } else { messages.append( ChatMessage( id: message.id, role: .assistant, text: message.text, reasoning: message.reasoning, isStreaming: false, createdAt: message.createdAt )) } // Whole message, not just its text: the tool calls and the opaque // provider items are what the next turn has to replay. if let index = transcript.firstIndex(where: { $0.id == message.id }) { transcript[index] = message } else { transcript.append(message) } contextEstimateTracker.upsert(message) publishContextEstimateImmediately() publishStagedTools( for: message.id, force: message.toolCalls.isEmpty ) updatedAt = .now case .toolProposed(let invocation, let descriptor): stageTool(invocation, descriptor: descriptor) case .toolStarted(let invocation): setToolState(invocation.id, .running) // The card only, and the card's state is left alone: this is a call // still running, showing its work. Nothing here reaches `transcript` — // the model is owed one result per call, and it gets that from // `toolFinished`. case .toolProgress(let invocation, let partial): updateToolCard(invocation.id) { $0.result = partial } case .toolFinished(let invocation, let result): updateToolCard(invocation.id) { card in card.result = result // A denied call did not fail — someone said no. The denial is // an error to the *model*, which is what makes the turn // replayable, but the card kept being relabelled `Failed` the // instant it was refused, so `denied` never reached the screen. if result.isApprovalPolicyDenied { card.state = .denied } else if card.state != .denied { if result.isInterrupted { card.state = .interrupted } else { card.state = result.isError ? .failed : .completed } } } // A card, but not a turn. A provider-native search happened inside // the assistant's own turn and was never a tool call here, so // adding it to the model-facing transcript would replay a tool // result the provider has no matching call for — which it rejects // on the *next* request. See `AgentToolResult.isProviderNative`. guard !result.isProviderNative else { break } let message = AgentTranscriptMessage( id: AgentTranscriptMessage.toolResultID(runID: invocation.runID, callID: result.callID), role: .tool, text: result.content, toolCallID: result.callID, toolName: invocation.call.name, isError: result.isError, modelText: result.modelContent ) messageRunIDs[message.id] = invocation.runID transcript.append(message) contextEstimateTracker.upsert(message) publishContextEstimateImmediately() // The card only. The queue belongs to `requestApproval`, which is the // path that also holds the continuation — see its comment. case .approvalRequested(let request): // The direct user reply can beat this queued channel event to the UI. // A late notification must not erase a denial already shown on the card. updateToolCard(request.invocation.id) { card in if !card.state.isTerminal { card.state = .waitingForApproval } } case .reviewStarted(let request): reviewing.insert(request.invocation.id) setToolState(request.invocation.id, .reviewing) case .reviewFinished(let request, let decision): reviewing.remove(request.invocation.id) updateToolCard(request.invocation.id) { $0.review = decision } case .reviewFailed(let request, _, _): reviewing.remove(request.invocation.id) setToolState(request.invocation.id, .failed) // Also card-only. `request(_:)` enqueues, for the same reason. case .userInputRequested: break case .usageUpdated(let reported): usage = reported contextEstimateTracker.setMeasured(reported) if isStreaming { scheduleContextEstimatePublish() } else { publishContextEstimateImmediately() } case .planPresented(let plan): plans.append(AgentPlanCard(plan: plan)) case .tasksUpdated(let updated): tasks = updated case .failed(let reason): guard let activeRunID, var run = runSummaries[activeRunID] else { break } run.state = .failed run.finishedAt = .now run.failure = reason runSummaries[activeRunID] = run } } private func stageTool( _ invocation: AgentToolInvocation, descriptor: AgentToolDescriptor ) { // A direct interaction may have staged this call before its queued event. // Refresh metadata without losing its state or creating a duplicate row. if updateToolCard( invocation.id, { card in card.invocation = invocation card.descriptor = descriptor }) { return } let card = AgentToolCard(invocation: invocation, descriptor: descriptor) guard let sourceMessageID = invocation.sourceMessageID else { toolCards.append(card) return } if stagedToolCards[invocation.id] == nil { stagedToolOrder.append(invocation.id) } stagedToolCards[invocation.id] = card guard let source = transcript.first(where: { $0.id == sourceMessageID }) else { // Provider-native activities arrive while the assistant message is // still streaming. Keep all of them staged until messageFinished // seals that turn, then publish the complete set in one mutation. if activeTurnMessageID == sourceMessageID { return } // Restored/legacy/manual event streams may not carry the source // assistant message. There is no authoritative call set to await, // so prefer a visible stable batch over hiding the card forever. publishStagedTools(for: sourceMessageID, force: true) return } publishStagedTools( for: sourceMessageID, force: source.toolCalls.isEmpty ) } /// Publishes every card for a provider response in one observable mutation. /// `force` is used for provider-native activities, whose synthetic calls do /// not occur in the assistant message's model-facing `toolCalls` array. private func publishStagedTools(for sourceMessageID: UUID, force: Bool = false) { let stagedIDs = stagedToolOrder.filter { stagedToolCards[$0]?.invocation.sourceMessageID == sourceMessageID } guard !stagedIDs.isEmpty else { return } if !force { guard let source = transcript.first(where: { $0.id == sourceMessageID }), !source.toolCalls.isEmpty else { return } let stagedCallIDs = Set( stagedIDs.compactMap { stagedToolCards[$0]?.invocation.call.id }) guard source.toolCalls.allSatisfy({ stagedCallIDs.contains($0.id) }) else { return } } let cards = stagedIDs.compactMap { stagedToolCards.removeValue(forKey: $0) } let published = Set(stagedIDs) stagedToolOrder.removeAll { published.contains($0) } toolCards.append(contentsOf: cards) } private func publishAllStagedTools() { guard !stagedToolOrder.isEmpty else { return } let cards = stagedToolOrder.compactMap { stagedToolCards.removeValue(forKey: $0) } stagedToolOrder.removeAll(keepingCapacity: true) toolCards.append(contentsOf: cards) } @discardableResult private func updateToolCard( _ id: UUID, _ update: (inout AgentToolCard) -> Void ) -> Bool { if var staged = stagedToolCards[id] { update(&staged) stagedToolCards[id] = staged return true } guard let index = toolCards.firstIndex(where: { $0.id == id }) else { return false } update(&toolCards[index]) return true } private func setToolState(_ id: UUID, _ state: AgentToolCard.State) { updateToolCard(id) { $0.state = state } } /// Adds a final speech-recognition result without disturbing text the user /// already wrote. The callback keeps the standalone composer and the /// sidebar's "promote draft to conversation" path on the same behavior. @discardableResult public func applyTranscription( _ transcript: String, sendAfterTranscription: Bool, send: () -> Void ) -> Bool { let cleaned = transcript.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { return false } if composer.draft.isEmpty { composer.draft = cleaned } else if composer.draft.last?.isWhitespace == true { composer.draft += cleaned } else { composer.draft += " " + cleaned } if sendAfterTranscription { send() } return true } } extension ConversationModel: AgentUserInteractionHandling {} /// Lets a run ask its conversation a question without keeping it alive. /// /// `AgentRuntime` holds its interaction handler for the whole run and the /// conversation holds the runtime, so handing the conversation over directly /// closed a reference cycle. A conversation that has been deallocated cannot /// answer, and an unanswerable prompt is a cancelled one. public final class WeakAgentUserInteraction { private weak var conversation: ConversationModel? public init(_ conversation: ConversationModel) { self.conversation = conversation } public func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse { guard let conversation else { return .cancelled } return await conversation.request(request) } /// The SSH checkpoints, in the shape the tools take them — see /// `AgentSSHInteraction`. Weak for the same reason `request` is. public var sshInteraction: AgentSSHInteraction { AgentSSHInteraction( authenticate: { [weak conversation] request in guard let conversation else { return .cancelled } return await conversation.requestSSHAuthentication(request) }, confirmHostKey: { [weak conversation] request in guard let conversation else { return .deny } return await conversation.requestSSHHostKeyConfirmation(request) } ) } } extension WeakAgentUserInteraction: AgentUserInteractionHandling {} // MARK: - Demo data extension ConversationModel { public static func demo(language: AppLanguage = .english) -> ConversationModel { func text(_ en: String, _ zhHans: String, _ zhHant: String) -> String { switch language { case .simplifiedChinese: zhHans case .traditionalChinese: zhHant case .system, .english: en } } let now = Date(timeIntervalSinceReferenceDate: 809_020_800) let user = ChatMessage( role: .user, text: text( "Find the cause of the 502 responses. Inspect only—do not change configuration.", "查明 502 响应的原因。仅检查,不要更改配置。", "找出 502 回應的原因。僅進行檢查,不要變更設定。" ), createdAt: now.addingTimeInterval(-18) ) let answer = ChatMessage( role: .assistant, text: text( "The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.", "上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。", "上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。" ), createdAt: now ) let runID = UUID() let descriptor = AgentToolDescriptor( name: "ssh_exec", summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。"), inputSchema: .object([:]), target: .host, approvalPolicy: .approve, concurrency: .parallel, presentation: AgentToolDescriptor.Presentation( symbol: "apple.terminal", activity: .argument(key: "command", fallback: "command"), actionKind: .run ) ) let commands = [ "systemctl status nginx --no-pager", "tail -n 80 /var/log/nginx/error.log", ] let cards = commands.enumerated().map { index, command in AgentToolCard( invocation: AgentToolInvocation( runID: runID, call: AgentToolCall( id: "demo-\(index)", name: "ssh_exec", arguments: .object(["command": .string(command)]) ), targetHostID: nil, createdAt: now.addingTimeInterval(Double(index - 12)) ), descriptor: descriptor, state: .completed ) } let conversation = ConversationModel( messages: [user, answer], runs: [ AgentRunSummary( id: runID, state: .completed, startedAt: now.addingTimeInterval(-15), finishedAt: now.addingTimeInterval(-3) ) ] ) conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應") conversation.messages = [ user, answer, ] conversation.toolCards = cards return conversation } }