13 KiB
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 = falsetoVoiceSession. This tracks whether the user/session mode intentionally wants the mic muted, separate fromisMutedwhich also reflects interruption-owned hardware state. - Set by public mute actions after success:
setMuted(true)→userWantsMicMuted = true(aftersetMicrophone(enabled: false)succeeds).setMuted(false)→userWantsMicMuted = false(aftersetMicrophone(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
.begancase no longer snapshotswasActivebefore the await. Instead, after awaiting the predecessor and checking cancellation/generation, it evaluates:This uses the user's explicit intent (not a pre-awaitlet shouldMute = !self.userWantsMicMuted && !self.settings.textMode && self.state == .readyisMutedsnapshot) and current text mode/state. - Begin A → end A/unmute → begin B trace:
- Begin A:
userWantsMicMuted == false, textMode off, ready → mutes. SetsisMuted = true,micMutedByInterruption = true. - End A (shouldResume): un-mutes. Sets
isMuted = false.userWantsMicMutedremainsfalse(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. ✓
- Begin A:
- 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 onVoiceSession, extracted from the.beganlogic. Five new tests inConversationStateTestsverify: 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: setisReplacingSession = true, launch a singleTask { @MainActor }. - The worker loops:
while let url = self.pendingServerURL { ... }. Each iteration:- Consumes/captures the pending URL (
self.pendingServerURL = nil). - Skips if
url == activeServerURL(re-apply of same value). - Disconnects the exact current session (
await oldSession.disconnect()). - Builds from the captured validated config (
ServerConfig.parse(url)), not by rereading mutable settings. - Atomically sets
session,activeServerURL, andsessionID. - Loops again in case a newer value arrived during disconnect.
- Consumes/captures the pending URL (
- Clears
isReplacingSession = falseonly after pending is empty.
- Every accepted Apply writes
- 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.swiftwith 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 = nilisMuted = falseuserWantsMicMuted = false(new field, Iteration 08 Item 1)micError = nilmodels = []selectedModel = nilisReconnecting = falsereconnectAttempted = falsemicMutedByInterruption = 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),transcriptHandlerevents (conversation history),taskStore(task history). These are not per-room ephemeral state. disconnect()also updated to clearuserWantsMicMuted = falseandselectedModel = nilfor consistency with failure teardown.- Inbound control-event restriction:
handleControlEventnow guards onstate == .connecting || state == .waitingAgent || state == .readyinstead ofstate != .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.failedstates are dropped.
Files Changed
Sources/LiveKit/VoiceSession.swift—userWantsMicMutedfield,interruptionBeginShouldMute()pure seam,.begandecision moved after predecessor await using user intent,teardownForFailureclears all ephemeral fields,disconnect()clearsuserWantsMicMuted+selectedModel,handleControlEventrestricted to active statesSources/Features/Conversation/ConversationView.swift— worker loop replacing finite nested replacement, every Apply writespendingServerURL, single retained Task drains until emptyTests/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
userWantsMicMutedvs. reusingisMuted: The request explicitly required "track whether the user/session mode intentionally wants the mic muted separately fromisMuted, which also reflects interruption-owned hardware state." Using a separate field makes the distinction explicit and testable.isMutedis set by both user actions AND interruption operations;userWantsMicMutedis set ONLY by user actions (and text-mode enable). The interruption begin decision usesuserWantsMicMutedbecause 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 letloop in one retained Task is simpler and more robust than nested/recursive tasks. The loop naturally handles any number of rapid Applies: each Apply writespendingServerURL, 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. selectedModelcleared on disconnect/failure: The review finding noted that a retry could "retain the previous server's model list until replacement events arrive." ClearingselectedModelensures the UI falls back tosettings.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
- 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
interruptionBeginShouldMuteseam covers the decision logic; the actualsetMicrophone(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 aVoiceSessionand callingteardownForFailure, 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
userWantsMicMutedis set/cleared at exactly the right points:setMuted(true)after success,setMuted(false)after success,enableMicrophone()after success, text-mode enable inagentReadyReceived. Confirm the.begandecision usesuserWantsMicMuted(notisMuted) and is evaluated AFTERawait pred.value. Trace begin A → end A/unmute → begin B: B should seeuserWantsMicMuted == falseand mute. - Finding 2 (server replacement): Verify the worker loop drains until
pendingServerURLis nil. Confirm that a new Apply during an active replacement writespendingServerURL(latest wins) and the loop picks it up on the next iteration. ConfirmisReplacingSessionis cleared only after the loop exits. Check thatactiveServerURLis updated atomically withsession. - Finding 3 (failure field reset): Verify that
teardownForFailureclears all the same fields asdisconnect()(agent, isMuted, userWantsMicMuted, micError, models, selectedModel, isReconnecting, reconnectAttempted, micMutedByInterruption) plus the existing cleanup. ConfirmhandleControlEventdrops events in.idle,.tearingDown, and.failedstates. Verify thatsettings,transcriptHandler.events, andtaskStoreare NOT cleared (deliberately preserved).