Files
2026-10-04 07:23:49 -04:00

13 KiB
Raw Permalink Blame History

Iteration 08 Implementation Report

  • Task: 20260831-105824-plan-code-ui-audit
  • Agent: coder
  • Model: litellm-lan/qwen3.8-27b
  • Status: complete

Summary

All 3 corrections from iterations/08-request.md (derived from iterations/07-review.md Findings 1–3) are implemented. The changes address the transient mic-state flaw with explicit user intent, replace the finite nested server replacement with a single retained worker loop that drains arbitrary pending URLs, and clear all per-room/session ephemeral fields during terminal failure teardown before publishing .failed. Inbound control events are now restricted to active lifecycle states.

Per-Item Detail

1. Transient mic-state flaw — explicit user intent (Finding 1)

Problem: .began computed wasActive = state == .ready && !isMuted && !settings.textMode before awaiting the predecessor. In begin A → end A/unmute in flight → begin B, A leaves isMuted == true until its SDK unmute completes, so B captures wasActive == false. B then waits for A's end task; A completes, enables the mic (isMuted = false), and B resumes but exits at guard wasActive without muting. Final state: microphone-on during interruption B.

Fix:

  • Added private(set) var userWantsMicMuted = false to VoiceSession. This tracks whether the user/session mode intentionally wants the mic muted, separate from isMuted which also reflects interruption-owned hardware state.
  • Set by public mute actions after success:
    • setMuted(true) → userWantsMicMuted = true (after setMicrophone(enabled: false) succeeds).
    • setMuted(false) → userWantsMicMuted = false (after setMicrophone(enabled: true) succeeds).
    • enableMicrophone() → userWantsMicMuted = false (after successful enable, which is the "normal mic enable" path).
    • agentReadyReceived() in text mode → userWantsMicMuted = true (text mode enforces disabled mic).
  • Interruption begin decision moved after predecessor await: The .began case no longer snapshots wasActive before the await. Instead, after awaiting the predecessor and checking cancellation/generation, it evaluates:
    let shouldMute = !self.userWantsMicMuted && !self.settings.textMode
        && self.state == .ready
    
    This uses the user's explicit intent (not a pre-await isMuted snapshot) and current text mode/state.
  • Begin A → end A/unmute → begin B trace:
    • Begin A: userWantsMicMuted == false, textMode off, ready → mutes. Sets isMuted = true, micMutedByInterruption = true.
    • End A (shouldResume): un-mutes. Sets isMuted = false. userWantsMicMuted remains false (user never muted).
    • Begin B: awaits predecessor (A's unmute, now complete). Evaluates userWantsMicMuted == false, textMode off, ready → mutes. Final state: muted during interruption B. ✓
  • Text mode invariant preserved: enableMicrophone() still guards on !settings.textMode. setMuted(false) is blocked in text mode. The interruption begin decision also checks !self.settings.textMode.
  • Pure test seam: Added interruptionBeginShouldMute(userWantsMicMuted:textMode:state:) as an internal method on VoiceSession, extracted from the .began logic. Five new tests in ConversationStateTests verify: mute when user not muted, no mute when user intentionally muted, no mute in text mode, no mute when not ready, and the full begin A → end A → begin B sequence finishes muted.

2. Server replacement worker loop (Finding 2)

Problem: The first replacement consumed one pendingServerURL and launched a nested second task. If URL C was accepted while the nested task was awaiting disconnect(), the top-level .onChange stored C in pendingServerURL; however, the nested task only set isReplacingSession = false at its end and never checked pending again. Rapid A→B→C Applies could finish with a session on B while the persisted setting is C.

Fix:

  • Replaced the finite nested replacement with one retained worker loop in ConversationView.onChange(of: settings.serverURL):
    • Every accepted Apply writes pendingServerURL = trimmed (latest wins), regardless of whether a replacement is active.
    • If no worker is active (!isReplacingSession), start one: set isReplacingSession = true, launch a single Task { @MainActor }.
    • The worker loops: while let url = self.pendingServerURL { ... }. Each iteration:
      1. Consumes/captures the pending URL (self.pendingServerURL = nil).
      2. Skips if url == activeServerURL (re-apply of same value).
      3. Disconnects the exact current session (await oldSession.disconnect()).
      4. Builds from the captured validated config (ServerConfig.parse(url)), not by rereading mutable settings.
      5. Atomically sets session, activeServerURL, and sessionID.
      6. Loops again in case a newer value arrived during disconnect.
    • Clears isReplacingSession = false only after pending is empty.
  • A→B→C trace: Apply B → pendingServerURL = B, worker starts. Worker consumes B, disconnects A-session, builds B-session. During disconnect, Apply C → pendingServerURL = C. Worker loops, consumes C, disconnects B-session, builds C-session. Final: session on C, activeServerURL == C. ✓
  • Pure coordinator tests: New Tests/ServerReplacementCoordinatorTests.swift with 5 tests modeling the same drain-until-empty semantics: single apply, rapid A→B→C, same-URL skip, latest-wins during replacement, and C arriving during B's disconnect.

3. Terminal failure field reset + control-event restriction (Finding 3)

Problem: teardownForFailure cleared only wait/generation/interruption/state-model/wake-lock/media/room state. It did NOT clear agent, isMuted, micError, models, selectedModel, or isReconnecting. ConversationView displays agent without requiring .ready, so the failed and subsequent connecting/waiting UI could show the old agent and microphone error. A retry could retain the previous server's model list until replacement events arrived.

Fix:

  • teardownForFailure(message:) now clears all per-room/session ephemeral fields before publishing .failed:
    • agent = nil
    • isMuted = false
    • userWantsMicMuted = false (new field, Iteration 08 Item 1)
    • micError = nil
    • models = []
    • selectedModel = nil
    • isReconnecting = false
    • reconnectAttempted = false
    • micMutedByInterruption = false
    • Plus the existing: agentWaitTask cancel, generation bump, micOpTask cancel, interruptionSequence increment, stateModel.stop(), idle timer restore, media end, room teardown.
  • Preserved across failure: settings (durable user preferences), transcriptHandler events (conversation history), taskStore (task history). These are not per-room ephemeral state.
  • disconnect() also updated to clear userWantsMicMuted = false and selectedModel = nil for consistency with failure teardown.
  • Inbound control-event restriction: handleControlEvent now guards on state == .connecting || state == .waitingAgent || state == .ready instead of state != .idle. This means late events from a failed/tearing-down room cannot repopulate stale fields (agent, models, mic state) into a fresh retry. Events arriving in .idle, .tearingDown, or .failed states are dropped.

Files Changed

  • Sources/LiveKit/VoiceSession.swift — userWantsMicMuted field, interruptionBeginShouldMute() pure seam, .began decision moved after predecessor await using user intent, teardownForFailure clears all ephemeral fields, disconnect() clears userWantsMicMuted + selectedModel, handleControlEvent restricted to active states
  • Sources/Features/Conversation/ConversationView.swift — worker loop replacing finite nested replacement, every Apply writes pendingServerURL, single retained Task drains until empty
  • Tests/ConversationStateTests.swift — 5 new interruption begin decision tests (was 5, now 10)
  • Tests/ServerReplacementCoordinatorTests.swift — new file, 5 pure coordinator tests for A→B→C drain semantics

Design Decisions

  • userWantsMicMuted vs. reusing isMuted: The request explicitly required "track whether the user/session mode intentionally wants the mic muted separately from isMuted, which also reflects interruption-owned hardware state." Using a separate field makes the distinction explicit and testable. isMuted is set by both user actions AND interruption operations; userWantsMicMuted is set ONLY by user actions (and text-mode enable). The interruption begin decision uses userWantsMicMuted because it represents the pre-interruption intent, not the transient hardware state.
  • Decision after await, not before: Moving the mute decision inside the Task (after await pred.value) is the core fix. The pre-await snapshot was fundamentally broken because the predecessor's completion changes the very state being snapshotted. Post-await evaluation sees the true current state.
  • Worker loop vs. recursive task: A single while let loop in one retained Task is simpler and more robust than nested/recursive tasks. The loop naturally handles any number of rapid Applies: each Apply writes pendingServerURL, and the loop drains it. No risk of a nested task forgetting to check pending.
  • Control-event state restriction: Using an explicit allowlist (connecting, waitingAgent, ready) rather than a blocklist (!= idle) is safer: any new state added in the future will be excluded by default, preventing accidental application of stale events.
  • selectedModel cleared on disconnect/failure: The review finding noted that a retry could "retain the previous server's model list until replacement events arrive." Clearing selectedModel ensures the UI falls back to settings.modelID (the persisted preference) during connecting/waiting, which is the correct behavior for a fresh lifecycle.

Verification

  • XcodeBuildMCP test_sim (scheme Voice_iOS, iPhone 17 Pro simulator): 59 passed, 0 failed, 0 skipped (⏱️ 9.6s). No compiler warnings.
    • Suites: TranscriptDedupeTests (24), ConversationStateTests (10), MediaReconcileTests (7), ServerReplacementCoordinatorTests (5), PinnedMediaResourceLoaderTests (12), MediaAPITests (1)
    • Log: ~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T21-42-23-821Z_pid94423_e6f0b81b.log
    • Result bundle: ~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/test_sim_2026-08-31T21-42-23-821Z_pid94423_9b7f8cf9.xcresult
  • XcodeBuildMCP build_sim: Build succeeded (⏱️ 2.9s). No warnings.
    • Log: ~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/build_sim_2026-08-31T21-42-36-151Z_pid94423_26f18482.log
  • grep -c '@Test(' Tests/*.swift: ConversationState 10, MediaAPI 1, MediaReconcile 7, PinnedMediaResourceLoader 12, ServerReplacementCoordinator 5, TranscriptDedupe 24 = 59 total.
  • git diff --check: no whitespace issues.

Risks And Limitations

  • The mic operation chain is verified by the pure decision seam test and source inspection, but a full lifecycle test (real AVAudioSession interruption) would require a device. The interruptionBeginShouldMute seam covers the decision logic; the actual setMicrophone(enabled:) calls remain untested in CI.
  • The server replacement worker loop is verified by pure coordinator tests modeling the same drain semantics, but the actual SwiftUI .onChange → Task → VoiceSession.disconnect() flow requires a running app. The pure tests confirm the coalescing/drain logic; the integration path is verified by source inspection.
  • The failure field reset is verified by source inspection (the cleared fields match those in disconnect()). A state-reset test would require constructing a VoiceSession and calling teardownForFailure, which requires a live Room — not available in the current test infrastructure.
  • Real-device, current-library media-format, and separate Hope UI-redesign work remain qualifications below, not code findings.

Follow-up For Reviewer

  • Finding 1 (mic transient state): Verify that userWantsMicMuted is set/cleared at exactly the right points: setMuted(true) after success, setMuted(false) after success, enableMicrophone() after success, text-mode enable in agentReadyReceived. Confirm the .began decision uses userWantsMicMuted (not isMuted) and is evaluated AFTER await pred.value. Trace begin A → end A/unmute → begin B: B should see userWantsMicMuted == false and mute.
  • Finding 2 (server replacement): Verify the worker loop drains until pendingServerURL is nil. Confirm that a new Apply during an active replacement writes pendingServerURL (latest wins) and the loop picks it up on the next iteration. Confirm isReplacingSession is cleared only after the loop exits. Check that activeServerURL is updated atomically with session.
  • Finding 3 (failure field reset): Verify that teardownForFailure clears all the same fields as disconnect() (agent, isMuted, userWantsMicMuted, micError, models, selectedModel, isReconnecting, reconnectAttempted, micMutedByInterruption) plus the existing cleanup. Confirm handleControlEvent drops events in .idle, .tearingDown, and .failed states. Verify that settings, transcriptHandler.events, and taskStore are NOT cleared (deliberately preserved).