Compare commits
16
Commits
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# OpenSSF Scorecard — security supply-chain posture for KofTwentyTwo/Jarvis.
|
||||
#
|
||||
# Issue #178. Scorecard scores 18 security checks (Branch-Protection, Code-Review,
|
||||
# Pinned-Dependencies, SAST, Token-Permissions, etc.) and publishes results as
|
||||
# SARIF to GitHub code-scanning. A weekly schedule keeps the score fresh; we
|
||||
# also re-run on every push to develop so significant posture regressions show
|
||||
# up promptly. The badge in README.md links back to the public scorecard page.
|
||||
#
|
||||
# Permissions are the canonical Scorecard set:
|
||||
# - id-token: write OIDC for publish-results
|
||||
# - contents: read read repo tree
|
||||
# - security-events: write upload SARIF to code-scanning
|
||||
#
|
||||
# This workflow runs on github-hosted ubuntu-latest. Self-hosted Grogu is not
|
||||
# used here -- Scorecard is short, network-bound, and benefits from a clean
|
||||
# ephemeral environment.
|
||||
#
|
||||
# Workflow consumes no untrusted user inputs (no github.event.* interpolation
|
||||
# in run: blocks), so it is safe from workflow-injection patterns.
|
||||
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays at 06:00 UTC. Off-peak relative to weekday push spikes.
|
||||
- cron: "0 6 * * 1"
|
||||
push:
|
||||
branches: [develop]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@v2.4.0
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# publish_results: true uploads anonymized scores to the OpenSSF
|
||||
# public dataset, which is what powers the README badge URL
|
||||
# (api.scorecard.dev). Safe for a public repo; would need
|
||||
# reconsidering if the repo were ever made private.
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -740,10 +740,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// 4.5 Bus wiring — construct the `WebviewBridge` around the HUD's
|
||||
// `WKWebView`, install the `WKUserScript` at document-start in
|
||||
// the isolated `JarvisBusWorld`, start the single-writer
|
||||
// `HudStateCoordinator`, and load the R3F bundle's
|
||||
// `index.html` so the handshake kicks off. See `installBus`
|
||||
// for the seven-step sequence.
|
||||
// the page world (the shared `WKContentWorld.page` used by
|
||||
// Injection.js and the HUD bundle alike since `fb41c5f`),
|
||||
// start the single-writer `HudStateCoordinator`, and load the
|
||||
// R3F bundle's `index.html` so the handshake kicks off. See
|
||||
// `installBus` for the seven-step sequence.
|
||||
installBus()
|
||||
|
||||
// 5. Keychain probe. Banner suppression: when the API key is
|
||||
@@ -3320,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,7 @@ bash scripts/check-corpus-secrets.sh
|
||||
bash scripts/check-embedding-dim-literal.sh
|
||||
bash scripts/check-install-order.sh
|
||||
bash scripts/check-no-evaluate-javascript.sh
|
||||
bash scripts/check-no-jarvis-bus-world-references.sh
|
||||
bash scripts/check-no-leftover-stubs.sh
|
||||
bash scripts/check-no-modal-presentation.sh
|
||||
bash scripts/check-no-null-voice-adapters.sh
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[](https://github.com/KofTwentyTwo/Jarvis/issues)
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://swift.org)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/Jarvis)
|
||||
|
||||
Jarvis is a hybrid native + embedded-web macOS app that runs as an ambient
|
||||
menu-bar presence. A Swift host owns the operating system (mic, camera,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Handoff — 2026-05-14: multi-runner shipped, v0.2 ready
|
||||
|
||||
**Session window:** 2026-05-13 afternoon (Grogu single runner setup) → 2026-05-14 (multi-runner expansion + first auto-Release)
|
||||
**Branch:** `develop`, clean
|
||||
**HEAD at write time:** `774e60a0` — `ci(actions): create GitHub Release directly in publish-tag job (#187)`
|
||||
**Open PR count:** 2 (#185, #191 — both with shepherds running)
|
||||
**Repo visibility:** PUBLIC
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **3-runner self-hosted CI on Grogu is live** — `Grogu-jarvis`, `-2`, `-3`. Matrix parallelism observed (≥3 SPM jobs concurrent). Develop CI green in ~5 min vs. ~13 min single-runner.
|
||||
- **Auto-Release flow proven** — `v0.1.0-dev.64` has a real GitHub Release object, created by the new `gh release create` step in `publish-tag`. No more manual `gh release create` needed.
|
||||
- **Brainstorm → spec → plan → execute discipline established** via the Superpowers skills. Multi-runner work has a committed spec + plan under `docs/superpowers/{specs,plans}/`. Pattern to follow for v0.2 onward.
|
||||
- **Memory test flake** surfaced: `MemoryWiringEndToEndTests.testMemoryEnqueueReceivesNonNilUserAndAssistantText` fails intermittently on PR branches but passes on develop. Hypothesis: race in `TurnTranscriptStore` actor under concurrent-runner load. Pre-existing; not from any 2026-05-13/14 code change.
|
||||
|
||||
---
|
||||
|
||||
## What shipped today
|
||||
|
||||
### v0.1 mop-up + first dev tag
|
||||
- **PR #184** (squash `5fc2d3fa`) — Ollama model baseline reconciled to `qwen3.6:latest` (extractor) + `qwen2.5-coder:32b-instruct-q8_0` (agent fallback). Closes #58 (last v0.1 issue).
|
||||
- **v0.1.0-dev.50** — first release tag of the project. Release object created manually (workflow couldn't trigger downstream).
|
||||
- **v0.1.0-dev.64** — second release tag. Release object created **automatically** via the new inline `gh release create` step in `publish-tag`.
|
||||
|
||||
### Self-hosted runner saga
|
||||
- **PR #181** (`eef17252`) — Moved `swift-packages` matrix to `Grogu-jarvis`. DEVELOPER_DIR Xcode-select. Fork-PR gate.
|
||||
- **PR #183** (`81ce64c3`) — Orphan-process reaper at job start (closes #182).
|
||||
- **PR #189** (`4cdb3560`) — Reaper gets a `>=1h` age filter so sibling runners on Grogu don't kill each other.
|
||||
- **PR #187** (`774e60a0`) — `publish-tag` step creates the Release object directly instead of relying on `release.yml` triggering on tag push (GITHUB_TOKEN can't trigger downstream workflows).
|
||||
- **PR #190** (`a26f49fd`) — Multi-runner spec + plan committed to `docs/superpowers/`.
|
||||
- **PR #185** — open, AgentCore #23+#24 fix. Currently has a Memory-flake CI failure under shepherd watch.
|
||||
- **PR #191** — open, CLAUDE.md inventory update. Same Memory flake situation.
|
||||
|
||||
### Grogu state
|
||||
- 3 runners installed at `~/actions-runner-jarvis{,-2,-3}/`, each with `_work` symlinked to `/Volumes/HD-2/jarvis-runner-work{,-2,-3}/`.
|
||||
- LaunchAgents managed by `~/Library/LaunchAgents/actions.runner.KofTwentyTwo-Jarvis.Grogu-jarvis{,-2,-3}.plist`.
|
||||
- Keychain workflow: `~/Library/Keychains/jarvis-runner.keychain-db`, password in repo secret `JARVIS_RUNNER_KEYCHAIN_PASSWORD`. CI unlocks + makes default + prepends to search list.
|
||||
- Side effect: `jarvis-runner.keychain-db` is your user's default keychain after CI runs. Undo command in CLAUDE.md.
|
||||
|
||||
---
|
||||
|
||||
## Active background processes at handoff
|
||||
|
||||
| Task ID | What | Expected exit |
|
||||
|---|---|---|
|
||||
| `bny76q2ad` | Final shepherd watching PR #185 + #191 | When both PRs are handled (merged or marked `ci-failed`), or 60-min timeout |
|
||||
|
||||
**Log location:** `/tmp/final-shepherd.log`
|
||||
|
||||
Reading the log will tell you what the shepherd did. If it marked both PRs `ci-failed` because of the Memory flake, see "Memory flake disposition" below.
|
||||
|
||||
---
|
||||
|
||||
## Memory flake disposition (what to do when you pick up)
|
||||
|
||||
`MemoryWiringEndToEndTests.testMemoryEnqueueReceivesNonNilUserAndAssistantText` fails with:
|
||||
```
|
||||
XCTAssertEqual failed: ("0") is not equal to ("1") - enqueue was not called — BLOCKER-1 regression
|
||||
XCTAssertEqual failed: ("nil") is not equal to ("Optional("hi")") - userText empty/nil — Plan 4's submit-time append did not propagate via TurnTranscriptStore
|
||||
XCTAssertEqual failed: ("nil") is not equal to ("Optional("hello there")") - assistantText empty/nil — Plan 1's transcript subscriber did not propagate via TurnTranscriptStore
|
||||
```
|
||||
|
||||
**Evidence it's a flake, not a regression:**
|
||||
- PR #191 doesn't touch AgentCore at all (only `CLAUDE.md`) yet hits the same exact failure.
|
||||
- Develop's latest CI (`25832341662`, on `774e60a0`) passed Memory in 45s.
|
||||
- Both #185 and #191 are branched off slightly different develop SHAs — the test passes on the latest develop but fails on these branch HEADs after forward-merge.
|
||||
|
||||
**Recommended next steps:**
|
||||
1. Open a new issue: `bug,severity:medium,area:memory` — "MemoryWiringEndToEndTests intermittent failure under concurrent runner load". Describe the `TurnTranscriptStore` race hypothesis.
|
||||
2. Either rerun the failed jobs (CI may pass on next attempt) OR merge both PRs as-is since branch protection has 0 required checks and the test passes reliably on develop.
|
||||
3. Long-term: file the test as known-flaky in v0.2 milestone.
|
||||
|
||||
---
|
||||
|
||||
## v0.2 Wave 1 (next session start)
|
||||
|
||||
Tiny / doc-only / deterministic fixes — safe to land in a single session each:
|
||||
|
||||
| Issue | What | Effort | Why first |
|
||||
|---|---|---|---|
|
||||
| **#19** | MemoryStore.swift stale Phase 7 hedge doc-comments | ~5 min | Pure docs, zero risk |
|
||||
| **#45** | Bus 'JarvisBusWorld' docstring drift (20+ docstrings) | ~15 min | Pure docs, mechanical |
|
||||
| **#46** | Stale webview bundle artifacts in `App/Resources/webview/assets/` (~50MB) | ~10 min | File deletes |
|
||||
| **#37** | STTBackendSelector snake_case vs camelCase mismatch | ~15 min | Single rename |
|
||||
| **#178** | Add OpenSSF Scorecard workflow | ~15 min | One new `.github/workflows/` file |
|
||||
| **#47** | WebviewBridge.sendRaw NaN→null JSON guard | ~20 min | One-line bug fix |
|
||||
|
||||
Each gets brainstorm → spec → plan via the Superpowers skills (specs/plans templates established in `docs/superpowers/`). Brief specs (a few paragraphs) — these are "issue body IS the design" cases.
|
||||
|
||||
## v0.2 Wave 2 / Wave 3 — wait
|
||||
|
||||
Larger items that need real diagnostic work or design decisions:
|
||||
- #18 MemoryReplaySink error propagation — needs care
|
||||
- #23 + #24 already in PR #185 (held)
|
||||
- #44 HUD audioLevel consumer rewire — MED, design choice
|
||||
- #43 sessionHistory dead-letter — overlaps M-7, defer
|
||||
- #176 vitest 4.x major bump — needs eval
|
||||
- #26 confirmation panel multi-monitor — needs real hardware
|
||||
- Any M-0..M-7 issue — strategic, gets its own spec → plan
|
||||
|
||||
---
|
||||
|
||||
## Key references
|
||||
|
||||
- **Spec template:** `docs/superpowers/specs/2026-05-13-multi-runner-grogu-design.md`
|
||||
- **Plan template:** `docs/superpowers/plans/2026-05-13-multi-runner-grogu.md`
|
||||
- **Previous handoff:** `docs/HANDOFF-2026-05-13-v0.1-shipped.md` (now superseded for the multi-runner story but still describes v0.1 mop-up)
|
||||
- **Roadmap:** `.planning/ROADMAP-2026-05-12.md`
|
||||
- **Live state:** GitHub Issues + Milestones at https://github.com/KofTwentyTwo/Jarvis
|
||||
- **CI workflow:** `.github/workflows/ci.yml` — has the age-filtered cleanup + inline release create
|
||||
- **CLAUDE.md `Self-hosted CI runner (Grogu)`** section — operational reference for the 3-runner setup
|
||||
|
||||
## What this handoff deliberately doesn't relitigate
|
||||
|
||||
- The Memory flake's root cause — open a v0.2 bug and move on; develop's CI is reliably green so it's not blocking.
|
||||
- Whether to merge PR #185 / PR #191 with a known-flaky failure — branch protection allows it; user judgment.
|
||||
- Wave 2/3 v0.2 issues — they need design conversations; pick them up after Wave 1.
|
||||
- M-0..M-7 — strategic, gets its own session.
|
||||
|
||||
---
|
||||
|
||||
*Written 2026-05-14 at the natural session-end after multi-runner expansion shipped. Next session should start with `/clear`, read this doc, then read the latest `/tmp/final-shepherd.log` to see what the background shepherd did, then pick up Wave 1.*
|
||||
@@ -0,0 +1,114 @@
|
||||
# v0.2 Wave 1 sequencing and execution — design
|
||||
|
||||
**Status:** approved 2026-05-14 (autonomous execution authorized)
|
||||
**Authors:** brainstorm with James + Claude (Opus 4.7)
|
||||
**Scope:** Six tiny v0.2 issues (#19, #37, #45, #46, #47, #178) — sequenced into three PRs.
|
||||
|
||||
## Goal
|
||||
|
||||
Land all six Wave 1 issues against `develop` in three coherent PRs, in an order that minimises CI exposure to the known `MemoryWiringEndToEndTests` flake and keeps each PR's narrative reviewable as a single story.
|
||||
|
||||
## Why three PRs, not six and not two
|
||||
|
||||
Six individual PRs (the handoff's implicit default) maximises history granularity but burns 6× the SPM CI matrix and exposes us to the Memory flake on every branch. Two giant PRs maximise throughput but mix surfaces (build script + new workflow + bundle rebuild + Swift code) into a single review and a single revert unit.
|
||||
|
||||
Three PRs grouped by surface area give us:
|
||||
|
||||
- 3× CI runs instead of 6×.
|
||||
- Each PR has one coherent narrative (docs ⋄ infra ⋄ code).
|
||||
- Each PR can be reverted independently without untangling unrelated work.
|
||||
|
||||
## Composition
|
||||
|
||||
### PR 1 — "Doc drift cleanup" (`docs/v0.2-wave-1-doc-drift`)
|
||||
|
||||
**Issues:** #19 (MemoryStore stale Phase-7 doc-comments) + #45 (Bus JarvisBusWorld drift across 20+ docstrings post `fb41c5f`).
|
||||
|
||||
**Why grouped:** both are descriptive/textual changes to documentation that no longer matches the code. #45 already requires a `@jarvis/bus` bundle rebuild and a new grep-gate, so it warrants doc-class company rather than mixing with real code fixes. Both touch package-internal documentation only; no behavioural change.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `packages/Memory/Sources/Memory/MemoryStore.swift` — scrub "Plan 07-02 wires", "Plan 07-03 will replace", "future ops plan" hedges. Keep the 2026-05-11 comment at line 464–468 intact (it is correct).
|
||||
- Bus comments renamed across `WebviewBridge.swift`, `Injection.js`, `webview/packages/bus/src/bridge.ts`, `webview/packages/hud/src/main.tsx`, `App/AppDelegate.swift` — replace "JarvisBusWorld" / "WKContentWorld('JarvisBusWorld')" / cross-world-attach language with "page world" / "shared world" / "defensive double-install guard".
|
||||
- `bridge.ts:60–99` rationale rewritten — the duck-type `isJarvisBusShape` guard is now defensive against double-install (both `Injection.js`'s stub and the bundle's `installJarvisBus` running in the same page world), not cross-world attach. Comment must say this without misleading; do **not** delete the guard itself.
|
||||
- Bundle rebuilt: `webview/packages/bus/dist/bridge.{js,d.ts}` regenerated via `pnpm --filter @jarvis/bus build` (or equivalent).
|
||||
- `@jarvis/bus` `package.json` patch-version bumped to reflect comment changes in dist (matches existing project convention of patch-bumping when the published comments shift).
|
||||
- New grep-gate: `scripts/check-no-jarvis-bus-world-references.sh` — fails if any tracked file outside `.planning/`, `docs/`, or git-history references contains the literal `JarvisBusWorld`. Wired into the boundary-gates list in CI and in `CLAUDE.md`.
|
||||
|
||||
**Closes:** #19, #45.
|
||||
|
||||
### PR 2 — "Build hygiene" (`ci/v0.2-wave-1-build-hygiene`)
|
||||
|
||||
**Issues:** #46 (stale webview bundle artifacts) + #178 (OpenSSF Scorecard workflow).
|
||||
|
||||
**Why grouped:** both touch repo-level infrastructure (`scripts/` and `.github/workflows/`) and neither touches Swift packages, so the heavy SPM matrix runs but with low chance of new test failures. Quick CI; safe to land first.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `scripts/build-webview.sh` — before the existing `rsync -a ... dist/ -> App/Resources/webview/` at line 88, add an explicit cleanup of `App/Resources/webview/assets/*.js`, `*.js.map`, `*.css` (preserving `bus-harness.html`, the directory's `.gitignore`, and any other already-excluded files). Easiest mechanism: pass `--delete-excluded` plus an `--exclude` for each preserve target, or `rm -f` the asset glob before rsync. Either works; preference is `rm -f` of the explicit asset glob because it's grep-able.
|
||||
- `App/Resources/webview/assets/` — delete the 10 stale `index-*.js` files and their `.map` siblings in the same commit so the directory is clean post-merge.
|
||||
- `.github/workflows/scorecard.yml` — new file. Trigger `schedule: cron: "0 6 * * 1"` (weekly Mondays) + `push: branches: [develop]` + `workflow_dispatch`. Permissions: `id-token: write`, `contents: read`, `security-events: write`. Uses `ossf/scorecard-action@v2.4.0` (or latest at execution time). Upload SARIF to GitHub code-scanning.
|
||||
- `README.md` — add OpenSSF Scorecard badge near the top (under the title or alongside existing badges). Badge URL pattern is the standard `https://api.scorecard.dev/projects/github.com/KofTwentyTwo/Jarvis/badge`.
|
||||
|
||||
**Closes:** #46, #178.
|
||||
|
||||
### PR 3 — "Small code fixes" (`fix/v0.2-wave-1-code-fixes`)
|
||||
|
||||
**Issues:** #37 (STTBackendSelector snake_case vs call-site camelCase) + #47 (WebviewBridge NaN→null JSON guard for `audioLevel`).
|
||||
|
||||
**Why grouped:** both are genuine one-shot code fixes with a unit test each, in different packages (Voice/App + Bus). Landing them together gives one Swift-package-test cycle for both fixes.
|
||||
|
||||
**Deliverables for #37:**
|
||||
|
||||
- Decision: standardise on **camelCase** at call sites (matches existing identifier style elsewhere in the codebase). Update the constants in `STTBackendSelector` to camelCase string values, OR delete the unused constants in favour of an enum if call sites can adopt cheaply. Master agent picks the smaller diff after a quick read.
|
||||
- Single rename or constant-shape change; ensure `App/AppDelegate.swift:2028` (and any other call sites a grep reveals) is consistent.
|
||||
- Existing tests must still pass; if none cover the constant identity, add a small one in `packages/Voice/Tests/` asserting the chosen shape.
|
||||
|
||||
**Deliverables for #47:**
|
||||
|
||||
- First verify #47 is still latent or has become active post-#42: grep for the audioLevel consumer in `webview/packages/hud/` and check whether real decode errors are firing in any logged scenario. Either way, ship the clamp.
|
||||
- `packages/Bus/Sources/Bus/WebviewBridge.swift` — in `sendRaw` (or the `BusOutbound.audioLevel.encode` path, whichever is the cleanest place), replace non-finite `rms` floats with `0.0` before encoding. Inline fix only; do not refactor `AudioLevelEmitter` simultaneously (separate concern).
|
||||
- New unit test: `BusOutboundEncodingTests` (or extend the nearest existing suite) asserts `BusOutbound.audioLevel(rms: .nan)` decodes JS-side with `rms == 0.0`. Use the existing harness convention (`Mock*` only if recording, `Stub*` for canned data, `Fake*` for stateful) per CLAUDE.md.
|
||||
|
||||
**Closes:** #37, #47.
|
||||
|
||||
## Landing order
|
||||
|
||||
1. **PR 2 first** — lowest blast radius, lightest CI footprint, validates the multi-runner pipeline on something we can revert in seconds if it breaks.
|
||||
2. **PR 1 second** — purely descriptive, no behavioural change. CI surface is large (Bus + Memory packages, full webview build), but new tests should not fail because no logic moves.
|
||||
3. **PR 3 last** — real code touches. By the time we get here, the Memory flake is the only plausible failure mode, so if a job goes red we know it is the flake.
|
||||
|
||||
## Memory-flake disposition
|
||||
|
||||
Per `docs/HANDOFF-2026-05-14-multi-runner-shipped.md`: `MemoryWiringEndToEndTests` flakes intermittently on PR branches under concurrent-runner load. Branch protection has zero required checks, so flake-induced red is not blocking.
|
||||
|
||||
Master agent policy:
|
||||
|
||||
1. If a PR's CI fails on Memory only, rerun the failed jobs once.
|
||||
2. If they pass on rerun, merge.
|
||||
3. If they fail a second time, leave the PR open with the Memory failure visible and open `bug,severity:medium,area:memory` issue documenting the test as known-flaky in v0.2. Do not merge without user sign-off.
|
||||
4. If any *non-Memory* check fails, stop and report back — do not rerun, do not merge.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not touch PRs #185, #191, #192. They are tracked in a separate disposition.
|
||||
- Do not open or address Wave 2/3 issues (#18, #23/#24, #43, #44, #176, #26, M-series epics).
|
||||
- Do not refactor `AudioLevelEmitter` while fixing #47.
|
||||
- Do not change branch protection settings.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`).
|
||||
- Do not force-push.
|
||||
- Do not rebase #185/#191/#192 onto the new tip of develop.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Issues #19, #37, #45, #46, #47, #178 all closed via merge.
|
||||
- `develop` HEAD's CI is green after all three merges.
|
||||
- `App/Resources/webview/assets/` contains only the active bundle (one `index-*.js` + `.map` + `.css`) after PR 2 merges.
|
||||
- OpenSSF Scorecard workflow appears in Actions → Workflows and has at least one successful run (or a clearly-named permission failure to address in a follow-up).
|
||||
- Grep for `JarvisBusWorld` outside `.planning/`, `docs/`, and committed history returns nothing.
|
||||
- `swift test --package-path packages/Bus` passes including the new NaN-clamp test.
|
||||
- No new test doubles violate the `Mock*`/`Stub*`/`Fake*` convention.
|
||||
|
||||
## Effort budget
|
||||
|
||||
~90 min of coding across the three PRs, plus CI wait time. Master agent should target completion in 90 min wall clock; if it falls behind, it must report partial progress rather than rush.
|
||||
@@ -43,6 +43,20 @@ public struct CacheHints: Sendable, Equatable {
|
||||
/// Once the system prompt grows past the threshold (e.g. memory
|
||||
/// hydration adds context, presence enrichment lengthens the prompt)
|
||||
/// the hint resumes automatically.
|
||||
///
|
||||
/// **Current production state (as of v0.1):** `selfAwarePreamble` is
|
||||
/// ~700 chars and the orchestrator is constructed with an `.empty`
|
||||
/// initial context, so the threshold is never crossed on a healthy
|
||||
/// boot — `eligibleForSystemPrompt` returns `nil` on every turn and
|
||||
/// the prompt cache is effectively dormant. This is intentional
|
||||
/// cost-acceptance: padding the preamble to ≥4096 chars would
|
||||
/// require real prompt-engineering work and risks bleeding
|
||||
/// irrelevant context into the model. Cache hits resume naturally
|
||||
/// once Memory M-4 (v0.8 milestone) lands `searchFacts`-driven
|
||||
/// hydration that grows the prompt past 1024 tokens organically;
|
||||
/// see #23 for the audit context. Until then, every turn pays full
|
||||
/// input-token cost — a ~30–80% cost reduction is on the table once
|
||||
/// M-4 ships.
|
||||
public static func eligibleForSystemPrompt(
|
||||
_ systemPromptText: String,
|
||||
ttl: CacheTTL = .extended1h
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -508,10 +508,29 @@ public actor AgentOrchestrator {
|
||||
// for the current pass. Reset on each pass restart (initial entry +
|
||||
// T1→T2 escalation re-enter via `continue outer`).
|
||||
var assistantTextSoFar: String = ""
|
||||
// #24: text accumulated since the most recent toolUse append (or
|
||||
// since the start of the round if no toolUse yet). Distinct from
|
||||
// `assistantTextSoFar` which stays cumulative for D-02 vision
|
||||
// routing on endTurn. This buffer feeds the assistant message
|
||||
// history alongside each toolUse block so the model sees its own
|
||||
// narration ("Let me check..." → tool_use(get_time)) on subsequent
|
||||
// round-trips; previously the text was dropped entirely from
|
||||
// history and the model would deny ever saying it.
|
||||
var assistantTextSinceLastFlush: String = ""
|
||||
// D-02: escalation gets ONE shot. The second low-confidence outcome
|
||||
// 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.
|
||||
@@ -575,6 +594,10 @@ public actor AgentOrchestrator {
|
||||
// can hand it to VisionRouter.evaluatePostResponse.
|
||||
// Reset on `continue outer` from the escalation arm.
|
||||
assistantTextSoFar += s
|
||||
// #24: also accumulate into the per-tool-use flush
|
||||
// buffer so the next toolUseRequested attaches the
|
||||
// preceding narration to its assistant message.
|
||||
assistantTextSinceLastFlush += s
|
||||
await replayLog.record(.textDelta(s), for: currentTurnId)
|
||||
await events.send(.tokenDelta(turnId: currentTurnId, text: s))
|
||||
|
||||
@@ -601,9 +624,29 @@ public actor AgentOrchestrator {
|
||||
// history so providers that require the original
|
||||
// tool_use round-trip (Anthropic) can resolve the
|
||||
// tool_use_id when the next tool_result lands.
|
||||
messages.append(LLMMessage(role: .assistant, content: [
|
||||
.toolUse(id: req.id, name: req.name, argsJSON: req.argsJSON),
|
||||
]))
|
||||
//
|
||||
// #24: bundle any text the model emitted since the
|
||||
// last flush — Opus 4.7 commonly narrates ("Let me
|
||||
// check the time...") before calling a tool. Without
|
||||
// this the text was dropped from history and the
|
||||
// model would deny ever saying it on a follow-up.
|
||||
// Trim whitespace-only text to avoid emitting empty
|
||||
// text blocks (which Anthropic rejects with a 400).
|
||||
var assistantContent: [LLMMessage.ContentBlock] = []
|
||||
let pendingText = assistantTextSinceLastFlush
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pendingText.isEmpty {
|
||||
assistantContent.append(.text(pendingText))
|
||||
}
|
||||
assistantContent.append(
|
||||
.toolUse(id: req.id, name: req.name, argsJSON: req.argsJSON)
|
||||
)
|
||||
messages.append(LLMMessage(role: .assistant, content: assistantContent))
|
||||
// Consumed — the next toolUseRequested in the same
|
||||
// round (rare but possible) gets only the text that
|
||||
// came BETWEEN the two tool_use blocks. D-02's
|
||||
// cumulative `assistantTextSoFar` is untouched.
|
||||
assistantTextSinceLastFlush = ""
|
||||
|
||||
do {
|
||||
let resultData = try await toolDispatcher.dispatch(toolUse: req)
|
||||
@@ -812,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
|
||||
@@ -887,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import XCTest
|
||||
import AgentCore
|
||||
import Config
|
||||
import Replay
|
||||
@testable import AgentOrchestrator
|
||||
|
||||
/// **#24 — assistant text blocks alongside tool_use must not be dropped.**
|
||||
///
|
||||
/// Opus 4.7 frequently narrates before invoking a tool ("Let me check the
|
||||
/// time for you..." → `tool_use(get_time)`). Before this fix, the
|
||||
/// orchestrator appended only the `.toolUse` block to `messages`, so on
|
||||
/// the next round-trip the model had no record of saying anything — a
|
||||
/// user follow-up like "what did you say before checking the time?"
|
||||
/// would get a denial.
|
||||
///
|
||||
/// These tests assert the assistant message at the round-trip boundary
|
||||
/// contains BOTH the preceding text and the tool_use block. D-02's
|
||||
/// cumulative `assistantTextSoFar` is preserved separately; only the
|
||||
/// per-flush buffer is consumed.
|
||||
@MainActor
|
||||
final class AssistantTextWithToolUseTests: 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()
|
||||
}
|
||||
|
||||
private func buildOrchestrator(
|
||||
provider: MockLLMProvider,
|
||||
dispatcher: any ToolDispatcher
|
||||
) async throws -> AgentOrchestrator {
|
||||
let replay = try ReplayLog(databaseURL: tempHome.dbURL)
|
||||
let session = try await replay.beginSession(appVersion: "test", buildSHA: "deadbeef")
|
||||
return AgentOrchestrator(
|
||||
configStore: makeConfigStore(),
|
||||
providerFactory: { _ in provider },
|
||||
toolDispatcher: dispatcher,
|
||||
replayLog: replay,
|
||||
sessionId: session,
|
||||
systemPrompt: "you are jarvis",
|
||||
availableTools: []
|
||||
)
|
||||
}
|
||||
|
||||
private func collectUntilIdle(orch: AgentOrchestrator, timeout: TimeInterval = 5.0) async {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
for await event in orch.events {
|
||||
if case .stateChange(.idle) = event { return }
|
||||
if Date() > deadline { return }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ATU-1
|
||||
|
||||
/// Single text-then-tool sequence: model emits "Let me check..." then
|
||||
/// `tool_use(get_time)`. The assistant message in the second provider
|
||||
/// call must contain a `.text` block AND a `.toolUse` block.
|
||||
func test_ATU1_textBeforeToolUseBundledIntoSameAssistantMessage() async throws {
|
||||
let toolReq = ToolUseRequest(id: "tu1", name: "get_time", argsJSON: Data("{}".utf8))
|
||||
let mock = MockLLMProvider(scripts: [
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m1", model: "x", usagePrefix: nil)),
|
||||
.textDelta("Let me check the time for you... "),
|
||||
.toolUseRequested(toolReq),
|
||||
.stopReason(.toolUse),
|
||||
.messageStop,
|
||||
]),
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m2", model: "x", usagePrefix: nil)),
|
||||
.textDelta("It's 12:34."),
|
||||
.stopReason(.endTurn),
|
||||
.messageStop,
|
||||
]),
|
||||
])
|
||||
let dispatcher = StubToolDispatcher(dispatch: { _ in Data("12:34".utf8) })
|
||||
|
||||
let orch = try await buildOrchestrator(provider: mock, dispatcher: dispatcher)
|
||||
_ = await orch.submit(.text("what time"))
|
||||
await collectUntilIdle(orch: orch)
|
||||
|
||||
let calls = await mock.getRecordedCalls()
|
||||
XCTAssertEqual(calls.count, 2, "expected 2 provider calls (tool-use loop)")
|
||||
|
||||
// Find the assistant message containing the tool_use block in the
|
||||
// second call's history.
|
||||
let secondCall = calls[1].messages
|
||||
guard let assistantWithTool = secondCall.first(where: { msg in
|
||||
msg.role == .assistant && msg.content.contains(where: { block in
|
||||
if case .toolUse(id: "tu1", _, _) = block { return true }
|
||||
return false
|
||||
})
|
||||
}) else {
|
||||
return XCTFail("no assistant message with the tool_use block found in second call")
|
||||
}
|
||||
|
||||
// Block ordering: text first, then tool_use.
|
||||
let blockKinds = assistantWithTool.content.map { block -> String in
|
||||
switch block {
|
||||
case .text: return "text"
|
||||
case .toolUse: return "toolUse"
|
||||
case .toolResult: return "toolResult"
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(blockKinds, ["text", "toolUse"],
|
||||
"assistant block sequence must be [text, toolUse] — got \(blockKinds)")
|
||||
|
||||
// The text content matches what the model streamed (trimmed).
|
||||
if case .text(let txt) = assistantWithTool.content[0] {
|
||||
XCTAssertEqual(txt, "Let me check the time for you...",
|
||||
"text block must contain the streamed narration (trimmed)")
|
||||
} else {
|
||||
XCTFail("expected first block to be .text")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ATU-2
|
||||
|
||||
/// If the model emits a tool_use with NO preceding text (Opus
|
||||
/// occasionally does this — straight to tool), the assistant message
|
||||
/// must contain ONLY the toolUse block (no empty `.text("")` block,
|
||||
/// which Anthropic rejects with a 400).
|
||||
func test_ATU2_noPrecedingTextOnlyToolUseBlock() async throws {
|
||||
let toolReq = ToolUseRequest(id: "tu2", name: "get_time", argsJSON: Data("{}".utf8))
|
||||
let mock = MockLLMProvider(scripts: [
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m1", model: "x", usagePrefix: nil)),
|
||||
.toolUseRequested(toolReq),
|
||||
.stopReason(.toolUse),
|
||||
.messageStop,
|
||||
]),
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m2", model: "x", usagePrefix: nil)),
|
||||
.textDelta("done"),
|
||||
.stopReason(.endTurn),
|
||||
.messageStop,
|
||||
]),
|
||||
])
|
||||
let dispatcher = StubToolDispatcher(dispatch: { _ in Data("12:34".utf8) })
|
||||
|
||||
let orch = try await buildOrchestrator(provider: mock, dispatcher: dispatcher)
|
||||
_ = await orch.submit(.text("time"))
|
||||
await collectUntilIdle(orch: orch)
|
||||
|
||||
let calls = await mock.getRecordedCalls()
|
||||
guard let assistantWithTool = calls[1].messages.first(where: { msg in
|
||||
msg.role == .assistant && msg.content.contains(where: { block in
|
||||
if case .toolUse(id: "tu2", _, _) = block { return true }
|
||||
return false
|
||||
})
|
||||
}) else {
|
||||
return XCTFail("expected assistant message with tool_use block")
|
||||
}
|
||||
|
||||
XCTAssertEqual(assistantWithTool.content.count, 1,
|
||||
"no preceding text — assistant message must contain only the toolUse block, got \(assistantWithTool.content.count) blocks")
|
||||
if case .toolUse = assistantWithTool.content[0] {
|
||||
// ok
|
||||
} else {
|
||||
XCTFail("expected sole content block to be .toolUse")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ATU-3
|
||||
|
||||
/// Whitespace-only preceding text must NOT be emitted as a `.text`
|
||||
/// block (Anthropic rejects empty/whitespace-only text blocks). The
|
||||
/// implementation trims whitespace before testing for emptiness.
|
||||
func test_ATU3_whitespaceOnlyTextNotEmitted() async throws {
|
||||
let toolReq = ToolUseRequest(id: "tu3", name: "get_time", argsJSON: Data("{}".utf8))
|
||||
let mock = MockLLMProvider(scripts: [
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m1", model: "x", usagePrefix: nil)),
|
||||
.textDelta(" \n\t "), // whitespace only
|
||||
.toolUseRequested(toolReq),
|
||||
.stopReason(.toolUse),
|
||||
.messageStop,
|
||||
]),
|
||||
.init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m2", model: "x", usagePrefix: nil)),
|
||||
.stopReason(.endTurn),
|
||||
.messageStop,
|
||||
]),
|
||||
])
|
||||
let dispatcher = StubToolDispatcher(dispatch: { _ in Data("ok".utf8) })
|
||||
|
||||
let orch = try await buildOrchestrator(provider: mock, dispatcher: dispatcher)
|
||||
_ = await orch.submit(.text("hi"))
|
||||
await collectUntilIdle(orch: orch)
|
||||
|
||||
let calls = await mock.getRecordedCalls()
|
||||
guard let assistantWithTool = calls[1].messages.first(where: { msg in
|
||||
msg.role == .assistant && msg.content.contains(where: { block in
|
||||
if case .toolUse(id: "tu3", _, _) = block { return true }
|
||||
return false
|
||||
})
|
||||
}) else {
|
||||
return XCTFail("expected assistant message with tool_use block")
|
||||
}
|
||||
|
||||
XCTAssertEqual(assistantWithTool.content.count, 1,
|
||||
"whitespace-only preceding text must be dropped, leaving only the toolUse block")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -159,7 +217,20 @@ extension BusOutbound: Codable {
|
||||
try container.encode(text, forKey: .text)
|
||||
case .audioLevel(let rms):
|
||||
try container.encode(Discriminator.audioLevel, forKey: .type)
|
||||
try container.encode(rms, forKey: .rms)
|
||||
// Issue #47: JSON has no canonical encoding for `Float.nan` or
|
||||
// `±Float.infinity`. Foundation's `JSONEncoder` defaults to
|
||||
// throwing for non-finite floats (`JSONEncoder.NonConformingFloat-
|
||||
// EncodingStrategy.throw`), which would crash the per-frame bus
|
||||
// path the moment `AudioLevelEmitter` produces a NaN — easy to
|
||||
// hit on a CoreAudio over-/underflow or a buffer of all-zero
|
||||
// samples. Clamp non-finite to 0.0 inline at the encode site so
|
||||
// the rms float in the wire JSON is always a finite, ring-shader-
|
||||
// safe number. The HUD-side consumer (`webview/packages/hud/src/
|
||||
// bus/client.ts`) currently no-ops on audioLevel; the clamp is
|
||||
// defensive against the bug becoming active once the consumer is
|
||||
// wired up.
|
||||
let safe = rms.isFinite ? rms : 0.0
|
||||
try container.encode(safe, forKey: .rms)
|
||||
case .toolCallStart(let id, let name, let argsPreview):
|
||||
try container.encode(Discriminator.toolCallStart, forKey: .type)
|
||||
try container.encode(id.uuidString.lowercased(), forKey: .id)
|
||||
@@ -183,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:)`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Injected at document-start in WKContentWorld("JarvisBusWorld").
|
||||
// Installs window.jarvisBus with the minimum surface needed to complete
|
||||
// the handshake.
|
||||
// Injected at document-start in the page world (the default
|
||||
// `WKContentWorld.page` since `fb41c5f` retired the prior isolated named
|
||||
// world; see WebviewBridge.swift). Installs window.jarvisBus with the
|
||||
// minimum surface needed to complete the handshake.
|
||||
//
|
||||
// IMPORTANT — protocolVersion drift hazard: the full TS bundle's installer
|
||||
// (Vy() in the minified output) shape-checks window.jarvisBus and SKIPS
|
||||
@@ -16,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
|
||||
|
||||
@@ -6,8 +6,9 @@ import JarvisLogging
|
||||
/// Main-actor-bound bridge between Swift and the webview's JS layer.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - Install a `WKScriptMessageHandlerWithReply` in an isolated content world
|
||||
/// (`JarvisBusWorld`) so page scripts cannot observe or post to it.
|
||||
/// - Install a `WKScriptMessageHandlerWithReply` on the page world
|
||||
/// (`WKContentWorld.page`, the default since `fb41c5f`) so page scripts
|
||||
/// in the loaded HUD bundle can address it via `window.webkit.messageHandlers.jarvisBus`.
|
||||
/// - Inject a `WKUserScript` at `.atDocumentStart` in the same world so
|
||||
/// `window.jarvisBus` exists before any page script runs (pitfall 3).
|
||||
/// - Decode inbound JSON into `BusInbound` and either (a) handle the handshake
|
||||
|
||||
@@ -123,6 +123,44 @@ final class CodableRoundTripTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// Issue #47: `BusOutbound.audioLevel` encodes a `Float`, and Foundation's
|
||||
/// default `JSONEncoder.NonConformingFloatEncodingStrategy` is `.throw`.
|
||||
/// A non-finite `rms` (NaN from a CoreAudio over-/underflow, or ±∞ from a
|
||||
/// pathological emitter buffer) would crash the per-frame bus path inside
|
||||
/// `WebviewBridge.sendRaw`. The encode site in `BusOutbound.swift` clamps
|
||||
/// non-finite floats to `0.0`; this test pins the contract so the clamp
|
||||
/// can't silently regress.
|
||||
func test_audioLevel_nanIsClampedToZero() throws {
|
||||
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.audioLevel(rms: .nan))
|
||||
let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: encoded)
|
||||
guard case .audioLevel(let rms) = decoded else {
|
||||
XCTFail("expected .audioLevel after round-trip, got \(decoded)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(rms, 0.0, "NaN rms must be clamped to 0.0 at encode time (#47)")
|
||||
XCTAssertTrue(rms.isFinite, "post-clamp rms must be finite")
|
||||
}
|
||||
|
||||
/// Companion to `test_audioLevel_nanIsClampedToZero` — covers the ±∞
|
||||
/// edge cases. CoreAudio drivers don't normally emit infinities, but the
|
||||
/// encode-time clamp is sourced from `Float.isFinite`, which covers both
|
||||
/// NaN and ±∞ via one check; locking the contract here prevents a future
|
||||
/// refactor from narrowing the guard to NaN-only.
|
||||
func test_audioLevel_infinityIsClampedToZero() throws {
|
||||
let encoder = BusCoder.makeEncoder()
|
||||
let decoder = BusCoder.makeDecoder()
|
||||
|
||||
for value in [Float.infinity, -Float.infinity] {
|
||||
let encoded = try encoder.encode(BusOutbound.audioLevel(rms: value))
|
||||
let decoded = try decoder.decode(BusOutbound.self, from: encoded)
|
||||
guard case .audioLevel(let rms) = decoded else {
|
||||
XCTFail("expected .audioLevel for \(value)")
|
||||
continue
|
||||
}
|
||||
XCTAssertEqual(rms, 0.0, "±∞ rms (\(value)) must be clamped to 0.0 (#47)")
|
||||
}
|
||||
}
|
||||
|
||||
func test_roundTrip_toolCallStart() throws {
|
||||
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
|
||||
try assertOutboundRoundTrips(
|
||||
@@ -196,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"}}
|
||||
@@ -15,7 +15,7 @@ import WebKit
|
||||
/// **Why this exists** — Phase D-Phase-2 / Phase F1 of
|
||||
/// `.planning/AUDIT-AND-FIX-PLAN.md`. The 2026-05-02 audit-and-fix
|
||||
/// session uncovered a `WKContentWorld` isolation bug
|
||||
/// (`Injection.js` ran in `WKContentWorld(name: "JarvisBusWorld")`
|
||||
/// (`Injection.js` ran in a prior isolated named content world
|
||||
/// while the bundle ran in the page world; `window.jarvisBus`
|
||||
/// properties were per-world isolated; bundle's `attachBus()` saw
|
||||
/// `undefined` and created a SECOND fresh bus object; two stubs, two
|
||||
@@ -127,13 +127,13 @@ final class RealWKWebViewIntegrationTests: XCTestCase {
|
||||
|
||||
/// **The test that would have caught the WKContentWorld bug.**
|
||||
///
|
||||
/// On the bug branch, `Injection.js` ran in
|
||||
/// `WKContentWorld(name: "JarvisBusWorld")` while
|
||||
/// `WebviewBridge.sendRaw` invoked `callAsyncJavaScript` against
|
||||
/// the same named world — but the message *handler* was registered
|
||||
/// against `WKContentWorld.page`, so JS-side `postMessage` calls
|
||||
/// from the named-world stub never reached Swift. Handshake hung
|
||||
/// at `.sentHello`, eventually timed out with an `NSAlert`.
|
||||
/// On the bug branch, `Injection.js` ran in the prior named
|
||||
/// content world while `WebviewBridge.sendRaw` invoked
|
||||
/// `callAsyncJavaScript` against the same named world — but the
|
||||
/// message *handler* was registered against `WKContentWorld.page`,
|
||||
/// so JS-side `postMessage` calls from the named-world stub never
|
||||
/// reached Swift. Handshake hung at `.sentHello`, eventually timed
|
||||
/// out with an `NSAlert`.
|
||||
///
|
||||
/// On the fixed branch (fb41c5f), Injection.js auto-acks `hello`
|
||||
/// at the stub layer in the unified `.page` world, the bridge's
|
||||
|
||||
@@ -78,8 +78,8 @@ final class WebviewBridgeOutboundTests: XCTestCase {
|
||||
.decode(BusOutbound.self, from: Data((payload ?? "").utf8))
|
||||
XCTAssertEqual(decoded, .hudState(.idle))
|
||||
// F-A2-01: production WebviewBridge uses `WKContentWorld.page` (the
|
||||
// default since `fb41c5f`); the prior `JarvisBusWorld` named-world
|
||||
// assertion was retired in that commit but this test wasn't updated.
|
||||
// default since `fb41c5f`); the prior named-world assertion was
|
||||
// retired in that commit but this test wasn't updated until #45.
|
||||
XCTAssertEqual(call.contentWorld, .page)
|
||||
}
|
||||
|
||||
|
||||
@@ -160,11 +160,14 @@ final class WebviewBridgeTests: XCTestCase {
|
||||
// Re-adding a handler with the same (contentWorld, name) pair would
|
||||
// raise `NSInvalidArgumentException`. The constructor succeeding is
|
||||
// itself evidence of registration. As a double-check, `remove` on a
|
||||
// registered handler must succeed without throwing.
|
||||
// registered handler must succeed without throwing. We remove from
|
||||
// `WKContentWorld.page` because that is the default world
|
||||
// `WebviewBridge.init` registers the handler against since `fb41c5f`
|
||||
// unified bus and bundle into the same page world.
|
||||
let controller = webView.configuration.userContentController
|
||||
controller.removeScriptMessageHandler(
|
||||
forName: "jarvisBus",
|
||||
contentWorld: WKContentWorld.world(name: "JarvisBusWorld")
|
||||
contentWorld: .page
|
||||
)
|
||||
// No XCTAssert needed — the remove call succeeding on a registered
|
||||
// handler is the assertion.
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -12,7 +12,7 @@ import Replay
|
||||
|
||||
/// Test seam over `Replay.ReplayLog`. The Memory module deliberately does NOT
|
||||
/// depend on a concrete `ReplayLog` instance — `AppDelegate.installMemory`
|
||||
/// (Plan 07-06) hands a wrapper through `setReplayLog`. Tests inject a
|
||||
/// hands a wrapper through `setReplayLog` at startup. Tests inject a
|
||||
/// mock conforming to this protocol.
|
||||
public protocol MemoryReplaySink: Sendable {
|
||||
func record(_ event: ReplayEvent, forTriggerTurnId triggerTurnId: Int64)
|
||||
@@ -34,8 +34,10 @@ public protocol MemoryReplaySink: Sendable {
|
||||
/// memory store the app refuses to launch. This eliminates the silent
|
||||
/// dormancy that hid B-02 / B-04-class substrate failures.
|
||||
///
|
||||
/// The actor is the only writer. Plan 07-02 wires the bounded extraction
|
||||
/// channel; Plan 07-03 wires the search path + ReplayEvent emission.
|
||||
/// The actor is the only writer. The bounded extraction channel writes
|
||||
/// here via `applyOp`; the hybrid (FTS5 + vec0) search path reads through
|
||||
/// `MemoryReadStore` conformance and emits `ReplayEvent.memoryRetrieval`
|
||||
/// via `recordRetrieval`.
|
||||
public actor MemoryStore {
|
||||
|
||||
public static let logChannel = "memory"
|
||||
@@ -331,11 +333,11 @@ public actor MemoryStore {
|
||||
sink.record(.memoryMutation(bytes), forTriggerTurnId: triggerTurnId)
|
||||
}
|
||||
|
||||
// MARK: - Read path (Plan 07-03 — MEM-07 + TEXT-03 + D-02 + D-05)
|
||||
// MARK: - Read path (MEM-07 + TEXT-03 + D-02 + D-05)
|
||||
|
||||
/// SOLE production emission site for ReplayEvent.memoryRetrieval (D-05).
|
||||
/// Mirrors recordMemoryMutation from 07-02. Best-effort; if no replay
|
||||
/// sink has been injected, the call is a silent no-op.
|
||||
/// Mirrors `recordMemoryMutation` on the write path. Best-effort; if no
|
||||
/// replay sink has been injected, the call is a silent no-op.
|
||||
public func recordRetrieval(_ ref: FactRef, triggerTurnId: Int64) {
|
||||
guard let sink = replayLog else { return }
|
||||
let stamp: Int64 = ref.timestamp == 0
|
||||
@@ -538,13 +540,13 @@ public actor MemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HybridSearch + SessionHistory conformance (Plan 07-03 Task 2)
|
||||
// MARK: - HybridSearch + SessionHistory conformance
|
||||
|
||||
extension MemoryStore: MemoryReadStore {}
|
||||
extension MemoryStore: SessionHistoryReading {}
|
||||
extension OllamaEmbeddingClient: EmbeddingProviding {}
|
||||
|
||||
// MARK: - Plan 07-06 / Regression corpus test seams
|
||||
// MARK: - Regression corpus test seams
|
||||
//
|
||||
// These small read-only seams are exercised exclusively by the env-gated
|
||||
// MemoryRegressionCorpusTests suite (JARVIS_REAL_MODELS=1). They wrap raw
|
||||
|
||||
@@ -28,6 +28,16 @@ public protocol STTWarningLogger: Sendable {
|
||||
public enum STTBackendSelector {
|
||||
|
||||
// MARK: - Valid backend strings
|
||||
//
|
||||
// Canonical backend identifiers. Snake_case is the wire shape stored in
|
||||
// `PerTurnSnapshot.stt.backend`. Call sites (`AppDelegate.sttBackend`)
|
||||
// SHOULD reference these constants rather than the bare string literals;
|
||||
// `STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
|
||||
// locks the round-trip contract by asserting both that the constants
|
||||
// map to the right `STTProvider` AND that camelCase string literals
|
||||
// (the pre-2026-05-12 anti-pattern from Issue #29 / #37) fall through
|
||||
// to the default branch with a warning. Do not rename without updating
|
||||
// `PerTurnSnapshot` and the regression test in lockstep.
|
||||
|
||||
public static let backendSpeechAnalyzer = "speech_analyzer"
|
||||
public static let backendWhisperKit = "whisperkit"
|
||||
|
||||
@@ -77,6 +77,16 @@ pnpm --filter @jarvis/hud build
|
||||
|
||||
DEST="$REPO_ROOT/App/Resources/webview"
|
||||
mkdir -p "$DEST"
|
||||
mkdir -p "$DEST/assets"
|
||||
|
||||
# Issue #46: Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`),
|
||||
# so the active bundle filename changes on every dependency or source edit.
|
||||
# Without explicit cleanup, stale chunks accumulate in `assets/` because we
|
||||
# don't pass `--delete` to rsync below. Sweep the asset glob (JS + maps + CSS)
|
||||
# before the rsync drops the fresh bundle. `bus-harness.html`, `index.html`,
|
||||
# and the directory's `.gitignore` live one level up (in $DEST), not in
|
||||
# $DEST/assets, so they are not at risk from this rm.
|
||||
rm -f "$DEST"/assets/*.js "$DEST"/assets/*.js.map "$DEST"/assets/*.css "$DEST"/assets/*.css.map
|
||||
|
||||
echo "[build-webview] rsync dist/ -> $DEST"
|
||||
# --exclude bus-harness.html: keep 02-03's committed copy intact (parity
|
||||
@@ -85,6 +95,7 @@ echo "[build-webview] rsync dist/ -> $DEST"
|
||||
# --exclude .gitignore: preserve the App/Resources/webview/.gitignore that
|
||||
# lets index.html stay source-committed while assets/ regenerates.
|
||||
# We do NOT pass --delete: other vendored assets coexist in this dir.
|
||||
# (The explicit `rm -f` above handles stale `assets/` chunks; see #46.)
|
||||
rsync -a \
|
||||
--exclude=bus-harness.html \
|
||||
--exclude=.gitignore \
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# check-no-jarvis-bus-world-references.sh
|
||||
#
|
||||
# Issue #45 boundary gate.
|
||||
#
|
||||
# The named `WKContentWorld("JarvisBusWorld")` was retired in `fb41c5f` —
|
||||
# the bus message handler and `Injection.js` user script now both register
|
||||
# against `WKContentWorld.page` (the default page world). Old doc-comments
|
||||
# and test fixtures hung onto the name for months afterward; #45 closed
|
||||
# the cleanup.
|
||||
#
|
||||
# This gate prevents new code or comments from re-introducing the literal
|
||||
# string `JarvisBusWorld` outside the historical reference corpora:
|
||||
#
|
||||
# - `.planning/` archived plans / audit reports / handoffs
|
||||
# - `docs/` spec documents that explain the migration
|
||||
# - `node_modules/` vendored deps (out of repo control anyway)
|
||||
# - `.build/`, build/ SwiftPM + Xcode derived data
|
||||
# - `webview/packages/*/dist/` TypeScript build output (regenerated from src)
|
||||
# - this script itself we have to reference the literal to grep for it
|
||||
# - `.git/` git internals
|
||||
#
|
||||
# Fails CI if any other tracked file contains the literal string.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Use `git grep` so we automatically respect .gitignore (so dist/ etc. that
|
||||
# aren't tracked don't trip the gate even if they exist on disk locally).
|
||||
# `--cached` would miss new working-tree files; default mode greps the
|
||||
# working tree filtered by tracked-or-untracked-not-ignored, which is what
|
||||
# we want before a commit.
|
||||
matches=$(git grep --no-color -n "JarvisBusWorld" -- \
|
||||
':!.planning/' \
|
||||
':!docs/' \
|
||||
':!scripts/check-no-jarvis-bus-world-references.sh' \
|
||||
':!webview/packages/*/dist/' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$matches" ]; then
|
||||
echo "[check-no-jarvis-bus-world-references] FAIL: tracked source still references the retired 'JarvisBusWorld' named content world." >&2
|
||||
echo "" >&2
|
||||
echo "$matches" >&2
|
||||
echo "" >&2
|
||||
echo "[check-no-jarvis-bus-world-references] Rewrite these to 'page world' / 'shared world' / 'defensive double-install guard' — see #45 and the JSDoc in webview/packages/bus/src/bridge.ts for the canonical phrasing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[check-no-jarvis-bus-world-references] PASS (no tracked source references the retired named world)"
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"malformedToolCall","firedAt":"2026-05-14T12:00:00Z"}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@jarvis/bus",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -57,27 +57,28 @@ export interface InstallOptions {
|
||||
* - `send(msg)` JSON-encodes and posts to `webkit.messageHandlers.jarvisBus`.
|
||||
* Callers await the returned Promise to observe Swift's reply.
|
||||
*
|
||||
* ## WKContentWorld attach-to-existing path (H-02)
|
||||
* ## Defensive double-install guard (H-02)
|
||||
*
|
||||
* When the HUD bundle runs in the page's default JS world (via
|
||||
* `<script type="module" src="…">`), `webkit.messageHandlers.jarvisBus` is
|
||||
* NOT visible — Swift registered the handler `in: JarvisBusWorld` and
|
||||
* WebKit isolates message handlers per content world. However,
|
||||
* `window.jarvisBus` is a property on the (cross-world-shared) `window`
|
||||
* object, so the bus instance installed by `Injection.js` inside
|
||||
* `JarvisBusWorld` IS observable from the default world.
|
||||
* Both `Injection.js` (a `WKUserScript` injected at `.atDocumentStart`) and
|
||||
* this bundle's `installJarvisBus()` run in the SAME page world — the
|
||||
* default `WKContentWorld.page` since `fb41c5f` retired the prior
|
||||
* isolated named world. Injection.js fires first and stamps a
|
||||
* minimal `window.jarvisBus` with `protocolVersion`, `_handler`, plus the
|
||||
* `send` / `receive` / `onOutbound` surface needed for the handshake.
|
||||
* The bundle's installer fires later (top-level side-effect of `main.tsx`),
|
||||
* and if we naively overwrote `window.jarvisBus` we would orphan
|
||||
* Injection.js's queued early messages and any handshake state already
|
||||
* captured against the original closure.
|
||||
*
|
||||
* If we detect a pre-existing `window.jarvisBus` with the expected
|
||||
* `send` + `onOutbound` + `receive` shape, we attach our options to it
|
||||
* and return it verbatim rather than overwriting it. This routes every
|
||||
* outbound `send()` through the JarvisBusWorld-captured message handler
|
||||
* reference and keeps `onOutbound` hooked to the same `_handler` slot the
|
||||
* Injection.js bus exposes. Without this branch the default-world bundle
|
||||
* replaces `window.jarvisBus` with a bus whose `send()` cannot reach
|
||||
* `webkit.messageHandlers.jarvisBus` and every outbound frame — including
|
||||
* the first `uiReady` — is lost.
|
||||
* The shape check below (`isJarvisBusShape`) is therefore a defensive
|
||||
* double-install guard inside one page world, not a cross-world attach.
|
||||
* When we detect a pre-existing `window.jarvisBus` with the canonical
|
||||
* `send` + `onOutbound` + `receive` surface, we return early and leave
|
||||
* Injection.js's instance in place. Outbound `send()` calls still reach
|
||||
* `window.webkit.messageHandlers.jarvisBus` because that handler lives on
|
||||
* the same page world too.
|
||||
*
|
||||
* bus-harness.html (Phase 2) loads its marker script in a page where
|
||||
* `bus-harness.html` (Phase 2) loads its marker script in a page where
|
||||
* Injection.js has installed the same bus; the harness never called `send()`
|
||||
* so the regression wasn't observed until Phase 3. For that path this
|
||||
* function still returns the pre-installed bus, which is exactly what the
|
||||
@@ -93,10 +94,11 @@ export function installJarvisBus(options: InstallOptions = {}): () => void {
|
||||
console.error("[bus] decode failed:", error, raw);
|
||||
});
|
||||
|
||||
// H-02: attach-to-existing path. Injection.js in JarvisBusWorld installs a
|
||||
// minimal bus with the same surface; if it's already on window we MUST NOT
|
||||
// replace it (that would orphan webkit.messageHandlers.jarvisBus, which is
|
||||
// only visible inside JarvisBusWorld).
|
||||
// H-02: defensive double-install guard. Injection.js runs first (at
|
||||
// document-start in the same page world) and stamps a minimal bus with the
|
||||
// same surface; if it's already on `window`, we MUST NOT replace it — doing
|
||||
// so would orphan Injection.js's queued early messages and captured handshake
|
||||
// state. See the class doc above for the full rationale.
|
||||
const existing: unknown =
|
||||
typeof window !== "undefined"
|
||||
? (window as unknown as { jarvisBus?: unknown }).jarvisBus
|
||||
@@ -156,7 +158,7 @@ export function installJarvisBus(options: InstallOptions = {}): () => void {
|
||||
/**
|
||||
* Duck-type guard for a pre-existing `window.jarvisBus`. Matches the shape
|
||||
* installed by `packages/Bus/Sources/Bus/Resources/Injection.js` at
|
||||
* document-start in JarvisBusWorld. Intentionally narrow: if the shape
|
||||
* document-start in the page world. Intentionally narrow: if the shape
|
||||
* doesn't match (e.g. a left-over test stub), we fall through to the
|
||||
* fresh-install branch and overwrite it.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,14 +74,13 @@ describe("installJarvisBus", () => {
|
||||
expect(b).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// H-02: the HUD bundle (`main.tsx`) runs in the default JS world, but
|
||||
// Swift registered `webkit.messageHandlers.jarvisBus` in JarvisBusWorld,
|
||||
// so a default-world `send()` cannot reach the message handler. The
|
||||
// Injection.js-installed bus in JarvisBusWorld CAN reach it (it captured
|
||||
// the reference at document-start inside that world), and because
|
||||
// `window.jarvisBus` is a cross-world-shared property the default world
|
||||
// observes the Injection.js bus. Therefore `installJarvisBus()` must
|
||||
// attach to — not replace — a pre-existing bus.
|
||||
// H-02: both `Injection.js` (`WKUserScript` at document-start) and this
|
||||
// bundle's `installJarvisBus()` run in the SAME page world since `fb41c5f`
|
||||
// retired the prior isolated named content world. Injection.js fires first
|
||||
// and stamps a minimal `window.jarvisBus`; if `installJarvisBus()` naively
|
||||
// overwrote it, the early handshake state and queued messages captured
|
||||
// against the original closure would be orphaned. Therefore the bundle's
|
||||
// installer must attach to — not replace — a pre-existing bus.
|
||||
describe("H-02 attach-to-existing (pre-installed window.jarvisBus)", () => {
|
||||
it("returns the pre-existing bus unchanged when one is already installed", () => {
|
||||
const preExistingSend = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -96,9 +95,10 @@ describe("installJarvisBus", () => {
|
||||
// @ts-expect-error — test shim: seed a pre-installed bus shape matching Injection.js
|
||||
globalThis.window = {
|
||||
jarvisBus: preExisting,
|
||||
// Note: webkit is intentionally absent — in real JarvisBusWorld the
|
||||
// pre-existing bus captured webkit at install time; the default world
|
||||
// never sees webkit. The fix MUST NOT touch the pre-existing bus.
|
||||
// Note: webkit is intentionally absent — Injection.js's pre-existing
|
||||
// bus captures a reference to `window.webkit` at install time, and the
|
||||
// shape guard must trust the pre-existing bus to route through that
|
||||
// captured reference rather than re-resolving `webkit` here.
|
||||
};
|
||||
|
||||
installJarvisBus();
|
||||
@@ -110,7 +110,7 @@ describe("installJarvisBus", () => {
|
||||
expect(window.jarvisBus.onOutbound).toBe(preExistingOnOutbound);
|
||||
});
|
||||
|
||||
it("routes send() through the pre-existing bus's send (reaches JarvisBusWorld-captured handler)", async () => {
|
||||
it("routes send() through the pre-existing bus's send (reaches Injection.js-captured handler)", async () => {
|
||||
const preExistingSend = vi.fn().mockResolvedValue({ ok: true });
|
||||
const preExisting = {
|
||||
protocolVersion: "2.0.0",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { attachBus } from './bus/client'
|
||||
import './theme/tokens.css'
|
||||
|
||||
// `attachBus()` runs in the page's default JS world (this module loads via
|
||||
// `<script type="module" src="…">` in index.html). Swift installed the real
|
||||
// `window.jarvisBus` inside `JarvisBusWorld` via Injection.js — WebKit
|
||||
// content worlds isolate `webkit.messageHandlers`, so the default-world
|
||||
// bundle can't reach them directly. `window.jarvisBus` IS visible though,
|
||||
// because WebKit keeps `window` as one cross-world object (only *variables*
|
||||
// are world-scoped). The H-02 fix in `@jarvis/bus` detects the pre-existing
|
||||
// `window.jarvisBus` and attaches to it instead of replacing it, which
|
||||
// routes every outbound `send()` back through the JarvisBusWorld-captured
|
||||
// message handler reference. See `packages/bus/src/bridge.ts` for details.
|
||||
// `<script type="module" src="…">` in index.html). Swift's
|
||||
// `WebviewBridge.installScriptHandlers` (since `fb41c5f`) registers the
|
||||
// message handler on the same `WKContentWorld.page`, and Injection.js — also
|
||||
// in the page world — has already stamped a minimal `window.jarvisBus` at
|
||||
// document-start. `installJarvisBus()` here detects that pre-existing
|
||||
// instance via a duck-type shape guard and returns early, leaving the
|
||||
// Injection.js bus untouched so its captured handshake state and queued
|
||||
// early messages are preserved. See `packages/bus/src/bridge.ts` for the
|
||||
// full defensive-double-install rationale.
|
||||
attachBus()
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
|
||||
@@ -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