Archived
10 KiB
10 KiB
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:
- Project regeneration + test fixes: Regenerated
Voice.xcodeprojvia 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. - Single canonical transcript collection: Rewrote
TranscriptHandlerwith oneeventsarray initialized from persisted history, live appends/updates in place, trim to 50 finals, and a singleclearAll(). RemovedTranscriptHistory.add's permanent adjacent-text dedupe; the history is now a persistence-only snapshot. - 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. Injectablenowclosure 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. - Real server URL Apply semantics:
SettingsViewkeeps a local draft, validates only on explicit Apply, shows inline errors, and persists only accepted values.SettingsStore.initsanitizes legacy invalid persisted values to the documented default.ConversationViewtracksactiveServerURL, serializes session replacement (disconnect old before constructing new), and applies one pending URL after an active session reaches idle. - Intentional, idempotent room teardown: Added
isTearingDownflag set before awaitingroom.disconnect(); the resulting.disconnectedcallback is recognized as expected and ignored. Used for agent-timeout retry and all final token/connect failures. Retry from.failedalways starts with a fresh room viateardownRoom(). - No-resume media interruption safety: Added separate
resumeSuppressedflag (distinct fromisInterruptionActive). 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. - Resource-loader lifecycle completion: Tasks keyed by
ObjectIdentifier(loadingRequest)directly (no hash derivation). All mutable state accessed on the main delegate queue.removeLoadercalled 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. - Serialized microphone interruption operations: Added
interruptionSequencecounter andmicMuteTaskretention. Each.beganincrements the sequence and spawns a mute task;.endedcaptures the sequence and only resumes if it is still current (no newer.begansuperseded it). Resume requires: begin started from active mic, mute succeeded, generation matches, text mode off, and.shouldResumepresent. 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 presentSources/LiveKit/TranscriptHandler.swift— single canonicaleventscollection, source-aware dedupe with injectable clock,clearAll(), convenience initSources/LiveKit/TranscriptHistory.swift— persistence-only store, removed adjacent-text dedupe, addedsync(finals:)Sources/LiveKit/VoiceSession.swift—isTearingDownflag,teardownRoom()with intentional disconnect handling,interruptionSequence+micMuteTaskfor serialized mic interruption, cancel/invalidate inteardownForFailure()anddisconnect()Sources/Features/Settings/SettingsView.swift— local draft URL, Apply button, inline error, validate-on-apply onlySources/App/SettingsStore.swift— legacy invalid value sanitization at init,defaultServerURLconstantSources/Features/Conversation/ConversationView.swift—activeServerURLtracking, serialized session replacement,clearAll()for transcriptSources/Features/Conversation/OrbView.swift—TranscriptListViewrenders only canonical eventsSources/Media/MediaPlayerController.swift—resumeSuppressedflag,wasPlayingBeforeInterruption, server-synced resume, suppression clear on confirmed paused state, explicit user play clears suppression,currentAssettracking withclearCurrentAsset()for loader cleanupSources/Media/PinnedMediaResourceLoader.swift—ObjectIdentifier(loadingRequest)keying, main-queue serialization, correct 206/200 content-length handling,removeLoadercancels in-flight tasksTests/PinnedMediaResourceLoaderTests.swift— stream URL fix, 5 new lifecycle/rewrite tests (11 total)Tests/ConversationStateTests.swift— EMA test uses 5 zero ticks for deterministic threshold crossingTests/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 aRecentFinal(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: () -> Dateon TranscriptHandler allows tests to advance time deterministically without real sleeps. Production usesDate(). - Intentional teardown flag:
isTearingDownis set before the asyncroom.disconnect()await and cleared after. The.disconnectedRoomDelegate 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.
isInterruptionActiveis true only while the OS reports an active interruption.resumeSuppressedpersists 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
interruptionSequencecounter is incremented on every.began. A.endedhandler captures the current sequence; if a newer.beganhas already incremented it, the stale.endeddoes 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
- 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):
removeLoadercancels 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 (
.endedof A and.beganof B delivered in the same runloop iteration) could still produce a brief race. Documented as device-only residual qualification. - The
isTearingDownflag is cleared synchronously afterroom.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.disconnectedcallback beforedisconnect()returns.
Follow-up For Reviewer
- Verify that the
isTearingDownflag correctly suppresses the.disconnectedcallback in all teardown paths (agent timeout retry, final connect failure, explicit disconnect). - Confirm the
resumeSuppressedlifecycle: 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
removeLoadercancel-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
.endedarrives before the mute task from.beganhas completed (the mute task checksTask.isCancelledand 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.