Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 4.7 2f2e9edf9a feat(settings): provider mode picker — Local-first / Anthropic only / Ollama only
Three-way picker maps to {provider, escalationEnabled} pairs. Wired
to ConfigStore for hot-reload. Local-first is the new default
(default-config.json).

- ProviderMode enum (Local-first / Anthropic only / Ollama only) +
  bidirectional mapping to {provider, escalationEnabled}.
- New Provider section in SettingsView, hydrated from ConfigStore
  on .onAppear, persists through ConfigStore.updatePerTurn(_:) on
  .onChange.
- PerTurnSnapshot.with(provider:escalationEnabled:) — multi-field
  copy so the picker can mutate both fields atomically.
- ConfigStore threaded through SettingsWindowController.open(...)
  from AppDelegate.openSettings().
- ProviderModeTests covers canonical mapping, (anthropic, true)
  edge case normalization, and round-trip via .pair.

Refs design spec §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:51:03 -05:00
james.maesandClaude Opus 4.7 9ff05be0a2 feat(hud): EscalationBadge — renders ollama→anthropic transition reason
When a chat turn is escalated, the bus client attaches the
EscalationDecision to the most-recent assistant message; the message
renderer shows an inline badge with friendly reason text. Six
OllamaFailureKind reasons mapped to plain English.

If escalation fires before any assistant tokens have streamed (e.g.
connectionFailure on the very first response), the decision is parked
on the store as pendingEscalation and stamped onto the first text part
created by a subsequent tokenDelta — so the badge is never dropped.

Refs design spec §4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:33:41 -05:00
james.maesandClaude Opus 4.7 a7aae9df77 feat(bus): add BusOutbound.escalated event for HUD escalation badge
Local-first LLM routing Task 7. Carries an EscalationDecision from the
orchestrator to the HUD so the escalation badge can render the
ollama->anthropic transition + reason. BusForwarder gains an .escalated
arm that routes the AgentCore-side decision through a new
BusForwarderSink.sendEscalated hook; AppBusForwarderSink translates the
AgentCore types into the dependency-free Bus mirror types
(BusEscalationDecision / BusProviderSelection / BusOllamaFailureKind),
preserving the lightweight-bridge-layer invariant.

Bus protocol bumped 2.3.0 -> 2.4.0 (MINOR, additive). TS decoder gains
matching guards; HUD client adds a no-op acknowledgment for the
exhaustiveness sentinel (the EscalationBadge component lands in Task 8).

Round-trip coverage:
- packages/Bus: fixture-driven Codable round-trip + new BusForwarder
  tests asserting sendEscalated fires without spurious lifecycle events.
- webview/packages/bus: fixture parity + protocol-version test bumped.

Refs spec docs/superpowers/specs/2026-05-14-local-first-llm-routing-design.md §4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:21:38 -05:00
james.maesandClaude Opus 4.7 038e35152b feat(orchestrator): reactive escalation from ollama to anthropic
Extend the per-turn retry hook to handle OllamaFailureKind-bearing
provider errors via providerError.ollamaFailureKind. When the local
Ollama provider emits a malformedToolCall / emptyResponse on a turn
with escalationEnabled, the orchestrator allocates a fresh turn row
(retryOf the original), swaps to Anthropic, and re-runs the turn from
initialMessages. Enforces at-most-one escalation per turn — a second
provider failure surfaces a terminal `.error` event. The
`escalatedThisTurn` flag is local to `runTurnLoop` so it resets across
turns naturally (no sticky cross-turn state).

Adds:
- `OrchestratorEvent.escalated(EscalationDecision)` — surfaced once
  per escalation; Task 7 wires this to BusOutbound for the HUD badge.
- `PerTurnSnapshot.with(provider:)` — convenience copy used by the
  orchestrator to swap providers per-turn without mutating ConfigStore.

The existing AGENT-09 streamTruncated retry (in `.stopReason`) is left
parallel; it triggers on a different LLMEvent shape (transport-mid-
stream EOF) and was independently validated by OrchestratorRetryTests.

Refs design spec §2, §3. Tests: LocalFirstEscalationTests A/B/C
(reactive fire-once / budget honored / disabled passthrough).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:48:59 -05:00
james.maesandClaude Opus 4.7 9ddc32548e feat(ollama): detect malformedToolCall and emptyResponse failure kinds
Wire detection into the NDJSON parser. Adds two new LLMProviderError
cases (`.malformedToolCall(reason:)`, `.emptyResponse`) and an
`ollamaFailureKind: OllamaFailureKind?` accessor so the orchestrator
(Task 6) can route to Anthropic on these failure modes.

NDJSONDecoder tracks `sawAnyTextDelta` / `sawAnyToolUseRequested`; on
the `done:true` terminator it emits `.providerError(.emptyResponse)`
before `.messageStop` when neither was observed. The tool_calls path
now validates string-form `arguments` via `JSONSerialization` and
emits `.malformedToolCall(reason:)` on parse failure instead of
silently forwarding garbage bytes.

OpenAI-compat path is scoped to follow-up — `/api/chat` is the
default transport per CLAUDE.md and where reactive escalation lands
first.

Refs design spec §2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:18:53 -05:00
james.maesandClaude Opus 4.7 e9a54eedd4 feat(config): default to local-first (ollama + escalation enabled)
Closes the policy half of the local-first routing spec. Pinned-anthropic
and pinned-ollama remain valid via Settings (Task 9). Memory extraction
and Anthropic API key wiring untouched.

Also updates ConfigLoaderTests.test_loadDefaultConfigFromBundle to
assert the new ollama default (it asserted .anthropic against the same
bundled resource — a mirror of the policy being flipped).

Refs design spec §1, §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:45:08 -05:00
james.maesandClaude Opus 4.7 3a825009ff test(config): assert escalationEnabled survives encode-decode round-trip
Guard against a future regression where a custom encode(to:) drops the
field. Today encoder symmetry holds via Swift's Codable synthesis;
this test pins it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:41:17 -05:00
james.maesandClaude Opus 4.7 e268688d56 feat(config): add escalationEnabled field to PerTurnSnapshot
Defaults to true. Custom decoder uses decodeIfPresent so older configs
without the field opt into the new local-first behavior on upgrade.
Init parameter default keeps existing call sites source-compatible.

Refs design spec §6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:37:19 -05:00
james.maesandClaude Opus 4.7 11169d19eb test(agent-core): tighten OllamaFailureKindTests — allCases + full struct round-trip
Code-quality review feedback. Loop OllamaFailureKind.allCases so adding
a case fails the test by construction. Strengthen EscalationDecision
test to assert whole-struct equality and firedAt round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:33:41 -05:00
james.maesandClaude Opus 4.7 48dc664ead feat(agent-core): add OllamaFailureKind enum + EscalationDecision struct
Closed enum of six detectable Ollama failure modes that trigger reactive
escalation to Anthropic per the local-first routing spec. EscalationDecision
is the record emitted once per escalated turn.

Refs design spec §2-§3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:29:41 -05:00
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()