This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
2026-08-31 19:50:46 -04:00

115 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```swift
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).