Files
james.maes 1aeec87682 feat(04-05): DevOverlay package + DevSnapshot/ToolCallRow in AgentOrchestrator (OBS-01)
- NSPanel-backed DevOverlayWindow (.floating + .nonactivating) hidden by default
- SwiftUI DevOverlayView renders OBS-01 surface: state/turn, provider/model,
  token counts, cache creation/read + hit %, latency, last-5 tool calls
- @Observable @MainActor DevOverlayViewModel — single write path via apply()
- DevOverlayBridge pumps BoundedAsyncChannel<DevSnapshot> → ViewModel
- DevSnapshot + ToolCallRow live in AgentOrchestrator target (Rule 3
  deviation from plan's files_modified: DevSnapshotEmitter consumes both
  OrchestratorEvent and DevSnapshot, so parking both in AgentOrchestrator
  avoids the circular AgentOrchestrator→DevOverlay→AgentOrchestrator dep)
- Additive OrchestratorEvent.usage(turnId:usage:) — surfaces TurnUsage to
  DevOverlay without reading replay; no nonce (SEC-06 preserved)
- AgentOrchestrator now emits .usage on LLMEvent.usage alongside replay write
- 14 DevOverlay tests pass; 114 AgentCore tests pass (no regression)
2026-04-24 18:32:56 -05:00

114 lines
4.1 KiB
Swift

import Foundation
import AgentCore
/// Atomic state bag rendered by `DevOverlayView` (Plan 04-05, OBS-01).
///
/// Produced by `DevSnapshotEmitter` from the `OrchestratorEvent` stream and
/// shipped through a `BoundedAsyncChannel<DevSnapshot>` (capacity 32,
/// `.dropOldest` — see AGENT-10 four-seam topology). DevOverlay is
/// observational; stale snapshots are acceptable so we favour freshness over
/// completeness.
///
/// **Lives in the AgentOrchestrator target, not DevOverlay.** Rule 3
/// deviation: the plan frontmatter placed `DevSnapshot.swift` under
/// `packages/DevOverlay/Sources/DevOverlay/`, but `DevSnapshotEmitter` (Task
/// 2) consumes `OrchestratorEvent` + `DevSnapshot` and lives alongside the
/// orchestrator. Hosting `DevSnapshot` in DevOverlay would create a cycle
/// (AgentOrchestrator → DevOverlay → AgentOrchestrator). Parked here; the
/// DevOverlay package imports `AgentOrchestrator` and re-exposes the type
/// transitively.
public struct DevSnapshot: Sendable, Equatable {
public let state: TurnState
public let turnId: TurnID?
public let provider: String
public let modelId: String
public let inputTokens: Int
public let outputTokens: Int
public let cacheCreationInputTokens: Int
public let cacheReadInputTokens: Int
public let ttfbMs: Int
public let totalMs: Int
public let toolCalls: [ToolCallRow] // last 5, newest last
public init(
state: TurnState,
turnId: TurnID?,
provider: String,
modelId: String,
inputTokens: Int,
outputTokens: Int,
cacheCreationInputTokens: Int,
cacheReadInputTokens: Int,
ttfbMs: Int,
totalMs: Int,
toolCalls: [ToolCallRow]
) {
self.state = state
self.turnId = turnId
self.provider = provider
self.modelId = modelId
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheCreationInputTokens = cacheCreationInputTokens
self.cacheReadInputTokens = cacheReadInputTokens
self.ttfbMs = ttfbMs
self.totalMs = totalMs
self.toolCalls = toolCalls
}
/// `cacheReadInputTokens / (cacheReadInputTokens + cacheCreationInputTokens) * 100`
/// — clamped to 0 when both counters are zero (cold turn, no cache activity).
public var cacheHitPercentage: Double {
let total = cacheReadInputTokens + cacheCreationInputTokens
guard total > 0 else { return 0 }
return Double(cacheReadInputTokens) / Double(total) * 100.0
}
public static let initial = DevSnapshot(
state: .idle,
turnId: nil,
provider: "",
modelId: "",
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
ttfbMs: 0,
totalMs: 0,
toolCalls: []
)
}
public extension DevSnapshot {
/// Copy-with helper for emitter updates. Any `nil` argument preserves the
/// existing field. Keeps the emitter readable without manual mem-berwise
/// re-init every time one field changes.
func with(
state: TurnState? = nil,
turnId: TurnID? = nil,
provider: String? = nil,
modelId: String? = nil,
inputTokens: Int? = nil,
outputTokens: Int? = nil,
cacheCreationInputTokens: Int? = nil,
cacheReadInputTokens: Int? = nil,
ttfbMs: Int? = nil,
totalMs: Int? = nil,
toolCalls: [ToolCallRow]? = nil
) -> DevSnapshot {
DevSnapshot(
state: state ?? self.state,
turnId: turnId ?? self.turnId,
provider: provider ?? self.provider,
modelId: modelId ?? self.modelId,
inputTokens: inputTokens ?? self.inputTokens,
outputTokens: outputTokens ?? self.outputTokens,
cacheCreationInputTokens: cacheCreationInputTokens ?? self.cacheCreationInputTokens,
cacheReadInputTokens: cacheReadInputTokens ?? self.cacheReadInputTokens,
ttfbMs: ttfbMs ?? self.ttfbMs,
totalMs: totalMs ?? self.totalMs,
toolCalls: toolCalls ?? self.toolCalls
)
}
}