fix(vision): v0.1 cluster — T2 honesty + pendingFrame leak + TCC revoke observation #152
No files matched your search
@@ -489,6 +489,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
/// graceful denial). Drains `CameraCapture.degradationStream`.
|
||||
var cameraDegradationTask: Task<Void, Never>?
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — observer for `NSApplication.didBecomeActive`.
|
||||
/// macOS does not publish an `AVCaptureDevice.authorizationStatus`
|
||||
/// change notification, so we re-check the status whenever the app
|
||||
/// returns to the foreground. If the status flipped from
|
||||
/// non-authorized → `.authorized` (user granted via System Settings
|
||||
/// while the app was running), we call `becameAuthorized()` on the
|
||||
/// camera capture so presence + frame-attach come back online without a
|
||||
/// restart.
|
||||
var cameraAuthRecheckObserver: NSObjectProtocol?
|
||||
|
||||
/// Builds + starts the vision subsystem. Spawned BEFORE `agentInstallTask`
|
||||
/// because the orchestrator constructor takes `visionRouter` and the
|
||||
/// broadcaster's frame-attach release subscriber needs
|
||||
@@ -1202,6 +1212,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
memoryInstallTask?.cancel()
|
||||
visionInstallTask?.cancel()
|
||||
cameraDegradationTask?.cancel()
|
||||
if let obs = cameraAuthRecheckObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
cameraAuthRecheckObserver = nil
|
||||
}
|
||||
if let monitor = presenceMonitor {
|
||||
Task { await monitor.cancel() }
|
||||
}
|
||||
@@ -1895,6 +1909,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
availableTools: [],
|
||||
availableToolsResolver: toolCatalogResolver,
|
||||
visionRouter: self.visionRouter,
|
||||
// Audit 2026-05-12 F-V1: `hasRealT2Provider: false` because
|
||||
// `installVision` wires `MissingT2Provider` (the real T2 sidecar
|
||||
// is v1.1 work, GH issue #84). Without this gate, every low-
|
||||
// confidence local T1 response escalated into MissingT2Provider
|
||||
// and died with `t2ProviderUnavailable`. Flip to `true` only when
|
||||
// a real T2 LLMProvider replaces `MissingT2Provider` above.
|
||||
hasRealT2Provider: false,
|
||||
presenceSnapshot: PresenceStateSnapshot.shared,
|
||||
sessionHistoryLookup: sessionHistoryLookup
|
||||
)
|
||||
@@ -2357,11 +2378,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
await self.tryPhraseAttachIfMatch(text)
|
||||
let outcome: SubmitOutcome
|
||||
let hadImage: Bool
|
||||
if let imageBlock = await self.consumePendingFrameIfAny(userText: text) {
|
||||
hadImage = true
|
||||
outcome = await orch.submit(.withImages(.text, text: text, images: [imageBlock]))
|
||||
} else {
|
||||
hadImage = false
|
||||
outcome = await orch.submit(.text(text))
|
||||
}
|
||||
// Audit 2026-05-12 F-V2: on `.rejected` no turn is allocated, so the
|
||||
// broadcaster's `.turnEnd` watcher will never fire
|
||||
// `onAssistantTurnComplete()`. Release the pending frame explicitly so
|
||||
// the JPEG bytes don't sit in actor memory past the D-15 window.
|
||||
if hadImage, case .rejected = outcome {
|
||||
await self.frameAttachController?.releaseAfterRejectedSubmit()
|
||||
}
|
||||
await self.appendUserTextIfRunning(outcome, text: text)
|
||||
await self.handleTextOutcome(outcome)
|
||||
}
|
||||
@@ -2382,11 +2413,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
await self.tryPhraseAttachIfMatch(text)
|
||||
let outcome: SubmitOutcome
|
||||
let hadImage: Bool
|
||||
if let imageBlock = await self.consumePendingFrameIfAny(userText: text) {
|
||||
hadImage = true
|
||||
outcome = await orch.cancelAndSubmit(.withImages(.text, text: text, images: [imageBlock]))
|
||||
} else {
|
||||
hadImage = false
|
||||
outcome = await orch.cancelAndSubmit(.text(text))
|
||||
}
|
||||
// Audit 2026-05-12 F-V2: see handleChatSubmit — same release-on-rejected
|
||||
// path applies to the barge-in handler.
|
||||
if hadImage, case .rejected = outcome {
|
||||
await self.frameAttachController?.releaseAfterRejectedSubmit()
|
||||
}
|
||||
await self.appendUserTextIfRunning(outcome, text: text)
|
||||
await self.handleTextOutcome(outcome)
|
||||
}
|
||||
@@ -2588,11 +2627,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
// 8. Audit 2026-05-12 F-V3 — TCC re-grant detection via foreground
|
||||
// re-check. macOS does not surface an `AVCaptureDevice` auth
|
||||
// status change notification, so we observe
|
||||
// `NSApplication.didBecomeActive` and compare current status
|
||||
// against the last-seen value. On a `non-authorized → authorized`
|
||||
// flip we call `capture.becameAuthorized()` to bring the camera
|
||||
// session back online. Skip under XCTest (no AppKit run loop).
|
||||
if !Self.isRunningAsTestHost {
|
||||
installCameraAuthRecheckObserver(capture: capture)
|
||||
}
|
||||
|
||||
systemLogger?.info(
|
||||
"installVision: presence + VisionRouter + FrameAttachController wired (T2 sidecar deferred)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — last camera TCC status observed during
|
||||
/// foreground re-checks. Updated only by
|
||||
/// `handleCameraAuthRecheck(capture:)` which runs on the main actor; the
|
||||
/// observer closure dispatches there so this property stays
|
||||
/// MainActor-isolated.
|
||||
private var lastCameraAuthStatus: AVAuthorizationStatus?
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — install the foreground TCC re-check observer.
|
||||
/// macOS does not surface a camera authorization-change notification, so
|
||||
/// we observe `NSApplication.didBecomeActive` and compare the current
|
||||
/// status against the last-seen value. On a `non-authorized → authorized`
|
||||
/// flip we call `capture.becameAuthorized()`.
|
||||
@MainActor
|
||||
private func installCameraAuthRecheckObserver(capture: CameraCapture) {
|
||||
lastCameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthRecheckObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
// The block lands on `.main`; dispatch to MainActor for property
|
||||
// access. `weak self` keeps the AppDelegate retain cycle clean.
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.handleCameraAuthRecheck(capture: capture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreground TCC re-check body. Invoked from
|
||||
/// `installCameraAuthRecheckObserver`'s notification closure.
|
||||
@MainActor
|
||||
func handleCameraAuthRecheck(capture: CameraCapture) async {
|
||||
let current = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
let previous = lastCameraAuthStatus
|
||||
lastCameraAuthStatus = current
|
||||
guard previous != .authorized, current == .authorized else { return }
|
||||
systemLogger?.info(
|
||||
"installVision: foreground re-check detected camera permission grant — calling becameAuthorized()"
|
||||
)
|
||||
do {
|
||||
try await capture.becameAuthorized()
|
||||
} catch {
|
||||
systemLogger?.warning(
|
||||
"installVision: becameAuthorized() after re-grant threw \(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test-host detection
|
||||
|
||||
/// Detects whether this process is hosting an XCTest bundle. XCTest sets
|
||||
|
||||
@@ -91,6 +91,18 @@ public actor AgentOrchestrator {
|
||||
/// who care about that pre-route through their own dispatcher).
|
||||
private let visionRouter: VisionRouter?
|
||||
|
||||
/// Audit 2026-05-12 F-V1 — honest T2 availability flag. The orchestrator
|
||||
/// previously derived `t2AvailableForThisTurn = (decision.tier != .t3Cloud)`,
|
||||
/// passing `true` on every local-tier vision turn. In production T2 is
|
||||
/// `MissingT2Provider` (no real T2 sidecar exists yet — that's v1.1, GH
|
||||
/// issue #84). Combined with the router's escalate-on-low-confidence
|
||||
/// heuristic, every short / "I'm not sure" T1 response escalated into
|
||||
/// `MissingT2Provider.stream(...)` which throws `t2ProviderUnavailable`,
|
||||
/// killing the turn. Until a real T2 provider is wired, production passes
|
||||
/// `false`; tests that exercise T1→T2 escalation pass `true` and supply a
|
||||
/// real mock T2 provider.
|
||||
private let hasRealT2Provider: Bool
|
||||
|
||||
/// Plan 09-03 / D-13 + D-14 — read-only ambient presence snapshot.
|
||||
/// `runTurn` queries `currentEnrichment()` during system-prompt
|
||||
/// composition and appends a plain sentence (e.g., "User is at the
|
||||
@@ -168,6 +180,7 @@ public actor AgentOrchestrator {
|
||||
availableTools: [ToolSchema] = [],
|
||||
availableToolsResolver: (@Sendable () async -> [ToolSchema])? = nil,
|
||||
visionRouter: VisionRouter? = nil,
|
||||
hasRealT2Provider: Bool = false,
|
||||
presenceSnapshot: PresenceStateSnapshot? = nil,
|
||||
sessionHistoryLookup: @escaping SessionHistoryLookup = AgentOrchestrator.emptySessionHistoryLookup,
|
||||
degradationSummary: String? = nil
|
||||
@@ -181,6 +194,7 @@ public actor AgentOrchestrator {
|
||||
self.availableToolsList = availableTools
|
||||
self.availableToolsResolver = availableToolsResolver
|
||||
self.visionRouter = visionRouter
|
||||
self.hasRealT2Provider = hasRealT2Provider
|
||||
self.presenceSnapshot = presenceSnapshot
|
||||
self.sessionHistoryLookup = sessionHistoryLookup
|
||||
self.degradationSummary = degradationSummary
|
||||
@@ -314,11 +328,16 @@ public actor AgentOrchestrator {
|
||||
explicitCloudOptIn: cloudOptIn
|
||||
)
|
||||
provider = await router.providerForTier(decision.tier)
|
||||
// T2 is available iff we routed local — D-04 ships with
|
||||
// t2Provider == t1Provider, but evaluatePostResponse correctly
|
||||
// stays-on-T1 when t2Available == false, so passing the actual
|
||||
// tier here keeps the contract honest.
|
||||
t2AvailableForThisTurn = (decision.tier != .t3Cloud)
|
||||
// Audit 2026-05-12 F-V1: T2 is available iff we routed local AND
|
||||
// a real T2 provider is wired. Production passes
|
||||
// `hasRealT2Provider: false` because AppDelegate wires
|
||||
// `MissingT2Provider` (the real T2 sidecar is v1.1 work, GH
|
||||
// issue #84). Without the AND-gate, every low-confidence local
|
||||
// T1 response escalated into `MissingT2Provider.stream(...)`
|
||||
// which throws `t2ProviderUnavailable`, killing the turn. The
|
||||
// router's `evaluatePostResponse(..., t2Available: false)`
|
||||
// correctly stays-on-T1 in that case.
|
||||
t2AvailableForThisTurn = (decision.tier != .t3Cloud) && hasRealT2Provider
|
||||
imageBearingTurns.insert(turnId)
|
||||
}
|
||||
|
||||
|
||||
+62
-4
@@ -40,6 +40,7 @@ final class AgentOrchestratorVisionDispatchTests: XCTestCase {
|
||||
private func buildOrchestrator(
|
||||
provider mock: any LLMProvider,
|
||||
visionRouter: VisionRouter? = nil,
|
||||
hasRealT2Provider: Bool = false,
|
||||
dispatcher: any ToolDispatcher = StubToolDispatcher()
|
||||
) async throws -> (AgentOrchestrator, ReplayLog, SessionID) {
|
||||
let replay = try ReplayLog(databaseURL: tempHome.dbURL)
|
||||
@@ -52,7 +53,8 @@ final class AgentOrchestratorVisionDispatchTests: XCTestCase {
|
||||
sessionId: session,
|
||||
systemPrompt: "you are jarvis",
|
||||
availableTools: [],
|
||||
visionRouter: visionRouter
|
||||
visionRouter: visionRouter,
|
||||
hasRealT2Provider: hasRealT2Provider
|
||||
)
|
||||
return (orch, replay, session)
|
||||
}
|
||||
@@ -163,9 +165,14 @@ final class AgentOrchestratorVisionDispatchTests: XCTestCase {
|
||||
.messageStop,
|
||||
]))
|
||||
let router = makeRouter(t1: t1Mock, t2: t2Mock)
|
||||
// D-04 ships with t1Provider == t2Provider in production; this test
|
||||
// uses distinct providers to verify the swap actually occurs.
|
||||
let (orch, _, _) = try await buildOrchestrator(provider: t1Mock, visionRouter: router)
|
||||
// Production today wires `MissingT2Provider` (no real T2 sidecar
|
||||
// yet); this test passes `hasRealT2Provider: true` and a real mock
|
||||
// T2 to exercise the escalation path.
|
||||
let (orch, _, _) = try await buildOrchestrator(
|
||||
provider: t1Mock,
|
||||
visionRouter: router,
|
||||
hasRealT2Provider: true
|
||||
)
|
||||
|
||||
let input = TurnInput.withImages(.text, text: "what is this", images: [Self.fakeImage])
|
||||
let outcome = await orch.submit(input)
|
||||
@@ -245,6 +252,57 @@ final class AgentOrchestratorVisionDispatchTests: XCTestCase {
|
||||
XCTAssertFalse(textHad, "text-only turn should NOT be tracked")
|
||||
}
|
||||
|
||||
/// Audit 2026-05-12 F-V1 regression — when only `MissingT2Provider` is
|
||||
/// wired (production today), a low-confidence T1 response must NOT
|
||||
/// escalate to T2. Without the `hasRealT2Provider: false` gate, the
|
||||
/// orchestrator would swap to `MissingT2Provider`, whose stream throws
|
||||
/// `t2ProviderUnavailable`, and the turn would die with an error event.
|
||||
func testLowConfidenceT1StaysOnT1WhenT2ProviderIsMissing() async throws {
|
||||
// T1 returns a low-confidence answer that would trigger escalation if
|
||||
// T2 were available.
|
||||
let t1Mock = MockLLMProvider(script: .init(events: [
|
||||
.messageStart(LLMMessageStart(messageId: "m_t1", model: "gemma4:31b", usagePrefix: nil)),
|
||||
.textDelta("I'm not sure"),
|
||||
.stopReason(.endTurn),
|
||||
.messageStop,
|
||||
]))
|
||||
// Production wiring: T2 is `MissingT2Provider`. If reached its stream
|
||||
// throws `VisionError.t2ProviderUnavailable`.
|
||||
let router = VisionRouter(
|
||||
t1Provider: t1Mock,
|
||||
t2Provider: MissingT2Provider(),
|
||||
t3Provider: t1Mock
|
||||
)
|
||||
// Default `hasRealT2Provider: false` mirrors AppDelegate.installVision.
|
||||
let (orch, _, _) = try await buildOrchestrator(provider: t1Mock, visionRouter: router)
|
||||
|
||||
let input = TurnInput.withImages(.text, text: "what is this", images: [Self.fakeImage])
|
||||
let outcome = await orch.submit(input)
|
||||
guard case .ran(let turnId) = outcome else { return XCTFail("expected .ran") }
|
||||
|
||||
let events = await collectUntilIdle(orch: orch)
|
||||
|
||||
// T1 was called exactly once — escalation did NOT fire, so
|
||||
// MissingT2Provider was never streamed.
|
||||
let t1Calls = await t1Mock.getRecordedCalls()
|
||||
XCTAssertEqual(t1Calls.count, 1, "T1 should be the only provider invoked when hasRealT2Provider is false")
|
||||
|
||||
// Exactly one .turnEnd — no error event.
|
||||
let turnEnds = events.compactMap { event -> TurnID? in
|
||||
if case .turnEnd(let id, _) = event { return id }
|
||||
return nil
|
||||
}
|
||||
XCTAssertEqual(turnEnds.count, 1, "exactly one turnEnd; the turn must not die with a provider error")
|
||||
XCTAssertEqual(turnEnds.first, turnId)
|
||||
|
||||
// No error event surfaced.
|
||||
let errored = events.contains { event in
|
||||
if case .error = event { return true }
|
||||
return false
|
||||
}
|
||||
XCTAssertFalse(errored, "no error event should surface — MissingT2Provider must never be reached")
|
||||
}
|
||||
|
||||
/// Test 6 — escalationAttempt is a distinct ReplayEvent case from
|
||||
/// streamTruncatedRetry (the AGENT-09 retry path). Pattern matching on
|
||||
/// each is mutually exclusive.
|
||||
|
||||
@@ -29,6 +29,11 @@ public actor CameraCapture {
|
||||
private var videoOutput: AVCaptureVideoDataOutput?
|
||||
private var photoOutput: AVCapturePhotoOutput?
|
||||
private var runtimeErrorObserver: NSObjectProtocol?
|
||||
/// Audit 2026-05-12 F-V3 — observer for `AVCaptureSessionWasInterrupted`.
|
||||
/// macOS surfaces mid-session Camera TCC revoke via the interruption
|
||||
/// notification, NOT `RuntimeError`. Without this observer the revoke
|
||||
/// arrives silently and the `cameraRevoked` HUD banner never fires.
|
||||
private var interruptionObserver: NSObjectProtocol?
|
||||
|
||||
/// Test seam — defaults to AVCaptureDevice.authorizationStatus(for: .video).
|
||||
/// Tests inject a closure to simulate denied/notDetermined/authorized.
|
||||
@@ -100,6 +105,10 @@ public actor CameraCapture {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
runtimeErrorObserver = nil
|
||||
}
|
||||
if let obs = interruptionObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
interruptionObserver = nil
|
||||
}
|
||||
session?.stopRunning()
|
||||
videoSampleDelegate?.finishAllStreams()
|
||||
videoSampleDelegate = nil
|
||||
@@ -237,6 +246,42 @@ public actor CameraCapture {
|
||||
localLogger.warning("CameraCapture mid-session error: \(err)")
|
||||
cont.yield(.midSessionRevoked)
|
||||
}
|
||||
// Audit 2026-05-12 F-V3 — observe `AVCaptureSessionWasInterrupted`.
|
||||
// macOS surfaces TCC revoke + device-stolen-by-other-client events
|
||||
// through the interruption notification, NOT through RuntimeError.
|
||||
//
|
||||
// Important macOS quirk: `AVCaptureSessionInterruptionReasonKey` and
|
||||
// the `AVCaptureSession.InterruptionReason` enum are
|
||||
// **API_UNAVAILABLE(macos)** (iOS / macCatalyst / tvOS / visionOS
|
||||
// only — see AVCaptureSession.h, macOS SDK 26). So we cannot
|
||||
// discriminate by reason here. Any interruption on macOS means the
|
||||
// session can't continue capturing — we surface the revoked banner
|
||||
// unconditionally. The most common causes are user-revoke via System
|
||||
// Settings and device-in-use-by-another-process; both warrant the
|
||||
// banner.
|
||||
interruptionObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVCaptureSession.wasInterruptedNotification,
|
||||
object: session,
|
||||
queue: nil
|
||||
) { _ in
|
||||
let localLogger = Logger(label: label)
|
||||
localLogger.warning("CameraCapture mid-session interrupted")
|
||||
cont.yield(.midSessionRevoked)
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — test seam for the mid-session interruption
|
||||
/// path. Posts an `AVCaptureSession.wasInterruptedNotification` against
|
||||
/// the live session; the registered observer yields `.midSessionRevoked`.
|
||||
/// Routes through the SAME code path as a real OS-fired notification,
|
||||
/// so a passing test proves the production observer is wired correctly.
|
||||
/// Throws when `open()` hasn't built a session yet.
|
||||
internal func simulateInterruption() throws {
|
||||
guard let session else { throw VisionError.sessionNotRunning }
|
||||
NotificationCenter.default.post(
|
||||
name: AVCaptureSession.wasInterruptedNotification,
|
||||
object: session
|
||||
)
|
||||
}
|
||||
|
||||
private static func liveAuthStatus() -> AVAuthorizationStatus {
|
||||
|
||||
@@ -134,6 +134,19 @@ public actor FrameAttachController {
|
||||
discardFrame()
|
||||
}
|
||||
|
||||
/// Release the pending frame when `confirmSend` returned a block but the
|
||||
/// orchestrator rejected the submit (no turn was allocated, so the
|
||||
/// broadcaster's `.turnEnd` watcher will never fire `onAssistantTurnComplete`).
|
||||
/// Without this hook the JPEG bytes would sit in actor memory until the
|
||||
/// next `requestAttach` overwrites the slot — a privacy regression on the
|
||||
/// D-15 byte-release window. Routes through the SOLE emission site
|
||||
/// (`discardFrame`) so the single-emission-site invariant is preserved.
|
||||
/// Idempotent — safe to call when no frame is pending.
|
||||
public func releaseAfterRejectedSubmit() {
|
||||
pendingTimeoutTask?.cancel()
|
||||
discardFrame()
|
||||
}
|
||||
|
||||
// MARK: - SOLE EMISSION SITE — D-15
|
||||
|
||||
/// D-15 single emission site. The ONLY place in this file where the
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import XCTest
|
||||
import AVFoundation
|
||||
@testable import JarvisVision
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — mid-session TCC revoke observation.
|
||||
///
|
||||
/// Before the fix, `CameraCapture.startWatchers()` only observed
|
||||
/// `AVCaptureSessionRuntimeError`. macOS Sequoia / 26 Tahoe surface
|
||||
/// mid-session Camera TCC revoke via `AVCaptureSessionWasInterrupted`,
|
||||
/// not `RuntimeError`. Without an interruption observer, a user revoking
|
||||
/// camera access via System Settings while the app was running never
|
||||
/// drove the `cameraRevoked` HUD banner — the producer signal never fired.
|
||||
///
|
||||
/// **macOS quirk:** `AVCaptureSessionInterruptionReasonKey` and the
|
||||
/// associated `InterruptionReason` enum are `API_UNAVAILABLE(macos)` per
|
||||
/// AVFoundation headers (iOS / macCatalyst / tvOS / visionOS only). So
|
||||
/// on macOS the observer cannot discriminate reasons; any interruption
|
||||
/// means we can't capture, and the user should see the revoked banner.
|
||||
final class CameraCaptureMidSessionRevokeTests: XCTestCase {
|
||||
|
||||
/// Posting an `AVCaptureSessionWasInterrupted` against the live session
|
||||
/// must drive the degradation stream to yield `.midSessionRevoked`.
|
||||
/// Without the observer this notification would arrive silently.
|
||||
func testInterruptionYieldsMidSessionRevoked() async throws {
|
||||
let (stream, cont) = AsyncStream<VisionDegradationReason>.makeStream()
|
||||
let capture = CameraCapture(degradationContinuation: cont, degradationStream: stream)
|
||||
await capture.setAuthStatusProbe { .authorized }
|
||||
|
||||
do {
|
||||
try await capture.open()
|
||||
} catch VisionError.noCameraDevice {
|
||||
try XCTSkipIf(true, "no camera device available — see Track-C 6 hardware test")
|
||||
return
|
||||
} catch {
|
||||
try XCTSkipIf(true, "camera open failed (\(error)) — see Track-C 6 hardware test")
|
||||
return
|
||||
}
|
||||
|
||||
try await capture.simulateInterruption()
|
||||
|
||||
let reason = await Self.firstDeg(stream: stream, timeout: 1.0)
|
||||
XCTAssertEqual(reason, .midSessionRevoked,
|
||||
"AVCaptureSession.wasInterruptedNotification must yield .midSessionRevoked")
|
||||
|
||||
await capture.shutdown()
|
||||
}
|
||||
|
||||
/// Race the next event off the stream against a sleep so the test does
|
||||
/// not hang when no event will ever arrive. Mirrors the helper in
|
||||
/// `CameraCaptureTCCTests`.
|
||||
private static func firstDeg(
|
||||
stream: AsyncStream<VisionDegradationReason>,
|
||||
timeout: Double
|
||||
) async -> VisionDegradationReason? {
|
||||
let collector = Task { () -> VisionDegradationReason? in
|
||||
for await reason in stream {
|
||||
return reason
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let timer = Task { () -> VisionDegradationReason? in
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
collector.cancel()
|
||||
return nil
|
||||
}
|
||||
let result = await collector.value
|
||||
timer.cancel()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,55 @@ final class CameraCaptureTCCTests: XCTestCase {
|
||||
await capture.shutdown()
|
||||
}
|
||||
|
||||
/// Audit 2026-05-12 F-V3 — `becameAuthorized()` is the re-grant entry
|
||||
/// point AppDelegate's foreground TCC re-check calls when the user
|
||||
/// flips camera permission `.denied → .authorized` via System Settings.
|
||||
/// Before the fix, this method had zero non-doc callsites in production
|
||||
/// (grep returned nothing outside its own declaration), so a re-grant
|
||||
/// after first-launch denial never reopened the session. The
|
||||
/// AppDelegate-side wiring is exercised by host integration; this test
|
||||
/// covers the Vision-side contract: after the auth probe flips to
|
||||
/// .authorized, calling becameAuthorized() opens the session
|
||||
/// successfully.
|
||||
func testBecameAuthorizedOpensSessionAfterRegrant() async throws {
|
||||
// Class-backed status box so the @Sendable probe can read the
|
||||
// latest value (simulating the user flipping permission in
|
||||
// System Settings between calls without needing an actor hop).
|
||||
final class StatusBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: AVAuthorizationStatus
|
||||
init(_ v: AVAuthorizationStatus) { self.value = v }
|
||||
func read() -> AVAuthorizationStatus { lock.lock(); defer { lock.unlock() }; return value }
|
||||
func write(_ v: AVAuthorizationStatus) { lock.lock(); defer { lock.unlock() }; value = v }
|
||||
}
|
||||
let box = StatusBox(.denied)
|
||||
|
||||
let (stream, cont) = AsyncStream<VisionDegradationReason>.makeStream()
|
||||
let capture = CameraCapture(degradationContinuation: cont, degradationStream: stream)
|
||||
await capture.setAuthStatusProbe { box.read() }
|
||||
|
||||
do {
|
||||
try await capture.open()
|
||||
XCTFail("Expected VisionError.tccDenied on first open")
|
||||
} catch VisionError.tccDenied {
|
||||
// Expected — first-launch denial.
|
||||
}
|
||||
|
||||
// Flip the probe — user granted via Settings.
|
||||
box.write(.authorized)
|
||||
do {
|
||||
try await capture.becameAuthorized()
|
||||
} catch VisionError.noCameraDevice {
|
||||
try XCTSkipIf(true, "no camera device available — see Track-C 6 hardware test")
|
||||
return
|
||||
} catch {
|
||||
XCTFail("becameAuthorized() threw after re-grant: \(error)")
|
||||
}
|
||||
|
||||
await capture.shutdown()
|
||||
_ = stream
|
||||
}
|
||||
|
||||
func testCaptureFrameThrowsWhenSessionNotRunning() async throws {
|
||||
let (stream, cont) = AsyncStream<VisionDegradationReason>.makeStream()
|
||||
let capture = CameraCapture(degradationContinuation: cont, degradationStream: stream)
|
||||
|
||||
@@ -117,6 +117,39 @@ final class FrameAttachControllerTests: XCTestCase {
|
||||
let stillFalse = await controller.hasPendingFrame
|
||||
XCTAssertFalse(stillFalse)
|
||||
}
|
||||
|
||||
// MARK: - Test 6: rejected-submit release path (#2)
|
||||
|
||||
/// Audit 2026-05-12 F-V2 — when `confirmSend` produced an ImageBlock but
|
||||
/// the orchestrator returned `.rejected`, the controller must release the
|
||||
/// frame on demand. Without `releaseAfterRejectedSubmit()` the JPEG bytes
|
||||
/// would sit in actor memory until the next `requestAttach` overwrites
|
||||
/// them — privacy regression on the D-15 byte-release window.
|
||||
func testReleaseAfterRejectedSubmitClearsPendingFrame() async throws {
|
||||
let capture = FakeCapture(frame: fixtureFrame)
|
||||
let sink = FakeReplaySink()
|
||||
let controller = FrameAttachController(
|
||||
captureSession: capture,
|
||||
replaySink: sink,
|
||||
config: VisionRouterConfig.default
|
||||
)
|
||||
await controller.requestAttach(reason: .hudButton)
|
||||
let block = await controller.confirmSend(userText: "what is this")
|
||||
XCTAssertNotNil(block, "confirmSend should have produced an ImageBlock")
|
||||
// After confirmSend the slot is still populated by design — the
|
||||
// assistant-turn-complete edge would normally release it. Simulate a
|
||||
// rejected submit (no .turnEnd will ever fire) and assert the
|
||||
// dedicated release entry point clears the slot.
|
||||
let stillPending = await controller.hasPendingFrame
|
||||
XCTAssertTrue(stillPending, "confirmSend keeps the slot populated until release")
|
||||
await controller.releaseAfterRejectedSubmit()
|
||||
let pending = await controller.hasPendingFrame
|
||||
XCTAssertFalse(pending, "releaseAfterRejectedSubmit must clear pendingFrame")
|
||||
// Idempotent.
|
||||
await controller.releaseAfterRejectedSubmit()
|
||||
let stillFalse = await controller.hasPendingFrame
|
||||
XCTAssertFalse(stillFalse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test fakes
|
||||
|
||||
Reference in new issue
Block a user