feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s

This commit is contained in:
snowspeeder committed 2026-10-04 07:23:49 -04:00
commit d735ee72fa
102 files changed
+13077 -258

No files matched your search

@@ -0,0 +1,72 @@
# Iteration 05 Implementation Report
- Task: 20260831-105824-plan-code-ui-audit
- Agent: coder
- Model: litellm-lan/qwen3.8-27b
- Status: complete
## Summary
All 8 corrections from `iterations/05-request.md` are implemented:
1. **Project regeneration + test fixes:** Regenerated `Voice.xcodeproj` via xcodegen so all 5 test files are present in the build. Fixed stream URL expectation to `/media/api/stream/{id}` and made the EMA test cross the threshold deterministically with 5 zero ticks.
2. **Single canonical transcript collection:** Rewrote `TranscriptHandler` with one `events` array initialized from persisted history, live appends/updates in place, trim to 50 finals, and a single `clearAll()`. Removed `TranscriptHistory.add`'s permanent adjacent-text dedupe; the history is now a persistence-only snapshot.
3. **Source-aware, clock-testable dedupe:** Records `(role, normalized text, source, timestamp)` and suppresses only the opposite-source copy within 5 seconds. Same-source repeats are never suppressed. Injectable `now` closure enables deterministic before/after-window tests. 17 comprehensive tests cover both source orders, interleaving, same-source repeats, post-window expiry, boundary conditions, restoration, max-50 trimming, live append, clear, and persistence sync.
4. **Real server URL Apply semantics:** `SettingsView` keeps a local draft, validates only on explicit Apply, shows inline errors, and persists only accepted values. `SettingsStore.init` sanitizes legacy invalid persisted values to the documented default. `ConversationView` tracks `activeServerURL`, serializes session replacement (disconnect old before constructing new), and applies one pending URL after an active session reaches idle.
5. **Intentional, idempotent room teardown:** Added `isTearingDown` flag set before awaiting `room.disconnect()`; the resulting `.disconnected` callback is recognized as expected and ignored. Used for agent-timeout retry and all final token/connect failures. Retry from `.failed` always starts with a fresh room via `teardownRoom()`.
6. **No-resume media interruption safety:** Added separate `resumeSuppressed` flag (distinct from `isInterruptionActive`). Suppression is set on no-resume end and cleared only by explicit user play/resume or confirmed coherent paused state from the server. For should-resume, server resume is synchronized before local play, gated on scope/generation match.
7. **Resource-loader lifecycle completion:** Tasks keyed by `ObjectIdentifier(loadingRequest)` directly (no hash derivation). All mutable state accessed on the main delegate queue. `removeLoader` called whenever MediaPlayerController replaces or clears the prior AVURLAsset. For 206, content length set only when Content-Range provides a known total (never falls back to range expectedContentLength); for 200, uses expectedContentLength. Cleanup on success, error, cancellation, and item replacement. 5 new focused tests added.
8. **Serialized microphone interruption operations:** Added `interruptionSequence` counter and `micMuteTask` retention. Each `.began` increments the sequence and spawns a mute task; `.ended` captures the sequence and only resumes if it is still current (no newer `.began` superseded it). Resume requires: begin started from active mic, mute succeeded, generation matches, text mode off, and `.shouldResume` present. Cancel/invalidate on disconnect/failure/new begin. Device-only residual qualification documented.
## Files Changed
- `Voice.xcodeproj/project.pbxproj` — regenerated via xcodegen; all 5 test files present
- `Sources/LiveKit/TranscriptHandler.swift` — single canonical `events` collection, source-aware dedupe with injectable clock, `clearAll()`, convenience init
- `Sources/LiveKit/TranscriptHistory.swift` — persistence-only store, removed adjacent-text dedupe, added `sync(finals:)`
- `Sources/LiveKit/VoiceSession.swift` — `isTearingDown` flag, `teardownRoom()` with intentional disconnect handling, `interruptionSequence` + `micMuteTask` for serialized mic interruption, cancel/invalidate in `teardownForFailure()` and `disconnect()`
- `Sources/Features/Settings/SettingsView.swift` — local draft URL, Apply button, inline error, validate-on-apply only
- `Sources/App/SettingsStore.swift` — legacy invalid value sanitization at init, `defaultServerURL` constant
- `Sources/Features/Conversation/ConversationView.swift` — `activeServerURL` tracking, serialized session replacement, `clearAll()` for transcript
- `Sources/Features/Conversation/OrbView.swift` — `TranscriptListView` renders only canonical events
- `Sources/Media/MediaPlayerController.swift` — `resumeSuppressed` flag, `wasPlayingBeforeInterruption`, server-synced resume, suppression clear on confirmed paused state, explicit user play clears suppression, `currentAsset` tracking with `clearCurrentAsset()` for loader cleanup
- `Sources/Media/PinnedMediaResourceLoader.swift` — `ObjectIdentifier(loadingRequest)` keying, main-queue serialization, correct 206/200 content-length handling, `removeLoader` cancels in-flight tasks
- `Tests/PinnedMediaResourceLoaderTests.swift` — stream URL fix, 5 new lifecycle/rewrite tests (11 total)
- `Tests/ConversationStateTests.swift` — EMA test uses 5 zero ticks for deterministic threshold crossing
- `Tests/TranscriptDedupeTests.swift` — rewritten with 17 comprehensive tests using ClockBox
## Design Decisions
- **Canonical transcript:** One array (`events`) is the single source of truth for visible transcript. The view renders only this array. Persistence is a mirror, not a second store. This eliminates the two-store divergence class of bugs entirely.
- **Source-aware dedupe key:** `(role, normalized text)` with a `RecentFinal(source, timestamp)` value. Only the opposite source is suppressed; same-source repeats always pass. This matches the product requirement that a user saying "hello" twice in quick succession shows both rows.
- **Injectable clock:** `var now: () -> Date` on TranscriptHandler allows tests to advance time deterministically without real sleeps. Production uses `Date()`.
- **Intentional teardown flag:** `isTearingDown` is set before the async `room.disconnect()` await and cleared after. The `.disconnected` RoomDelegate callback checks this flag first and returns early if true, preventing the "Connection lost" failure state from being set by our own teardown.
- **Resume suppression vs interruption active:** Two separate flags because their lifetimes differ. `isInterruptionActive` is true only while the OS reports an active interruption. `resumeSuppressed` persists after the interruption ends (no-resume path) until the server confirms paused or the user explicitly plays. This prevents the 1.5s poll from restarting audio that the system told us not to resume.
- **Sequence-based mic serialization:** The `interruptionSequence` counter is incremented on every `.began`. A `.ended` handler captures the current sequence; if a newer `.began` has already incremented it, the stale `.ended` does nothing. This handles the rapid call/Siri overlap case without requiring locks (everything runs on MainActor).
- **Resource loader keying:** `ObjectIdentifier(loadingRequest)` is the identity of the AVAssetResourceLoadingRequest instance itself — no hash derivation, no collision risk. The main delegate queue serializes all map mutations.
## Verification
- `/opt/homebrew/bin/xcodegen generate` → success; all 5 test files present in pbxproj.
- XcodeBuildMCP `test_sim` (scheme Voice_iOS, iPhone 17 Pro simulator): **42 passed, 0 failed, 0 skipped** (⏱️ 9.0s). No compiler warnings.
- Suites confirmed in log: ConversationStateTests (5), MediaAPITests (3), TranscriptDedupeTests (17), PinnedMediaResourceLoaderTests (11), MediaReconcileTests (6)
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T13-28-46-192Z_pid94423_55b32e1d.log`
- Result bundle: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/test_sim_2026-08-31T13-28-46-192Z_pid94423_d2ea27f4.xcresult`
- XcodeBuildMCP `build_sim`: **Build succeeded** (⏱️ 2.9s). No warnings.
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/build_sim_2026-08-31T13-29-09-951Z_pid94423_8d28d092.log`
- `git diff --check`: no whitespace issues.
## Risks And Limitations
- Simulator-only verification; real-device mic/interruption/playback behavior not claimed as qualified.
- Item 6 (media interruption): the should-resume path synchronizes server resume before local play, but if the server control call fails, local playback still resumes (the poll will reconcile). This is a deliberate choice: the user expects audio to return after a call ends.
- Item 7 (resource loader): `removeLoader` cancels all active tasks when called, not just those belonging to the specific asset being removed. This is safe because MediaPlayerController only has one current asset at a time; a more granular per-asset task map would be over-engineering for the current single-stream use case.
- Item 8 (mic interruption): the sequence check handles the common case of overlapping interruptions, but a truly simultaneous double-interruption (`.ended` of A and `.began` of B delivered in the same runloop iteration) could still produce a brief race. Documented as device-only residual qualification.
- The `isTearingDown` flag is cleared synchronously after `room.disconnect()` returns. If LiveKit's disconnect callback fires asynchronously *after* the flag is cleared (unlikely but possible), it would be treated as a terminal disconnect. In practice, LiveKit delivers the `.disconnected` callback before `disconnect()` returns.
## Follow-up For Reviewer
- Verify that the `isTearingDown` flag correctly suppresses the `.disconnected` callback in all teardown paths (agent timeout retry, final connect failure, explicit disconnect).
- Confirm the `resumeSuppressed` lifecycle: set on no-resume end, cleared on confirmed paused poll or explicit user play. Check that a rapid play→pause→interruption sequence doesn't leave stale suppression.
- Review the resource loader's `removeLoader` cancel-all behavior: is it acceptable for the single-stream use case, or should tasks be keyed per-asset?
- Verify the mic interruption sequence logic handles the edge case where `.ended` arrives before the mute task from `.began` has completed (the mute task checks `Task.isCancelled` and generation, so it becomes a no-op).
- Confirm that `ConversationView`'s serialized session replacement (disconnect old → construct new) doesn't create a window where both sessions are alive.