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,125 @@
# Review Report
- Task: 20260831-105824-plan-code-ui-audit
- Agent: reviewer
- Model: openai-codex/gpt-5.6-sol
- Effort: high
- Reviewed: 2026-08-31T13:02:29Z
- Verdict: changes-requested
## Findings
### 1. High — 12 newly claimed tests are not in the generated Xcode project; the reported count is impossible
- **Evidence:** `Tests/PinnedMediaResourceLoaderTests.swift` defines 7 tests and `Tests/ConversationStateTests.swift` defines 5, but neither filename appears anywhere in `Voice.xcodeproj/project.pbxproj`. The current test log starts only `MediaAPITests`, `MediaReconcileTests`, and `TranscriptDedupeTests`. XcodeBuildMCP therefore reports 16 tests: 1 API + 7 media reconcile + 8 transcript tests. It does not run either new file.
- **Contradicted claim:** `iterations/04-implementation.md:88-94` says 16 total while separately claiming 7 transcript + 7 resource-loader + 5 conversation-state tests plus existing media/API tests. Those components total 28, not 16. `iterations/03-implementation.md:49-54` likewise claims the undiscovered resource tests ran.
- **Additional failures hidden by non-discovery:**
- `PinnedMediaResourceLoaderTests.swift:67` expects `https://192.168.86.2:9178/stream/track-123`, but `MediaAPI` correctly builds under its `/media/api` base (`Sources/Media/MediaAPI.swift:125-133,185-190`), so the expected URL must be `https://192.168.86.2:9178/media/api/stream/track-123`.
- `ConversationStateTests.swift:18-26` expects listening after two zero ticks. EMA remains `0.02304` after those ticks, above the `0.02` agent threshold, so the test needs a third zero tick or a deterministic threshold-crossing loop.
- **Impact:** the parser, stream URL, and new state-machine tests have never compiled or executed. Current “16 passed” does not verify the claimed fixes.
- **Correction:** regenerate `Voice.xcodeproj` from `project.yml`, correct the two invalid expectations, rerun all expected 28 tests, and report suites/counts from the actual result bundle.
### 2. High — visible history duplicates every live final, while legitimate repeated finals are still lost
- **Evidence:** every accepted final is added both to `events` and `history.entries` (`Sources/LiveKit/TranscriptHandler.swift:45-61,64-89`). `TranscriptListView.visibleRows` then concatenates all history entries with all live events (`Sources/Features/Conversation/OrbView.swift:78-92`). A live final therefore renders once as a “restored” history row and once as its live event.
- **Repeat semantics:** dedupe state records only role/text/time, not source (`TranscriptHandler.swift:31-35,45-60,82-88`). Two distinct primary utterances with identical text inside five seconds are suppressed even though cross-source dedupe should suppress only the opposite-source copy. After five seconds, `TranscriptHistory.add` still permanently drops an adjacent same-role/same-text repeat (`Sources/LiveKit/TranscriptHistory.swift:25-31`), so it will not survive relaunch.
- **Test gap:** `TranscriptDedupeTests.swift:100-120` is named “legitimate repeat after dedupe window,” but does not advance time and explicitly asserts that the repeat is suppressed. There are no restoration/max-50/live-visible-row/clear integration tests requested by iteration 04.
- **Impact:** current messages appear twice; valid repetitions such as “yes” twice can disappear from events within five seconds or from persisted history indefinitely.
- **Correction:** snapshot restored entries separately from live-session history, or render a single canonical transcript collection; do not concatenate two stores containing the same finals. Track source in recent dedupe records and consume only an opposite-source duplicate. Remove permanent dedupe from persistence. Inject a clock and test before/after-window same-source repeats, cross-source pairs, restoration, live append, max-50, and clear.
### 3. High — server URL changes still write/apply per keystroke, active-session changes are dropped rather than deferred, and invalid values still fall back silently for media
- **Evidence:** Settings continues assigning `settings.serverURL` from every `urlText` change (`Sources/Features/Settings/SettingsView.swift:18-24`), and `SettingsStore` persists each assignment. Conversation replacement fires for every intermediate value that parses as a valid URL (`Sources/Features/Conversation/ConversationView.swift:71-83`), so typing a valid hostname can launch repeated unstructured disconnect/replacement tasks.
- The active-session guard simply returns (`ConversationView.swift:75`); there is no state observer or pending configuration, so the change is never applied when the session later becomes idle.
- `makeSession` mutates an invalid value to the default before `onAppear` validation (`ConversationView.swift:22-35,58-61`), so the promised original-value error cannot appear. Invalid values entered later get no visible validation error. Media still silently uses the default host for invalid visible settings (`Sources/Features/Media/MediaPanelView.swift:448-459`).
- **Impact:** the iteration-04 configuration contract is not met; conversation/media can use stale or fallback configuration, and valid typing can race multiple session replacements.
- **Correction:** keep draft URL state local, validate on explicit Apply/Submit, show validation in Settings, persist only accepted values, serialize one replacement, and queue a pending accepted config until the current session reaches idle. Media must use the last accepted config, never derive a fallback from invalid draft text.
### 4. High — terminal cleanup can recursively react to its own disconnect, agent-timeout retry can be mistaken for terminal loss, and non-timeout connect failures still bypass cleanup
- **Evidence:** `teardownForFailure()` calls `teardownRoom()` while session state remains `.ready`/`.waitingAgent` (`Sources/LiveKit/VoiceSession.swift:182-212`). `teardownRoom()` calls `room.disconnect()` before replacing `room` (`:177-180`). The resulting `.disconnected` callback sees the same room and active state and can invoke `teardownForFailure()` again (`:515-533`).
- The intended first agent-timeout retry also calls `teardownRoom()` while state is `.waitingAgent` (`:192-196`), so its intentional disconnect can enter terminal-failure cleanup instead of retrying.
- A non-timeout `room.connect` failure still only assigns `.failed` (`:134-166`) after media scope may already have started; it does not recreate the room or clean media state. A user retry can therefore reuse that room.
- **Impact:** retries and terminal teardown are race-dependent and can recurse, abort the single retry, or leave stale media/room state.
- **Correction:** introduce an idempotent teardown flag/state before awaiting disconnect; distinguish intentional disconnects from remote terminal disconnects; set the externally visible failure/transitional state before disconnect callbacks can run; and route every post-token/connect failure through fresh-room cleanup. Add a fake-room lifecycle seam or deterministic integration tests.
### 5. High — no-resume media interruption can still restart before the pause control completes
- **Evidence:** on `.ended`, `defer` clears `isInterruptionActive` synchronously (`Sources/Media/MediaPlayerController.swift:77-98`), while the server pause is sent in an unstructured asynchronous task (`:91-96`). Until that request completes, polling may still receive `desired == "playing"`; reconciliation sees the interruption flag already false and calls `player.play()` (`:188-200,237-252`). If there is no room or the request fails, server desired remains playing indefinitely.
- **Impact:** media can restart immediately after an interruption that explicitly did not authorize resume.
- **Correction:** retain a separate resume-suppression state through successful server reconciliation (or synchronous local desired override plus acknowledged pause), and do not permit reconcile-driven play until a later explicit resume/play action. Add deterministic begin/end-without-resume/poll-before-ack/control-failure tests.
### 6. Medium — resource-loader cleanup is unused and task bookkeeping is data-racy
- **Evidence:** `removeLoader(_:)` has no caller; repository search finds only its declaration (`Sources/Media/PinnedMediaResourceLoader.swift:70-75`). `MediaPlayerController.wireSource` and `clearMedia` never remove the previous asset mapping (`Sources/Media/MediaPlayerController.swift:274-283,477-486`), so `loaderURLs` remains unbounded.
- `activeTasks` is written on the main resource-loader delegate queue (`PinnedMediaResourceLoader.swift:134-135,139-149`) and also from URLSession completion callbacks on another queue (`:98-101`) with no actor, lock, or serial queue. It uses a `hashValue` integer instead of `ObjectIdentifier` directly (`:98,146`), allowing collisions.
- For 206 responses with missing/malformed/unknown total, code falls back to `expectedContentLength`, which is the returned range length, and incorrectly labels it as the full resource (`:117-127,153-165`). The authored tests endorse this fallback and, in any event, are not discovered. Status/cancellation behavior requested in iteration 04 has no test.
- **Impact:** loader mappings leak across track changes, cancellation/completion can race dictionary mutation, and AVPlayer can receive an incorrect total length for partial content.
- **Correction:** invoke removal when replacing/clearing an item, serialize all dictionaries on one queue/lock, key by `ObjectIdentifier`, and assign `expectedContentLength` only for 200; for 206 set content length only from a known Content-Range total. Add discovered status/cancellation/bookkeeping tests.
### 7. Medium — microphone interruption ownership is still set before the mute succeeds and mute/unmute tasks can cross
- **Evidence:** `.began` sets `micMutedByInterruption = true` before launching asynchronous `setMuted(true)` (`Sources/LiveKit/VoiceSession.swift:435-448`). `.ended` can then launch `setMuted(false)` without awaiting/cancelling the begin task (`:449-461`). The flag therefore means “mute requested,” not “this interruption successfully muted the mic.”
- **Impact:** rapid or malformed begin/end sequences can leave SDK mute operations completing out of order, including ending muted after a should-resume event. A failed begin-mute still causes an interruption-owned unmute attempt.
- **Correction:** serialize interruption microphone operations under a generation/token, mark ownership only after successful mute, and cancel/ignore superseded completions. Device qualification remains required.
## Acceptance Criteria
### Prior finding disposition
| Prior finding | Iteration-04 disposition |
|---|---|
| 1. Compile failure | Fixed; current build succeeds |
| 2. Failure/disconnect lifecycle | Not fixed; Finding 4 |
| 3. Media interruption | Not fixed; Finding 5 |
| 4. Text-mode mic privacy | Fixed by API guards + disabled toggle; device-unverified |
| 5. Server configuration | Not fixed; Finding 3 |
| 6. Transcript dedupe/repeats | Partial; symmetry improved, legitimate repeats still wrong; Finding 2 |
| 7. Visible history/Clear | Partial; Clear is truthful, rendering duplicates live finals; Finding 2 |
| 8. Mic interruption ownership | Partial; lifecycle resets added, async ownership race remains; Finding 7 |
| 9. Conversation state | Source fix is correct; claimed tests are undiscovered and currently contain an invalid EMA expectation |
| 10. Stale media publish | Fixed by room/session/poll generation capture; device-unverified |
| 11. Resource loader | Partial; Finding 6 |
| 12. Generic-host TLS | Fixed by host-scoped pin/default handling; network/device-unverified |
| 13. UI/accessibility parity | Correctly documented as pending separate redesign |
| 14. Model picker copy | Fixed by readiness check |
### Iteration 04 request disposition
| Request item | Verdict |
|---|---|
| 1. Complete iteration 03 | Partial: stream helper and parser compile; dedupe/interruption/resource tests and semantics remain incomplete |
| 2. Terminal failure cleanup | Changes required (Finding 4) |
| 3. Text-mode mic invariant | Implemented; device-unverified |
| 4. Server config semantics | Changes required (Finding 3) |
| 5. Visible history/Clear | Changes required (Finding 2) |
| 6. Current-reference state inference | Source implemented; test target/expectation correction required |
| 7. Stale-safe media publish | Implemented by inspection; device-unverified |
| 8. Resource-loader hardening | Changes required (Findings 1 and 6) |
| 9. Configurable HTTPS trust | Implemented by inspection; external-host/device test absent |
| 10. Model picker copy | Implemented |
| 11. PLAN/README qualification | Implemented; docs now classify redesign/device work as pending |
| 12. Idle-timer wording | Implemented correctly |
## Verification Evidence
- Loaded the repository XcodeBuildMCP skill and confirmed defaults: project `/Users/shane/dev/voice-ios/Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator `AFDA137E-1657-428E-BF01-E5CD7989772C`.
- XcodeBuildMCP `test_sim` → **16 passed, 0 failed, 0 skipped** in 9.6 s. Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/test_sim_2026-08-31T12-58-36-052Z_pid94423_12510633.log`. The log lists only `MediaAPITests`, `MediaReconcileTests`, and `TranscriptDedupeTests`; it proves the two new test files were omitted.
- XcodeBuildMCP `build_sim` → **Build succeeded** in 2.8 s. Log: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/logs/build_sim_2026-08-31T12-58-53-892Z_pid94423_5f92c809.log`.
- `git diff --check` → clean.
- Inspected the complete current diff, all five test files, generated project membership, iteration 03/04 reports, product sources, PLAN/README/device docs, and prior review findings.
- Reviewer changed only artifact reports/manifest, not product implementation.
## Residual Risks
- Real-device microphone, interruption, LiveKit terminal failure, wake-lock, TLS alternate-host, and supported-media behavior remain unqualified.
- Current YouTube library webm remains server-blocked for AVPlayer; a supported MP3/AAC stream is still needed for client qualification.
- UI redesign/accessibility/app icon remain intentionally pending and out of this correction scope.
- Plain LAN WebSocket signaling and rotating self-signed pin remain documented deployment risks.
## Required Actions
1. Regenerate the Xcode project, fix undiscovered test expectations, and prove the full expected 28-test suite runs.
2. Correct Findings 2–7 and add deterministic tests or explicit integration seams for the high-risk lifecycle paths.
3. Update implementation reports/test counts to match actual result-bundle evidence.
4. Request another independent review before approval; retain device-only qualifications.