Archived
103 lines
9.5 KiB
Markdown
103 lines
9.5 KiB
Markdown
# Iteration 01 Implementation Report
|
|
|
|
- Task: 20260831-105824-plan-code-ui-audit
|
|
- Agent: coder
|
|
- Model: litellm-lan/qwen3.8-27b (actual fixed model for this session)
|
|
- Status: complete
|
|
|
|
## Summary
|
|
|
|
All 10 confirmed defects from `iterations/01-request.md` are addressed with minimal, targeted changes. A new unit-test target (`VoiceTests`) was added to `project.yml`, the Xcode project regenerated, and all 7 tests pass on the iOS simulator via XcodeBuildMCP with zero new compiler warnings.
|
|
|
|
## Defect Fixes
|
|
|
|
### 1. MediaAPI POST decode failure — FIXED
|
|
`Sources/Media/MediaAPI.swift`: refactored `post`/`postDecoded` to share a private `postRaw(_ path:body:) -> Data` that validates HTTP status without requiring a decodable body. No-response POSTs (`/play`, `/control`, `/report`, `/streams`) now discard the returned data instead of attempting `Data.self` decode (which threw `decodeFailed` on JSON object responses).
|
|
|
|
### 2. MediaPlayerController.reconcile never assigns desired — FIXED
|
|
`Sources/Media/MediaPlayerController.swift`: `reconcile` now assigns `desired = s.desired ?? "stopped"` so the UI's play/pause icon and status text reflect server state, and interruption resume can read a correct published value.
|
|
|
|
### 3. Play/pause/stop/seek reporting protocol — FIXED
|
|
`Sources/Media/MediaPlayerController.swift`:
|
|
- Added KVO observation on `player.rate` (`observePlaybackState`) that reports + publishes `media_state` on every actual play transition and on non-user-initiated pauses (1s dedupe window via `lastReportDate`).
|
|
- `togglePlayPause` reports/publishes the pause it initiates; the rate observer handles the play transition.
|
|
- `stop()` reports "stopped" before clearing media, then publishes.
|
|
- `seek(to:)` reports current state after seeking; `seek(by:)` delegates to it.
|
|
- `playerItemDidEnd` resets `lastReportDate` so the end-of-track report isn't suppressed.
|
|
- Stale-callback protection preserved via `pollGeneration` checks in `pollState`.
|
|
|
|
### 4. endConversation stops polling but keeps room — FIXED
|
|
`Sources/Media/MediaPlayerController.swift`:
|
|
- Added `resetScope()` for app-level teardown (stops polling, bumps generation, nils room).
|
|
- `endConversation()` now restarts polling after the optional post-talk resume, so the resumed state stays visible without the panel being open.
|
|
- `startDefaultPolling()` guard changed from `room == nil` to `pollTask == nil`, so it can restart for a kept conversation scope or the default room.
|
|
|
|
### 5. Persisted text mode not synced on join — FIXED
|
|
`Sources/LiveKit/VoiceSession.swift`: `agentReadyReceived()` now checks `settings.textMode`. In text mode the mic stays off (`isMuted = true`) and no `enableMicrophone()` is scheduled; in voice mode the mic is enabled as before. `syncSettingsOnJoin` now also sends `set_mode:text` or `set_mode:voice` to synchronize the agent's conversation mode with the persisted setting.
|
|
|
|
### 6. Settings text-mode toggle only writes UserDefaults — FIXED
|
|
- `Sources/App/SettingsStore.swift`: added `onTextModeChange: ((Bool) -> Void)?` callback fired from `textMode.didSet` when the value actually changes.
|
|
- `Sources/LiveKit/VoiceSession.swift`: added `applyTextMode(_ enabled:)` (mute + set_mode:text when enabling; set_mode:voice + mic on when disabling).
|
|
- `Sources/Features/Conversation/ConversationView.swift`: registers the callback in `.onAppear`, routing any text-mode flip (conversation toggle or settings sheet) through `session.applyTextMode`. The conversation toggle's binding is now a read-only passthrough since the callback handles application.
|
|
|
|
### 7. .failed session retry is a no-op — FIXED
|
|
`Sources/LiveKit/VoiceSession.swift`: added `VoiceSessionState.isFailed` computed property; `connect()` now accepts `.idle || .isFailed` and resets `reconnectAttempted = false` before starting, preserving the one-time agent-timeout retry guard (which still sets the flag and only retries once internally).
|
|
|
|
### 8. Early models.selected overwrites persisted preference — FIXED
|
|
`Sources/LiveKit/VoiceSession.swift`:
|
|
- `handleControlEvent(.models)` now only adopts `selected` when both `selectedModel == nil` AND `settings.modelID == nil`, so an early server message cannot silently replace a persisted preference.
|
|
- `syncSettingsOnJoin` now prefers `settings.modelID ?? selectedModel`, making the persisted preference deterministic.
|
|
|
|
### 9. Transcript primary/secondary finals not deduplicated — FIXED
|
|
- `Sources/LiveKit/TranscriptHandler.swift`: `receiveDataTranscript` first checks for an open partial matching the data final's text and marks it final in place (no duplicate row). Otherwise it closes other open partials for that role and appends the final. Added a secondary `init(history:)` for test injection.
|
|
- `Sources/LiveKit/TranscriptHistory.swift`: `add` now skips when the last entry has the same role+text (cross-source identical-final dedupe). `key` made internal-static for test isolation.
|
|
|
|
### 10. Focused automated tests — DONE
|
|
- `project.yml`: added `VoiceTests` unit-test target (`bundle.unit-test`, iOS, depends on `Voice_iOS`). Note: xcodegen 2.45.4 requires the platform-suffixed dependency name `Voice_iOS`; plain `Voice` fails spec validation.
|
|
- Regenerated `Voice.xcodeproj` via `/opt/homebrew/bin/xcodegen generate`.
|
|
- New tests in `Tests/`:
|
|
- `MediaAPITests.swift` — BSD-socket HTTP stub returning a JSON object body; proves `/control`, `/report`, `/play` no-response POSTs succeed (no `decodeFailed`).
|
|
- `TranscriptDedupeTests.swift` — data final replaces matching open partial; identical primary/data finals recorded once in history; different partial closed when data final arrives. Uses isolated `TranscriptHistory` instances.
|
|
- `MediaReconcileTests.swift` — desired-state coherence, polling restart after `endConversation`, toggle no-op without a loaded track.
|
|
|
|
## Files Changed
|
|
|
|
- `Sources/Media/MediaAPI.swift` (defect 1)
|
|
- `Sources/Media/MediaPlayerController.swift` (defects 2, 3, 4)
|
|
- `Sources/LiveKit/VoiceSession.swift` (defects 5, 7, 8)
|
|
- `Sources/App/SettingsStore.swift` (defect 6)
|
|
- `Sources/Features/Conversation/ConversationView.swift` (defect 6)
|
|
- `Sources/LiveKit/TranscriptHandler.swift` (defect 9)
|
|
- `Sources/LiveKit/TranscriptHistory.swift` (defect 9)
|
|
- `project.yml` + regenerated `Voice.xcodeproj/project.pbxproj` (defect 10)
|
|
- `Tests/MediaAPITests.swift`, `Tests/TranscriptDedupeTests.swift`, `Tests/MediaReconcileTests.swift` (new, defect 10)
|
|
|
|
## Verification
|
|
|
|
Tooling: XcodeBuildMCP (skill loaded; defaults confirmed: project `/Users/shane/dev/voice-ios/Voice.xcodeproj`, scheme `Voice_iOS`, simulator iPhone 17 Pro).
|
|
|
|
Commands/tools and outcomes:
|
|
1. `/opt/homebrew/bin/xcodegen generate` → "Created project at /Users/shane/dev/voice-ios/Voice.xcodeproj" (after initially regenerating; verified `VoiceTests` present in pbxproj, TEST_HOST = Voice.app).
|
|
2. `xcodebuildmcp_test_sim` (final run) → **7 tests passed, 0 failed, 0 skipped** (⏱️ 11.8s).
|
|
- Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T11-42-09-094Z_pid79604_74bb46fe.log`
|
|
- Result bundle: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/test_sim_2026-08-31T11-42-09-094Z_pid79604_6f2671aa.xcresult`
|
|
3. Warning scan of the final test log (`grep -iE "warning"` excluding appintentsmetadataprocessor/CoreSimulator/xcodebuild noise) → **no Swift compiler warnings**.
|
|
|
|
Earlier intermediate runs (all superseded): a `private extension` inside a class body, an invalid `.AVPlayerDidBeginPlaying` notification name, a missing closing brace in `observePlayer`, `[weak self]` on a struct View, and Network-framework API mismatches in the first test stub — all fixed during iteration.
|
|
|
|
## Risks And Limitations
|
|
|
|
- **Simulator-only verification.** Build + unit tests pass on iPhone 17 Pro simulator. Real-device behavior (microphone permission flow, AVAudioSession interruptions, actual media playback) remains unverified per task constraints; not claimed as qualified.
|
|
- **MediaAPITests stub** is a single-threaded accept loop handling one request at a time; adequate for the sequential test calls made, but not a general-purpose HTTP server. The three POSTs in the test run sequentially, so this is safe.
|
|
- **Rate KVO observation** uses `player.observe(\.rate)` with `.initial` — the initial callback fires with rate 0 and an item possibly nil; the `currentItem != nil` guard prevents a spurious "paused" report at attach time.
|
|
- **Transcript dedupe** matches on exact role+text equality. Near-duplicate finals (e.g., trailing punctuation differences between sources) are not deduped; that would require fuzzy matching, which was out of scope for a minimal fix.
|
|
- **SettingsStore.onTextModeChange** is a single callback slot; only one view registers it (ConversationView). If future views need the hook, it should become an array or Combine publisher.
|
|
- **xcodegen quirk**: test target dependency must reference `Voice_iOS` (platform-suffixed), not `Voice`. Documented here in case the spec is edited later.
|
|
|
|
## Follow-up For Reviewer
|
|
|
|
- Verify defect 3's rate-KVO approach against the web UI's `play`/`pause` listener semantics (`hope-voice-api/web/app.js`) — particularly that reconcile-driven play (server desired flips to "playing") produces exactly one report.
|
|
- Confirm the `.failed` retry path in a live session: Start button → `connect()` from `.failed` → fresh token fetch + room connect.
|
|
- Check text-mode join behavior end-to-end against the agent (`agent/agent.py` set_mode handling) with a real server.
|
|
- Review whether `endConversation()` restarting polling (defect 4) is the desired lifecycle, or whether the panel's `startDefaultPolling` alone should own it.
|