Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2e9edf9a | ||
|
|
9ff05be0a2 | ||
|
|
a7aae9df77 | ||
|
|
038e35152b | ||
|
|
9ddc32548e | ||
|
|
e9a54eedd4 | ||
|
|
3a825009ff | ||
|
|
e268688d56 | ||
|
|
11169d19eb | ||
|
|
48dc664ead |
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+1
@@ -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.
|
||||
|
||||
@@ -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"}}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } })),
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user