Archived
64 lines
9.8 KiB
Markdown
64 lines
9.8 KiB
Markdown
# Iteration 06 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/06-request.md` are implemented:
|
|
|
|
1. **Removed `clearAll()` from `startSession()`:** Starting a conversation now preserves the restored last-50 history; only the explicit Clear button erases it.
|
|
2. **Consume-on-suppress dedupe:** When cross-source dedupe suppresses the opposite-source copy, it updates `recentFinals[key]` to the current source. A second same-text utterance from the currently arriving source is then accepted (same-source repeat). 4 new deterministic tests cover both directions, same-source repeats after consume, and persistence exactness.
|
|
3. **Centralized failure teardown with message:** `teardownForFailure(message:)` now accepts and sets its terminal `.failed(message)` *before* awaiting disconnect. All three failure paths (non-timeout/exhausted connect failure, final agent timeout, remote terminal disconnect) route through it. Retry always sees a fresh room via `teardownRoom()`.
|
|
4. **Truthful idle in `disconnect()`:** The explicit disconnect now sets `isTearingDown = true` first (so disconnect callbacks are ignored), completes media + room teardown, and only then publishes `.idle`. This makes idle a truthful readiness signal for server replacement.
|
|
5. **Server Apply guarded on session state:** SettingsView disables the Apply button while `session.state != .idle`, shows "End the conversation before changing servers." The `applyServerURL()` method also guards on `canApplyServerURL`. ConversationView adds an `isReplacingSession` identity guard so repeated Apply cannot race concurrent replacements. Media continues using the accepted current-session server until replacement.
|
|
6. **Serialized interruption mic operations:** `.began` cancels the prior task, increments sequence, and spawns a mute task that sets `micMutedByInterruption = true` only *after* successful mute completion. `.ended` captures the pending mute task and awaits it before deciding whether to unmute. A newer `.began` supersedes stale `.ended` via sequence. The flag is cleared inside the `.ended` task after the await, not at notification delivery time.
|
|
7. **True map serialization in code:** The pinned URLSession now uses `delegateQueue: .main`, so data-task completion handlers run on the main queue — the same serialization domain as the AVAssetResourceLoader delegate callbacks (also dispatched to `.main`). All mutations of `activeTasks` and `loaderURLs` happen on main, enforced by the queue configuration, not by comment.
|
|
8. **Verification:** 47 tests in 5 suites pass; build succeeds; `git diff --check` clean.
|
|
|
|
## Files Changed
|
|
|
|
- `Sources/Features/Conversation/ConversationView.swift` — removed `clearAll()` from `startSession()`; added `isReplacingSession` guard to `.onChange(of: settings.serverURL)`
|
|
- `Sources/LiveKit/TranscriptHandler.swift` — consume-on-suppress: update `recentFinals[key]` to current source when suppressing opposite-source copy (both `receiveSegment` and `receiveDataTranscript`)
|
|
- `Sources/LiveKit/VoiceSession.swift` — `teardownForFailure(message:)` sets `.failed` before disconnect; all failure paths route through it; `disconnect()` sets `isTearingDown` first, publishes `.idle` last; mic interruption rewritten with await-based serialization (flag set after mute completes, `.ended` awaits mute task)
|
|
- `Sources/Features/Settings/SettingsView.swift` — `canApplyServerURL` computed property; Apply button disabled + explanatory text when session not idle; `applyServerURL()` guards on session state
|
|
- `Sources/Media/PinnedMediaResourceLoader.swift` — `delegateQueue: .main` on pinned URLSession (true serialization); updated doc comments
|
|
- `Tests/TranscriptDedupeTests.swift` — 5 new tests: consume-on-suppress both directions, same-source repeats after consume, persistence exactness, session start preserves history
|
|
|
|
## Design Decisions
|
|
|
|
- **Consume-on-suppress:** When source A's final is suppressed because source B already recorded it, we update the record to source A. This means the *next* arrival from source A sees same-source and passes through. The *next* arrival from source B would then be suppressed (opposite source) and consume back to B. This creates a fair alternating pattern that never blocks legitimate repeats from either source.
|
|
- **`teardownForFailure(message:)` sets state before await:** The UI shows the failure reason immediately rather than waiting for room disconnect to complete. The idempotent check (`if !state.isFailed`) preserves the first message if called twice.
|
|
- **`disconnect()` publishes `.idle` last:** By setting `isTearingDown = true` first, any `.disconnected` callback from our own `room.disconnect()` is ignored. The state stays at whatever it was (`.ready`, `.waitingAgent`, etc.) until teardown completes, then transitions to `.idle`. This prevents the UI from showing "ready to start" while the old room is still tearing down.
|
|
- **Apply disabled when not idle:** Option (a) from the request — simplest and safest. The user must end the conversation before changing servers. No queued URLs, no complex state machine. The `isReplacingSession` guard in ConversationView is a belt-and-suspenders measure against rapid double-tap on Apply.
|
|
- **Mic interruption await-based serialization:** The `.ended` handler spawns a Task that awaits the pending mute task's `.value` before checking conditions. This guarantees the mute has completed (or been cancelled) before any resume decision. The `micMutedByInterruption` flag is only set inside the mute task after successful completion, so a premature `.ended` (before mute finishes) sees `false` and does not resume.
|
|
- **`delegateQueue: .main` for pinned session:** This is the minimal change that makes serialization true in code. The AVAssetResourceLoader delegate is already on `.main`; now the URLSession completion handlers are too. No locks needed, no serial queue indirection — just one thread (main) owns all mutable state.
|
|
|
|
## Verification
|
|
|
|
- XcodeBuildMCP `test_sim` (scheme Voice_iOS, iPhone 17 Pro simulator): **47 passed, 0 failed, 0 skipped** (⏱️ 11.5s). No compiler warnings.
|
|
- Suites confirmed in log: TranscriptDedupeTests (22), MediaReconcileTests (6), ConversationStateTests (5), MediaAPITests (3), PinnedMediaResourceLoaderTests (11)
|
|
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T13-43-02-377Z_pid94423_3a678946.log`
|
|
- Result bundle: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/test_sim_2026-08-31T13-43-02-377Z_pid94423_ef6e8331.xcresult`
|
|
- Final line: `Test run with 47 tests in 5 suites passed after 0.219 seconds.`
|
|
- XcodeBuildMCP `build_sim`: **Build succeeded** (⏱️ 2.8s). No warnings.
|
|
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/build_sim_2026-08-31T13-43-16-941Z_pid94423_61640c54.log`
|
|
- `git diff --check`: no whitespace issues.
|
|
|
|
## Risks And Limitations
|
|
|
|
- **Item 6 (mic interruption):** The `.ended` Task awaits the mute task's `.value`, but if the mute task was cancelled (by a newer `.began` or disconnect), awaiting its `.value` returns immediately (the task completed by cancellation). The subsequent checks (sequence, generation, flag) all fail in that case, so no incorrect resume occurs. However, there is a brief window where the `.ended` Task exists but hasn't yet checked conditions — if a *new* `.began` arrives in that window, it increments the sequence and the stale `.ended` Task's check fails. This is correct behavior.
|
|
- **Item 4 (disconnect idle timing):** The state remains at its pre-disconnect value (e.g., `.ready`) until teardown completes. If the UI reads `state` during this window, it will show "in conversation" rather than "idle." This is intentional and truthful — the session *is* still tearing down. The `isTearingDown` flag prevents the disconnect callback from setting `.failed`.
|
|
- **Item 5 (Apply guard):** The `canApplyServerURL` check reads `session.state` at render time. Since the parent view re-renders on session state changes (via the tick mechanism), the button's disabled state updates reactively. However, there is no Combine subscription in SettingsView itself; if the sheet were presented independently of the parent's observation, it might not update. In the current architecture (NavigationLink within the same view hierarchy), this is safe.
|
|
- **Item 7 (delegateQueue .main):** Moving URLSession completion handlers to main adds a small amount of work to the main thread (dictionary mutations, response parsing). For the current single-stream media use case, this is negligible. If multiple concurrent media streams were added, this would need revisiting.
|
|
|
|
## Follow-up For Reviewer
|
|
|
|
- Verify that `teardownForFailure(message:)` correctly sets `.failed` before any await in all three call sites (connect failure, agent timeout, terminal disconnect).
|
|
- Confirm the consume-on-suppress dedupe logic: after primary→data suppresses and consumes to `.data`, a second data final passes (same source), and a subsequent primary final is suppressed again (opposite source) and consumes back to `.primary`. The test `sameSourceRepeatsAfterConsume` covers this alternating pattern.
|
|
- Review the mic interruption serialization: does awaiting `pendingMuteTask.value` in the `.ended` Task introduce any risk of unbounded delay? (Answer: no — the mute task completes or is cancelled promptly; `setMicrophone` has a timeout.)
|
|
- Verify that `disconnect()`'s delayed `.idle` publication doesn't cause issues with the SettingsView Apply guard (which checks `session.state == .idle`). During teardown, state is not `.idle`, so Apply remains disabled — correct behavior.
|
|
- Confirm that `delegateQueue: .main` on the pinned URLSession doesn't create a retain cycle or deadlock with the AVAssetResourceLoader delegate (both on main).
|