chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
import Foundation
|
||||
|
||||
/// One entry in the rendered transcript.
|
||||
public struct TranscriptEntry: Equatable, Sendable, Identifiable {
|
||||
public enum Role: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
|
||||
public struct ToolCall: Equatable, Sendable, Identifiable {
|
||||
public enum Status: Equatable, Sendable {
|
||||
case streamingInput
|
||||
case running
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var input: String
|
||||
public var output: String
|
||||
public var status: Status
|
||||
|
||||
public init(
|
||||
id: String, name: String, input: String = "", output: String = "",
|
||||
status: Status = .streamingInput
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var role: Role
|
||||
public var text: String
|
||||
public var reasoning: String
|
||||
public var toolCalls: [ToolCall]
|
||||
/// True while this entry is still receiving streamed content.
|
||||
public var isStreaming: Bool
|
||||
/// True while this entry is a soft-interrupt waiting for the server to
|
||||
/// inject it at a safe point. Cleared by `soft_interrupt_injected`.
|
||||
public var isQueued: Bool
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
role: Role,
|
||||
text: String,
|
||||
reasoning: String = "",
|
||||
toolCalls: [ToolCall] = [],
|
||||
isStreaming: Bool = false,
|
||||
isQueued: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.reasoning = reasoning
|
||||
self.toolCalls = toolCalls
|
||||
self.isStreaming = isStreaming
|
||||
self.isQueued = isQueued
|
||||
}
|
||||
}
|
||||
|
||||
/// A transient, user-dismissible notice surfaced to the UI.
|
||||
///
|
||||
/// Covers out-of-band server signals that must never be silently dropped:
|
||||
/// push notifications, interrupts, and context compaction events.
|
||||
public struct Notice: Equatable, Sendable, Identifiable {
|
||||
public enum Kind: Equatable, Sendable {
|
||||
case info
|
||||
case notification
|
||||
case compaction
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var kind: Kind
|
||||
public var message: String
|
||||
|
||||
public init(id: UUID = UUID(), kind: Kind = .info, message: String) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
/// Full client-side session state derived from server events.
|
||||
public struct SessionState: Equatable, Sendable {
|
||||
public var phase: ConnectionPhase
|
||||
public var transcript: [TranscriptEntry]
|
||||
public var sessionID: String?
|
||||
public var sessionTitle: String?
|
||||
public var allSessions: [String]
|
||||
/// Human titles for sessions in `allSessions`, keyed by session ID.
|
||||
/// Populated from `session_renamed` broadcasts and history payloads.
|
||||
public var sessionTitles: [String: String]
|
||||
public var isProcessing: Bool
|
||||
public var isReasoning: Bool
|
||||
public var modelName: String?
|
||||
public var providerName: String?
|
||||
public var availableModels: [String]
|
||||
public var reasoningEffort: String?
|
||||
public var serverVersion: String?
|
||||
public var tokenInput: UInt64
|
||||
public var tokenOutput: UInt64
|
||||
public var statusDetail: String?
|
||||
/// Live turn-level provider phase (e.g. "authenticating", "waiting").
|
||||
public var serverPhase: String?
|
||||
public var errorBanner: String?
|
||||
public var notices: [Notice]
|
||||
/// Soft-interrupt messages queued mid-run, in send order, until the
|
||||
/// server confirms injection.
|
||||
public var pendingInterrupts: [String]
|
||||
|
||||
public var hasPendingInterrupts: Bool { !pendingInterrupts.isEmpty }
|
||||
|
||||
/// Human title for a session in the list, falling back to nil when unknown.
|
||||
public func title(forSession sessionID: String) -> String? {
|
||||
sessionTitles[sessionID]
|
||||
}
|
||||
|
||||
public init() {
|
||||
phase = .disconnected
|
||||
transcript = []
|
||||
sessionID = nil
|
||||
sessionTitle = nil
|
||||
allSessions = []
|
||||
sessionTitles = [:]
|
||||
isProcessing = false
|
||||
isReasoning = false
|
||||
modelName = nil
|
||||
providerName = nil
|
||||
availableModels = []
|
||||
reasoningEffort = nil
|
||||
serverVersion = nil
|
||||
tokenInput = 0
|
||||
tokenOutput = 0
|
||||
statusDetail = nil
|
||||
serverPhase = nil
|
||||
errorBanner = nil
|
||||
notices = []
|
||||
pendingInterrupts = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Local intents that mutate state without a server round-trip.
|
||||
public enum LocalIntent: Equatable, Sendable {
|
||||
/// User submitted a message; append it optimistically.
|
||||
case userSentMessage(String)
|
||||
/// User queued a soft-interrupt message mid-run.
|
||||
case userQueuedInterrupt(String)
|
||||
/// User cancelled all queued soft-interrupts before they injected; the
|
||||
/// optimistic bubbles are removed because they will never reach the agent.
|
||||
case cancelledQueuedInterrupts
|
||||
/// Dismiss the current error banner.
|
||||
case dismissError
|
||||
/// Dismiss a transient notice by id.
|
||||
case dismissNotice(UUID)
|
||||
/// User cleared the conversation; wipe the transcript optimistically while
|
||||
/// keeping the connection and session metadata.
|
||||
case clearedConversation
|
||||
/// Reset everything (switching servers/sessions).
|
||||
case reset
|
||||
}
|
||||
|
||||
/// Pure state machine turning connection output into session state.
|
||||
///
|
||||
/// All streaming, tool lifecycle, and history-sync behavior lives here so it
|
||||
/// can be exhaustively unit tested on macOS without UI or network.
|
||||
public enum SessionReducer {
|
||||
public static func reduce(_ state: SessionState, _ output: ConnectionOutput) -> SessionState {
|
||||
switch output {
|
||||
case .phase(let phase):
|
||||
return reducePhase(state, phase)
|
||||
case .event(let event):
|
||||
return reduceEvent(state, event)
|
||||
}
|
||||
}
|
||||
|
||||
public static func reduce(_ state: SessionState, intent: LocalIntent) -> SessionState {
|
||||
var state = state
|
||||
switch intent {
|
||||
case .userSentMessage(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text))
|
||||
state.isProcessing = true
|
||||
state.errorBanner = nil
|
||||
case .userQueuedInterrupt(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text, isQueued: true))
|
||||
state.pendingInterrupts.append(text)
|
||||
case .cancelledQueuedInterrupts:
|
||||
state.transcript.removeAll { $0.isQueued }
|
||||
state.pendingInterrupts = []
|
||||
case .dismissError:
|
||||
state.errorBanner = nil
|
||||
case .dismissNotice(let id):
|
||||
state.notices.removeAll { $0.id == id }
|
||||
case .clearedConversation:
|
||||
state.transcript = []
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.errorBanner = nil
|
||||
case .reset:
|
||||
state = SessionState()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Phase
|
||||
|
||||
private static func reducePhase(_ state: SessionState, _ phase: ConnectionPhase)
|
||||
-> SessionState
|
||||
{
|
||||
var state = state
|
||||
state.phase = phase
|
||||
switch phase {
|
||||
case .connected:
|
||||
state.errorBanner = nil
|
||||
case .failed(let reason):
|
||||
state.errorBanner = reason
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
case .disconnected, .reconnecting:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
case .connecting:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
private static func reduceEvent(_ state: SessionState, _ event: ServerEvent) -> SessionState {
|
||||
var state = state
|
||||
switch event {
|
||||
case .textDelta(let text):
|
||||
withStreamingAssistant(&state) { $0.text += text }
|
||||
|
||||
case .reasoningDelta(let text):
|
||||
state.isReasoning = true
|
||||
withStreamingAssistant(&state) { $0.reasoning += text }
|
||||
|
||||
case .reasoningDone:
|
||||
state.isReasoning = false
|
||||
|
||||
case .textReplace(let text):
|
||||
withStreamingAssistant(&state) { $0.text = text }
|
||||
|
||||
case .toolStart(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
entry.toolCalls.append(.init(id: id, name: name))
|
||||
}
|
||||
|
||||
case .toolInput(let delta):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if !entry.toolCalls.isEmpty {
|
||||
entry.toolCalls[entry.toolCalls.count - 1].input += delta
|
||||
}
|
||||
}
|
||||
|
||||
case .toolExec(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].status = .running
|
||||
} else {
|
||||
entry.toolCalls.append(.init(id: id, name: name, status: .running))
|
||||
}
|
||||
}
|
||||
|
||||
case .toolDone(let id, let name, let output, let error):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].output = output
|
||||
entry.toolCalls[index].status = error.map { .failed($0) } ?? .succeeded
|
||||
} else {
|
||||
entry.toolCalls.append(
|
||||
.init(
|
||||
id: id, name: name, output: output,
|
||||
status: error.map { .failed($0) } ?? .succeeded
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
case .messageEnd:
|
||||
finishStreaming(&state)
|
||||
|
||||
case .connectionPhase(let phase):
|
||||
state.serverPhase = phase.isEmpty ? nil : phase
|
||||
|
||||
case .softInterruptInjected(let content, let displayRole, _, _):
|
||||
resolvePendingInterrupt(&state, content: content, displayRole: displayRole)
|
||||
|
||||
case .retryRollback(let attempt, let max):
|
||||
// The provider is replaying the response from the top: discard all
|
||||
// partial output from the current attempt so it does not duplicate.
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
state.isReasoning = false
|
||||
state.statusDetail = "Retrying (\(attempt)/\(max))"
|
||||
|
||||
case .done:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
|
||||
case .interrupted:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
state.notices.append(Notice(message: "Interrupted"))
|
||||
|
||||
case .error(_, let message, let retryAfterSecs):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
if let retry = retryAfterSecs {
|
||||
state.errorBanner = "\(message) (retry in \(retry)s)"
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .tokenUsage(let input, let output):
|
||||
state.tokenInput = input
|
||||
state.tokenOutput = output
|
||||
|
||||
case .statusDetail(let detail):
|
||||
state.statusDetail = detail
|
||||
|
||||
case .state(_, let sessionID, _, let isProcessing):
|
||||
state.sessionID = sessionID
|
||||
state.isProcessing = isProcessing
|
||||
|
||||
case .sessionID(let sessionID):
|
||||
state.sessionID = sessionID
|
||||
|
||||
case .sessionRenamed(let sessionID, let displayTitle):
|
||||
state.sessionTitles[sessionID] = displayTitle
|
||||
if state.sessionID == nil || state.sessionID == sessionID {
|
||||
state.sessionTitle = displayTitle
|
||||
}
|
||||
|
||||
case .history(let payload):
|
||||
state = applyHistory(state, payload)
|
||||
|
||||
case .modelChanged(_, let model, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.modelName = model
|
||||
}
|
||||
|
||||
case .reasoningEffortChanged(_, let effort, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.reasoningEffort = effort
|
||||
}
|
||||
|
||||
case .compactResult(_, let message, let success):
|
||||
if success {
|
||||
state.notices.append(Notice(kind: .compaction, message: message))
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .availableModelsUpdated(let models, let providerModel):
|
||||
state.availableModels = models
|
||||
if let providerModel {
|
||||
state.modelName = providerModel
|
||||
}
|
||||
|
||||
case .compaction(let trigger, let tokensSaved):
|
||||
if let saved = tokensSaved, trigger != "background" {
|
||||
state.notices.append(
|
||||
Notice(kind: .compaction, message: "Context compacted (\(saved) tokens saved)"))
|
||||
}
|
||||
|
||||
case .notification(let fromName, let message):
|
||||
let prefix = fromName.map { "\($0): " } ?? ""
|
||||
state.notices.append(Notice(kind: .notification, message: prefix + message))
|
||||
|
||||
case .reloading:
|
||||
state.notices.append(Notice(message: "Server is updating, reconnecting shortly"))
|
||||
|
||||
case .sessionCloseRequested(let reason):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
finishStreaming(&state)
|
||||
state.errorBanner = reason.isEmpty ? "Server closed this session" : reason
|
||||
|
||||
case .ack, .pong, .unknown:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private static func applyHistory(
|
||||
_ state: SessionState, _ payload: ServerEvent.HistoryPayload
|
||||
) -> SessionState {
|
||||
var state = state
|
||||
state.sessionID = payload.sessionID
|
||||
state.providerName = payload.providerName ?? state.providerName
|
||||
state.modelName = payload.providerModel ?? state.modelName
|
||||
if !payload.availableModels.isEmpty {
|
||||
state.availableModels = payload.availableModels
|
||||
}
|
||||
if !payload.allSessions.isEmpty {
|
||||
state.allSessions = payload.allSessions
|
||||
}
|
||||
state.serverVersion = payload.serverVersion ?? state.serverVersion
|
||||
state.sessionTitle = payload.displayTitle ?? state.sessionTitle
|
||||
state.reasoningEffort = payload.reasoningEffort ?? state.reasoningEffort
|
||||
if let title = payload.displayTitle {
|
||||
state.sessionTitles[payload.sessionID] = title
|
||||
}
|
||||
// History replaces the transcript wholesale, so optimistic queued
|
||||
// bubbles are rebuilt from the server's authoritative view.
|
||||
state.pendingInterrupts = []
|
||||
if let totals = payload.totalTokens {
|
||||
state.tokenInput = totals.input
|
||||
state.tokenOutput = totals.output
|
||||
}
|
||||
|
||||
// History replaces the transcript wholesale: it is the server's
|
||||
// authoritative view, used on connect and reconnect.
|
||||
state.transcript = payload.messages.compactMap { message in
|
||||
let role: TranscriptEntry.Role
|
||||
switch message.role {
|
||||
case "user": role = .user
|
||||
case "assistant": role = .assistant
|
||||
case "system": role = .system
|
||||
default: return nil
|
||||
}
|
||||
var toolCalls: [TranscriptEntry.ToolCall] = []
|
||||
if let data = message.toolData {
|
||||
toolCalls.append(
|
||||
.init(
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
input: data.input,
|
||||
output: data.output ?? "",
|
||||
status: data.error.map { .failed($0) }
|
||||
?? (data.output != nil ? .succeeded : .running)
|
||||
))
|
||||
} else {
|
||||
toolCalls = message.toolCalls.map { name in
|
||||
.init(id: name, name: name, status: .succeeded)
|
||||
}
|
||||
}
|
||||
// Skip empty assistant placeholders.
|
||||
if message.content.isEmpty && toolCalls.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return TranscriptEntry(role: role, text: message.content, toolCalls: toolCalls)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Marks the matching queued soft-interrupt as delivered. If the injection
|
||||
/// came from another client (no optimistic bubble), appends the content so
|
||||
/// the transcript still reflects what the agent saw.
|
||||
private static func resolvePendingInterrupt(
|
||||
_ state: inout SessionState, content: String, displayRole: String?
|
||||
) {
|
||||
if let index = state.pendingInterrupts.firstIndex(of: content) {
|
||||
state.pendingInterrupts.remove(at: index)
|
||||
}
|
||||
if let index = state.transcript.firstIndex(where: { $0.isQueued && $0.text == content })
|
||||
?? state.transcript.firstIndex(where: { $0.isQueued })
|
||||
{
|
||||
state.transcript[index].isQueued = false
|
||||
} else {
|
||||
let role: TranscriptEntry.Role = displayRole == "system" ? .system : .user
|
||||
state.transcript.append(TranscriptEntry(role: role, text: content))
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn is over: anything still marked queued has either been consumed
|
||||
/// server-side or is moot, so stop showing it as pending.
|
||||
private static func drainPendingInterrupts(_ state: inout SessionState) {
|
||||
state.pendingInterrupts = []
|
||||
for index in state.transcript.indices where state.transcript[index].isQueued {
|
||||
state.transcript[index].isQueued = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutates the trailing streaming assistant entry, creating it if needed.
|
||||
private static func withStreamingAssistant(
|
||||
_ state: inout SessionState, _ mutate: (inout TranscriptEntry) -> Void
|
||||
) {
|
||||
if let last = state.transcript.indices.last,
|
||||
state.transcript[last].role == .assistant,
|
||||
state.transcript[last].isStreaming
|
||||
{
|
||||
mutate(&state.transcript[last])
|
||||
} else {
|
||||
var entry = TranscriptEntry(role: .assistant, text: "", isStreaming: true)
|
||||
mutate(&entry)
|
||||
state.transcript.append(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private static func finishStreaming(_ state: inout SessionState) {
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript[last].isStreaming = false
|
||||
// Drop fully-empty assistant stubs (e.g. tool-only turns that
|
||||
// were replaced or cancelled before any text arrived).
|
||||
if state.transcript[last].text.isEmpty
|
||||
&& state.transcript[last].toolCalls.isEmpty
|
||||
&& state.transcript[last].reasoning.isEmpty
|
||||
{
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user