3602 lines
157 KiB
Swift
3602 lines
157 KiB
Swift
// 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
|
||
}
|
||
}
|