feat: local-first LLM routing with reactive Opus escalation #198

Open
KofTwentyTwo wants to merge 10 commits from feat/local-first-llm-routing into develop
pull from: feat/local-first-llm-routing
40 changed files with 1422 additions and 23 deletions

No files matched your search

+43 -1
View File
@@ -1,6 +1,8 @@
import Foundation
import AgentCore // EscalationDecision (AgentCore type)
import AgentOrchestrator // BusForwarder, BusForwarderSink
import Bus // OutboundBatcher, BusOutbound, TurnTerminator
import Bus // OutboundBatcher, BusOutbound, TurnTerminator, EscalationDecision (Bus mirror)
import Config // ProviderSelection (AgentCore-side)
/// App-side adapter that wires `BusForwarder` (pure logic, lives in
/// AgentOrchestrator for unit-testability) to `OutboundBatcher` (the
@@ -36,6 +38,21 @@ struct AppBusForwarderSink: BusForwarderSink {
try? await batcher.flushAndSend(.submitRejected(reason: reason))
}
/// Local-first LLM routing Task 7 — translate the AgentCore-side
/// `EscalationDecision` (which carries `Config.ProviderSelection` +
/// `AgentCore.OllamaFailureKind`) into the Bus wire-format
/// `Bus.EscalationDecision` (which mirrors those enums in the
/// dependency-free bridge layer) and flush as `BusOutbound.escalated`.
func sendEscalated(decision: AgentCore.EscalationDecision) async {
let wire = Bus.EscalationDecision(
from: Self.wireProvider(from: decision.from),
to: Self.wireProvider(from: decision.to),
reason: Self.wireFailureKind(from: decision.reason),
firedAt: decision.firedAt
)
try? await batcher.flushAndSend(.escalated(wire))
}
/// 1:1 mapping from `BusForwarder.Terminator` (protocol-agnostic, lives
/// in AgentCore) to `Bus.TurnTerminator` (wire format). Both enums must
/// stay in sync; the cases are deliberately identical so each call site
@@ -49,4 +66,29 @@ struct AppBusForwarderSink: BusForwarderSink {
case .superseded: return .superseded
}
}
/// 1:1 mapping from `Config.ProviderSelection` (Swift-side authority) to
/// `Bus.BusProviderSelection` (wire-format mirror). Bus deliberately
/// does NOT depend on Config — the exhaustive switch is the
/// compile-time drift catcher when either enum adds a case.
static func wireProvider(from p: ProviderSelection) -> BusProviderSelection {
switch p {
case .anthropic: return .anthropic
case .ollama: return .ollama
}
}
/// 1:1 mapping from `AgentCore.OllamaFailureKind` to
/// `Bus.BusOllamaFailureKind`. Same dependency-isolation rationale as
/// `wireProvider`.
static func wireFailureKind(from k: OllamaFailureKind) -> BusOllamaFailureKind {
switch k {
case .streamTruncated: return .streamTruncated
case .malformedToolCall: return .malformedToolCall
case .unknownTool: return .unknownTool
case .refusal: return .refusal
case .connectionFailure: return .connectionFailure
case .emptyResponse: return .emptyResponse
}
}
}
+5 -1
View File
@@ -3321,7 +3321,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `WizardState.pttHotkey` is already persisted; rebind
// PushToTalk so the new shortcut takes effect immediately.
self?.rebindPTTFromState()
}
},
// Local-first LLM routing (Task 9 / spec §6): pass the live
// ConfigStore so the provider-mode picker can hydrate from /
// hot-reload-write to the per-turn snapshot.
configStore: configStore
)
}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
<title>Jarvis HUD</title>
<script defer src="./assets/index-ClequpMD.js"></script>
<script defer src="./assets/index-CDTLe5s8.js"></script>
</head>
<body>
<div id="root"></div>
+114
View File
@@ -1,6 +1,54 @@
import SwiftUI
import Keychain
import Shell
import Config
/// Local-first LLM routing (Task 9 / spec §6): three-way picker that maps
/// to `{provider, escalationEnabled}` pairs on `PerTurnSnapshot`.
///
/// | Mode | provider | escalationEnabled |
/// |------------------|------------|--------------------------|
/// | Local-first | .ollama | true |
/// | Anthropic only | .anthropic | false (moot, pinned) |
/// | Ollama only | .ollama | false |
///
/// `escalationEnabled` is "moot" for `.anthropic` because escalation is
/// an Ollama→Anthropic-only fallback path; we normalize it to `false` so
/// the persisted state is unambiguous.
enum ProviderMode: String, CaseIterable, Identifiable {
case localFirst
case anthropicOnly
case ollamaOnly
var id: String { rawValue }
var label: String {
switch self {
case .localFirst: return "Local-first (recommended)"
case .anthropicOnly: return "Anthropic only"
case .ollamaOnly: return "Ollama only"
}
}
static func from(
provider: ProviderSelection,
escalationEnabled: Bool
) -> ProviderMode {
switch (provider, escalationEnabled) {
case (.ollama, true): return .localFirst
case (.anthropic, _): return .anthropicOnly
case (.ollama, false): return .ollamaOnly
}
}
var pair: (provider: ProviderSelection, escalationEnabled: Bool) {
switch self {
case .localFirst: return (.ollama, true)
case .anthropicOnly: return (.anthropic, false)
case .ollamaOnly: return (.ollama, false)
}
}
}
/// Real Settings panel — single window, three rows, every row independently
/// editable. Distinct from the first-run wizard:
@@ -28,6 +76,11 @@ struct SettingsView: View {
/// this still compile.
let onPTTHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
/// Local-first LLM routing (Task 9 / spec §6): optional because the
/// SwiftUI preview/test seams may instantiate the view without a live
/// `ConfigStore`. When nil, the provider-mode section is hidden.
let configStore: ConfigStore?
let onClose: () -> Void
init(
@@ -37,6 +90,7 @@ struct SettingsView: View {
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
configStore: ConfigStore? = nil,
onClose: @escaping () -> Void
) {
self.state = state
@@ -45,6 +99,7 @@ struct SettingsView: View {
self.onGrantInputMonitoring = onGrantInputMonitoring
self.onHotkeyChanged = onHotkeyChanged
self.onPTTHotkeyChanged = onPTTHotkeyChanged
self.configStore = configStore
self.onClose = onClose
}
@@ -61,6 +116,13 @@ struct SettingsView: View {
@State private var pttHotkeyDraft: Shell.KeyboardShortcut?
@State private var pttHotkeyError: String?
/// Local-first LLM routing (Task 9 / spec §6): derived from the
/// `ConfigStore`'s current `PerTurnSnapshot`. Hydrated on `.onAppear`
/// from `configStore.perTurn()`; mutated through the `Picker`.
/// `nil` until hydrated so the picker doesn't flash a wrong default.
@State private var providerMode: ProviderMode = .localFirst
@State private var providerModeHydrated: Bool = false
enum APIKeyStatus: Equatable {
case unchanged
case validating
@@ -76,6 +138,10 @@ struct SettingsView: View {
apiKeySection
Divider()
if configStore != nil {
providerModeSection
Divider()
}
permissionsSection
Divider()
hotkeySection
@@ -99,6 +165,54 @@ struct SettingsView: View {
state.refresh()
hotkeyDraft = state.hotkey
pttHotkeyDraft = state.pttHotkey
// Local-first LLM routing (Task 9): hydrate the picker from
// the live ConfigStore. The `providerModeHydrated` flag
// suppresses the spurious .onChange fire that would otherwise
// immediately write back the (just-read) snapshot.
if let configStore {
Task {
let snap = await configStore.perTurn()
await MainActor.run {
providerMode = ProviderMode.from(
provider: snap.provider,
escalationEnabled: snap.escalationEnabled
)
providerModeHydrated = true
}
}
}
}
}
// MARK: - Provider mode (Local-first LLM routing, Task 9 / spec §6)
private var providerModeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
"Provider",
subtitle: "Local-first runs the agent on Ollama and only reaches Anthropic if Ollama can't handle the turn. Anthropic only / Ollama only pin to a single backend."
)
Picker("Mode", selection: $providerMode) {
ForEach(ProviderMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.menu)
.onChange(of: providerMode) { _, newValue in
// Suppress the initial fire from hydration; only persist
// user-driven edits.
guard providerModeHydrated, let configStore else { return }
let pair = newValue.pair
Task {
let current = await configStore.perTurn()
let next = current.with(
provider: pair.provider,
escalationEnabled: pair.escalationEnabled
)
await configStore.updatePerTurn(next)
}
}
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import AppKit
import SwiftUI
import Keychain
import Shell
import Config
/// Hosts the `SettingsView` in an `NSWindow`. Distinct from the first-run
/// wizard:
@@ -31,7 +32,8 @@ public final class SettingsWindowController {
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
configStore: ConfigStore? = nil
) {
// Refresh truth before view creation: TCC grants and Keychain entries
// can change between sessions, same reasoning as the wizard's
@@ -51,6 +53,7 @@ public final class SettingsWindowController {
onGrantInputMonitoring: onGrantInputMonitoring,
onHotkeyChanged: onHotkeyChanged,
onPTTHotkeyChanged: onPTTHotkeyChanged,
configStore: configStore,
onClose: { [weak self] in self?.close() }
)
let hosting = NSHostingController(rootView: rootView)
@@ -0,0 +1,51 @@
import XCTest
import Config
@testable import Jarvis
/// Local-first LLM routing (Task 9 / spec §6) — asserts the
/// `ProviderMode.from(provider:, escalationEnabled:)` mapping that
/// backs the Settings provider picker:
///
/// .ollama + true → .localFirst
/// .anthropic + * → .anthropicOnly (escalation moot, pinned)
/// .ollama + false → .ollamaOnly
final class ProviderModeTests: XCTestCase {
func test_provider_mode_mapping_canonical() {
XCTAssertEqual(
ProviderMode.from(provider: .ollama, escalationEnabled: true),
.localFirst
)
XCTAssertEqual(
ProviderMode.from(provider: .anthropic, escalationEnabled: false),
.anthropicOnly
)
XCTAssertEqual(
ProviderMode.from(provider: .ollama, escalationEnabled: false),
.ollamaOnly
)
}
/// `escalationEnabled` is moot when provider is `.anthropic` because
/// escalation is an Ollama→Anthropic-only fallback. Both inputs
/// collapse to `.anthropicOnly` so the picker shows a consistent
/// mode regardless of historical/migrated state.
func test_anthropic_with_escalation_true_normalizes_to_anthropic_only() {
XCTAssertEqual(
ProviderMode.from(provider: .anthropic, escalationEnabled: true),
.anthropicOnly
)
}
/// The `pair` accessor must round-trip back to the same mode via
/// `from(...)`. Guards against an asymmetric edit in either direction.
func test_pair_round_trips_through_from() {
for mode in ProviderMode.allCases {
let pair = mode.pair
let roundTripped = ProviderMode.from(
provider: pair.provider,
escalationEnabled: pair.escalationEnabled
)
XCTAssertEqual(roundTripped, mode, "round-trip failed for \(mode)")
}
}
}
+4
View File
@@ -77,6 +77,7 @@
B72852E1EF52C0E6F8170852 /* Memory in Frameworks */ = {isa = PBXBuildFile; productRef = 59F56604A76635EC59D2671B /* Memory */; };
B8F411ADE70402B71887E377 /* VoiceOrchestratorAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590D5A359A5C3E915D274436 /* VoiceOrchestratorAdapter.swift */; };
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */; };
95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B66305B3801453E88D2341F /* ProviderModeTests.swift */; };
BEFC509502C7940A21001F9F /* JarvisHUDPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424DD9447F84F35372A48FF /* JarvisHUDPanel.swift */; };
C336B40332A148A5A1F2DDC8 /* WebviewBundleLoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16929275D5323F505DF12E9 /* WebviewBundleLoadTests.swift */; };
C3917516BFFF438A4AD5CE80 /* BrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */; };
@@ -178,6 +179,7 @@
4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = "<group>"; };
4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconController.swift; sourceTree = "<group>"; };
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateEnumTests.swift; sourceTree = "<group>"; };
4B66305B3801453E88D2341F /* ProviderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModeTests.swift; sourceTree = "<group>"; };
511D7B2406F485ED0D4B9E0D /* HudStateIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateIntent.swift; sourceTree = "<group>"; };
528FED2A364DE6BD1F2BA20F /* AppBusForwarderSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBusForwarderSink.swift; sourceTree = "<group>"; };
54B2C24637464D380D4450AF /* BootHealthProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootHealthProbes.swift; sourceTree = "<group>"; };
@@ -380,6 +382,7 @@
46CB8B7693CEA625D7331C3E /* HudStateCoordinatorBusWiringTests.swift */,
8B7E85856B679CE0788D38DB /* HudStateCoordinatorTests.swift */,
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */,
4B66305B3801453E88D2341F /* ProviderModeTests.swift */,
3527AF52C1E81BAC4EF70766 /* JarvisHUDPanelTests.swift */,
6639284829C6DB4F69ED47F7 /* MCPBusGatewayAdapterTests.swift */,
0B5B16BDAC02919861AA4B0B /* MCPRuntimeWiringTests.swift */,
@@ -1298,6 +1301,7 @@
C9F3B34208084769106C4B07 /* HudStateCoordinatorBusWiringTests.swift in Sources */,
8E4590089A7955545E2146D3 /* HudStateCoordinatorTests.swift in Sources */,
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */,
95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */,
461A5FACC5A3C2025FB0A233 /* JarvisHUDPanelTests.swift in Sources */,
70D09A556654E11B140AFBD4 /* MCPBusGatewayAdapterTests.swift in Sources */,
5AD556F5095852328E72BE36 /* MCPRuntimeWiringTests.swift in Sources */,
@@ -22,4 +22,34 @@ public enum LLMProviderError: Error, Sendable, Equatable {
/// after stream-truncation retry policy gives up. The provider does
/// not emit this directly.
case streamTruncatedFinal
/// Ollama `/api/chat` emitted a `tool_calls` block whose `arguments`
/// failed to decode as JSON. Carries a redacted underlying reason for
/// diagnostics. Surfaces `OllamaFailureKind.malformedToolCall` via
/// `ollamaFailureKind` so the orchestrator (Task 6) can escalate to
/// Anthropic. See spec §2.
case malformedToolCall(reason: String)
/// Ollama stream terminated with zero text deltas AND zero tool-use
/// requests — a model-side empty response. Surfaces
/// `OllamaFailureKind.emptyResponse` so the orchestrator (Task 6) can
/// escalate to Anthropic. See spec §2.
case emptyResponse
/// If this error originated from the local Ollama provider and matches
/// one of the closed failure kinds the orchestrator escalates on,
/// return that kind. Non-Ollama-specific errors (`.api`, `.decode`,
/// `.transport`, `.streamTruncatedFinal`) return `nil`.
///
/// `streamTruncated`, `refusal`, `connectionFailure`, and `unknownTool`
/// are detected orchestrator-side from `LLMEvent.stopReason`, the
/// transport-layer `URLSession` error path, and the tool registry —
/// not from this enum.
public var ollamaFailureKind: OllamaFailureKind? {
switch self {
case .malformedToolCall: return .malformedToolCall
case .emptyResponse: return .emptyResponse
case .api, .decode, .transport, .streamTruncatedFinal: return nil
}
}
}
@@ -0,0 +1,40 @@
import Foundation
import Config
/// Closed enumeration of detectable failure modes for the local Ollama provider
/// that trigger one-shot escalation to Anthropic Claude Opus 4.7.
///
/// See `docs/superpowers/specs/2026-05-14-local-first-llm-routing-design.md` §2.
///
/// Adding a new case is a breaking change: orchestrator + tests + HUD badge
/// reason mapping all need to be updated together.
public enum OllamaFailureKind: String, Sendable, Codable, Equatable, CaseIterable {
/// Mid-stream EOF before `messageStop`. Already wired pre-spec.
case streamTruncated
/// `tool_calls` payload failed to decode against `ToolUseRequest`.
case malformedToolCall
/// Model invoked a tool name not in the registry.
case unknownTool
/// `stopReason: .refusal` event observed.
case refusal
/// URLSession error before any tokens received.
case connectionFailure
/// `messageStop` with zero `textDelta` and zero `toolUseRequested`.
case emptyResponse
}
/// Recorded once when reactive escalation fires. Emitted to the HUD via
/// `BusOutbound.escalated(...)`.
public struct EscalationDecision: Sendable, Codable, Equatable {
public let from: ProviderSelection
public let to: ProviderSelection
public let reason: OllamaFailureKind
public let firedAt: Date
public init(from: ProviderSelection, to: ProviderSelection, reason: OllamaFailureKind, firedAt: Date) {
self.from = from
self.to = to
self.reason = reason
self.firedAt = firedAt
}
}
@@ -521,6 +521,16 @@ public actor AgentOrchestrator {
// stays on T1 (the user got the response we have). This mirrors the
// AGENT-09 retry budget but is independent of it.
var escalationConsumed = false
// Local-first LLM routing (Task 6 / spec §2): one-shot reactive
// escalation from Ollama → Anthropic on a `providerError` carrying
// an `OllamaFailureKind`. Distinct from the D-02 vision escalation
// above (which swaps T1↔T2 mid-turn under the SAME turnId) and from
// AGENT-09's stream-truncation retry below (which allocates a fresh
// retry turnId). At-most-one fire per logical turn; the second
// provider error surfaces a terminal `.error` event. Resets
// naturally across turns because this var is declared inside
// `runTurnLoop` — each new turn call gets a fresh stack frame.
var escalatedThisTurn = false
outer: while true {
// Compute tool_choice based on remaining budget.
@@ -845,6 +855,97 @@ public actor AgentOrchestrator {
}
case .providerError(let err):
// Local-first LLM routing (Task 6 / spec §2):
// reactive escalation from Ollama → Anthropic when
// the provider error carries an `OllamaFailureKind`
// AND the current turn is running on Ollama AND
// escalation is enabled AND we haven't already
// escalated this turn. Otherwise fall through to
// the terminal error path below.
if let failureKind = err.ollamaFailureKind,
currentPerTurn.resolvedProvider == .ollama,
currentPerTurn.escalationEnabled,
!escalatedThisTurn {
escalatedThisTurn = true
let decision = EscalationDecision(
from: .ollama,
to: .anthropic,
reason: failureKind,
firedAt: Date()
)
await events.send(.escalated(decision))
// Build the Anthropic provider. If the factory
// throws, surface a terminal error — we've
// already consumed the escalation budget so
// there's no second-chance retry path.
let nextProvider: any LLMProvider
do {
nextProvider = try await providerFactory(.anthropic)
} catch {
logger.error("providerFactory(.anthropic) failed during escalation", metadata: [
"error": "\(error)",
])
let redactedErr = Self.redact(err)
await events.send(.error(turnId: currentTurnId, error: redactedErr))
await replayLog.record(.error(Data(String(describing: redactedErr).utf8)), for: currentTurnId)
await replayLog.endTurn(currentTurnId, stopReason: "provider_error")
await events.send(.stateChange(.idle))
currentTurn = nil
return
}
// Close the original (Ollama) turn row with a
// distinct stop reason so the replay log
// captures the escalation point. Allocate a
// fresh turn row for the Anthropic attempt,
// mirroring the AGENT-09 streamTruncated-retry
// pattern (retryOf set on the new row).
await replayLog.endTurn(currentTurnId, stopReason: "ollama_escalation")
let newTurnId = TurnID.fresh()
let originalTurnId = retry.originalTurnId
do {
try await replayLog.startTurn(
turnId: newTurnId,
sessionId: sessionId,
retryOf: originalTurnId,
turnNonce: wrapper.nonce.rawValue,
source: source,
provider: ProviderSelection.anthropic.rawValue,
modelId: modelIDFor(.anthropic).rawValue
)
} catch {
logger.error("escalation startTurn failed", metadata: ["error": "\(error)"])
let redactedErr = Self.redact(err)
await events.send(.error(turnId: currentTurnId, error: redactedErr))
await events.send(.stateChange(.idle))
currentTurn = nil
return
}
currentTurnId = newTurnId
currentProvider = nextProvider
currentPerTurn = currentPerTurn.with(provider: .anthropic)
// Clean state: restart from initial messages so
// Anthropic doesn't inherit Ollama's partial
// tool-use round-trips (which may have been
// what tripped the malformed-tool-call path).
messages = initialMessages
toolCallBudget = currentPerTurn.maxToolCallsPerTurn()
assistantTextSoFar = ""
assistantTextSinceLastFlush = ""
if let exec = currentTurn {
currentTurn = TurnExecution(
id: newTurnId, task: exec.task,
startedAt: exec.startedAt, retryOf: originalTurnId
)
}
await events.send(.stateChange(.reconfiguring))
continue outer
}
// HI-01: redact credential-shaped substrings from the
// provider error body BEFORE forwarding to either the
// bus or the replay log. Anthropic's API can echo a
@@ -920,6 +1021,10 @@ public actor AgentOrchestrator {
return .transport(description: Redact.apply(description))
case .streamTruncatedFinal:
return .streamTruncatedFinal
case .malformedToolCall(let reason):
return .malformedToolCall(reason: Redact.apply(reason))
case .emptyResponse:
return .emptyResponse
}
}
@@ -27,6 +27,10 @@ import AgentCore
/// error event in the chat panel — without this branch, mid-stream
/// failures (401 / network drop / streamTruncated) leave the panel
/// silent and the user has no idea their turn died.
/// - `escalated(decision)` — emits `sendEscalated(decision:)`. Local-first
/// LLM routing Task 7 — the HUD renders an EscalationBadge keyed by the
/// `decision.reason`. App-side sink translates the AgentCore type into
/// the Bus wire type.
/// - All other cases (`stateChange`, `thinkingDelta`, `toolCardUpdate`,
/// `usage`) — ignored. They have dedicated subscribers elsewhere.
public enum BusForwarder {
@@ -93,6 +97,13 @@ public enum BusForwarder {
let reason = "Turn failed: \(String(describing: error))"
await sink.sendSubmitRejected(reason: reason)
case let .escalated(decision):
// Local-first LLM routing Task 7 — translate to
// BusOutbound.escalated downstream. The sink owns the
// AgentCore → Bus type translation so this file stays
// free of the Bus dependency.
await sink.sendEscalated(decision: decision)
default:
break
}
@@ -114,4 +125,8 @@ public protocol BusForwarderSink: Sendable {
func sendTurnStarted(id: UUID) async
func sendTurnEnded(id: UUID, terminator: BusForwarder.Terminator) async
func sendSubmitRejected(reason: String) async
/// Local-first LLM routing Task 7 — emits `BusOutbound.escalated`. The
/// App-side conformer translates `AgentCore.EscalationDecision` into the
/// `Bus.EscalationDecision` wire type before flushing.
func sendEscalated(decision: EscalationDecision) async
}
@@ -157,6 +157,15 @@ public actor DevSnapshotEmitter {
current = current.with(state: .idle, turnId: turnId)
thinkingStartedAt = nil
firstTokenAt = nil
case .escalated:
// Local-first LLM routing (Task 6): the dev overlay's snapshot
// shape predates escalation. Treat as a no-op here — Task 8
// adds a dedicated HUD badge for the user-visible signal, and
// Plan 04-05's DevOverlay can read the `.escalated` event off
// its own subscription if needed. No latency/usage fields to
// update at this seam.
break
}
await output.send(current)
}
@@ -21,6 +21,14 @@ public enum OrchestratorEvent: Sendable {
case usage(turnId: TurnID, usage: TurnUsage)
case turnEnd(turnId: TurnID, stopReason: StopReason)
case error(turnId: TurnID, error: LLMProviderError)
/// Local-first LLM routing (Task 6 / spec §2): one-shot reactive
/// escalation from Ollama → Anthropic fired by the orchestrator when
/// the local provider emits a `providerError` carrying an
/// `OllamaFailureKind`. Emitted at most once per logical turn (budget
/// resets across turns — see `escalatedThisTurn` in `runTurnLoop`).
/// Task 7 wires this to the HUD badge via `BusOutbound.escalated`.
case escalated(EscalationDecision)
}
public struct ToolCardUpdate: Sendable {
@@ -20,6 +20,13 @@ struct NDJSONDecoder {
/// array — used to drive the implicit terminator mapping when
/// `done_reason` is missing or `"stop"` but tool calls were observed.
private(set) var sawToolCalls = false
/// Set to `true` once any non-empty text delta has been yielded.
/// Combined with `sawAnyToolUseRequested`, drives `emptyResponse`
/// detection on the terminator chunk (Task 5 / spec §2).
private(set) var sawAnyTextDelta = false
/// Set to `true` once at least one `toolUseRequested` event has been
/// yielded. Mirrors `sawAnyTextDelta` for the empty-response check.
private(set) var sawAnyToolUseRequested = false
/// Final `prompt_eval_count` observed (Ollama only emits it on the
/// terminator chunk in practice; keeping it as an accumulator is safe).
private(set) var promptEvalCount: Int = 0
@@ -83,12 +90,38 @@ struct NDJSONDecoder {
if let args = function["arguments"] {
// Ollama native emits arguments as a JSON object.
if let str = args as? String {
argsJSON = Data(str.utf8)
// String form: validate it parses as JSON before
// forwarding — orchestrator escalates on this kind
// (Task 5 / spec §2).
let strData = Data(str.utf8)
do {
_ = try JSONSerialization.jsonObject(
with: strData,
options: [.allowFragments]
)
argsJSON = strData
} catch {
yield(.providerError(.malformedToolCall(
reason: "tool_calls[].function.arguments string failed to parse as JSON: \(error.localizedDescription)"
)))
yield(.messageStop)
messageStopEmitted = true
return
}
} else {
argsJSON = (try? JSONSerialization.data(
withJSONObject: args,
options: [.sortedKeys]
)) ?? Data("{}".utf8)
do {
argsJSON = try JSONSerialization.data(
withJSONObject: args,
options: [.sortedKeys]
)
} catch {
yield(.providerError(.malformedToolCall(
reason: "tool_calls[].function.arguments object failed to re-encode: \(error.localizedDescription)"
)))
yield(.messageStop)
messageStopEmitted = true
return
}
}
} else {
argsJSON = Data("{}".utf8)
@@ -100,6 +133,7 @@ struct NDJSONDecoder {
name: name,
argsJSON: argsJSON
)))
sawAnyToolUseRequested = true
}
sawToolCalls = true
}
@@ -109,6 +143,7 @@ struct NDJSONDecoder {
let content = message["content"] as? String,
!content.isEmpty {
yield(.textDelta(content))
sawAnyTextDelta = true
}
// 3) usage accumulators (Ollama only fills these on the terminator
@@ -136,6 +171,14 @@ struct NDJSONDecoder {
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0
)))
// Empty-response detection (Task 5 / spec §2): if the terminator
// arrives with zero textDelta AND zero toolUseRequested, surface
// `.emptyResponse` so the orchestrator can escalate to Anthropic.
// The providerError MUST precede messageStop so consumers that
// break on messageStop still observe it.
if !sawAnyTextDelta && !sawAnyToolUseRequested {
yield(.providerError(.emptyResponse))
}
yield(.messageStop)
messageStopEmitted = true
}
@@ -0,0 +1,26 @@
import XCTest
@testable import AgentCore
final class OllamaFailureKindTests: XCTestCase {
func test_failure_kind_round_trips_codable() throws {
for kind in OllamaFailureKind.allCases {
let data = try JSONEncoder().encode(kind)
let decoded = try JSONDecoder().decode(OllamaFailureKind.self, from: data)
XCTAssertEqual(decoded, kind)
}
}
func test_escalation_decision_round_trips_codable() throws {
let firedAt = Date(timeIntervalSince1970: 1_700_000_000)
let decision = EscalationDecision(
from: .ollama,
to: .anthropic,
reason: .malformedToolCall,
firedAt: firedAt
)
let data = try JSONEncoder().encode(decision)
let decoded = try JSONDecoder().decode(EscalationDecision.self, from: data)
XCTAssertEqual(decoded, decision)
XCTAssertEqual(decoded.firedAt, firedAt)
}
}
@@ -1,5 +1,6 @@
import XCTest
import AgentCore
import Config
@testable import AgentOrchestrator
/// Coverage for `BusForwarder.drain` — the .bus subscriber's translation
@@ -22,6 +23,7 @@ final class BusForwarderTests: XCTestCase {
private(set) var turnStarts: [UUID] = []
private(set) var turnEnds: [(UUID, BusForwarder.Terminator)] = []
private(set) var rejections: [String] = []
private(set) var escalations: [EscalationDecision] = []
func postToken(_ chunk: String) async { tokens.append(chunk) }
func sendTurnStarted(id: UUID) async { turnStarts.append(id) }
@@ -29,14 +31,18 @@ final class BusForwarderTests: XCTestCase {
turnEnds.append((id, terminator))
}
func sendSubmitRejected(reason: String) async { rejections.append(reason) }
func sendEscalated(decision: EscalationDecision) async {
escalations.append(decision)
}
func snapshot() -> (
tokens: [String],
turnStarts: [UUID],
turnEnds: [(UUID, BusForwarder.Terminator)],
rejections: [String]
rejections: [String],
escalations: [EscalationDecision]
) {
(tokens, turnStarts, turnEnds, rejections)
(tokens, turnStarts, turnEnds, rejections, escalations)
}
}
@@ -260,6 +266,58 @@ final class BusForwarderTests: XCTestCase {
XCTAssertTrue(snap.rejections.isEmpty)
}
// MARK: - Escalation forwarding (BF-ESC1..2)
/// Local-first LLM routing Task 7 — `.escalated` must surface the
/// decision to the sink so the App-side adapter can translate to
/// `BusOutbound.escalated` and the HUD can render its badge.
func testDrain_escalated_emitsSendEscalatedWithMatchingDecision() async {
let sink = RecordingSink()
let firedAt = Date(timeIntervalSince1970: 1_700_000_000)
let decision = EscalationDecision(
from: .ollama,
to: .anthropic,
reason: .malformedToolCall,
firedAt: firedAt
)
let stream = makeStream([
.escalated(decision)
])
await BusForwarder.drain(events: stream, sink: sink)
let snap = await sink.snapshot()
XCTAssertEqual(snap.escalations.count, 1)
XCTAssertEqual(snap.escalations.first, decision)
XCTAssertTrue(snap.tokens.isEmpty)
XCTAssertTrue(snap.turnStarts.isEmpty)
XCTAssertTrue(snap.turnEnds.isEmpty)
XCTAssertTrue(snap.rejections.isEmpty)
}
/// `.escalated` does NOT terminate the turn — the orchestrator continues
/// streaming on the next provider. The forwarder must not synthesize a
/// turnStarted/turnEnded pair on top of the escalation event.
func testDrain_escalated_doesNotEmitTurnLifecycleEvents() async {
let sink = RecordingSink()
let decision = EscalationDecision(
from: .ollama,
to: .anthropic,
reason: .connectionFailure,
firedAt: Date(timeIntervalSince1970: 1_700_000_001)
)
let stream = makeStream([
.escalated(decision)
])
await BusForwarder.drain(events: stream, sink: sink)
let snap = await sink.snapshot()
XCTAssertEqual(snap.escalations.count, 1)
XCTAssertTrue(snap.turnStarts.isEmpty)
XCTAssertTrue(snap.turnEnds.isEmpty)
}
// MARK: - Malformed turn id guard (BF-G1)
func testDrain_tokenDelta_withNonUUIDTurnId_doesNotCrashOrEmitStart() async {
@@ -0,0 +1,235 @@
import XCTest
import AgentCore
import Config
import Replay
@testable import AgentOrchestrator
/// Task 6 (local-first LLM routing spec §2, §3): reactive escalation from
/// Ollama → Anthropic when the local provider emits a
/// `providerError(ollamaFailureKind: ...)`. Budget: at most ONE escalation
/// per turn; second failure surfaces a terminal error. Disabling
/// `escalationEnabled` short-circuits the retry entirely.
@MainActor
final class LocalFirstEscalationTests: XCTestCase {
var tempHome: TempReplayHome!
override func setUp() async throws {
try await super.setUp()
tempHome = TempReplayHome.make()
}
override func tearDown() async throws {
tempHome?.cleanup()
try await super.tearDown()
}
// MARK: - Helpers
/// Build a `PerTurnSnapshot` honouring `escalationEnabled`. The fixture in
/// `Helpers.swift` doesn't expose the flag because pre-Task-3 snapshots
/// always defaulted to `true`; this test file forces the flag explicitly.
private func snapshot(
provider: ProviderSelection,
escalationEnabled: Bool
) -> PerTurnSnapshot {
let providerString = provider.rawValue
let json = """
{"schemaVersion":1,"provider":"\(providerString)","escalationEnabled":\(escalationEnabled),"tts":{"tier":"tier1"},"stt":{"whisperKitFallback":false},"featureFlags":{}}
""".data(using: .utf8)!
return try! JSONDecoder().decode(PerTurnSnapshot.self, from: json)
}
private func buildOrchestrator(
provider mock: MockLLMProvider,
configStore cs: ConfigStore
) async throws -> (AgentOrchestrator, ReplayLog) {
let replay = try ReplayLog(databaseURL: tempHome.dbURL)
let session = try await replay.beginSession(appVersion: "test", buildSHA: "abc")
let orch = AgentOrchestrator(
configStore: cs,
providerFactory: { _ in mock },
toolDispatcher: StubToolDispatcher(),
replayLog: replay,
sessionId: session,
systemPrompt: "you are jarvis"
)
return (orch, replay)
}
private func collectUntil(
orch: AgentOrchestrator,
timeout: TimeInterval = 5.0,
predicate: @escaping (OrchestratorEvent) -> Bool
) async -> [OrchestratorEvent] {
var collected: [OrchestratorEvent] = []
let deadline = Date().addingTimeInterval(timeout)
for await event in orch.events {
collected.append(event)
if predicate(event) { return collected }
if Date() > deadline { return collected }
}
return collected
}
// MARK: - A. Reactive fallback fires once on malformedToolCall
/// Provider running on Ollama emits providerError(.malformedToolCall) on
/// the first call; orchestrator escalates to Anthropic; second call
/// returns a clean happy-path stream. Exactly one `.escalated` event;
/// the turn ends cleanly.
func test_A_reactiveFallbackFiresOnceOnMalformedToolCall() async throws {
let mock = MockLLMProvider(scripts: [
// First attempt (Ollama): malformed tool call surfaces as
// providerError mid-stream. The Ollama provider always emits
// .messageStart before any failure detection (see Task 5).
.init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
.providerError(.malformedToolCall(reason: "tool args were not valid JSON")),
]),
// Second attempt (Anthropic after escalation): completes cleanly.
.init(events: [
.messageStart(LLMMessageStart(messageId: "m2", model: "opus", usagePrefix: nil)),
.textDelta("Hello."),
.stopReason(.endTurn),
.messageStop,
]),
])
let cs = ConfigStore(
launch: makeLaunchSnapshot(),
initial: snapshot(provider: .ollama, escalationEnabled: true)
)
let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
_ = await orch.submit(.text("ping"))
let events = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }; return false
}
// Exactly one escalation event observed with reason == .malformedToolCall.
let escalations: [EscalationDecision] = events.compactMap { ev in
if case .escalated(let d) = ev { return d }
return nil
}
XCTAssertEqual(escalations.count, 1, "expected exactly one escalation; got \(escalations.count) in \(events)")
XCTAssertEqual(escalations.first?.reason, .malformedToolCall)
XCTAssertEqual(escalations.first?.from, .ollama)
XCTAssertEqual(escalations.first?.to, .anthropic)
// Turn ended cleanly (no terminal error).
let sawError = events.contains { ev in
if case .error = ev { return true }; return false
}
XCTAssertFalse(sawError, "turn should end cleanly post-escalation; got \(events)")
XCTAssertTrue(events.contains(where: { ev in
if case .turnEnd(_, let r) = ev, r == .endTurn { return true }; return false
}), "expected terminal .turnEnd(.endTurn) on Anthropic")
// Two provider calls: Ollama (failed) + Anthropic (succeeded).
let calls = await mock.getRecordedCalls()
XCTAssertEqual(calls.count, 2, "expected one escalation → 2 provider calls; got \(calls.count)")
}
// MARK: - B. Escalation budget honoured (both providers fail)
/// Ollama emits providerError(.malformedToolCall); Anthropic (post-
/// escalation) ALSO emits a providerError. Orchestrator does NOT
/// escalate a second time — surfaces a terminal error. Exactly one
/// `.escalated` event.
func test_B_escalationBudgetHonoredBothProvidersFail() async throws {
let mock = MockLLMProvider(scripts: [
// First attempt (Ollama): malformedToolCall.
.init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
.providerError(.malformedToolCall(reason: "bad tool args")),
]),
// Second attempt (Anthropic post-escalation): emptyResponse —
// doesn't matter that it's an Ollama-flavoured kind; the
// orchestrator's budget check is purely on escalatedThisTurn,
// not on the kind. We just need a providerError outcome.
.init(events: [
.messageStart(LLMMessageStart(messageId: "m2", model: "opus", usagePrefix: nil)),
.providerError(.api(statusCode: 500, body: "upstream borked")),
]),
// Third attempt sentinel — must NOT be invoked.
.init(events: [
.messageStart(LLMMessageStart(messageId: "should-not-fire", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
])
let cs = ConfigStore(
launch: makeLaunchSnapshot(),
initial: snapshot(provider: .ollama, escalationEnabled: true)
)
let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
_ = await orch.submit(.text("ping"))
let events = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }; return false
}
// Exactly one escalation event.
let escalations: [EscalationDecision] = events.compactMap { ev in
if case .escalated(let d) = ev { return d }
return nil
}
XCTAssertEqual(escalations.count, 1, "expected exactly one escalation despite second failure")
// Terminal .error event present.
let sawError = events.contains { ev in
if case .error = ev { return true }; return false
}
XCTAssertTrue(sawError, "second failure must surface a terminal error; got \(events)")
// Exactly two provider calls: no third attempt.
let calls = await mock.getRecordedCalls()
XCTAssertEqual(calls.count, 2, "must NOT escalate twice; got \(calls.count) calls")
}
// MARK: - C. Escalation disabled surfaces error directly
/// `escalationEnabled == false`, provider is .ollama, providerError
/// fires. Orchestrator does NOT escalate; surfaces a terminal error and
/// calls the provider only ONCE.
func test_C_escalationDisabledSurfacesErrorDirectly() async throws {
let mock = MockLLMProvider(scripts: [
.init(events: [
.messageStart(LLMMessageStart(messageId: "m1", model: "qwen", usagePrefix: nil)),
.providerError(.malformedToolCall(reason: "bad tool args")),
]),
// Sentinel — must NOT be invoked.
.init(events: [
.messageStart(LLMMessageStart(messageId: "should-not-fire", model: "x", usagePrefix: nil)),
.stopReason(.endTurn),
.messageStop,
]),
])
let cs = ConfigStore(
launch: makeLaunchSnapshot(),
initial: snapshot(provider: .ollama, escalationEnabled: false)
)
let (orch, _) = try await buildOrchestrator(provider: mock, configStore: cs)
_ = await orch.submit(.text("ping"))
let events = await collectUntil(orch: orch) { ev in
if case .stateChange(.idle) = ev { return true }; return false
}
// ZERO escalation events.
let escalations: [EscalationDecision] = events.compactMap { ev in
if case .escalated(let d) = ev { return d }
return nil
}
XCTAssertEqual(escalations.count, 0, "escalationEnabled=false must NOT emit .escalated; got \(escalations)")
// Terminal .error event surfaced.
let sawError = events.contains { ev in
if case .error = ev { return true }; return false
}
XCTAssertTrue(sawError, "disabled escalation must surface a terminal error; got \(events)")
// Exactly one provider call.
let calls = await mock.getRecordedCalls()
XCTAssertEqual(calls.count, 1, "disabled escalation must NOT retry; got \(calls.count) calls")
}
}
@@ -327,6 +327,7 @@ final class OrchestratorEventBroadcasterTests: XCTestCase {
case .usage: return "usage"
case .turnEnd: return "turnEnd"
case .error: return "error"
case .escalated: return "escalated"
}
}
}
@@ -211,6 +211,7 @@ final class TextInputEndToEndTests: XCTestCase {
case .usage: return "usage"
case .turnEnd: return "turnEnd"
case .error: return "error"
case .escalated: return "escalated"
}
}
}
@@ -181,4 +181,80 @@ final class NDJSONDecoderTests: XCTestCase {
})
XCTAssertTrue(events.contains { if case .messageStop = $0 { return true } else { return false } })
}
// MARK: - N8: malformedToolCall failure kind (Task 5 / spec §2)
/// `tool_calls[].function.arguments` is present as a string but is not
/// decodable as JSON — the decoder MUST emit a `providerError` carrying
/// `OllamaFailureKind.malformedToolCall` so the orchestrator (Task 6)
/// can escalate to Anthropic.
func testMalformedToolCallEmitsProviderError() {
let events = run([
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_time","arguments":"this is not json"}}]},"done":false}"#,
#"{"message":{"content":""},"done":true}"#,
])
XCTAssertTrue(events.contains { event in
if case .providerError(let err) = event,
err.ollamaFailureKind == .malformedToolCall { return true }
return false
}, "Expected providerError carrying ollamaFailureKind == .malformedToolCall, got: \(events)")
}
// MARK: - N9: emptyResponse failure kind (Task 5 / spec §2)
/// Stream that terminates with zero textDelta and zero toolUseRequested
/// must emit `providerError(.emptyResponse)` BEFORE `messageStop`. This
/// drives the orchestrator's reactive escalation to Anthropic.
func testEmptyResponseEmitsProviderError() {
let events = run([
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}"#,
])
XCTAssertTrue(events.contains { event in
if case .providerError(let err) = event,
err.ollamaFailureKind == .emptyResponse { return true }
return false
}, "Expected providerError carrying ollamaFailureKind == .emptyResponse, got: \(events)")
// emptyResponse must precede messageStop.
let errorIdx = events.firstIndex { event in
if case .providerError(let err) = event,
err.ollamaFailureKind == .emptyResponse { return true }
return false
}
let stopIdx = events.firstIndex { event in
if case .messageStop = event { return true } else { return false }
}
XCTAssertNotNil(errorIdx)
XCTAssertNotNil(stopIdx)
if let e = errorIdx, let s = stopIdx {
XCTAssertLessThan(e, s, "emptyResponse providerError must precede messageStop")
}
}
/// Negative case: a stream with at least one textDelta must NOT emit
/// `.emptyResponse` on terminator.
func testTextDeltaSuppressesEmptyResponse() {
let events = run([
#"{"message":{"role":"assistant","content":"hi"},"done":false}"#,
#"{"message":{"content":""},"done":true,"done_reason":"stop"}"#,
])
XCTAssertFalse(events.contains { event in
if case .providerError(let err) = event,
err.ollamaFailureKind == .emptyResponse { return true }
return false
}, "non-empty stream must not emit emptyResponse, got: \(events)")
}
/// Negative case: a stream with at least one toolUseRequested must NOT
/// emit `.emptyResponse` on terminator.
func testToolUseSuppressesEmptyResponse() {
let events = run([
#"{"message":{"content":"","tool_calls":[{"function":{"name":"get_time","arguments":{}}}]},"done":false}"#,
#"{"message":{"content":""},"done":true,"done_reason":"tool_calls"}"#,
])
XCTAssertFalse(events.contains { event in
if case .providerError(let err) = event,
err.ollamaFailureKind == .emptyResponse { return true }
return false
}, "tool-use stream must not emit emptyResponse, got: \(events)")
}
}
@@ -29,6 +29,59 @@ public enum BusOutbound: Equatable, Sendable {
/// — byte-identical to the voice-path HUD banner so users see the same
/// wording regardless of input source.
case submitRejected(reason: String)
/// Local-first LLM routing (spec §4 / Task 7) — emitted exactly once per
/// logical turn when the orchestrator reactively escalates from Ollama
/// to Anthropic Claude on a detected failure. The HUD renders an
/// EscalationBadge keyed by `EscalationDecision.reason`.
case escalated(EscalationDecision)
}
/// Local mirror of `Config.ProviderSelection` used by `EscalationDecision`.
///
/// Bus deliberately does NOT depend on the Config or AgentCore packages —
/// adding those edges would pull their transitive deps into the lightweight
/// bridge layer. This mirrors `Config.ProviderSelection` one-for-one; the
/// App-side `AppBusForwarderSink` translates between the two when emitting.
public enum BusProviderSelection: String, Codable, Sendable, CaseIterable, Equatable {
case anthropic
case ollama
}
/// Local mirror of `AgentCore.OllamaFailureKind` used by `EscalationDecision`.
///
/// Same dependency-isolation rationale as `BusProviderSelection`. Adding a
/// new case is a breaking change for the bus wire format; mirror it on the
/// TS side at the same time.
public enum BusOllamaFailureKind: String, Codable, Sendable, CaseIterable, Equatable {
case streamTruncated
case malformedToolCall
case unknownTool
case refusal
case connectionFailure
case emptyResponse
}
/// Local mirror of `AgentCore.EscalationDecision` carried by
/// `BusOutbound.escalated(_:)`. Same dependency-isolation rationale as
/// `TurnRow`. App-side `AppBusForwarderSink` translates between the two
/// when emitting.
public struct EscalationDecision: Codable, Equatable, Sendable {
public let from: BusProviderSelection
public let to: BusProviderSelection
public let reason: BusOllamaFailureKind
public let firedAt: Date
public init(
from: BusProviderSelection,
to: BusProviderSelection,
reason: BusOllamaFailureKind,
firedAt: Date
) {
self.from = from
self.to = to
self.reason = reason
self.firedAt = firedAt
}
}
/// Local mirror of Memory.TurnRow used by `BusOutbound.sessionHistory`.
@@ -79,6 +132,7 @@ extension BusOutbound: Codable {
case turnEnded
case sessionHistory
case submitRejected
case escalated
}
/// All `CodingKeys` across every case. Swift's keyed container does not
@@ -98,6 +152,7 @@ extension BusOutbound: Codable {
case terminator
case turns
case reason
case decision
}
public init(from decoder: Decoder) throws {
@@ -139,6 +194,9 @@ extension BusOutbound: Codable {
case .submitRejected:
let reason = try container.decode(String.self, forKey: .reason)
self = .submitRejected(reason: reason)
case .escalated:
let decision = try container.decode(EscalationDecision.self, forKey: .decision)
self = .escalated(decision)
}
// NO default branch — adding a Discriminator case without also adding
// a matching switch arm here is a compile error. That is the entire
@@ -196,6 +254,9 @@ extension BusOutbound: Codable {
case .submitRejected(let reason):
try container.encode(Discriminator.submitRejected, forKey: .type)
try container.encode(reason, forKey: .reason)
case .escalated(let decision):
try container.encode(Discriminator.escalated, forKey: .type)
try container.encode(decision, forKey: .decision)
}
// Exhaustive at encode site — adding a case without encoding it is a
// compile error, mirroring the init(from:) drift preventer.
+5 -1
View File
@@ -25,7 +25,11 @@ import Foundation
/// `chatSubmit(text:)` + `chatCancelAndSubmit(text:)` (chat-panel submit +
/// barge-in) and additive BusOutbound case `submitRejected(reason:)` (D-10
/// text-path rejection toast, byte-identical body to voice-path HUD banner).
public let BUS_PROTOCOL_VERSION: String = "2.3.0"
///
/// Local-first LLM routing Task 7 — bumped 2.3.0 → 2.4.0 for additive
/// BusOutbound case `escalated(EscalationDecision)`. MINOR per HUD-05
/// (additive; older webviews can still decode the outbound shape).
public let BUS_PROTOCOL_VERSION: String = "2.4.0"
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
@@ -17,7 +17,7 @@
"use strict";
if (window.jarvisBus) { return; }
window.jarvisBus = {
protocolVersion: "2.3.0",
protocolVersion: "2.4.0",
_handler: null,
// Queue (not a single slot) of messages received before the bundle
// registered its handler via onOutbound. With a single slot, an early
@@ -234,6 +234,26 @@ final class CodableRoundTripTests: XCTestCase {
)
}
/// Local-first LLM routing Task 7 — additive `escalated(decision)` case
/// carrying an `EscalationDecision` for the HUD escalation badge.
/// ISO-8601 date encoding is exercised here; `BusCoder.makeEncoder/Decoder`
/// pin the strategy at `.iso8601`.
func test_roundTrip_escalated() throws {
// `firedAt` chosen to round-trip cleanly through the `.iso8601`
// strategy with no fractional seconds (matches the fixture verbatim).
let firedAt = ISO8601DateFormatter().date(from: "2026-05-14T12:00:00Z")!
let decision = EscalationDecision(
from: .ollama,
to: .anthropic,
reason: .malformedToolCall,
firedAt: firedAt
)
try assertOutboundRoundTrips(
fixture: "escalated",
expect: .escalated(decision)
)
}
// MARK: - Shape invariants (these prove hand-written Codable is load-bearing)
/// If SE-0295 synthesis leaked, we'd see `{"hudState":{"state":"idle"}}`.
@@ -0,0 +1 @@
{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
@@ -3,8 +3,74 @@ import Foundation
public struct PerTurnSnapshot: Sendable, Codable, Equatable {
public let schemaVersion: Int
public let provider: ProviderSelection
public let escalationEnabled: Bool
public let tts: TTSConfig
public let stt: STTConfig
public let featureFlags: FeatureFlags
// DO NOT add: ollama host, applescript policy, toolBlocklist, confirmationPolicy (all Launch-pinned per SEC-05).
public init(
schemaVersion: Int,
provider: ProviderSelection,
escalationEnabled: Bool = true,
tts: TTSConfig,
stt: STTConfig,
featureFlags: FeatureFlags
) {
self.schemaVersion = schemaVersion
self.provider = provider
self.escalationEnabled = escalationEnabled
self.tts = tts
self.stt = stt
self.featureFlags = featureFlags
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, provider, escalationEnabled, tts, stt, featureFlags
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.schemaVersion = try c.decode(Int.self, forKey: .schemaVersion)
self.provider = try c.decode(ProviderSelection.self, forKey: .provider)
self.escalationEnabled = try c.decodeIfPresent(Bool.self, forKey: .escalationEnabled) ?? true
self.tts = try c.decode(TTSConfig.self, forKey: .tts)
self.stt = try c.decode(STTConfig.self, forKey: .stt)
self.featureFlags = try c.decode(FeatureFlags.self, forKey: .featureFlags)
}
/// Local-first LLM routing (Task 6 / spec §2): convenience copy with a
/// different provider. Used by `AgentOrchestrator` on reactive
/// escalation to swap `.ollama` → `.anthropic` for the same turn
/// without mutating the underlying `ConfigStore` (the escalation is a
/// per-turn decision, not a config update).
public func with(provider: ProviderSelection) -> PerTurnSnapshot {
PerTurnSnapshot(
schemaVersion: schemaVersion,
provider: provider,
escalationEnabled: escalationEnabled,
tts: tts,
stt: stt,
featureFlags: featureFlags
)
}
/// Local-first LLM routing (Task 9 / spec §6): convenience copy that
/// mutates the `{provider, escalationEnabled}` pair atomically. Used
/// by the Settings provider-mode picker so a single toggle persists
/// both fields through `ConfigStore.updatePerTurn(_:)`. Other fields
/// are preserved verbatim.
public func with(
provider: ProviderSelection,
escalationEnabled: Bool
) -> PerTurnSnapshot {
PerTurnSnapshot(
schemaVersion: schemaVersion,
provider: provider,
escalationEnabled: escalationEnabled,
tts: tts,
stt: stt,
featureFlags: featureFlags
)
}
}
@@ -6,7 +6,8 @@
"applescript": {
"confirmationRequired": true
},
"provider": "anthropic",
"provider": "ollama",
"escalationEnabled": true,
"tts": { "tier": "tier1" },
"stt": { "whisperKitFallback": false },
"featureFlags": {
@@ -10,7 +10,7 @@ final class ConfigLoaderTests: XCTestCase {
XCTAssertEqual(launch.schemaVersion, 1)
XCTAssertEqual(perTurn.schemaVersion, 1)
XCTAssertEqual(launch.ollama.baseURL.host, "127.0.0.1")
XCTAssertEqual(perTurn.provider, .anthropic)
XCTAssertEqual(perTurn.provider, .ollama)
}
func test_malformedConfigThrowsMalformed() throws {
@@ -38,6 +38,72 @@ final class PerTurnSnapshotTests: XCTestCase {
}
}
final class PerTurnSnapshotEscalationTests: XCTestCase {
func test_escalation_enabled_round_trips() throws {
let json = """
{
"schemaVersion": 1,
"provider": "ollama",
"escalationEnabled": true,
"tts": { "tier": "tier1" },
"stt": { "whisperKitFallback": false },
"featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
}
""".data(using: .utf8)!
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
XCTAssertEqual(snapshot.provider, .ollama)
XCTAssertTrue(snapshot.escalationEnabled)
}
func test_escalation_enabled_defaults_to_true_when_absent() throws {
let json = """
{
"schemaVersion": 1,
"provider": "ollama",
"tts": { "tier": "tier1" },
"stt": { "whisperKitFallback": false },
"featureFlags": { "orpheusTTSEnabled": false, "whisperKitSTTEnabled": false }
}
""".data(using: .utf8)!
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: json)
XCTAssertTrue(snapshot.escalationEnabled)
}
func test_default_config_resource_is_local_first() throws {
let url = ConfigLoader.bundledDefaultConfigURL()!
let data = try Data(contentsOf: url)
let snapshot = try JSONDecoder().decode(PerTurnSnapshot.self, from: data)
XCTAssertEqual(snapshot.provider, .ollama,
"default-config.json must specify ollama as the default chat-turn provider (local-first)")
XCTAssertTrue(snapshot.escalationEnabled,
"default-config.json must enable escalation for new users (local-first)")
}
func test_escalation_enabled_survives_encode_decode_round_trip_when_false() throws {
// Catches a future regression where someone adds a custom encode(to:)
// that drops escalationEnabled — encoder must continue emitting the
// field so decoder reads the user's explicit `false` rather than
// defaulting back to `true`.
let original = PerTurnSnapshot(
schemaVersion: 1,
provider: .ollama,
escalationEnabled: false,
tts: TTSConfig(tier: "tier1"),
stt: STTConfig(whisperKitFallback: false),
featureFlags: FeatureFlags(["orpheusTTSEnabled": false, "whisperKitSTTEnabled": false])
)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(PerTurnSnapshot.self, from: encoded)
XCTAssertFalse(decoded.escalationEnabled,
"Encoder must emit escalationEnabled so round-trip preserves the user's explicit false")
XCTAssertEqual(decoded, original)
}
}
enum ConfigTestFixtures {
static func launch() throws -> LaunchSnapshot {
let json = """
@@ -0,0 +1 @@
{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
+105 -2
View File
@@ -13,9 +13,12 @@
* Plan 09-04 bumped 2.2.0 -> 2.3.0 (additive BusInbound cases chatSubmit +
* chatCancelAndSubmit for chat-panel submit + barge-in; additive BusOutbound
* case submitRejected for D-10 text-path rejection toast).
*
* Local-first LLM routing Task 7 bumped 2.3.0 -> 2.4.0 (additive BusOutbound
* case escalated{decision} for HUD escalation badge).
*/
export const BUS_PROTOCOL_VERSION = "2.3.0";
export const BUS_PROTOCOL_VERSION = "2.4.0";
export type HudState =
| "idle"
@@ -32,6 +35,42 @@ export type TurnTerminator =
| "errored"
| "superseded";
/**
* Provider identity mirror — `Config.ProviderSelection` (Swift) and
* `BusProviderSelection` (Bus). Carried by `EscalationDecision.from` /
* `EscalationDecision.to`.
*/
export type ProviderSelection = "anthropic" | "ollama";
/**
* Detectable Ollama failure modes that trigger one-shot reactive escalation
* to Anthropic. Mirror of `AgentCore.OllamaFailureKind` (Swift) and
* `BusOllamaFailureKind` (Bus). The HUD escalation badge keys reason copy
* off this value.
*/
export type OllamaFailureKind =
| "streamTruncated"
| "malformedToolCall"
| "unknownTool"
| "refusal"
| "connectionFailure"
| "emptyResponse";
/**
* Local-first LLM routing — payload of `BusOutbound.escalated`. Emitted at
* most once per turn when the orchestrator reactively escalates Ollama →
* Anthropic. The HUD renders an EscalationBadge keyed by `reason`.
*
* `firedAt` is an ISO-8601 string (Swift `JSONEncoder` with
* `dateEncodingStrategy = .iso8601`).
*/
export interface EscalationDecision {
from: ProviderSelection;
to: ProviderSelection;
reason: OllamaFailureKind;
firedAt: string;
}
/**
* Local mirror of `Memory.TurnRow` (the backend Swift type) and
* `Bus.TurnRow` (the Swift bus mirror). Bus deliberately does NOT depend on
@@ -64,7 +103,14 @@ export type BusOutbound =
* transient toast. Body string is byte-identical to the voice-path HUD
* banner (single source of truth: RejectReasonCopy.swift).
*/
| { type: "submitRejected"; reason: string };
| { type: "submitRejected"; reason: string }
/**
* Local-first LLM routing Task 7 — additive case carrying an
* `EscalationDecision` for the HUD escalation badge. Emitted at most
* once per turn when the orchestrator reactively escalates Ollama →
* Anthropic.
*/
| { type: "escalated"; decision: EscalationDecision };
export type BusInbound =
| { type: "helloAck"; version: string }
@@ -115,6 +161,20 @@ const TURN_TERMINATORS = [
"superseded",
] as const satisfies readonly TurnTerminator[];
const PROVIDER_SELECTIONS = [
"anthropic",
"ollama",
] as const satisfies readonly ProviderSelection[];
const OLLAMA_FAILURE_KINDS = [
"streamTruncated",
"malformedToolCall",
"unknownTool",
"refusal",
"connectionFailure",
"emptyResponse",
] as const satisfies readonly OllamaFailureKind[];
function isHudState(v: string): v is HudState {
return (HUD_STATES as readonly string[]).includes(v);
}
@@ -123,6 +183,14 @@ function isTurnTerminator(v: string): v is TurnTerminator {
return (TURN_TERMINATORS as readonly string[]).includes(v);
}
function isProviderSelection(v: string): v is ProviderSelection {
return (PROVIDER_SELECTIONS as readonly string[]).includes(v);
}
function isOllamaFailureKind(v: string): v is OllamaFailureKind {
return (OLLAMA_FAILURE_KINDS as readonly string[]).includes(v);
}
/**
* Decode a BusOutbound JSON string. Never throws: malformed input returns
* `{ok: false, error}`. The default branch hits a `const _exhaustive: never`
@@ -237,6 +305,41 @@ export function decodeOutbound(json: string): DecodeResult<BusOutbound> {
case "submitRejected":
if (typeof parsed.reason !== "string") return { ok: false, error: "submitRejected: reason must be string" };
return { ok: true, value: { type: "submitRejected", reason: parsed.reason } };
case "escalated": {
if (!isObject(parsed.decision)) {
return { ok: false, error: "escalated: decision must be an object" };
}
const d = parsed.decision;
if (
typeof d.from !== "string" ||
typeof d.to !== "string" ||
typeof d.reason !== "string" ||
typeof d.firedAt !== "string"
) {
return { ok: false, error: "escalated: invalid decision fields" };
}
if (!isProviderSelection(d.from)) {
return { ok: false, error: `escalated: unknown 'from' provider '${d.from}'` };
}
if (!isProviderSelection(d.to)) {
return { ok: false, error: `escalated: unknown 'to' provider '${d.to}'` };
}
if (!isOllamaFailureKind(d.reason)) {
return { ok: false, error: `escalated: unknown reason '${d.reason}'` };
}
return {
ok: true,
value: {
type: "escalated",
decision: {
from: d.from,
to: d.to,
reason: d.reason,
firedAt: d.firedAt,
},
},
};
}
default: {
// Compile-time exhaustiveness. After all cases above narrow `type`, the
// default branch should see `type: never`. Adding a new BusOutbound
@@ -19,8 +19,8 @@ function readFixture(name: string): { raw: string; parsed: unknown } {
}
describe("BUS_PROTOCOL_VERSION", () => {
it("equals 2.3.0 (must match Swift constant — Plan 09-04 chatSubmit + chatCancelAndSubmit + submitRejected bump)", () => {
expect(BUS_PROTOCOL_VERSION).toBe("2.3.0");
it("equals 2.4.0 (must match Swift constant — Local-first LLM routing Task 7 escalated bump)", () => {
expect(BUS_PROTOCOL_VERSION).toBe("2.4.0");
});
});
@@ -41,6 +41,7 @@ describe("BusOutbound round-trip", () => {
"turnStarted.json",
"turnEnded.json",
"submitRejected.json",
"escalated.json",
];
for (const name of outboundFixtures) {
+19 -1
View File
@@ -80,14 +80,24 @@ export function attachBus(): void {
let id = store.activeTextPartId
if (!id) {
id = nextTextPartId(turnId, store.chatEvents)
// If an escalation decision arrived before any assistant text
// event (e.g. connectionFailure on the very first response),
// attach it to the freshly-created text part so the badge isn't
// dropped. The pending slot is cleared as a side-effect of the
// store mutation below.
const pending = store.pendingEscalation
store.pushEvent({
kind: 'text',
id,
text: '',
role: 'assistant',
turnId,
...(pending ? { escalation: pending } : {}),
})
useJarvisStore.setState({
activeTextPartId: id,
...(pending ? { pendingEscalation: null } : {}),
})
useJarvisStore.setState({ activeTextPartId: id })
}
store.appendTokenToLastText(id, msg.text)
break
@@ -155,6 +165,14 @@ export function attachBus(): void {
// hydrate (= WARN-INT-2). Until then we acknowledge but no-op so the
// exhaustiveness sentinel below remains tight.
break
case 'escalated':
// Local-first LLM routing Task 8 — attach the EscalationDecision
// to the most-recent assistant text event. If none exists yet (the
// failure fired before the first token), the store parks the
// decision and the next assistant text event inherits it (see
// tokenDelta arm above).
store.attachEscalationToLastAssistantText(msg.decision)
break
default: {
const _exhaustive: never = msg
void _exhaustive
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { EscalationBadge } from './EscalationBadge'
describe('EscalationBadge', () => {
it('renders the failure reason in a friendly form', () => {
render(<EscalationBadge reason="malformedToolCall" />)
expect(screen.getByText(/malformed tool call/i)).toBeInTheDocument()
expect(screen.getByText(/escalated to opus/i)).toBeInTheDocument()
})
it('uses a stable test id for selector use', () => {
render(<EscalationBadge reason="streamTruncated" />)
expect(screen.getByTestId('escalation-badge')).toBeInTheDocument()
})
it('renders all six failure kinds without throwing', () => {
const reasons = [
'streamTruncated',
'malformedToolCall',
'unknownTool',
'refusal',
'connectionFailure',
'emptyResponse',
] as const
for (const r of reasons) {
render(<EscalationBadge reason={r} />)
}
// If any reason was not mapped, render would have thrown.
expect(screen.getAllByTestId('escalation-badge').length).toBe(reasons.length)
})
})
@@ -0,0 +1,33 @@
import type { OllamaFailureKind } from '@jarvis/bus'
/**
* EscalationBadge — small inline marker rendered inside an assistant
* message when the orchestrator reactively escalated that turn from the
* local Ollama provider to Anthropic Opus. The reason string is the
* `OllamaFailureKind` carried on `BusOutbound.escalated.decision.reason`;
* the friendly map below covers all six kinds defined in
* `packages/bus/src/protocol.ts` and `AgentCore.OllamaFailureKind`.
*
* Visual styling (`.escalation-badge` in `chat-panel.css`) follows the
* existing chip pattern — small font, low-contrast amber accent, unobtrusive.
*/
const FRIENDLY: Record<OllamaFailureKind, string> = {
streamTruncated: 'stream truncated',
malformedToolCall: 'malformed tool call',
unknownTool: 'unknown tool',
refusal: 'refusal',
connectionFailure: 'connection failure',
emptyResponse: 'empty response',
}
export function EscalationBadge({ reason }: { reason: OllamaFailureKind }) {
return (
<span
data-testid="escalation-badge"
className="escalation-badge"
role="status"
>
Escalated to Opus — {FRIENDLY[reason]}
</span>
)
}
@@ -1,4 +1,5 @@
import type { ChatEvent } from '../store/types'
import { EscalationBadge } from './EscalationBadge'
type TextEvent = Extract<ChatEvent, { kind: 'text' }>
@@ -9,6 +10,10 @@ type TextEvent = Extract<ChatEvent, { kind: 'text' }>
* output cannot inject markup here (T-03-31). Parent ChatPanel uses
* `key={event.id}` so React reuses this DOM node across streaming updates;
* only the text span's textContent diffs as tokens append.
*
* When the bus dispatcher attached an `EscalationDecision` to this event
* (local-first LLM routing Task 8), an inline EscalationBadge is rendered
* as a footer affordance under the message body.
*/
export function TextPart({ event }: { event: TextEvent }) {
return (
@@ -21,6 +26,7 @@ export function TextPart({ event }: { event: TextEvent }) {
{event.role === 'user' ? 'You' : 'Jarvis'}
</span>
<span className="chat-panel__text-content">{event.text}</span>
{event.escalation && <EscalationBadge reason={event.escalation.reason} />}
</div>
)
}
@@ -43,6 +43,23 @@
color: rgba(255, 200, 200, 0.92);
}
/* Escalation badge — rendered as a footer chip inside an assistant text
* event when the orchestrator escalated that turn from Ollama to
* Anthropic. Amber accent so it reads as informational, not error. */
.escalation-badge {
align-self: flex-start;
margin-top: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(245, 166, 35, 0.10);
border: 1px solid rgba(245, 166, 35, 0.32);
color: rgba(245, 200, 130, 0.92);
font-size: 11px;
text-transform: none;
letter-spacing: 0.02em;
}
/* Tool call card */
.tool-call-card {
+39 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import type { EscalationDecision } from '@jarvis/bus'
import type {
A11y,
ChatEvent,
@@ -26,6 +27,14 @@ export interface JarvisState {
* correct text-part after the interruption.
*/
activeTextPartId: string | null
/**
* Local-first LLM routing (Task 8): when `BusOutbound.escalated`
* arrives before any assistant text event exists for the current turn
* (e.g. connectionFailure on the very first response), the decision is
* parked here and stamped onto the *first* assistant text event created
* by a subsequent tokenDelta. Cleared on `beginTurn` and after consumption.
*/
pendingEscalation: EscalationDecision | null
a11y: A11y
theme: Theme
connection: Connection
@@ -45,6 +54,17 @@ export interface JarvisState {
) => void
beginTurn: (id: string) => void
endTurn: () => void
/**
* Local-first LLM routing (Task 8): attach an EscalationDecision to the
* most-recent assistant text event. Called from the bus dispatcher when
* `BusOutbound.escalated` arrives. No-op if no assistant text event
* exists yet — escalation may fire before any token streamed (e.g.
* connectionFailure/refusal on the first response). The Anthropic
* stream that follows the escalation will produce assistant text whose
* tokenDeltas land *after* this call; for that case the badge is
* surfaced on the subsequent text event via a small pending hand-off.
*/
attachEscalationToLastAssistantText: (decision: EscalationDecision) => void
setA11y: (patch: Partial<A11y>) => void
setTheme: (patch: Partial<Theme>) => void
setConnection: (c: Connection) => void
@@ -56,6 +76,7 @@ export const useJarvisStore = create<JarvisState>()(
chatEvents: [],
currentTurnId: null,
activeTextPartId: null,
pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#4FC3F7' },
connection: 'booting',
@@ -111,10 +132,27 @@ export const useJarvisStore = create<JarvisState>()(
return { chatEvents: next }
}),
beginTurn: (id) => set({ currentTurnId: id, activeTextPartId: null }),
beginTurn: (id) =>
set({ currentTurnId: id, activeTextPartId: null, pendingEscalation: null }),
endTurn: () => set({ currentTurnId: null, activeTextPartId: null }),
attachEscalationToLastAssistantText: (decision) =>
set((state) => {
// Walk from the tail to find the most-recent assistant text event.
for (let i = state.chatEvents.length - 1; i >= 0; i--) {
const ev = state.chatEvents[i]
if (ev && ev.kind === 'text' && ev.role === 'assistant') {
const next = state.chatEvents.slice()
next[i] = { ...ev, escalation: decision }
return { chatEvents: next, pendingEscalation: null }
}
}
// No assistant text yet — park the decision; the next assistant
// text event created by tokenDelta inherits it.
return { pendingEscalation: decision }
}),
setA11y: (patch) =>
set((state) => ({ a11y: { ...state.a11y, ...patch } })),
+16 -2
View File
@@ -1,4 +1,4 @@
import type { HudState } from '@jarvis/bus'
import type { EscalationDecision, HudState } from '@jarvis/bus'
export type { HudState }
@@ -9,8 +9,22 @@ export type ToolCallStatus =
| 'completed'
| 'failed'
/**
* Local-first LLM routing (Task 8): when the orchestrator reactively
* escalates the active Ollama turn to Anthropic, the bus emits
* `BusOutbound.escalated`; the dispatcher attaches the decision to the
* most-recent assistant text event so the renderer can stamp an inline
* EscalationBadge under that message body.
*/
export type ChatEvent =
| { id: string; kind: 'text'; text: string; role: 'user' | 'assistant'; turnId: string }
| {
id: string
kind: 'text'
text: string
role: 'user' | 'assistant'
turnId: string
escalation?: EscalationDecision
}
| {
id: string
kind: 'tool-call'
@@ -10,6 +10,9 @@ function resetStore() {
useJarvisStore.setState({
hudState: 'booting',
chatEvents: [],
currentTurnId: null,
activeTextPartId: null,
pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#1E88E5' },
connection: 'booting',
@@ -89,6 +92,55 @@ describe('bus dispatcher — submitRejected pushes error event (B5)', () => {
})
})
describe('bus dispatcher — escalated attaches to last assistant text (Task 8)', () => {
beforeEach(() => {
resetStore()
installWebkitMock()
clearBus()
})
it('attaches the decision to the most-recent assistant text event', () => {
attachBus()
// Synthesize a turn with an assistant text event in flight.
window.jarvisBus.receive('{"type":"turnStarted","id":"t1"}')
window.jarvisBus.receive('{"type":"tokenDelta","text":"partial..."}')
window.jarvisBus.receive(
'{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"streamTruncated","firedAt":"2026-05-14T16:00:00Z"}}',
)
const events = useJarvisStore.getState().chatEvents
const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
expect(textEv).toBeDefined()
if (textEv && textEv.kind === 'text') {
expect(textEv.escalation?.reason).toBe('streamTruncated')
expect(textEv.escalation?.from).toBe('ollama')
expect(textEv.escalation?.to).toBe('anthropic')
}
})
it('parks the decision when no assistant text exists yet, then stamps the next text event', () => {
attachBus()
window.jarvisBus.receive('{"type":"turnStarted","id":"t2"}')
// Escalation fires before any tokenDelta (e.g. connectionFailure).
window.jarvisBus.receive(
'{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"connectionFailure","firedAt":"2026-05-14T16:00:00Z"}}',
)
// No text event yet; decision is parked.
expect(useJarvisStore.getState().chatEvents.length).toBe(0)
expect(useJarvisStore.getState().pendingEscalation?.reason).toBe('connectionFailure')
// First post-escalation tokenDelta creates the text event and inherits the badge.
window.jarvisBus.receive('{"type":"tokenDelta","text":"hello from opus"}')
const events = useJarvisStore.getState().chatEvents
const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
expect(textEv).toBeDefined()
if (textEv && textEv.kind === 'text') {
expect(textEv.escalation?.reason).toBe('connectionFailure')
}
// Pending slot was consumed.
expect(useJarvisStore.getState().pendingEscalation).toBe(null)
})
})
describe('bus dispatcher — decode errors surface (B2)', () => {
beforeEach(() => {
resetStore()