Files

3602 lines
157 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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 createdAt: Date = .now
public var risk: RiskAssessment {
CommandRiskClassifier.assess(command)
}
public init(id: UUID = UUID(), command: String, createdAt: Date = .now) {
self.id = id
self.command = command
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[..<boundary])
}
/// Rebuilds lightweight bubble chips from the model-facing markup without
/// retaining or rendering the payload between the tags.
public static func visibleReferences(
in composedText: String, images: [AgentImageAttachment] = [],
contextAttachments: [AgentContextAttachment] = []
) -> [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..<suffix.endIndex) {
let labelStart = opening.upperBound
guard
let labelEnd = suffix.range(
of: "\">", range: labelStart..<suffix.endIndex
)
else { break }
let contentStart = labelEnd.upperBound
let closing = "</\(kind.blockName)>"
guard
let contentEnd = suffix.range(
of: closing, range: contentStart..<suffix.endIndex
)
else { break }
let label = String(suffix[labelStart..<labelEnd.lowerBound])
let storedIndex = unusedAttachments.firstIndex {
$0.label.replacingOccurrences(of: "\"", with: "'") == label
&& $0.kind == kind.agentAttachmentKind
}
let attachment: AgentContextAttachment? =
storedIndex.map {
unusedAttachments.remove(at: $0)
}
?? kind.fallbackAttachment(
label: label,
content: String(suffix[contentStart..<contentEnd.lowerBound])
)
matches.append(
(
opening.lowerBound,
ContextChipReference(
id: attachment?.id ?? UUID(), kind: kind,
label: attachment?.label ?? label,
attachment: attachment
)
))
cursor = contentEnd.upperBound
}
}
}
let textReferences = matches.sorted { $0.0 < $1.0 }.map(\.1)
let imageReferences = images.map {
ContextChipReference(id: $0.id, kind: .image, label: $0.label)
}
return textReferences + imageReferences
}
private static func payloadBoundary(in text: String) -> 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
/// App-authored provenance for an incoming agent message.
public var origin: AgentMessageOrigin?
public init(
id: UUID = UUID(), role: Role, text: String, reasoning: [AgentReasoningBlock] = [],
contextChips: [ContextChipReference] = [], images: [AgentImageAttachment] = [],
commands: [SuggestedCommand] = [], retryProgress: AgentModelRetryProgress? = nil,
isStreaming: Bool = false, origin: AgentMessageOrigin? = nil, 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.origin = origin
self.createdAt = createdAt
}
}
nonisolated public enum AgentMessageDeliveryState: String, Hashable, Sendable {
case queued, delivered, undelivered
}
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,
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
/// In-memory capabilities survive turns but are never restored or copied to a fork.
public let secretBroker = SecretBroker()
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] = [] {
didSet {
let openingTitle = Self.openingTitle(in: messages)
if openingTitle != self.openingTitle { self.openingTitle = openingTitle }
}
}
private var openingTitle: String?
public let composer = ConversationComposerState()
public var attachmentLoader = ContextAttachmentLoading.unavailable
private var attachmentImportTasks: [PendingContextAttachment.ID: Task<Void, Never>] = [:]
/// The last state the run reported, before anything outstanding in the UI
/// is taken into account.
private var reportedRunState: AgentRunState = .idle { didSet { publishInteractionChanges() } }
/// 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 || !pendingMCPAuthorizations.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] = [] {
didSet { publishInteractionChanges() }
}
public private(set) var pendingUserInputs: [AgentUserInputRequest] = [] {
didSet { publishInteractionChanges() }
}
public private(set) var pendingSSHAuthentications: [AgentSSHAuthenticationRequest] = [] {
didSet { publishInteractionChanges() }
}
public private(set) var pendingSSHHostKeys: [AgentSSHHostKeyRequest] = [] {
didSet { publishInteractionChanges() }
}
/// 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<UUID> = []
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 private(set) var pendingMCPAuthorizations: [MCPAuthorizationPrompt] = [] {
didSet { publishInteractionChanges() }
}
private var mcpAuthorizationWaiters: [UUID: CheckedContinuation<URL, any Error>] = [:]
public enum PendingUserInteraction {
case mcpAuthorization(MCPAuthorizationPrompt)
case questions(AgentUserInputRequest)
case sshAuthentication(AgentSSHAuthenticationRequest)
case sshHostKey(AgentSSHHostKeyRequest)
public var createdAt: Date {
switch self {
case .mcpAuthorization(let request): request.createdAt
case .questions(let request): request.createdAt
case .sshAuthentication(let request): request.createdAt
case .sshHostKey(let request): request.createdAt
}
}
}
public var pendingUserInteraction: PendingUserInteraction? {
[
pendingMCPAuthorizations.first.map(PendingUserInteraction.mcpAuthorization),
pendingUserInput.map(PendingUserInteraction.questions),
pendingSSHAuthentication.map(PendingUserInteraction.sshAuthentication),
pendingSSHHostKey.map(PendingUserInteraction.sshHostKey),
]
.compactMap { $0 }
.min { $0.createdAt < $1.createdAt }
}
public enum PendingInteraction {
case approval(AgentApprovalRequest)
case userInteraction(PendingUserInteraction)
}
/// Presents the earliest checkpoint across queues, with approvals winning ties.
public var pendingInteraction: PendingInteraction? {
let interaction = pendingUserInteraction
if let approval = pendingApproval,
interaction.map({ approval.invocation.createdAt <= $0.createdAt }) ?? true
{
return .approval(approval)
}
return interaction.map(PendingInteraction.userInteraction)
}
/// 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<String> = []
/// 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<Void, Never>?
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<Void, Never>?
var consumer: Task<Void, Never>?
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<Void, Never>)?
private var approvalWaiters: [UUID: CheckedContinuation<AgentApprovalDecision, Never>] = [:]
private var inputWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var userInputTimeoutTask: Task<Void, Never>?
private var timedUserInputID: UUID?
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<AgentSSHAuthenticationResponse, Never>] = [:]
private var sshHostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
/// 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 posture captured by the active run. The UI toggle remains editable while
/// a run is executing, so collaboration policy must use the frozen request until
/// that run releases ownership.
public var effectiveIsPlanning: Bool { activeRunOwner?.request.isPlanning ?? isPlanning }
/// 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 fixed for this conversation. New drafts are seeded from the
/// Chat role and durable conversations restore their saved reference.
/// `nil` is possible only before a model is available or in legacy callers.
///
/// 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?
/// Independent app notification events, separate from title and Memory callbacks.
@ObservationIgnored public var notificationEvent: ((AssistantNotificationEvent) -> Void)?
@ObservationIgnored public var chatStateDidChange: ((UUID) -> Void)?
@ObservationIgnored private var lastNotificationInteraction: AssistantNotificationInteraction?
private var unreadNotificationIDs = Set<String>()
public var unreadNotificationCount: Int { unreadNotificationIDs.count }
public func markNotificationsRead() { unreadNotificationIDs.removeAll() }
public func markNotificationsUnread() {
if unreadNotificationIDs.isEmpty { unreadNotificationIDs.insert("manual") }
}
func markNotificationUnread(_ id: String) { unreadNotificationIDs.insert(id) }
func removeUnreadNotification(_ id: String) { unreadNotificationIDs.remove(id) }
private func publishInteractionChanges() {
scheduleUserInputTimeout()
chatStateDidChange?(id)
let current = notificationInteraction
guard current != lastNotificationInteraction else { return }
lastNotificationInteraction = current
notificationEvent?(.interactionChanged(current))
}
/// 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 var memoryUserAccepted: ((AgentTranscriptMessage, [AgentTranscriptMessage]) -> Void)?
public var memoryRunFinished: ((AgentRunSummary, [UUID], [MemoryLearningEvent], [AgentTranscriptMessage]) -> Void)?
public var memoryToolBatchFinished: (([MemoryLearningEvent], [AgentTranscriptMessage]) -> Void)?
/// Durable delivery ownership stays in the app coordinator; the conversation reports
/// boundary transitions without knowing how receipts are stored.
public var agentMessageDeliveryChanged: ((AgentTranscriptMessage, AgentMessageDeliveryState) -> Void)?
private var startedMemoryTools: [UUID: [UUID: UUID]] = [:]
private var finishedMemoryTools: [UUID: [UUID: UUID]] = [:]
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
openingTitle = Self.openingTitle(in: 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 }
return openingTitle ?? String(localized: "New Chat")
}
private static func openingTitle(in messages: [ChatMessage]) -> String? {
guard let first = messages.first(where: { $0.role == .user }) else { return nil }
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 {
if !toolCards.isEmpty || !plans.isEmpty || !compactions.isEmpty || compactionActivity != nil {
return false
}
if runSummaries.values.contains(where: {
$0.state == .cancelled || ($0.state == .failed && !($0.failure?.isEmpty ?? true))
}) {
return false
}
if messages.contains(where: {
$0.role != .assistant || !$0.text.isEmpty || ($0.isStreaming && $0.reasoning.isEmpty)
}) {
return false
}
// Reasoning-only messages are visible only while their work group is active.
return workGroups.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
}
/// Build once per transcript projection rather than scanning history for every row.
public var messageActionAvailability: (editable: Set<UUID>, forkable: Set<UUID>) {
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging,
!isPersistingAgentPreparationFailure
else { return ([], []) }
var editable = Set<UUID>()
var forkable = Set<UUID>()
for message in transcript {
if message.role == .user && !message.isCompaction && message.origin == nil {
editable.insert(message.id)
}
if message.role == .assistant { forkable.insert(message.id) }
}
return (editable, forkable)
}
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 && $0.origin == nil
}
}
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[..<target])
let visibleTarget = messages.firstIndex(where: { $0.id == id })
let visiblePrefix = visibleTarget.map { Array(messages[..<$0]) } ?? []
let visibleRemoved = visibleTarget.map { Array(messages[$0...]) } ?? []
let removedMessageIDs = Set(transcript[target...].map(\.id)).union(visibleRemoved.map(\.id))
let keptMessageIDs = Set(prefix.map(\.id)).union(visiblePrefix.map(\.id))
var removedRunIDs = Set(removedMessageIDs.compactMap { messageRunIDs[$0] })
let prefixRunIDs = Set(prefix.compactMap { messageRunIDs[$0.id] })
.union(visiblePrefix.compactMap { messageRunIDs[$0.id] })
let targetRunID = messageRunIDs[id]
if let targetRunID, let targetSummary = runSummaries[targetRunID] {
removedRunIDs.formUnion(
runSummaries.values.compactMap { summary in
if summary.startedAt != targetSummary.startedAt {
return summary.startedAt > targetSummary.startedAt ? summary.id : nil
}
return summary.id.uuidString > targetSummary.id.uuidString ? summary.id : nil
})
}
let hasEarlierVisibleUser =
visibleTarget.map { index in
messages[..<index].contains { $0.role == .user }
} ?? false
let keptCallIDs = Set(prefix.flatMap { $0.toolCalls.map(\.id) })
let removedCallIDs = Set(transcript[target...].flatMap { $0.toolCalls.map(\.id) })
let whollyRemovedRunIDs = removedRunIDs.subtracting(prefixRunIDs)
let lastKeptRun = runSummaries.values.filter { !whollyRemovedRunIDs.contains($0.id) }
.max {
if $0.startedAt != $1.startedAt { return $0.startedAt < $1.startedAt }
return $0.id.uuidString < $1.id.uuidString
}
let stagingStartedAt =
if let lastKeptRun, replacement.createdAt <= lastKeptRun.startedAt {
lastKeptRun.startedAt.addingTimeInterval(0.001)
} else {
replacement.createdAt
}
transcript.replaceSubrange(target..., with: [replacement])
contextEstimateTracker.remove(removedMessageIDs)
contextEstimateTracker.upsert(replacement)
if let visibleIndex = visibleTarget {
messages.replaceSubrange(
visibleIndex...,
with: [
ChatMessage(
id: replacement.id, role: .user,
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
contextChips: ContextChip.visibleReferences(
in: replacement.text, images: replacement.images,
contextAttachments: replacement.contextAttachments
), images: replacement.images, createdAt: replacement.createdAt
)
])
}
toolCards.removeAll {
if whollyRemovedRunIDs.contains($0.invocation.runID) { return true }
if let source = $0.invocation.sourceMessageID {
if removedMessageIDs.contains(source) { return true }
if keptMessageIDs.contains(source) { return false }
}
if removedCallIDs.contains($0.invocation.call.id) { return true }
if keptCallIDs.contains($0.invocation.call.id) { return false }
let resultMessageID = AgentTranscriptMessage.toolResultID(
runID: $0.invocation.runID, callID: $0.invocation.call.id)
if messageRunIDs[resultMessageID] == $0.invocation.runID,
!removedMessageIDs.contains(resultMessageID)
{
return false
}
if $0.invocation.runID != targetRunID {
if prefixRunIDs.contains($0.invocation.runID) { return false }
if removedRunIDs.contains($0.invocation.runID) { return true }
}
// Legacy rows with no recoverable source, call, or result ownership
// belong to the edited suffix at the boundary as well as after it.
return $0.invocation.createdAt >= 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)
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, origin: message.origin,
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 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)
var processMessagesByRun: [UUID: [ChatMessage]] = [:]
var answersByRun: [UUID: [ChatMessage]] = [:]
// Prefix maxima preserve the first qualifying source position even when
// restored message timestamps are out of order.
var anchors: [(date: Date, id: UUID)] = []
for message in messages {
if anchors.last.map({ message.createdAt > $0.date }) ?? true {
anchors.append((message.createdAt, message.id))
}
if let runID = processRunIDs[message.id] {
processMessagesByRun[runID, default: []].append(message)
} else if message.role == .assistant, !message.text.isEmpty,
let runID = messageRunIDs[message.id]
{
answersByRun[runID, default: []].append(message)
}
}
return runIDs.compactMap { runID in
let cards = cardsByRun[runID] ?? []
let processMessages = processMessagesByRun[runID] ?? []
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
var lower = 0
var upper = anchors.count
while lower < upper {
let middle = lower + (upper - lower) / 2
if anchors[middle].date < orderedAt {
lower = middle + 1
} else {
upper = middle
}
}
let anchorID = lower < anchors.count ? anchors[lower].id : nil
let answerAt = answersByRun[runID]?.last(where: { $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<UUID> = Set(
groups.flatMap { group -> [UUID] in
group.items.compactMap { item -> UUID? in
guard case .message(let message) = item else { return nil }
return message.id
}
})
let groupsByAnchor = Dictionary(grouping: groups, by: \.anchorMessageID)
var emittedGroups = Set<UUID>()
for message in messages {
for group in groupsByAnchor[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[..<cut]), cut)
}
public func beginCompaction() {
isCompacting = true
compactionActivity = AgentCompactionActivity(state: .running)
}
public func endCompaction() {
isCompacting = false
compactionActivity = nil
}
public func failCompaction(_ reason: String) {
isCompacting = false
if var activity = compactionActivity {
activity.state = .failed(reason)
compactionActivity = activity
} else {
compactionActivity = AgentCompactionActivity(state: .failed(reason))
}
}
/// Builds the durable record without changing the conversation. AppModel
/// persists this value before applying it, so a failed save cannot leave
/// memory and disk describing different contexts.
public func prepareCompaction(summary: String, cut: Int) -> AgentCompactionRecord? {
guard cut > 0, cut <= transcript.count else { return nil }
let summarized = transcript[..<cut]
let summarizedTokens = summarized.reduce(0) {
$0
+ AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: $1.text)
)
}
let boundary =
summarized.reversed().first(where: { !$0.isCompaction })?.id
?? compactionRecords.last?.compactedThroughMessageID
return AgentCompactionRecord(
compactedThroughMessageID: boundary,
sequence: (compactionRecords.map(\.sequence).max() ?? -1) + 1,
summary: summary,
summarizedTokens: summarizedTokens,
keptTokens: AgentContextEstimator.tokens(
inCharacters: AgentContextEstimator.characterUnits(in: summary)
),
displayAnchor: transcript[cut - 1].createdAt
)
}
/// Replaces everything before `cut` with the already-persisted summary.
///
/// `transcript` — what the model is sent — is rewritten. `messages` — what
/// the reader sees — is not: a marker is added instead, so the conversation
/// stays scrollable and nothing the user wrote disappears. That is the same
/// rule `AgentContextPipeline` states for every other transform.
public func applyCompaction(_ record: AgentCompactionRecord, cut: Int) {
precondition(cut > 0 && cut <= transcript.count)
let replacement = record.summaryMessage
let removedIDs = transcript[..<cut].map(\.id)
transcript.replaceSubrange(..<cut, with: [replacement])
contextEstimateTracker.remove(removedIDs)
contextEstimateTracker.upsert(replacement)
compactionRecords.append(record)
compactions.append(AgentCompactionMark(record))
// The gauge is measuring a context that no longer exists. Dropping it
// rather than scaling it means the next turn reports a real number and
// until then the estimate is shown as an estimate.
usage = nil
contextEstimateTracker.setMeasured(nil)
publishContextEstimateImmediately()
}
/// Convenience for focused model tests that do not exercise persistence.
@discardableResult
public func applyCompaction(summary: String, cut: Int) -> 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 capture and submit
/// their turns through `AssistantExecutionController`.
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 var origin: AgentMessageOrigin?
public init(
id: UUID, text: String, authoredText: String, images: [AgentImageAttachment],
contextAttachments: [AgentContextAttachment], origin: AgentMessageOrigin? = nil
) {
self.id = id
self.text = text
self.authoredText = authoredText
self.images = images
self.contextAttachments = contextAttachments
self.origin = origin
}
}
@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,
origin: composed.origin
)
)
transcript.append(
AgentTranscriptMessage(
id: messageID, role: .user, text: composed.text,
authoredText: composed.origin == nil ? composed.authoredText : nil,
images: composed.images,
contextAttachments: composed.contextAttachments, contextSnapshot: contextSnapshot,
origin: composed.origin
)
)
if let message = transcript.last {
contextEstimateTracker.upsert(message)
publishContextEstimateImmediately()
}
updatedAt = .now
return composed
}
/// Appends an incoming agent turn without reading or mutating any composer state.
/// Routed messages never inherit the target's pending chips or armed skill.
@discardableResult
public func appendIncomingAgentTurnContent(
_ composed: ComposedUserTurn, contextSnapshot: AgentTurnContextSnapshot? = nil
) -> ComposedUserTurn? {
guard composed.origin != nil, composed.images.isEmpty,
composed.contextAttachments.isEmpty
else { return nil }
messages.append(
ChatMessage(
id: composed.id, role: .user, text: composed.authoredText,
contextChips: [], images: [], origin: composed.origin
)
)
transcript.append(
AgentTranscriptMessage(
id: composed.id, role: .user, text: composed.text,
authoredText: nil, contextSnapshot: contextSnapshot,
origin: composed.origin
)
)
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
"""
<use_skill name="\(name)">
The user asked for this skill by name. Load it with skill_read_file before answering, and \
follow it.
</use_skill>
"""
}
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)
</\(chip.kind.blockName)>
"""
}
// 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)
)
}
/// Builds a turn delivered by another agent without touching composer state.
public func composeIncomingAgentTurn(
_ text: String, origin: AgentMessageOrigin
) -> ComposedUserTurn {
ComposedUserTurn(
id: UUID(), text: text, authoredText: text, images: [],
contextAttachments: [], origin: origin)
}
/// 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[..<last])
}
/// Takes ownership of a run before it can emit its first event. Keeping the
/// runtime and its consumer together prevents an immediately-completing
/// provider response from racing the UI attachment path.
public private(set) var activeContextSnapshot: AgentTurnContextSnapshot?
@discardableResult
func beginAgentPreparation(
id: UUID, cancellation: @escaping @MainActor () -> Task<Void, Never>
) -> Bool {
guard agentPreparationID == nil else { return false }
agentPreparationID = id
cancelAgentPreparation = cancellation
isPreparingAgentRun = true
chatStateDidChange?(self.id)
return true
}
func supersedeAgentPreparation(
id: UUID, cancellation: @escaping @MainActor () -> Task<Void, Never>
) {
agentPreparationID = id
cancelAgentPreparation = cancellation
isPreparingAgentRun = true
chatStateDidChange?(self.id)
}
func finishAgentPreparation(id: UUID) {
guard agentPreparationID == id else { return }
agentPreparationID = nil
cancelAgentPreparation = nil
isPreparingAgentRun = false
isPersistingAgentPreparationFailure = false
chatStateDidChange?(self.id)
}
func beginAgentPreparationFailurePersistence(id: UUID) {
guard agentPreparationID == id else { return }
isPersistingAgentPreparationFailure = true
chatStateDidChange?(self.id)
}
@discardableResult
func cancelPendingAgentPreparation() -> Task<Void, Never>? {
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,
origin: request.promptOrigin,
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
notificationEvent?(.finished(runID: snapshot.id, kind: .failed))
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?) {
for prompt in pendingMCPAuthorizations {
respondToMCPAuthorization(prompt.id, result: .failure(CancellationError()))
}
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()
}
}
/// Queues an app-routed agent message without reading or changing the target composer.
@discardableResult
public func submitIncomingAgentSteering(_ message: AgentTranscriptMessage) -> Bool {
guard message.role == .user, message.origin != nil, message.authoredText == nil,
let owner = activeRunOwner, !isArchived, !isCompacting, !isHistoryChanging,
!isPreparingAgentRun
else { return false }
let pending = PendingSteeringMessage(draft: message.text, message: message)
pendingSteering.append(pending)
agentMessageDeliveryChanged?(message, .queued)
let runtime = owner.runtime
Task { [weak self] in
guard await runtime.steer(message) else {
guard self?.activeRunOwner === owner else { return }
self?.returnAllSteeringToDraft()
return
}
}
return true
}
/// 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)
if pending.message.origin != nil {
agentMessageDeliveryChanged?(pending.message, .undelivered)
return
}
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 requestMCPAuthorization(_ request: MCPAuthorizationRequest) async throws -> URL {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
mcpAuthorizationWaiters[request.id] = continuation
pendingMCPAuthorizations.append(MCPAuthorizationPrompt(request: request))
if let id = request.invocationID { setToolState(id, .waitingForUser) }
}
} onCancel: {
Task { @MainActor [weak self] in
self?.respondToMCPAuthorization(request.id, result: .failure(CancellationError()))
}
}
}
public func respondToMCPAuthorization(_ id: UUID, result: Result<URL, any Error>) {
guard let index = pendingMCPAuthorizations.firstIndex(where: { $0.id == id }) else { return }
let prompt = pendingMCPAuthorizations.remove(at: index)
if let invocationID = prompt.request.invocationID { setToolState(invocationID, .running) }
mcpAuthorizationWaiters.removeValue(forKey: id)?.resume(with: result)
}
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?.isInformational == 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()
}
}
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() {
if !pendingUserInputs.isEmpty { pendingUserInputs.removeFirst() }
clearUserInputDrafts()
}
/// 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 { clearUserInputDrafts() }
inputWaiters.removeValue(forKey: id)?.resume(returning: .cancelled)
}
private func scheduleUserInputTimeout() {
let request: AgentUserInputRequest?
if case .userInteraction(.questions(let visible))? = pendingInteraction {
request = visible
} else {
request = nil
}
guard timedUserInputID != request?.id else { return }
userInputTimeoutTask?.cancel()
userInputTimeoutTask = nil
timedUserInputID = request?.id
userInputDeadline = nil
guard let request else { return }
// A question hidden behind another checkpoint has no response budget to
// spend. Start a full budget when it becomes the presented interaction.
let deadline = questionClockNow().advanced(by: request.timeout)
userInputDeadline = deadline
let sleep = questionClockSleep
userInputTimeoutTask = Task { [weak self] in
do {
try await sleep(deadline)
} catch {
return
}
guard !Task.isCancelled else { return }
self?.timeOutUserInput(request.id)
}
}
private func timeOutUserInput(_ id: UUID) {
guard timedUserInputID == id,
case .userInteraction(.questions(let request))? = pendingInteraction, request.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
if let prompt = transcript.last, prompt.role == .user,
messageRunIDs[prompt.id] == adoptedRun.id
{
memoryUserAccepted?(prompt, transcript)
}
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
let userIDs =
run.state == .completed
? transcript.filter {
$0.role == .user && $0.origin == nil && messageRunIDs[$0.id] == run.id
}.map(\.id) : []
let tools = (startedMemoryTools.removeValue(forKey: run.id) ?? [:]).map {
MemoryLearningEvent(id: $0.key, sourceMessageID: $0.value)
}
finishedMemoryTools.removeValue(forKey: run.id)
memoryRunFinished?(run, userIDs, tools, transcript)
if run.state == .completed || run.state == .failed {
notificationEvent?(.finished(runID: run.id, kind: run.state == .completed ? .completed : .failed))
}
if activeRunID == run.id { activeRunID = nil }
case .turnStarted:
if let activeRunID, let finished = finishedMemoryTools.removeValue(forKey: activeRunID),
!finished.isEmpty
{
memoryToolBatchFinished?(
finished.map {
MemoryLearningEvent(id: $0.key, sourceMessageID: $0.value)
}, transcript)
}
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,
origin: message.origin,
createdAt: message.createdAt
))
transcript.append(message)
if message.origin != nil { agentMessageDeliveryChanged?(message, .delivered) }
memoryUserAccepted?(message, transcript)
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):
if !["memory", "memory_search", "session_search"].contains(invocation.call.name) {
startedMemoryTools[invocation.runID, default: [:]][invocation.id] =
invocation.sourceMessageID ?? activeTurnMessageID ?? transcript.last?.id ?? invocation.runID
}
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):
if let source = startedMemoryTools[invocation.runID]?[invocation.id] {
let reviewedSource =
result.isProviderNative
? source
: AgentTranscriptMessage.toolResultID(runID: invocation.runID, callID: result.callID)
startedMemoryTools[invocation.runID]?[invocation.id] = reviewedSource
finishedMemoryTools[invocation.runID, default: [:]][invocation.id] = reviewedSource
}
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.toolResult(
result, runID: invocation.runID, toolName: invocation.call.name
)
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:
// AgentKit supports task lists, but Assistant does not register that tool.
break
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 }
}
func acceptsUserInputTranscription(requestID: UUID, questionID: String) -> Bool {
guard !isArchived, pendingUserInput?.id == requestID,
let question = currentUserInputQuestion, question.id == questionID
else { return false }
switch question.kind {
case .text: return true
case .choice(let choice): return choice.allowsCustomAnswer
case .secret: return false
}
}
/// Speech is an answer draft, never a chat message or an implicit submission.
@discardableResult
public func applyUserInputTranscription(_ transcript: String, requestID: UUID, questionID: String) -> Bool {
guard acceptsUserInputTranscription(requestID: requestID, questionID: questionID) else { return false }
let cleaned = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { return false }
let draft = userInputDraft(for: questionID).text
let separator = draft.isEmpty || draft.last?.isWhitespace == true ? "" : " "
setUserInputCustomAnswer(draft + separator + cleaned, for: questionID)
return true
}
/// 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)
}
public var mcpAuthorization: MCPAuthorizationInteraction {
MCPAuthorizationInteraction { [weak conversation] request in
guard let conversation else { throw CancellationError() }
return try await conversation.requestMCPAuthorization(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(
presentationContextID: conversation?.id,
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, _ ja: String, _ ko: String, _ de: String, _ fr: String
) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .korean: ko
case .german: de
case .french: fr
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 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。",
"502 응답의 원인을 찾아 주세요. 점검만 하고 설정은 변경하지 마세요.",
"Finde die Ursache der 502-Antworten. Nur prüfen, keine Konfiguration ändern.",
"Trouvez la cause des réponses 502. Analysez uniquement, sans modifier la configuration."),
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`,再考慮重新載入。",
"上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。",
"업스트림 서비스가 `127.0.0.1:8080`에서 수신 대기하고 있지 않아요. Nginx는 정상이며 설정은 변경하지 않았어요. 먼저 업스트림 서비스를 시작하고 `/health`를 확인한 뒤 다시 로드를 검토하세요.",
"Der Upstream-Dienst lauscht nicht auf `127.0.0.1:8080`. Nginx läuft ordnungsgemäß; es wurde keine Konfiguration geändert. Starte den Upstream-Dienst und prüfe `/health`, bevor du ein Neuladen erwägst.",
"Le service en amont n’écoute pas sur `127.0.0.1:8080`. Nginx fonctionne normalement et aucune configuration n’a été modifiée. Démarrez le service en amont, puis vérifiez `/health` avant d’envisager un rechargement."
),
createdAt: now
)
let runID = UUID()
let descriptor = AgentToolDescriptor(
name: "ssh_exec_run",
summary: text(
"Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。",
"원격 호스트에서 명령을 실행해요.", "Einen Befehl auf einem Remote-Host ausführen.",
"Exécuter une commande sur un hôte distant."),
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_run",
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 回應", "502応答の診断", "502 응답 진단", "502-Antworten untersuchen",
"Diagnostiquer les réponses 502")
conversation.messages = [
user, answer,
]
conversation.toolCards = cards
return conversation
}
}