Compare commits

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

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

Refs design spec §6.

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

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

Refs design spec §4.

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

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

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

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

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

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

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

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

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

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

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

Refs design spec §2.

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

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

Refs design spec §1, §6.

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

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

Refs design spec §6.

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

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

Refs design spec §2-§3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:29:41 -05:00
james.maesandClaude Opus 4.7 5ebe510627 docs: scrub Phase-7 + JarvisBusWorld doc drift (#19, #45) (#195)
* docs(memory): scrub Phase-7 hedge language from MemoryStore (#19)

Audit 2026-05-12 flagged five doc-comment references that mis-described
the implementation state. The cited plans (07-02, 07-03, 07-06) shipped
months ago — the bounded extraction channel, the hybrid FTS5 + vec0 read
path, the ReplayEvent emission, the regression corpus test seams, and
`installMemory`'s replay-sink wiring are all live, not "wires" or
"will replace" promises.

Five touchpoints rewritten to describe the code as it is:

- `MemoryReplaySink` doc: drop the "Plan 07-06" plan reference; the seam
  is just used at startup.
- `MemoryStore` actor doc: replace the "Plan 07-02 wires / Plan 07-03
  wires" sentence with the actual contract (single writer; hybrid search
  through `MemoryReadStore`; `recordRetrieval` emits `memoryRetrieval`).
- Read-path MARK: drop the "Plan 07-03" plan label; keep the
  requirement-ID list (MEM-07 / TEXT-03 / D-02 / D-05) since those are
  still useful for traceability to the audit corpus.
- `recordRetrieval` doc: replace "Mirrors recordMemoryMutation from 07-02"
  with "Mirrors recordMemoryMutation on the write path."
- Test-seams MARK: drop "Plan 07-06" prefix; describe by the test target
  (JARVIS_REAL_MODELS=1 corpus suite) instead.

The 2026-05-11 D-5/D-6 markers and the "Boot policy (changed 2026-05-11)"
doc are preserved unchanged — they document current behaviour, not
historical hedges.

`swift build --package-path packages/Memory` clean.

Closes #19

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(bus): scrub JarvisBusWorld drift + add grep gate (#45)

`fb41c5f` (2026-05-02) unified `Injection.js` and the HUD bundle into the
default `WKContentWorld.page` and retired the prior isolated
`JarvisBusWorld` named world. The doc-comments and test fixtures across
20+ lines kept describing the bug-era topology — pre-existing-bus attach
"because cross-world isolation", message-handler-only-visible-in-named-
world, etc. — for months afterward. Result: a new reader following the
docs would conclude the bus uses two worlds when it actually uses one.

Five rationale rewrites:

- `WebviewBridge.swift` class doc — handler is on the page world, not an
  isolated named world. Page scripts in the loaded HUD bundle address it
  via `window.webkit.messageHandlers.jarvisBus`.
- `Injection.js` header — names the page world explicitly; one-line nod to
  `fb41c5f` for the historical context.
- `bridge.ts` JSDoc + `isJarvisBusShape` rationale — recast the H-02
  attach-to-existing path as a "defensive double-install guard" inside
  ONE page world. Injection.js fires first at document-start; the bundle
  installer fires later as a top-level `main.tsx` side-effect; the guard
  prevents the bundle from clobbering Injection.js's queued early
  messages and captured handshake state. The duck-type `isJarvisBusShape`
  CODE is unchanged — only the comment narrative was misleading.
- `webview/packages/hud/src/main.tsx` — drop the cross-world-isolation
  framing; describe the single-world reality.
- `App/AppDelegate.swift` install comment — page world, not isolated.

Test-side updates (same scrub, plus one latent test bug surfaced):

- `WebviewBridgeTests.test_scriptMessageHandlerIsRegisteredUnderConfigured-
  Name`: was removing from `WKContentWorld.world(name: "JarvisBusWorld")`
  while the bridge registered against `.page` — silent no-op since
  `removeScriptMessageHandler` doesn't throw on absent. Now removes from
  `.page` (the world the bridge actually uses), making the
  "registration-is-evidence-of-success" assertion meaningful again.
- `WebviewBridgeOutboundTests`: comment-only.
- `RealWKWebViewIntegrationTests`: comment-only (forensic narrative kept,
  literal world name replaced with "prior isolated named content world").
- `webview/packages/bus/tests/bridge.test.ts`: H-02 describe-block + one
  it-name rewritten to match the new defensive-double-install rationale.

Infrastructure:

- `scripts/check-no-jarvis-bus-world-references.sh`: new boundary gate.
  Greps for the literal `JarvisBusWorld` in tracked files; excludes
  `.planning/`, `docs/`, `webview/packages/*/dist/`, and this script
  itself. Wired into `CLAUDE.md`'s gate listing; the CI
  `boundary-gates` job auto-discovers all `scripts/check-*.sh`, so no
  workflow edit is needed.
- `webview/packages/bus/package.json`: 0.0.0 → 0.0.1 (per spec —
  comment-only changes still bump patch so consumers can pin).
- `webview/packages/bus/dist/bridge.{js,d.ts}`: rebuilt via `pnpm --filter
  @jarvis/bus build`; NOT committed because `dist/` is not tracked (the
  build phase regenerates on each xcodebuild invocation).

Verified locally:
- `swift build --package-path packages/Bus` clean
- `pnpm --filter @jarvis/bus test` — 37/37 pass
- `bash scripts/check-no-jarvis-bus-world-references.sh` — PASS
- `bash scripts/check-bus-protocol-version.sh` — PASS (v2.3.0)
- `bash scripts/check-bus-harness-parity.sh` — PASS

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:51:03 -05:00
james.maesandClaude Opus 4.7 a121d79174 fix: STT backend constant casing + audioLevel NaN guard (#37, #47) (#196)
* docs(voice): pin STT backend constant identity contract (#37)

The runtime mismatch flagged by #37 was already fixed at the call sites
in `App/AppDelegate.swift:1631-1642` and `:2562-2575` — both routes now
reference `STTBackendSelector.backendSpeechAnalyzer` /
`backendWhisperKit` (snake_case) instead of the pre-2026-05-12 camelCase
string literals. The regression test
`STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
already locks both halves of the contract:

  1. The snake_case constants round-trip through `make(backend:)` without
     hitting the unknown-backend default branch.
  2. The pre-fix camelCase shape (`"whisperKit"`) DOES land in the
     default branch and DOES emit an unknown-backend warning.

So the #37 close action is just to make that contract self-documenting
at the declaration site rather than relying on readers finding the test.
Add a doc-comment to the two `public static let backend*` constants that
explains the wire shape, names the regression test, references Issues
#29 + #37, and warns against renaming without updating `PerTurnSnapshot`
and the test in lockstep.

No code change; `swift test --package-path packages/Voice` continues to
pass (26 tests).

Closes #37

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(bus): clamp non-finite audioLevel rms to 0.0 at encode (#47)

`BusOutbound.audioLevel` carries a `Float rms` that `AudioLevelEmitter`
produces per audio frame (~100 Hz once the consumer is wired up).
Foundation's `JSONEncoder.NonConformingFloatEncodingStrategy` defaults
to `.throw`, so a single NaN — easy to land via a CoreAudio
over-/underflow or an all-zero buffer fed through certain RMS shapes —
would throw inside `WebviewBridge.sendRaw` and tear down the per-frame
bus path. The HUD-side consumer in `webview/packages/hud/src/bus/
client.ts:135` is currently a no-op (the ring shader is still on the
synthetic-sine animation from Plan 03-03), so the bug is LATENT, not
actively firing — but the clamp is cheap, defensive, and pins the
contract before the consumer activates.

Inline clamp at the encode site (`BusOutbound.encode(to:)` switch arm
for `.audioLevel`): replace non-finite floats with `0.0` via
`rms.isFinite ? rms : 0.0` immediately before `container.encode(_:)`.
No refactor of `AudioLevelEmitter` — the per-spec call here is
defensive at the serialization boundary, where every encoder-level
overflow path converges.

Two new unit tests in `CodableRoundTripTests`:
- `test_audioLevel_nanIsClampedToZero` — encodes `.audioLevel(rms: .nan)`,
  decodes, asserts `rms == 0.0` and `rms.isFinite`.
- `test_audioLevel_infinityIsClampedToZero` — companion for ±∞ so a
  future refactor cannot narrow the guard to NaN-only.

`swift test --package-path packages/Bus --filter CodableRoundTripTests`
passes (30 tests, +2 from this commit).

Closes #47

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:50:50 -05:00
james.maes 31f0a8289a docs(handoff): multi-runner shipped + v0.2 wave plan (#192)
Captures the multi-runner expansion (Grogu-jarvis, -2, -3 live),
auto-Release flow proof (v0.1.0-dev.64), Superpowers process
established, and Memory test flake disposition. Lists v0.2 Wave 1
candidates (#19, #45, #46, #37, #178, #47) for next session pickup.

Sibling to docs/HANDOFF-2026-05-13-v0.1-shipped.md.

Refs #180 #188
2026-05-14 09:48:26 -05:00
james.maesandClaude Opus 4.7 439e1ee2f5 ci: build hygiene — webview cleanup + OpenSSF Scorecard (#46, #178) (#194)
* ci(build-webview): sweep stale hashed chunks before rsync (#46)

Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`), so the
active bundle filename rotates on every source/dependency edit. The
existing rsync runs without `--delete` (to preserve `bus-harness.html`
and `.gitignore`), so old chunks accumulated in
`App/Resources/webview/assets/` — observed 9 stale `index-*.js` /
`.js.map` pairs locally before this fix.

Add an explicit `rm -f` of the asset glob (JS + JS map + CSS + CSS map)
before the rsync drops the fresh bundle. The preserved files
(`bus-harness.html`, `index.html`, `.gitignore`) live one level up in
`$DEST`, not in `$DEST/assets`, so the rm cannot reach them.

Verified locally: `bash scripts/build-webview.sh` now leaves exactly one
`index-*.js` + `.js.map` in `assets/` (matching the reference in
`index.html`).

Refs #46

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci(scorecard): add OpenSSF Scorecard workflow + README badge (#178)

Adds `.github/workflows/scorecard.yml` running `ossf/scorecard-action@v2.4.0`
on:
- Weekly cron (Mondays 06:00 UTC) for posture drift detection
- Push to develop for prompt regression visibility
- workflow_dispatch for ad-hoc runs

Permissions follow the canonical Scorecard set (`id-token: write`,
`contents: read`, `security-events: write`) with top-level `read-all` and
job-scoped writes. Results are published to the OpenSSF public dataset
(powers the README badge URL) and uploaded as SARIF to code-scanning.

The workflow consumes no untrusted user inputs in `run:` blocks, so it is
not exposed to workflow-injection patterns documented in
github.blog/security/.../how-to-catch-github-actions-workflow-injections.

README.md gains the standard Scorecard badge next to the existing
license/platform/swift badges.

Refs #178

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:29:48 -05:00
james.maesandClaude Opus 4.7 dced4cb9a3 docs(superpowers): v0.2 Wave 1 sequencing + execution spec (#193)
* docs(handoff): multi-runner shipped + v0.2 wave plan

Captures the multi-runner expansion (Grogu-jarvis, -2, -3 live),
auto-Release flow proof (v0.1.0-dev.64), Superpowers process
established, and Memory test flake disposition. Lists v0.2 Wave 1
candidates (#19, #45, #46, #37, #178, #47) for next session pickup.

Sibling to docs/HANDOFF-2026-05-13-v0.1-shipped.md.

Refs #180 #188

* docs(superpowers): v0.2 Wave 1 sequencing + execution spec

Three-PR plan for #19, #37, #45, #46, #47, #178 grouped by surface
area: doc drift, build hygiene, small code fixes. Landing order is
build hygiene first (lowest blast radius), doc drift second, code
fixes last. Memory-flake disposition documented.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:08:24 -05:00
james.maes 00f1075ea5 fix(agent): bundle assistant text with toolUse + document cache hint dormancy (#185)
Two related AgentCore tactical fixes from the 2026-05-12 audit.

#24 — Assistant text blocks alongside tool_use were dropped from message
history. Opus 4.7 commonly narrates ("Let me check the time for
you...") before calling a tool. On the next round-trip the appended
assistant message contained only the .toolUse block, so the model had
no record of the narration — a user follow-up like "what did you say
before checking the time?" would deny it.

Fix: introduce `assistantTextSinceLastFlush` parallel to
`assistantTextSoFar` (the latter stays cumulative for D-02 vision
routing). On every toolUseRequested, flush the per-flush buffer into a
.text(...) block bundled into the same assistant message as the
.toolUse, trim whitespace-only text to avoid Anthropic 400s on empty
text blocks, and reset the flush buffer so subsequent tool_uses in the
same round get only the text emitted between them. D-02 untouched.

#23 — CacheHints documentation flagged that the eligibility threshold
(4096 chars ≈ 1024 tokens) is structurally false on healthy boot
(preamble ~700 chars, empty initial context). Added a paragraph
explicitly documenting the dormancy, the intentional cost-acceptance
rationale, and the natural recovery once M-4 (v0.8) lands
searchFacts-driven hydration. No code change.

Tests: 3 new tests in AssistantTextWithToolUseTests cover text+toolUse
bundling, toolUse-alone (no spurious empty text block), and
whitespace-only-text rejection. Full AgentCore suite: 248 tests, 0
failures, 1 skipped.

Closes #23, #24
2026-05-14 06:53:35 -05:00
59 changed files with 2195 additions and 101 deletions

No files matched your search

+62
View File
@@ -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
+43 -1
View File
@@ -1,6 +1,8 @@
import Foundation
import AgentCore // EscalationDecision (AgentCore type)
import AgentOrchestrator // BusForwarder, BusForwarderSink
import Bus // OutboundBatcher, BusOutbound, TurnTerminator
import Bus // OutboundBatcher, BusOutbound, TurnTerminator, EscalationDecision (Bus mirror)
import Config // ProviderSelection (AgentCore-side)
/// App-side adapter that wires `BusForwarder` (pure logic, lives in
/// AgentOrchestrator for unit-testability) to `OutboundBatcher` (the
@@ -36,6 +38,21 @@ struct AppBusForwarderSink: BusForwarderSink {
try? await batcher.flushAndSend(.submitRejected(reason: reason))
}
/// Local-first LLM routing Task 7 — translate the AgentCore-side
/// `EscalationDecision` (which carries `Config.ProviderSelection` +
/// `AgentCore.OllamaFailureKind`) into the Bus wire-format
/// `Bus.EscalationDecision` (which mirrors those enums in the
/// dependency-free bridge layer) and flush as `BusOutbound.escalated`.
func sendEscalated(decision: AgentCore.EscalationDecision) async {
let wire = Bus.EscalationDecision(
from: Self.wireProvider(from: decision.from),
to: Self.wireProvider(from: decision.to),
reason: Self.wireFailureKind(from: decision.reason),
firedAt: decision.firedAt
)
try? await batcher.flushAndSend(.escalated(wire))
}
/// 1:1 mapping from `BusForwarder.Terminator` (protocol-agnostic, lives
/// in AgentCore) to `Bus.TurnTerminator` (wire format). Both enums must
/// stay in sync; the cases are deliberately identical so each call site
@@ -49,4 +66,29 @@ struct AppBusForwarderSink: BusForwarderSink {
case .superseded: return .superseded
}
}
/// 1:1 mapping from `Config.ProviderSelection` (Swift-side authority) to
/// `Bus.BusProviderSelection` (wire-format mirror). Bus deliberately
/// does NOT depend on Config — the exhaustive switch is the
/// compile-time drift catcher when either enum adds a case.
static func wireProvider(from p: ProviderSelection) -> BusProviderSelection {
switch p {
case .anthropic: return .anthropic
case .ollama: return .ollama
}
}
/// 1:1 mapping from `AgentCore.OllamaFailureKind` to
/// `Bus.BusOllamaFailureKind`. Same dependency-isolation rationale as
/// `wireProvider`.
static func wireFailureKind(from k: OllamaFailureKind) -> BusOllamaFailureKind {
switch k {
case .streamTruncated: return .streamTruncated
case .malformedToolCall: return .malformedToolCall
case .unknownTool: return .unknownTool
case .refusal: return .refusal
case .connectionFailure: return .connectionFailure
case .emptyResponse: return .emptyResponse
}
}
}
+10 -5
View File
@@ -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
)
}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
<title>Jarvis HUD</title>
<script defer src="./assets/index-ClequpMD.js"></script>
<script defer src="./assets/index-CDTLe5s8.js"></script>
</head>
<body>
<div id="root"></div>
+114
View File
@@ -1,6 +1,54 @@
import SwiftUI
import Keychain
import Shell
import Config
/// Local-first LLM routing (Task 9 / spec §6): three-way picker that maps
/// to `{provider, escalationEnabled}` pairs on `PerTurnSnapshot`.
///
/// | Mode | provider | escalationEnabled |
/// |------------------|------------|--------------------------|
/// | Local-first | .ollama | true |
/// | Anthropic only | .anthropic | false (moot, pinned) |
/// | Ollama only | .ollama | false |
///
/// `escalationEnabled` is "moot" for `.anthropic` because escalation is
/// an Ollama→Anthropic-only fallback path; we normalize it to `false` so
/// the persisted state is unambiguous.
enum ProviderMode: String, CaseIterable, Identifiable {
case localFirst
case anthropicOnly
case ollamaOnly
var id: String { rawValue }
var label: String {
switch self {
case .localFirst: return "Local-first (recommended)"
case .anthropicOnly: return "Anthropic only"
case .ollamaOnly: return "Ollama only"
}
}
static func from(
provider: ProviderSelection,
escalationEnabled: Bool
) -> ProviderMode {
switch (provider, escalationEnabled) {
case (.ollama, true): return .localFirst
case (.anthropic, _): return .anthropicOnly
case (.ollama, false): return .ollamaOnly
}
}
var pair: (provider: ProviderSelection, escalationEnabled: Bool) {
switch self {
case .localFirst: return (.ollama, true)
case .anthropicOnly: return (.anthropic, false)
case .ollamaOnly: return (.ollama, false)
}
}
}
/// Real Settings panel — single window, three rows, every row independently
/// editable. Distinct from the first-run wizard:
@@ -28,6 +76,11 @@ struct SettingsView: View {
/// this still compile.
let onPTTHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
/// Local-first LLM routing (Task 9 / spec §6): optional because the
/// SwiftUI preview/test seams may instantiate the view without a live
/// `ConfigStore`. When nil, the provider-mode section is hidden.
let configStore: ConfigStore?
let onClose: () -> Void
init(
@@ -37,6 +90,7 @@ struct SettingsView: View {
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
configStore: ConfigStore? = nil,
onClose: @escaping () -> Void
) {
self.state = state
@@ -45,6 +99,7 @@ struct SettingsView: View {
self.onGrantInputMonitoring = onGrantInputMonitoring
self.onHotkeyChanged = onHotkeyChanged
self.onPTTHotkeyChanged = onPTTHotkeyChanged
self.configStore = configStore
self.onClose = onClose
}
@@ -61,6 +116,13 @@ struct SettingsView: View {
@State private var pttHotkeyDraft: Shell.KeyboardShortcut?
@State private var pttHotkeyError: String?
/// Local-first LLM routing (Task 9 / spec §6): derived from the
/// `ConfigStore`'s current `PerTurnSnapshot`. Hydrated on `.onAppear`
/// from `configStore.perTurn()`; mutated through the `Picker`.
/// `nil` until hydrated so the picker doesn't flash a wrong default.
@State private var providerMode: ProviderMode = .localFirst
@State private var providerModeHydrated: Bool = false
enum APIKeyStatus: Equatable {
case unchanged
case validating
@@ -76,6 +138,10 @@ struct SettingsView: View {
apiKeySection
Divider()
if configStore != nil {
providerModeSection
Divider()
}
permissionsSection
Divider()
hotkeySection
@@ -99,6 +165,54 @@ struct SettingsView: View {
state.refresh()
hotkeyDraft = state.hotkey
pttHotkeyDraft = state.pttHotkey
// Local-first LLM routing (Task 9): hydrate the picker from
// the live ConfigStore. The `providerModeHydrated` flag
// suppresses the spurious .onChange fire that would otherwise
// immediately write back the (just-read) snapshot.
if let configStore {
Task {
let snap = await configStore.perTurn()
await MainActor.run {
providerMode = ProviderMode.from(
provider: snap.provider,
escalationEnabled: snap.escalationEnabled
)
providerModeHydrated = true
}
}
}
}
}
// MARK: - Provider mode (Local-first LLM routing, Task 9 / spec §6)
private var providerModeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
"Provider",
subtitle: "Local-first runs the agent on Ollama and only reaches Anthropic if Ollama can't handle the turn. Anthropic only / Ollama only pin to a single backend."
)
Picker("Mode", selection: $providerMode) {
ForEach(ProviderMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.menu)
.onChange(of: providerMode) { _, newValue in
// Suppress the initial fire from hydration; only persist
// user-driven edits.
guard providerModeHydrated, let configStore else { return }
let pair = newValue.pair
Task {
let current = await configStore.perTurn()
let next = current.with(
provider: pair.provider,
escalationEnabled: pair.escalationEnabled
)
await configStore.updatePerTurn(next)
}
}
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import AppKit
import SwiftUI
import Keychain
import Shell
import Config
/// Hosts the `SettingsView` in an `NSWindow`. Distinct from the first-run
/// wizard:
@@ -31,7 +32,8 @@ public final class SettingsWindowController {
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
configStore: ConfigStore? = nil
) {
// Refresh truth before view creation: TCC grants and Keychain entries
// can change between sessions, same reasoning as the wizard's
@@ -51,6 +53,7 @@ public final class SettingsWindowController {
onGrantInputMonitoring: onGrantInputMonitoring,
onHotkeyChanged: onHotkeyChanged,
onPTTHotkeyChanged: onPTTHotkeyChanged,
configStore: configStore,
onClose: { [weak self] in self?.close() }
)
let hosting = NSHostingController(rootView: rootView)
@@ -0,0 +1,51 @@
import XCTest
import Config
@testable import Jarvis
/// Local-first LLM routing (Task 9 / spec §6) — asserts the
/// `ProviderMode.from(provider:, escalationEnabled:)` mapping that
/// backs the Settings provider picker:
///
/// .ollama + true → .localFirst
/// .anthropic + * → .anthropicOnly (escalation moot, pinned)
/// .ollama + false → .ollamaOnly
final class ProviderModeTests: XCTestCase {
func test_provider_mode_mapping_canonical() {
XCTAssertEqual(
ProviderMode.from(provider: .ollama, escalationEnabled: true),
.localFirst
)
XCTAssertEqual(
ProviderMode.from(provider: .anthropic, escalationEnabled: false),
.anthropicOnly
)
XCTAssertEqual(
ProviderMode.from(provider: .ollama, escalationEnabled: false),
.ollamaOnly
)
}
/// `escalationEnabled` is moot when provider is `.anthropic` because
/// escalation is an Ollama→Anthropic-only fallback. Both inputs
/// collapse to `.anthropicOnly` so the picker shows a consistent
/// mode regardless of historical/migrated state.
func test_anthropic_with_escalation_true_normalizes_to_anthropic_only() {
XCTAssertEqual(
ProviderMode.from(provider: .anthropic, escalationEnabled: true),
.anthropicOnly
)
}
/// The `pair` accessor must round-trip back to the same mode via
/// `from(...)`. Guards against an asymmetric edit in either direction.
func test_pair_round_trips_through_from() {
for mode in ProviderMode.allCases {
let pair = mode.pair
let roundTripped = ProviderMode.from(
provider: pair.provider,
escalationEnabled: pair.escalationEnabled
)
XCTAssertEqual(roundTripped, mode, "round-trip failed for \(mode)")
}
}
}
+1
View File
@@ -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
+4
View File
@@ -77,6 +77,7 @@
B72852E1EF52C0E6F8170852 /* Memory in Frameworks */ = {isa = PBXBuildFile; productRef = 59F56604A76635EC59D2671B /* Memory */; };
B8F411ADE70402B71887E377 /* VoiceOrchestratorAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590D5A359A5C3E915D274436 /* VoiceOrchestratorAdapter.swift */; };
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */; };
95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B66305B3801453E88D2341F /* ProviderModeTests.swift */; };
BEFC509502C7940A21001F9F /* JarvisHUDPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424DD9447F84F35372A48FF /* JarvisHUDPanel.swift */; };
C336B40332A148A5A1F2DDC8 /* WebviewBundleLoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16929275D5323F505DF12E9 /* WebviewBundleLoadTests.swift */; };
C3917516BFFF438A4AD5CE80 /* BrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */; };
@@ -178,6 +179,7 @@
4BC1E50BF31BB6CCF895FCA3 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = "<group>"; };
4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconController.swift; sourceTree = "<group>"; };
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateEnumTests.swift; sourceTree = "<group>"; };
4B66305B3801453E88D2341F /* ProviderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModeTests.swift; sourceTree = "<group>"; };
511D7B2406F485ED0D4B9E0D /* HudStateIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudStateIntent.swift; sourceTree = "<group>"; };
528FED2A364DE6BD1F2BA20F /* AppBusForwarderSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBusForwarderSink.swift; sourceTree = "<group>"; };
54B2C24637464D380D4450AF /* BootHealthProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootHealthProbes.swift; sourceTree = "<group>"; };
@@ -380,6 +382,7 @@
46CB8B7693CEA625D7331C3E /* HudStateCoordinatorBusWiringTests.swift */,
8B7E85856B679CE0788D38DB /* HudStateCoordinatorTests.swift */,
510B6A716A10A8B7CEEC1D3F /* HudStateEnumTests.swift */,
4B66305B3801453E88D2341F /* ProviderModeTests.swift */,
3527AF52C1E81BAC4EF70766 /* JarvisHUDPanelTests.swift */,
6639284829C6DB4F69ED47F7 /* MCPBusGatewayAdapterTests.swift */,
0B5B16BDAC02919861AA4B0B /* MCPRuntimeWiringTests.swift */,
@@ -1298,6 +1301,7 @@
C9F3B34208084769106C4B07 /* HudStateCoordinatorBusWiringTests.swift in Sources */,
8E4590089A7955545E2146D3 /* HudStateCoordinatorTests.swift in Sources */,
BBD8BBC82E0D3C57E0DB4ED0 /* HudStateEnumTests.swift in Sources */,
95D90BA0F99D46E7A3715F0E /* ProviderModeTests.swift in Sources */,
461A5FACC5A3C2025FB0A233 /* JarvisHUDPanelTests.swift in Sources */,
70D09A556654E11B140AFBD4 /* MCPBusGatewayAdapterTests.swift in Sources */,
5AD556F5095852328E72BE36 /* MCPRuntimeWiringTests.swift in Sources */,
+1
View File
@@ -8,6 +8,7 @@
[![Issues](https://img.shields.io/github/issues/KofTwentyTwo/Jarvis)](https://github.com/KofTwentyTwo/Jarvis/issues)
[![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026%20Tahoe-blue)](https://www.apple.com/macos/)
[![Swift 6](https://img.shields.io/badge/swift-6.0-orange)](https://swift.org)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/Jarvis/badge)](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")
}
}
@@ -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)")
}
}
+75 -1
View File
@@ -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.
+5 -1
View File
@@ -25,7 +25,11 @@ import Foundation
/// `chatSubmit(text:)` + `chatCancelAndSubmit(text:)` (chat-panel submit +
/// barge-in) and additive BusOutbound case `submitRejected(reason:)` (D-10
/// text-path rejection toast, byte-identical body to voice-path HUD banner).
public let BUS_PROTOCOL_VERSION: String = "2.3.0"
///
/// Local-first LLM routing Task 7 — bumped 2.3.0 → 2.4.0 for additive
/// BusOutbound case `escalated(EscalationDecision)`. MINOR per HUD-05
/// (additive; older webviews can still decode the outbound shape).
public let BUS_PROTOCOL_VERSION: String = "2.4.0"
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
@@ -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
+3 -2
View File
@@ -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"
+11
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+25 -23
View File
@@ -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.
*/
+105 -2
View File
@@ -13,9 +13,12 @@
* Plan 09-04 bumped 2.2.0 -> 2.3.0 (additive BusInbound cases chatSubmit +
* chatCancelAndSubmit for chat-panel submit + barge-in; additive BusOutbound
* case submitRejected for D-10 text-path rejection toast).
*
* Local-first LLM routing Task 7 bumped 2.3.0 -> 2.4.0 (additive BusOutbound
* case escalated{decision} for HUD escalation badge).
*/
export const BUS_PROTOCOL_VERSION = "2.3.0";
export const BUS_PROTOCOL_VERSION = "2.4.0";
export type HudState =
| "idle"
@@ -32,6 +35,42 @@ export type TurnTerminator =
| "errored"
| "superseded";
/**
* Provider identity mirror — `Config.ProviderSelection` (Swift) and
* `BusProviderSelection` (Bus). Carried by `EscalationDecision.from` /
* `EscalationDecision.to`.
*/
export type ProviderSelection = "anthropic" | "ollama";
/**
* Detectable Ollama failure modes that trigger one-shot reactive escalation
* to Anthropic. Mirror of `AgentCore.OllamaFailureKind` (Swift) and
* `BusOllamaFailureKind` (Bus). The HUD escalation badge keys reason copy
* off this value.
*/
export type OllamaFailureKind =
| "streamTruncated"
| "malformedToolCall"
| "unknownTool"
| "refusal"
| "connectionFailure"
| "emptyResponse";
/**
* Local-first LLM routing — payload of `BusOutbound.escalated`. Emitted at
* most once per turn when the orchestrator reactively escalates Ollama →
* Anthropic. The HUD renders an EscalationBadge keyed by `reason`.
*
* `firedAt` is an ISO-8601 string (Swift `JSONEncoder` with
* `dateEncodingStrategy = .iso8601`).
*/
export interface EscalationDecision {
from: ProviderSelection;
to: ProviderSelection;
reason: OllamaFailureKind;
firedAt: string;
}
/**
* Local mirror of `Memory.TurnRow` (the backend Swift type) and
* `Bus.TurnRow` (the Swift bus mirror). Bus deliberately does NOT depend on
@@ -64,7 +103,14 @@ export type BusOutbound =
* transient toast. Body string is byte-identical to the voice-path HUD
* banner (single source of truth: RejectReasonCopy.swift).
*/
| { type: "submitRejected"; reason: string };
| { type: "submitRejected"; reason: string }
/**
* Local-first LLM routing Task 7 — additive case carrying an
* `EscalationDecision` for the HUD escalation badge. Emitted at most
* once per turn when the orchestrator reactively escalates Ollama →
* Anthropic.
*/
| { type: "escalated"; decision: EscalationDecision };
export type BusInbound =
| { type: "helloAck"; version: string }
@@ -115,6 +161,20 @@ const TURN_TERMINATORS = [
"superseded",
] as const satisfies readonly TurnTerminator[];
const PROVIDER_SELECTIONS = [
"anthropic",
"ollama",
] as const satisfies readonly ProviderSelection[];
const OLLAMA_FAILURE_KINDS = [
"streamTruncated",
"malformedToolCall",
"unknownTool",
"refusal",
"connectionFailure",
"emptyResponse",
] as const satisfies readonly OllamaFailureKind[];
function isHudState(v: string): v is HudState {
return (HUD_STATES as readonly string[]).includes(v);
}
@@ -123,6 +183,14 @@ function isTurnTerminator(v: string): v is TurnTerminator {
return (TURN_TERMINATORS as readonly string[]).includes(v);
}
function isProviderSelection(v: string): v is ProviderSelection {
return (PROVIDER_SELECTIONS as readonly string[]).includes(v);
}
function isOllamaFailureKind(v: string): v is OllamaFailureKind {
return (OLLAMA_FAILURE_KINDS as readonly string[]).includes(v);
}
/**
* Decode a BusOutbound JSON string. Never throws: malformed input returns
* `{ok: false, error}`. The default branch hits a `const _exhaustive: never`
@@ -237,6 +305,41 @@ export function decodeOutbound(json: string): DecodeResult<BusOutbound> {
case "submitRejected":
if (typeof parsed.reason !== "string") return { ok: false, error: "submitRejected: reason must be string" };
return { ok: true, value: { type: "submitRejected", reason: parsed.reason } };
case "escalated": {
if (!isObject(parsed.decision)) {
return { ok: false, error: "escalated: decision must be an object" };
}
const d = parsed.decision;
if (
typeof d.from !== "string" ||
typeof d.to !== "string" ||
typeof d.reason !== "string" ||
typeof d.firedAt !== "string"
) {
return { ok: false, error: "escalated: invalid decision fields" };
}
if (!isProviderSelection(d.from)) {
return { ok: false, error: `escalated: unknown 'from' provider '${d.from}'` };
}
if (!isProviderSelection(d.to)) {
return { ok: false, error: `escalated: unknown 'to' provider '${d.to}'` };
}
if (!isOllamaFailureKind(d.reason)) {
return { ok: false, error: `escalated: unknown reason '${d.reason}'` };
}
return {
ok: true,
value: {
type: "escalated",
decision: {
from: d.from,
to: d.to,
reason: d.reason,
firedAt: d.firedAt,
},
},
};
}
default: {
// Compile-time exhaustiveness. After all cases above narrow `type`, the
// default branch should see `type: never`. Adding a new BusOutbound
+12 -12
View File
@@ -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) {
+19 -1
View File
@@ -80,14 +80,24 @@ export function attachBus(): void {
let id = store.activeTextPartId
if (!id) {
id = nextTextPartId(turnId, store.chatEvents)
// If an escalation decision arrived before any assistant text
// event (e.g. connectionFailure on the very first response),
// attach it to the freshly-created text part so the badge isn't
// dropped. The pending slot is cleared as a side-effect of the
// store mutation below.
const pending = store.pendingEscalation
store.pushEvent({
kind: 'text',
id,
text: '',
role: 'assistant',
turnId,
...(pending ? { escalation: pending } : {}),
})
useJarvisStore.setState({
activeTextPartId: id,
...(pending ? { pendingEscalation: null } : {}),
})
useJarvisStore.setState({ activeTextPartId: id })
}
store.appendTokenToLastText(id, msg.text)
break
@@ -155,6 +165,14 @@ export function attachBus(): void {
// hydrate (= WARN-INT-2). Until then we acknowledge but no-op so the
// exhaustiveness sentinel below remains tight.
break
case 'escalated':
// Local-first LLM routing Task 8 — attach the EscalationDecision
// to the most-recent assistant text event. If none exists yet (the
// failure fired before the first token), the store parks the
// decision and the next assistant text event inherits it (see
// tokenDelta arm above).
store.attachEscalationToLastAssistantText(msg.decision)
break
default: {
const _exhaustive: never = msg
void _exhaustive
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { EscalationBadge } from './EscalationBadge'
describe('EscalationBadge', () => {
it('renders the failure reason in a friendly form', () => {
render(<EscalationBadge reason="malformedToolCall" />)
expect(screen.getByText(/malformed tool call/i)).toBeInTheDocument()
expect(screen.getByText(/escalated to opus/i)).toBeInTheDocument()
})
it('uses a stable test id for selector use', () => {
render(<EscalationBadge reason="streamTruncated" />)
expect(screen.getByTestId('escalation-badge')).toBeInTheDocument()
})
it('renders all six failure kinds without throwing', () => {
const reasons = [
'streamTruncated',
'malformedToolCall',
'unknownTool',
'refusal',
'connectionFailure',
'emptyResponse',
] as const
for (const r of reasons) {
render(<EscalationBadge reason={r} />)
}
// If any reason was not mapped, render would have thrown.
expect(screen.getAllByTestId('escalation-badge').length).toBe(reasons.length)
})
})
@@ -0,0 +1,33 @@
import type { OllamaFailureKind } from '@jarvis/bus'
/**
* EscalationBadge — small inline marker rendered inside an assistant
* message when the orchestrator reactively escalated that turn from the
* local Ollama provider to Anthropic Opus. The reason string is the
* `OllamaFailureKind` carried on `BusOutbound.escalated.decision.reason`;
* the friendly map below covers all six kinds defined in
* `packages/bus/src/protocol.ts` and `AgentCore.OllamaFailureKind`.
*
* Visual styling (`.escalation-badge` in `chat-panel.css`) follows the
* existing chip pattern — small font, low-contrast amber accent, unobtrusive.
*/
const FRIENDLY: Record<OllamaFailureKind, string> = {
streamTruncated: 'stream truncated',
malformedToolCall: 'malformed tool call',
unknownTool: 'unknown tool',
refusal: 'refusal',
connectionFailure: 'connection failure',
emptyResponse: 'empty response',
}
export function EscalationBadge({ reason }: { reason: OllamaFailureKind }) {
return (
<span
data-testid="escalation-badge"
className="escalation-badge"
role="status"
>
Escalated to Opus — {FRIENDLY[reason]}
</span>
)
}
@@ -1,4 +1,5 @@
import type { ChatEvent } from '../store/types'
import { EscalationBadge } from './EscalationBadge'
type TextEvent = Extract<ChatEvent, { kind: 'text' }>
@@ -9,6 +10,10 @@ type TextEvent = Extract<ChatEvent, { kind: 'text' }>
* output cannot inject markup here (T-03-31). Parent ChatPanel uses
* `key={event.id}` so React reuses this DOM node across streaming updates;
* only the text span's textContent diffs as tokens append.
*
* When the bus dispatcher attached an `EscalationDecision` to this event
* (local-first LLM routing Task 8), an inline EscalationBadge is rendered
* as a footer affordance under the message body.
*/
export function TextPart({ event }: { event: TextEvent }) {
return (
@@ -21,6 +26,7 @@ export function TextPart({ event }: { event: TextEvent }) {
{event.role === 'user' ? 'You' : 'Jarvis'}
</span>
<span className="chat-panel__text-content">{event.text}</span>
{event.escalation && <EscalationBadge reason={event.escalation.reason} />}
</div>
)
}
@@ -43,6 +43,23 @@
color: rgba(255, 200, 200, 0.92);
}
/* Escalation badge — rendered as a footer chip inside an assistant text
* event when the orchestrator escalated that turn from Ollama to
* Anthropic. Amber accent so it reads as informational, not error. */
.escalation-badge {
align-self: flex-start;
margin-top: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(245, 166, 35, 0.10);
border: 1px solid rgba(245, 166, 35, 0.32);
color: rgba(245, 200, 130, 0.92);
font-size: 11px;
text-transform: none;
letter-spacing: 0.02em;
}
/* Tool call card */
.tool-call-card {
+9 -9
View File
@@ -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')
+39 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import type { EscalationDecision } from '@jarvis/bus'
import type {
A11y,
ChatEvent,
@@ -26,6 +27,14 @@ export interface JarvisState {
* correct text-part after the interruption.
*/
activeTextPartId: string | null
/**
* Local-first LLM routing (Task 8): when `BusOutbound.escalated`
* arrives before any assistant text event exists for the current turn
* (e.g. connectionFailure on the very first response), the decision is
* parked here and stamped onto the *first* assistant text event created
* by a subsequent tokenDelta. Cleared on `beginTurn` and after consumption.
*/
pendingEscalation: EscalationDecision | null
a11y: A11y
theme: Theme
connection: Connection
@@ -45,6 +54,17 @@ export interface JarvisState {
) => void
beginTurn: (id: string) => void
endTurn: () => void
/**
* Local-first LLM routing (Task 8): attach an EscalationDecision to the
* most-recent assistant text event. Called from the bus dispatcher when
* `BusOutbound.escalated` arrives. No-op if no assistant text event
* exists yet — escalation may fire before any token streamed (e.g.
* connectionFailure/refusal on the first response). The Anthropic
* stream that follows the escalation will produce assistant text whose
* tokenDeltas land *after* this call; for that case the badge is
* surfaced on the subsequent text event via a small pending hand-off.
*/
attachEscalationToLastAssistantText: (decision: EscalationDecision) => void
setA11y: (patch: Partial<A11y>) => void
setTheme: (patch: Partial<Theme>) => void
setConnection: (c: Connection) => void
@@ -56,6 +76,7 @@ export const useJarvisStore = create<JarvisState>()(
chatEvents: [],
currentTurnId: null,
activeTextPartId: null,
pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#4FC3F7' },
connection: 'booting',
@@ -111,10 +132,27 @@ export const useJarvisStore = create<JarvisState>()(
return { chatEvents: next }
}),
beginTurn: (id) => set({ currentTurnId: id, activeTextPartId: null }),
beginTurn: (id) =>
set({ currentTurnId: id, activeTextPartId: null, pendingEscalation: null }),
endTurn: () => set({ currentTurnId: null, activeTextPartId: null }),
attachEscalationToLastAssistantText: (decision) =>
set((state) => {
// Walk from the tail to find the most-recent assistant text event.
for (let i = state.chatEvents.length - 1; i >= 0; i--) {
const ev = state.chatEvents[i]
if (ev && ev.kind === 'text' && ev.role === 'assistant') {
const next = state.chatEvents.slice()
next[i] = { ...ev, escalation: decision }
return { chatEvents: next, pendingEscalation: null }
}
}
// No assistant text yet — park the decision; the next assistant
// text event created by tokenDelta inherits it.
return { pendingEscalation: decision }
}),
setA11y: (patch) =>
set((state) => ({ a11y: { ...state.a11y, ...patch } })),
+16 -2
View File
@@ -1,4 +1,4 @@
import type { HudState } from '@jarvis/bus'
import type { EscalationDecision, HudState } from '@jarvis/bus'
export type { HudState }
@@ -9,8 +9,22 @@ export type ToolCallStatus =
| 'completed'
| 'failed'
/**
* Local-first LLM routing (Task 8): when the orchestrator reactively
* escalates the active Ollama turn to Anthropic, the bus emits
* `BusOutbound.escalated`; the dispatcher attaches the decision to the
* most-recent assistant text event so the renderer can stamp an inline
* EscalationBadge under that message body.
*/
export type ChatEvent =
| { id: string; kind: 'text'; text: string; role: 'user' | 'assistant'; turnId: string }
| {
id: string
kind: 'text'
text: string
role: 'user' | 'assistant'
turnId: string
escalation?: EscalationDecision
}
| {
id: string
kind: 'tool-call'
@@ -10,6 +10,9 @@ function resetStore() {
useJarvisStore.setState({
hudState: 'booting',
chatEvents: [],
currentTurnId: null,
activeTextPartId: null,
pendingEscalation: null,
a11y: { reduceMotion: false, reduceTransparency: false },
theme: { arcReactorGlow: '#1E88E5' },
connection: 'booting',
@@ -89,6 +92,55 @@ describe('bus dispatcher — submitRejected pushes error event (B5)', () => {
})
})
describe('bus dispatcher — escalated attaches to last assistant text (Task 8)', () => {
beforeEach(() => {
resetStore()
installWebkitMock()
clearBus()
})
it('attaches the decision to the most-recent assistant text event', () => {
attachBus()
// Synthesize a turn with an assistant text event in flight.
window.jarvisBus.receive('{"type":"turnStarted","id":"t1"}')
window.jarvisBus.receive('{"type":"tokenDelta","text":"partial..."}')
window.jarvisBus.receive(
'{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"streamTruncated","firedAt":"2026-05-14T16:00:00Z"}}',
)
const events = useJarvisStore.getState().chatEvents
const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
expect(textEv).toBeDefined()
if (textEv && textEv.kind === 'text') {
expect(textEv.escalation?.reason).toBe('streamTruncated')
expect(textEv.escalation?.from).toBe('ollama')
expect(textEv.escalation?.to).toBe('anthropic')
}
})
it('parks the decision when no assistant text exists yet, then stamps the next text event', () => {
attachBus()
window.jarvisBus.receive('{"type":"turnStarted","id":"t2"}')
// Escalation fires before any tokenDelta (e.g. connectionFailure).
window.jarvisBus.receive(
'{"type":"escalated","decision":{"from":"ollama","to":"anthropic","reason":"connectionFailure","firedAt":"2026-05-14T16:00:00Z"}}',
)
// No text event yet; decision is parked.
expect(useJarvisStore.getState().chatEvents.length).toBe(0)
expect(useJarvisStore.getState().pendingEscalation?.reason).toBe('connectionFailure')
// First post-escalation tokenDelta creates the text event and inherits the badge.
window.jarvisBus.receive('{"type":"tokenDelta","text":"hello from opus"}')
const events = useJarvisStore.getState().chatEvents
const textEv = events.find((e) => e.kind === 'text' && e.role === 'assistant')
expect(textEv).toBeDefined()
if (textEv && textEv.kind === 'text') {
expect(textEv.escalation?.reason).toBe('connectionFailure')
}
// Pending slot was consumed.
expect(useJarvisStore.getState().pendingEscalation).toBe(null)
})
})
describe('bus dispatcher — decode errors surface (B2)', () => {
beforeEach(() => {
resetStore()