@hasna/recordings 0.2.11 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -69
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +72 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1919 -98
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1973 -236
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -1,11 +1,97 @@
|
|
|
1
1
|
import Testing
|
|
2
|
+
import Foundation
|
|
2
3
|
@testable import RecordingsLib
|
|
3
4
|
|
|
5
|
+
@MainActor
|
|
6
|
+
private final class FakeRealtimeClock {
|
|
7
|
+
var nowMilliseconds: UInt64 = 0
|
|
8
|
+
|
|
9
|
+
func advance(by milliseconds: UInt64) {
|
|
10
|
+
nowMilliseconds += milliseconds
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@MainActor
|
|
15
|
+
private final class RealtimeDeliveryProbe {
|
|
16
|
+
var deliveredText: String?
|
|
17
|
+
var persistedResult: RealtimeFastPathSaveResult?
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private actor BlockingRealtimePersistence {
|
|
21
|
+
private let result: RealtimeFastPathSaveResult
|
|
22
|
+
private var started = false
|
|
23
|
+
private var released = false
|
|
24
|
+
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
|
25
|
+
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
|
|
26
|
+
|
|
27
|
+
init(result: RealtimeFastPathSaveResult = RealtimeFastPathSaveResult(text: "stored text", error: nil)) {
|
|
28
|
+
self.result = result
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func run() async -> RealtimeFastPathSaveResult {
|
|
32
|
+
started = true
|
|
33
|
+
let waiters = startWaiters
|
|
34
|
+
startWaiters.removeAll()
|
|
35
|
+
waiters.forEach { $0.resume() }
|
|
36
|
+
if !released {
|
|
37
|
+
await withCheckedContinuation { releaseWaiters.append($0) }
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func waitUntilStarted() async {
|
|
43
|
+
if started { return }
|
|
44
|
+
await withCheckedContinuation { startWaiters.append($0) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func hasStarted() -> Bool {
|
|
48
|
+
started
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func release() {
|
|
52
|
+
released = true
|
|
53
|
+
let waiters = releaseWaiters
|
|
54
|
+
releaseWaiters.removeAll()
|
|
55
|
+
waiters.forEach { $0.resume() }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private actor BlockingRealtimeDelivery {
|
|
60
|
+
private var started = false
|
|
61
|
+
private var released = false
|
|
62
|
+
private var startWaiters: [CheckedContinuation<Void, Never>] = []
|
|
63
|
+
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
|
|
64
|
+
|
|
65
|
+
func run() async {
|
|
66
|
+
started = true
|
|
67
|
+
let waiters = startWaiters
|
|
68
|
+
startWaiters.removeAll()
|
|
69
|
+
waiters.forEach { $0.resume() }
|
|
70
|
+
if !released {
|
|
71
|
+
await withCheckedContinuation { releaseWaiters.append($0) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func waitUntilStarted() async {
|
|
76
|
+
if started { return }
|
|
77
|
+
await withCheckedContinuation { startWaiters.append($0) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func release() {
|
|
81
|
+
released = true
|
|
82
|
+
let waiters = releaseWaiters
|
|
83
|
+
releaseWaiters.removeAll()
|
|
84
|
+
waiters.forEach { $0.resume() }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
4
88
|
// MARK: - RealtimeTranscriptionClient Event Parsing Tests
|
|
5
89
|
|
|
6
90
|
struct RealtimeTranscriptionTests {
|
|
7
91
|
@Test("Model ID is set to low-latency realtime transcription model")
|
|
8
92
|
func modelID() {
|
|
93
|
+
#expect(RealtimeTranscriptionClient.sessionModelID == "gpt-realtime")
|
|
94
|
+
#expect(RealtimeTranscriptionClient.transcriptionModelID == "gpt-realtime-whisper")
|
|
9
95
|
#expect(RealtimeTranscriptionClient.modelID == "gpt-realtime-whisper")
|
|
10
96
|
#expect(RealtimeTranscriptionClient.transcriptionDelay == "low")
|
|
11
97
|
}
|
|
@@ -71,6 +157,44 @@ struct RealtimeTranscriptionTests {
|
|
|
71
157
|
#expect(RealtimeTranscriptionClient.parseErrorTestHelper(errorJSON) == "Model not found")
|
|
72
158
|
}
|
|
73
159
|
|
|
160
|
+
@Test("Realtime error parsing redacts credentials before returning display-safe text")
|
|
161
|
+
func parseErrorMessageRedactsCredentials() throws {
|
|
162
|
+
let keyFragment = "sk-" + "synthetic-fragment-123456"
|
|
163
|
+
let bearerFragment = "synthetic-bearer-123456"
|
|
164
|
+
let tokenFragment = "synthetic-query-token-123456"
|
|
165
|
+
let message = "401 Incorrect API key provided: \(keyFragment); Authorization: Bearer \(bearerFragment); request?token=\(tokenFragment)"
|
|
166
|
+
let event: [String: Any] = ["type": "error", "error": ["message": message, "code": 401]]
|
|
167
|
+
let data = try JSONSerialization.data(withJSONObject: event)
|
|
168
|
+
let json = try #require(String(data: data, encoding: .utf8))
|
|
169
|
+
|
|
170
|
+
let parsed = try #require(RealtimeTranscriptionClient.parseErrorTestHelper(json))
|
|
171
|
+
|
|
172
|
+
#expect(!parsed.contains(keyFragment))
|
|
173
|
+
#expect(!parsed.contains(bearerFragment))
|
|
174
|
+
#expect(!parsed.contains(tokenFragment))
|
|
175
|
+
#expect(parsed.contains("401 Incorrect API key provided"))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@Test("Realtime error events store only sanitized state and preserve ordinary errors")
|
|
179
|
+
@MainActor
|
|
180
|
+
func errorEventStateIsSanitized() throws {
|
|
181
|
+
let keyFragment = "sk-" + "synthetic-state-fragment-123456"
|
|
182
|
+
let event: [String: Any] = [
|
|
183
|
+
"type": "conversation.item.input_audio_transcription.failed",
|
|
184
|
+
"error": ["message": "401 rejected \(keyFragment)"],
|
|
185
|
+
]
|
|
186
|
+
let data = try JSONSerialization.data(withJSONObject: event)
|
|
187
|
+
let json = try #require(String(data: data, encoding: .utf8))
|
|
188
|
+
let client = RealtimeTranscriptionClient(apiKey: "synthetic-test-value", homePath: "/tmp")
|
|
189
|
+
|
|
190
|
+
client.handleEventTestHelper(json)
|
|
191
|
+
|
|
192
|
+
#expect(client.error == "401 rejected [REDACTED]")
|
|
193
|
+
#expect(RealtimeTranscriptionClient.parseErrorTestHelper(
|
|
194
|
+
#"{"type":"error","error":{"message":"Model not found"}}"#
|
|
195
|
+
) == "Model not found")
|
|
196
|
+
}
|
|
197
|
+
|
|
74
198
|
@Test("Builds strict verbatim prompt with vocabulary context")
|
|
75
199
|
func buildPrompt() {
|
|
76
200
|
let prompt = RealtimeTranscriptionClient.buildPromptTestHelper("Alumia, Takumi")
|
|
@@ -100,8 +224,7 @@ struct RealtimeTranscriptionTests {
|
|
|
100
224
|
#expect(transcription?["prompt"] as? String == nil)
|
|
101
225
|
#expect(transcription?["language"] as? String == "en")
|
|
102
226
|
|
|
103
|
-
|
|
104
|
-
#expect(turnDetection == nil)
|
|
227
|
+
#expect(input?["turn_detection"] is NSNull)
|
|
105
228
|
|
|
106
229
|
let include = session?["include"] as? [String]
|
|
107
230
|
#expect(include == nil)
|
|
@@ -119,6 +242,710 @@ struct RealtimeTranscriptionTests {
|
|
|
119
242
|
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
|
|
120
243
|
}
|
|
121
244
|
|
|
245
|
+
@Test("Realtime configuration buffering fails closed before dropping early audio")
|
|
246
|
+
func pendingAudioOverflowRequiresBatchFallback() {
|
|
247
|
+
var buffer = RealtimePendingAudioBuffer(maximumByteCount: 6)
|
|
248
|
+
let acceptedFirstChunk = buffer.append(Data([1, 2, 3]))
|
|
249
|
+
let acceptedSecondChunk = buffer.append(Data([4, 5, 6]))
|
|
250
|
+
let acceptedOverflowChunk = buffer.append(Data([7, 8, 9]))
|
|
251
|
+
let retainedChunkCount = buffer.chunkCount
|
|
252
|
+
let retainedByteCount = buffer.byteCount
|
|
253
|
+
let retainedChunks = buffer.drain()
|
|
254
|
+
|
|
255
|
+
#expect(acceptedFirstChunk)
|
|
256
|
+
#expect(acceptedSecondChunk)
|
|
257
|
+
#expect(!acceptedOverflowChunk)
|
|
258
|
+
#expect(retainedChunkCount == 2)
|
|
259
|
+
#expect(retainedByteCount == 6)
|
|
260
|
+
#expect(retainedChunks == [Data([1, 2, 3]), Data([4, 5, 6])])
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Test("Configured realtime outbound audio stays bounded without Base64 task accumulation")
|
|
264
|
+
func configuredOutboundAudioQueueIsBounded() {
|
|
265
|
+
var queue = RealtimeOutboundEventBuffer(maximumByteCount: 8, maximumEventCount: 2)
|
|
266
|
+
|
|
267
|
+
let acceptedFirst = queue.append(.audio(Data([1, 2, 3, 4])), sequence: 0)
|
|
268
|
+
let acceptedSecond = queue.append(.audio(Data([5, 6, 7, 8])), sequence: 1)
|
|
269
|
+
let acceptedOverflow = queue.append(.audio(Data([9])), sequence: 2)
|
|
270
|
+
#expect(acceptedFirst)
|
|
271
|
+
#expect(acceptedSecond)
|
|
272
|
+
#expect(!acceptedOverflow)
|
|
273
|
+
#expect(queue.byteCount == 8)
|
|
274
|
+
#expect(queue.eventCount == 2)
|
|
275
|
+
|
|
276
|
+
#expect(queue.popFirst()?.payload == .audio(Data([1, 2, 3, 4])))
|
|
277
|
+
#expect(queue.byteCount == 4)
|
|
278
|
+
#expect(queue.eventCount == 1)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@Test("A stalled configured WebSocket send reaches a bounded deadline and forces fallback")
|
|
282
|
+
@MainActor
|
|
283
|
+
func configuredOutboundSendDeadlineIsBounded() async {
|
|
284
|
+
let client = RealtimeTranscriptionClient(apiKey: "synthetic-test-value", homePath: "/tmp")
|
|
285
|
+
let clock = FakeRealtimeClock()
|
|
286
|
+
let sent = await client.enqueueOutboundOperationTestHelper(
|
|
287
|
+
timeoutMilliseconds: 50,
|
|
288
|
+
operation: {
|
|
289
|
+
try await Task.sleep(for: .seconds(60))
|
|
290
|
+
},
|
|
291
|
+
nowMilliseconds: { clock.nowMilliseconds },
|
|
292
|
+
sleepMilliseconds: { milliseconds in
|
|
293
|
+
await Task.yield()
|
|
294
|
+
clock.advance(by: milliseconds)
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
#expect(!sent)
|
|
299
|
+
#expect(clock.nowMilliseconds == 50)
|
|
300
|
+
#expect(client.error?.contains("timed out") == true)
|
|
301
|
+
#expect(client.settlementResolutionTestHelper() == .failed)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@Test("Final realtime deadline includes a stalled outbound commit chain")
|
|
305
|
+
@MainActor
|
|
306
|
+
func stalledOutboundCommitCannotExtendFinishDeadline() async {
|
|
307
|
+
let clock = FakeRealtimeClock()
|
|
308
|
+
var commitStarted = false
|
|
309
|
+
let waitResult = await RealtimeTranscriptionClient.waitForSettlementWhileCommitRunsTestHelper(
|
|
310
|
+
timeoutMilliseconds: 700,
|
|
311
|
+
beginCommit: {
|
|
312
|
+
commitStarted = true
|
|
313
|
+
try? await Task.sleep(for: .seconds(60))
|
|
314
|
+
return false
|
|
315
|
+
},
|
|
316
|
+
resolution: { .waiting },
|
|
317
|
+
nowMilliseconds: { clock.nowMilliseconds },
|
|
318
|
+
sleepMilliseconds: { milliseconds in
|
|
319
|
+
await Task.yield()
|
|
320
|
+
clock.advance(by: milliseconds)
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
#expect(commitStarted)
|
|
325
|
+
#expect(waitResult.resolution == .waiting)
|
|
326
|
+
#expect(waitResult.elapsedMilliseconds == 700)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@Test("WebSocket send failure settles immediately and never accepts partial realtime text")
|
|
330
|
+
@MainActor
|
|
331
|
+
func outboundSendFailureRequiresImmediateBatchFallback() async {
|
|
332
|
+
struct SyntheticSendFailure: Error {}
|
|
333
|
+
let client = RealtimeTranscriptionClient(apiKey: "synthetic-test-value", homePath: "/tmp")
|
|
334
|
+
let sent = await client.enqueueOutboundOperationTestHelper {
|
|
335
|
+
throw SyntheticSendFailure()
|
|
336
|
+
}
|
|
337
|
+
#expect(!sent)
|
|
338
|
+
#expect(client.error?.contains("Realtime send failed") == true)
|
|
339
|
+
|
|
340
|
+
let clock = FakeRealtimeClock()
|
|
341
|
+
let waitResult = await RealtimeTranscriptionClient.waitForSettlementTestHelper(
|
|
342
|
+
timeoutMilliseconds: 700,
|
|
343
|
+
resolution: { client.settlementResolutionTestHelper() },
|
|
344
|
+
nowMilliseconds: { clock.nowMilliseconds },
|
|
345
|
+
sleepMilliseconds: { clock.advance(by: $0) }
|
|
346
|
+
)
|
|
347
|
+
#expect(waitResult.resolution == .failed)
|
|
348
|
+
#expect(waitResult.elapsedMilliseconds == 0)
|
|
349
|
+
|
|
350
|
+
let failedResult = RealtimeFinishResult(
|
|
351
|
+
text: "optimistic partial",
|
|
352
|
+
settled: false,
|
|
353
|
+
error: client.error
|
|
354
|
+
)
|
|
355
|
+
#expect(RecordingEngine.settledRealtimeFastPathTranscript(
|
|
356
|
+
finishResult: failedResult,
|
|
357
|
+
pcmByteCount: 96_000,
|
|
358
|
+
language: "en"
|
|
359
|
+
) == nil)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@Test("Realtime finish waits for the exact final item and every prior item")
|
|
363
|
+
func exactFinalItemSettlementHandlesOutOfOrderCompletion() {
|
|
364
|
+
var tracker = RealtimeCommitSettlementTracker()
|
|
365
|
+
tracker.queueCommit(isFinal: false)
|
|
366
|
+
tracker.queueCommit(isFinal: true)
|
|
367
|
+
tracker.complete(itemID: "item-final")
|
|
368
|
+
tracker.acknowledge(itemID: "item-prior")
|
|
369
|
+
tracker.acknowledge(itemID: "item-final")
|
|
370
|
+
#expect(tracker.resolution(uncommittedRealAudioBytes: 0) == .waiting)
|
|
371
|
+
|
|
372
|
+
tracker.complete(itemID: "item-prior")
|
|
373
|
+
#expect(tracker.finalItemID == "item-final")
|
|
374
|
+
#expect(tracker.resolution(uncommittedRealAudioBytes: 1) == .waiting)
|
|
375
|
+
#expect(tracker.resolution(uncommittedRealAudioBytes: 0) == .settled)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@Test("Commit settlement handles duplicate acknowledgements, prior failure, and periodic reuse")
|
|
379
|
+
func commitSettlementEdgeCases() {
|
|
380
|
+
var tracker = RealtimeCommitSettlementTracker()
|
|
381
|
+
let priorSequence = tracker.queueCommit(isFinal: false)
|
|
382
|
+
let finalSequence = tracker.queueCommit(isFinal: false)
|
|
383
|
+
#expect(tracker.acknowledge(itemID: "item-prior")?.sequence == priorSequence)
|
|
384
|
+
#expect(tracker.acknowledge(itemID: "item-prior")?.sequence == priorSequence)
|
|
385
|
+
#expect(tracker.acknowledge(itemID: "item-final")?.sequence == finalSequence)
|
|
386
|
+
let didMarkLatestCommitFinal = tracker.markLatestCommitFinal()
|
|
387
|
+
#expect(didMarkLatestCommitFinal)
|
|
388
|
+
#expect(tracker.finalItemID == "item-final")
|
|
389
|
+
|
|
390
|
+
tracker.complete(itemID: "item-final")
|
|
391
|
+
tracker.fail(itemID: "item-prior")
|
|
392
|
+
#expect(tracker.resolution(uncommittedRealAudioBytes: 0) == .failed)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
@Test("A final completion at 690 ms settles without a batch fallback")
|
|
396
|
+
@MainActor
|
|
397
|
+
func finalCompletionNearDeadlineUsesFastPath() async {
|
|
398
|
+
var tracker = RealtimeCommitSettlementTracker()
|
|
399
|
+
tracker.queueCommit(isFinal: true)
|
|
400
|
+
let clock = FakeRealtimeClock()
|
|
401
|
+
|
|
402
|
+
let waitResult = await RealtimeTranscriptionClient.waitForSettlementTestHelper(
|
|
403
|
+
timeoutMilliseconds: 700,
|
|
404
|
+
resolution: {
|
|
405
|
+
tracker.resolution(uncommittedRealAudioBytes: 0)
|
|
406
|
+
},
|
|
407
|
+
nowMilliseconds: {
|
|
408
|
+
clock.nowMilliseconds
|
|
409
|
+
},
|
|
410
|
+
sleepMilliseconds: { milliseconds in
|
|
411
|
+
clock.advance(by: milliseconds)
|
|
412
|
+
if clock.nowMilliseconds == 10 {
|
|
413
|
+
tracker.acknowledge(itemID: "item-final")
|
|
414
|
+
}
|
|
415
|
+
if clock.nowMilliseconds >= 690 {
|
|
416
|
+
tracker.complete(itemID: "item-final")
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
#expect(waitResult.resolution == .settled)
|
|
422
|
+
#expect(waitResult.elapsedMilliseconds == 690)
|
|
423
|
+
let finishResult = RealtimeFinishResult(text: "final words", settled: true, error: nil)
|
|
424
|
+
#expect(RecordingEngine.settledRealtimeFastPathTranscript(
|
|
425
|
+
finishResult: finishResult,
|
|
426
|
+
pcmByteCount: 12_000,
|
|
427
|
+
language: "en"
|
|
428
|
+
) == "final words")
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
@Test("Settlement deadline arithmetic saturates at UInt64 bounds")
|
|
432
|
+
@MainActor
|
|
433
|
+
func settlementDeadlineDoesNotOverflow() async {
|
|
434
|
+
var now = UInt64.max - 5
|
|
435
|
+
let waitResult = await RealtimeTranscriptionClient.waitForSettlementTestHelper(
|
|
436
|
+
timeoutMilliseconds: 10,
|
|
437
|
+
resolution: { .waiting },
|
|
438
|
+
nowMilliseconds: { now },
|
|
439
|
+
sleepMilliseconds: { milliseconds in
|
|
440
|
+
let (advanced, overflow) = now.addingReportingOverflow(milliseconds)
|
|
441
|
+
now = overflow ? UInt64.max : advanced
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
#expect(waitResult.resolution == .waiting)
|
|
446
|
+
#expect(waitResult.elapsedMilliseconds == 5)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
@Test("Periodic realtime commits use bounded monotonic elapsed time")
|
|
450
|
+
func periodicCommitSchedulingIsMonotonicAndBounded() {
|
|
451
|
+
#expect(RecordingEngine.realtimePeriodicCommitIsDue(
|
|
452
|
+
nowMilliseconds: 10,
|
|
453
|
+
lastCommitMilliseconds: nil
|
|
454
|
+
))
|
|
455
|
+
#expect(!RecordingEngine.realtimePeriodicCommitIsDue(
|
|
456
|
+
nowMilliseconds: 999,
|
|
457
|
+
lastCommitMilliseconds: 100
|
|
458
|
+
))
|
|
459
|
+
#expect(RecordingEngine.realtimePeriodicCommitIsDue(
|
|
460
|
+
nowMilliseconds: 1_000,
|
|
461
|
+
lastCommitMilliseconds: 100
|
|
462
|
+
))
|
|
463
|
+
#expect(!RecordingEngine.realtimePeriodicCommitIsDue(
|
|
464
|
+
nowMilliseconds: 99,
|
|
465
|
+
lastCommitMilliseconds: 100
|
|
466
|
+
))
|
|
467
|
+
#expect(!RecordingEngine.realtimePeriodicCommitIsDue(
|
|
468
|
+
nowMilliseconds: UInt64.max,
|
|
469
|
+
lastCommitMilliseconds: UInt64.max - 899
|
|
470
|
+
))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@Test("A sub-threshold final PCM tail is retained and padded only for realtime commit")
|
|
474
|
+
func subThresholdFinalTailIsPadded() {
|
|
475
|
+
let storedPCM = Data((0..<1_280).map { UInt8($0 % 251) })
|
|
476
|
+
let storedCopy = storedPCM
|
|
477
|
+
let plan = RealtimeTranscriptionClient.finalCommitPlanTestHelper(
|
|
478
|
+
realAudioByteCount: storedPCM.count
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
#expect(plan.realAudioByteCount == 1_280)
|
|
482
|
+
#expect(plan.paddingByteCount == 4_480)
|
|
483
|
+
#expect(plan.committedAudioByteCount == 5_760)
|
|
484
|
+
#expect(plan.realtimePadding.count == 4_480)
|
|
485
|
+
#expect(plan.realtimePadding.allSatisfy { $0 == 0 })
|
|
486
|
+
#expect(storedPCM == storedCopy)
|
|
487
|
+
#expect(RealtimeTranscriptionClient.finalCommitPlanTestHelper(
|
|
488
|
+
realAudioByteCount: 5_759
|
|
489
|
+
).paddingByteCount == 1)
|
|
490
|
+
#expect(RealtimeTranscriptionClient.finalCommitPlanTestHelper(
|
|
491
|
+
realAudioByteCount: 5_760
|
|
492
|
+
).paddingByteCount == 0)
|
|
493
|
+
#expect(RealtimeTranscriptionClient.finalCommitPlanTestHelper(
|
|
494
|
+
realAudioByteCount: 0
|
|
495
|
+
).committedAudioByteCount == 0)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
@Test("Failed and unacknowledged final items require batch fallback without partial paste")
|
|
499
|
+
func incompleteFinalItemsRejectFastPath() {
|
|
500
|
+
var failedTracker = RealtimeCommitSettlementTracker()
|
|
501
|
+
failedTracker.queueCommit(isFinal: true)
|
|
502
|
+
failedTracker.acknowledge(itemID: "item-final")
|
|
503
|
+
failedTracker.fail(itemID: "item-final")
|
|
504
|
+
failedTracker.complete(itemID: "item-final")
|
|
505
|
+
#expect(failedTracker.resolution(uncommittedRealAudioBytes: 0) == .failed)
|
|
506
|
+
|
|
507
|
+
var unacknowledgedTracker = RealtimeCommitSettlementTracker()
|
|
508
|
+
unacknowledgedTracker.queueCommit(isFinal: true)
|
|
509
|
+
unacknowledgedTracker.complete(itemID: "unbound-completion")
|
|
510
|
+
#expect(unacknowledgedTracker.resolution(uncommittedRealAudioBytes: 0) == .waiting)
|
|
511
|
+
|
|
512
|
+
let partialResult = RealtimeFinishResult(text: "optimistic partial", settled: false, error: nil)
|
|
513
|
+
#expect(RecordingEngine.settledRealtimeFastPathTranscript(
|
|
514
|
+
finishResult: partialResult,
|
|
515
|
+
pcmByteCount: 96_000,
|
|
516
|
+
language: "en"
|
|
517
|
+
) == nil)
|
|
518
|
+
#expect(RecordingEngine.settledRealtimeFallbackTranscript(
|
|
519
|
+
finishResult: partialResult,
|
|
520
|
+
pcmByteCount: 96_000,
|
|
521
|
+
language: "en"
|
|
522
|
+
) == nil)
|
|
523
|
+
|
|
524
|
+
let settledPartialResult = RealtimeFinishResult(text: "Hi", settled: true, error: nil)
|
|
525
|
+
#expect(RecordingEngine.settledRealtimeFallbackTranscript(
|
|
526
|
+
finishResult: settledPartialResult,
|
|
527
|
+
pcmByteCount: 96_000,
|
|
528
|
+
language: "en"
|
|
529
|
+
) == nil)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
@Test("Disabled transformation pastes before blocked persistence completes")
|
|
533
|
+
@MainActor
|
|
534
|
+
func blockedPersistenceDoesNotDelayPaste() async {
|
|
535
|
+
let delivery = BlockingRealtimeDelivery()
|
|
536
|
+
let persistence = BlockingRealtimePersistence()
|
|
537
|
+
let probe = RealtimeDeliveryProbe()
|
|
538
|
+
|
|
539
|
+
let persistenceTask = RecordingEngine.deliverRealtimeBeforePersistence(
|
|
540
|
+
text: "settled realtime text",
|
|
541
|
+
persist: { await persistence.run() },
|
|
542
|
+
deliver: {
|
|
543
|
+
probe.deliveredText = $0
|
|
544
|
+
await delivery.run()
|
|
545
|
+
},
|
|
546
|
+
persistenceCompleted: { probe.persistedResult = $0 }
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
#expect(probe.persistedResult?.text == nil)
|
|
550
|
+
await delivery.waitUntilStarted()
|
|
551
|
+
#expect(await persistence.hasStarted() == false)
|
|
552
|
+
await delivery.release()
|
|
553
|
+
await persistence.waitUntilStarted()
|
|
554
|
+
#expect(probe.deliveredText == "settled realtime text")
|
|
555
|
+
|
|
556
|
+
await persistence.release()
|
|
557
|
+
await persistenceTask.value
|
|
558
|
+
#expect(probe.persistedResult?.text == "stored text")
|
|
559
|
+
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
560
|
+
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
561
|
+
transcript: "settled realtime text",
|
|
562
|
+
hasSelection: false,
|
|
563
|
+
intentDetectionEnabled: true
|
|
564
|
+
))
|
|
565
|
+
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
566
|
+
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
567
|
+
transcript: "rewrite this to be more formal",
|
|
568
|
+
hasSelection: true,
|
|
569
|
+
intentDetectionEnabled: true
|
|
570
|
+
) == false)
|
|
571
|
+
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
572
|
+
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
573
|
+
transcript: "settled realtime text",
|
|
574
|
+
hasSelection: false,
|
|
575
|
+
intentDetectionEnabled: true
|
|
576
|
+
) == false)
|
|
577
|
+
#expect(RecordingEngine.shouldLabelRewriting(
|
|
578
|
+
postProcessingMode: PostProcessingMode.always.rawValue
|
|
579
|
+
))
|
|
580
|
+
#expect(RecordingEngine.shouldLabelRewriting(
|
|
581
|
+
postProcessingMode: PostProcessingMode.auto.rawValue
|
|
582
|
+
))
|
|
583
|
+
#expect(RecordingEngine.shouldLabelRewriting(
|
|
584
|
+
postProcessingMode: PostProcessingMode.off.rawValue
|
|
585
|
+
) == false)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
@Test("Async realtime delivery prompts once before persistence and normal launch never prompts")
|
|
589
|
+
@MainActor
|
|
590
|
+
func asyncRealtimeDeliveryUsesProcessPromptGate() async {
|
|
591
|
+
let normalLaunch = PermissionRequestLaunchPlan(arguments: ["Recordings"])
|
|
592
|
+
let gate = AccessibilityPromptGate()
|
|
593
|
+
let persistence = BlockingRealtimePersistence()
|
|
594
|
+
let probe = RealtimeDeliveryProbe()
|
|
595
|
+
var promptCalls = 0
|
|
596
|
+
|
|
597
|
+
#expect(!normalLaunch.requestsAccessibilityPrompt)
|
|
598
|
+
#expect(gate.promptRequestCount == 0)
|
|
599
|
+
|
|
600
|
+
let persistenceTask = RecordingEngine.deliverRealtimeBeforePersistence(
|
|
601
|
+
text: "settled realtime text",
|
|
602
|
+
persist: { await persistence.run() },
|
|
603
|
+
deliver: { text in
|
|
604
|
+
let first = gate.trustForProtectedOperation(
|
|
605
|
+
isTrusted: { false },
|
|
606
|
+
requestPrompt: {
|
|
607
|
+
promptCalls += 1
|
|
608
|
+
return false
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
let repeatedCheck = gate.trustForProtectedOperation(
|
|
612
|
+
isTrusted: { false },
|
|
613
|
+
requestPrompt: {
|
|
614
|
+
promptCalls += 1
|
|
615
|
+
return false
|
|
616
|
+
}
|
|
617
|
+
)
|
|
618
|
+
#expect(first.didPrompt)
|
|
619
|
+
#expect(!repeatedCheck.didPrompt)
|
|
620
|
+
probe.deliveredText = text
|
|
621
|
+
},
|
|
622
|
+
persistenceCompleted: { probe.persistedResult = $0 }
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
await persistence.waitUntilStarted()
|
|
626
|
+
#expect(probe.deliveredText == "settled realtime text")
|
|
627
|
+
#expect(promptCalls == 1)
|
|
628
|
+
#expect(gate.promptRequestCount == 1)
|
|
629
|
+
#expect(probe.persistedResult == nil)
|
|
630
|
+
|
|
631
|
+
await persistence.release()
|
|
632
|
+
await persistenceTask.value
|
|
633
|
+
#expect(probe.persistedResult?.text == "stored text")
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
@Test("Async save failure starts recovery only after realtime text is delivered")
|
|
637
|
+
@MainActor
|
|
638
|
+
func asyncSaveFailureRecoversAfterDelivery() async {
|
|
639
|
+
let persistence = BlockingRealtimePersistence(
|
|
640
|
+
result: RealtimeFastPathSaveResult(text: nil, error: "synthetic save failure")
|
|
641
|
+
)
|
|
642
|
+
let probe = RealtimeDeliveryProbe()
|
|
643
|
+
var recoveryError: String?
|
|
644
|
+
|
|
645
|
+
let persistenceTask = RecordingEngine.deliverRealtimeBeforePersistence(
|
|
646
|
+
text: "settled realtime text",
|
|
647
|
+
persist: { await persistence.run() },
|
|
648
|
+
deliver: { probe.deliveredText = $0 },
|
|
649
|
+
persistenceCompleted: { result in
|
|
650
|
+
#expect(probe.deliveredText == "settled realtime text")
|
|
651
|
+
recoveryError = result.error
|
|
652
|
+
probe.persistedResult = result
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
await persistence.waitUntilStarted()
|
|
657
|
+
#expect(probe.deliveredText == "settled realtime text")
|
|
658
|
+
#expect(recoveryError == nil)
|
|
659
|
+
|
|
660
|
+
await persistence.release()
|
|
661
|
+
await persistenceTask.value
|
|
662
|
+
#expect(recoveryError == "synthetic save failure")
|
|
663
|
+
#expect(probe.persistedResult?.text == nil)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
@Test("Persistence revision advances only for confirmed saves")
|
|
667
|
+
@MainActor
|
|
668
|
+
func persistenceRevisionTracksConfirmedSaves() {
|
|
669
|
+
let engine = RecordingEngine()
|
|
670
|
+
|
|
671
|
+
#expect(engine.persistedRecordingRevision == 0)
|
|
672
|
+
engine.recordPersistenceCompletion(savedText: nil)
|
|
673
|
+
#expect(engine.persistedRecordingRevision == 0)
|
|
674
|
+
|
|
675
|
+
engine.recordPersistenceCompletion(savedText: "stored realtime text")
|
|
676
|
+
#expect(engine.persistedRecordingRevision == 1)
|
|
677
|
+
engine.recordPersistenceCompletion(savedText: "recovered full-audio text")
|
|
678
|
+
#expect(engine.persistedRecordingRevision == 2)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
@Test("Background recovery never overwrites a newer recording pipeline or pastes twice")
|
|
682
|
+
func backgroundRecoveryRespectsPipelineGeneration() {
|
|
683
|
+
#expect(RecordingEngine.shouldApplyBackgroundRecoveryStatus(
|
|
684
|
+
recoveryGeneration: 4,
|
|
685
|
+
currentGeneration: 4,
|
|
686
|
+
isRecording: false,
|
|
687
|
+
isTranscribing: false
|
|
688
|
+
))
|
|
689
|
+
#expect(!RecordingEngine.shouldApplyBackgroundRecoveryStatus(
|
|
690
|
+
recoveryGeneration: 4,
|
|
691
|
+
currentGeneration: 5,
|
|
692
|
+
isRecording: false,
|
|
693
|
+
isTranscribing: false
|
|
694
|
+
))
|
|
695
|
+
#expect(!RecordingEngine.shouldApplyBackgroundRecoveryStatus(
|
|
696
|
+
recoveryGeneration: 4,
|
|
697
|
+
currentGeneration: 4,
|
|
698
|
+
isRecording: true,
|
|
699
|
+
isTranscribing: false
|
|
700
|
+
))
|
|
701
|
+
#expect(!RecordingEngine.shouldApplyBackgroundRecoveryStatus(
|
|
702
|
+
recoveryGeneration: 4,
|
|
703
|
+
currentGeneration: 4,
|
|
704
|
+
isRecording: false,
|
|
705
|
+
isTranscribing: true
|
|
706
|
+
))
|
|
707
|
+
|
|
708
|
+
#expect(RecordingEngine.fallbackCompletionAction(
|
|
709
|
+
cliText: "saved full-file transcript",
|
|
710
|
+
cliError: nil,
|
|
711
|
+
realtimeText: nil,
|
|
712
|
+
deliverResult: false
|
|
713
|
+
) == .backgroundRecovered)
|
|
714
|
+
#expect(RecordingEngine.fallbackCompletionAction(
|
|
715
|
+
cliText: nil,
|
|
716
|
+
cliError: "synthetic fallback failure",
|
|
717
|
+
realtimeText: nil,
|
|
718
|
+
deliverResult: false
|
|
719
|
+
) == .backgroundFailed("synthetic fallback failure"))
|
|
720
|
+
#expect(RecordingEngine.fallbackCompletionAction(
|
|
721
|
+
cliText: "foreground transcript",
|
|
722
|
+
cliError: nil,
|
|
723
|
+
realtimeText: nil,
|
|
724
|
+
deliverResult: true
|
|
725
|
+
) == .deliver("foreground transcript"))
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
@Test("Delayed delivery from recording A pastes once without overwriting recording B")
|
|
729
|
+
func delayedDeliveryRespectsNewerPipeline() {
|
|
730
|
+
var gate = PipelineDeliveryGate()
|
|
731
|
+
gate.registerPipeline(4)
|
|
732
|
+
gate.registerPipeline(5)
|
|
733
|
+
|
|
734
|
+
let firstA = gate.claimDelivery(for: 4)
|
|
735
|
+
let duplicateA = gate.claimDelivery(for: 4)
|
|
736
|
+
let firstB = gate.claimDelivery(for: 5)
|
|
737
|
+
#expect(firstA)
|
|
738
|
+
#expect(!duplicateA)
|
|
739
|
+
#expect(firstB)
|
|
740
|
+
#expect(!gate.shouldApplyStatus(
|
|
741
|
+
deliveryGeneration: 4,
|
|
742
|
+
currentGeneration: 5,
|
|
743
|
+
isRecording: true,
|
|
744
|
+
isTranscribing: false
|
|
745
|
+
))
|
|
746
|
+
#expect(gate.shouldApplyStatus(
|
|
747
|
+
deliveryGeneration: 5,
|
|
748
|
+
currentGeneration: 5,
|
|
749
|
+
isRecording: false,
|
|
750
|
+
isTranscribing: false
|
|
751
|
+
))
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
@Test("Delivery claims compact without forgetting old or out-of-order generations")
|
|
755
|
+
func deliveryClaimsRemainExactAfterCompaction() {
|
|
756
|
+
var gate = PipelineDeliveryGate()
|
|
757
|
+
var sequentialClaims: [Bool] = []
|
|
758
|
+
for generation in 1...64 {
|
|
759
|
+
gate.registerPipeline(UInt64(generation))
|
|
760
|
+
sequentialClaims.append(gate.claimDelivery(for: UInt64(generation)))
|
|
761
|
+
}
|
|
762
|
+
let duplicateOne = gate.claimDelivery(for: 1)
|
|
763
|
+
let duplicateThirtyTwo = gate.claimDelivery(for: 32)
|
|
764
|
+
#expect(sequentialClaims.allSatisfy { $0 })
|
|
765
|
+
#expect(!duplicateOne)
|
|
766
|
+
#expect(!duplicateThirtyTwo)
|
|
767
|
+
|
|
768
|
+
var outOfOrder = PipelineDeliveryGate()
|
|
769
|
+
outOfOrder.registerPipeline(100)
|
|
770
|
+
outOfOrder.registerPipeline(1)
|
|
771
|
+
let highClaim = outOfOrder.claimDelivery(for: 100)
|
|
772
|
+
let pendingLowClaim = outOfOrder.claimDelivery(for: 1)
|
|
773
|
+
let duplicateLowClaim = outOfOrder.claimDelivery(for: 1)
|
|
774
|
+
outOfOrder.registerPipeline(99)
|
|
775
|
+
let pendingMiddleClaim = outOfOrder.claimDelivery(for: 99)
|
|
776
|
+
let duplicateHighClaim = outOfOrder.claimDelivery(for: 100)
|
|
777
|
+
#expect(highClaim)
|
|
778
|
+
#expect(pendingLowClaim)
|
|
779
|
+
#expect(!duplicateLowClaim)
|
|
780
|
+
#expect(pendingMiddleClaim)
|
|
781
|
+
#expect(!duplicateHighClaim)
|
|
782
|
+
|
|
783
|
+
var gapped = PipelineDeliveryGate()
|
|
784
|
+
var gappedClaims: [Bool] = []
|
|
785
|
+
for generation in stride(from: 2, through: 200, by: 2) {
|
|
786
|
+
gapped.registerPipeline(UInt64(generation))
|
|
787
|
+
gappedClaims.append(gapped.claimDelivery(for: UInt64(generation)))
|
|
788
|
+
}
|
|
789
|
+
let duplicateTwo = gapped.claimDelivery(for: 2)
|
|
790
|
+
let duplicateOneNinetyEight = gapped.claimDelivery(for: 198)
|
|
791
|
+
#expect(gappedClaims.allSatisfy { $0 })
|
|
792
|
+
#expect(!duplicateTwo)
|
|
793
|
+
#expect(!duplicateOneNinetyEight)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
@Test("Background recovery reconstructs missing WAV without replacing an existing capture")
|
|
797
|
+
func backgroundRecoveryAudioReconstruction() throws {
|
|
798
|
+
let directory = FileManager.default.temporaryDirectory
|
|
799
|
+
.appendingPathComponent("recordings-recovery-\(UUID().uuidString)", isDirectory: true)
|
|
800
|
+
let audioURL = directory.appendingPathComponent("capture.wav")
|
|
801
|
+
defer { try? FileManager.default.removeItem(at: directory) }
|
|
802
|
+
|
|
803
|
+
let pcm = Data([0x01, 0x02, 0x03, 0x04])
|
|
804
|
+
#expect(RecordingEngine.ensureBackgroundRecoveryAudio(
|
|
805
|
+
audioPath: audioURL.path,
|
|
806
|
+
pcmData: pcm
|
|
807
|
+
) == audioURL.path)
|
|
808
|
+
let reconstructed = try Data(contentsOf: audioURL)
|
|
809
|
+
#expect(reconstructed.count == 44 + pcm.count)
|
|
810
|
+
#expect(reconstructed.suffix(pcm.count) == pcm)
|
|
811
|
+
|
|
812
|
+
#expect(RecordingEngine.ensureBackgroundRecoveryAudio(
|
|
813
|
+
audioPath: audioURL.path,
|
|
814
|
+
pcmData: Data([0xFF, 0xFF])
|
|
815
|
+
) == audioURL.path)
|
|
816
|
+
#expect(try Data(contentsOf: audioURL) == reconstructed)
|
|
817
|
+
#expect(RecordingEngine.ensureBackgroundRecoveryAudio(
|
|
818
|
+
audioPath: directory.appendingPathComponent("empty.wav").path,
|
|
819
|
+
pcmData: Data()
|
|
820
|
+
) == nil)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
@Test("Background recovery retains the failed recording's processing configuration")
|
|
824
|
+
func backgroundRecoveryUsesCapturedConfiguration() {
|
|
825
|
+
let recordingA = RecordingProcessingConfiguration(
|
|
826
|
+
transcriptionPrompt: "Project A vocabulary",
|
|
827
|
+
transcriberPrompt: "Project A vocabulary",
|
|
828
|
+
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
829
|
+
transcriptionLanguage: "es",
|
|
830
|
+
transcriptionModel: "whisper-1",
|
|
831
|
+
transcriberModel: "gpt-a",
|
|
832
|
+
enhancementModel: "gpt-a-fallback",
|
|
833
|
+
intentModel: "gpt-intent-a",
|
|
834
|
+
intentDetectionEnabled: true,
|
|
835
|
+
enhanceTriggersJSON: #"["rewrite a"]"#,
|
|
836
|
+
keywordTransformsJSON: #"{"code with":"Codewith"}"#
|
|
837
|
+
)
|
|
838
|
+
var currentProject = recordingA
|
|
839
|
+
let capturedForRecovery = currentProject
|
|
840
|
+
|
|
841
|
+
currentProject = RecordingProcessingConfiguration(
|
|
842
|
+
transcriptionPrompt: "Project B vocabulary",
|
|
843
|
+
transcriberPrompt: "Project B rewrite policy",
|
|
844
|
+
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
845
|
+
transcriptionLanguage: "en",
|
|
846
|
+
transcriptionModel: "gpt-4o-transcribe",
|
|
847
|
+
transcriberModel: "gpt-b",
|
|
848
|
+
enhancementModel: "gpt-b-fallback",
|
|
849
|
+
intentModel: "gpt-intent-b",
|
|
850
|
+
intentDetectionEnabled: true,
|
|
851
|
+
enhanceTriggersJSON: #"["rewrite b"]"#,
|
|
852
|
+
keywordTransformsJSON: #"{"open ai":"OpenAI"}"#
|
|
853
|
+
)
|
|
854
|
+
let recoveryArgs = RecordingEngine.transcribeCLIArgs(
|
|
855
|
+
audioPath: "/tmp/recording-a.wav",
|
|
856
|
+
activeProjectId: "project-a",
|
|
857
|
+
transcriberPrompt: capturedForRecovery.transcriberPrompt,
|
|
858
|
+
postProcessingMode: capturedForRecovery.postProcessingMode,
|
|
859
|
+
language: capturedForRecovery.transcriptionLanguage,
|
|
860
|
+
transcriptionPrompt: capturedForRecovery.transcriptionPrompt,
|
|
861
|
+
transcriptionModel: capturedForRecovery.transcriptionModel,
|
|
862
|
+
transcriberModel: capturedForRecovery.transcriberModel,
|
|
863
|
+
enhancementModel: capturedForRecovery.enhancementModel,
|
|
864
|
+
enhanceTriggersJSON: capturedForRecovery.enhanceTriggersJSON,
|
|
865
|
+
keywordTransformsJSON: capturedForRecovery.keywordTransformsJSON,
|
|
866
|
+
recordingId: "pipeline-a"
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
#expect(capturedForRecovery != currentProject)
|
|
870
|
+
#expect(recoveryArgs.contains("Project A vocabulary"))
|
|
871
|
+
#expect(recoveryArgs.contains(PostProcessingMode.off.rawValue))
|
|
872
|
+
#expect(recoveryArgs.contains("es"))
|
|
873
|
+
#expect(recoveryArgs.contains("whisper-1"))
|
|
874
|
+
#expect(recoveryArgs.contains("gpt-a"))
|
|
875
|
+
#expect(recoveryArgs.contains(#"["rewrite a"]"#))
|
|
876
|
+
#expect(recoveryArgs.contains(#"{"code with":"Codewith"}"#))
|
|
877
|
+
#expect(recoveryArgs.contains("pipeline-a"))
|
|
878
|
+
#expect(!recoveryArgs.contains("Project B rewrite policy"))
|
|
879
|
+
#expect(!recoveryArgs.contains(PostProcessingMode.always.rawValue))
|
|
880
|
+
#expect(!recoveryArgs.contains("en"))
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
@Test("Command rewrite retains recording A configuration after switching to project B")
|
|
884
|
+
func commandRewriteUsesCapturedConfiguration() {
|
|
885
|
+
let recordingA = RecordingProcessingConfiguration(
|
|
886
|
+
transcriptionPrompt: "Project A vocabulary",
|
|
887
|
+
transcriberPrompt: "Project A rewrite policy",
|
|
888
|
+
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
889
|
+
transcriptionLanguage: "es",
|
|
890
|
+
transcriptionModel: "whisper-a",
|
|
891
|
+
transcriberModel: "gpt-command-a",
|
|
892
|
+
enhancementModel: "gpt-fallback-a",
|
|
893
|
+
intentModel: "gpt-intent-a",
|
|
894
|
+
intentDetectionEnabled: true,
|
|
895
|
+
enhanceTriggersJSON: #"["rewrite a"]"#,
|
|
896
|
+
keywordTransformsJSON: #"{"code with":"Codewith A"}"#
|
|
897
|
+
)
|
|
898
|
+
let recordingB = RecordingProcessingConfiguration(
|
|
899
|
+
transcriptionPrompt: "Project B vocabulary",
|
|
900
|
+
transcriberPrompt: "Project B rewrite policy",
|
|
901
|
+
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
902
|
+
transcriptionLanguage: "en",
|
|
903
|
+
transcriptionModel: "whisper-b",
|
|
904
|
+
transcriberModel: "gpt-command-b",
|
|
905
|
+
enhancementModel: "gpt-fallback-b",
|
|
906
|
+
intentModel: "gpt-intent-b",
|
|
907
|
+
intentDetectionEnabled: true,
|
|
908
|
+
enhanceTriggersJSON: #"["rewrite b"]"#,
|
|
909
|
+
keywordTransformsJSON: #"{"open ai":"OpenAI B"}"#
|
|
910
|
+
)
|
|
911
|
+
var mutableCurrentConfiguration = recordingA
|
|
912
|
+
let capturedRequest = mutableCurrentConfiguration
|
|
913
|
+
mutableCurrentConfiguration = recordingB
|
|
914
|
+
|
|
915
|
+
let args = RecordingEngine.rewriteCLIArgs(
|
|
916
|
+
selectedText: "selected A",
|
|
917
|
+
instruction: "instruction A",
|
|
918
|
+
activeProjectId: "project-a",
|
|
919
|
+
processingConfiguration: capturedRequest
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
#expect(capturedRequest != mutableCurrentConfiguration)
|
|
923
|
+
for expected in [
|
|
924
|
+
"selected A", "instruction A", "project-a", "Project A vocabulary",
|
|
925
|
+
"Project A rewrite policy", PostProcessingMode.always.rawValue, "es", "whisper-a",
|
|
926
|
+
"gpt-command-a", "gpt-fallback-a", #"["rewrite a"]"#,
|
|
927
|
+
#"{"code with":"Codewith A"}"#,
|
|
928
|
+
] {
|
|
929
|
+
#expect(args.contains(expected))
|
|
930
|
+
}
|
|
931
|
+
for forbidden in [
|
|
932
|
+
"Project B vocabulary", "Project B rewrite policy", PostProcessingMode.off.rawValue,
|
|
933
|
+
"whisper-b", "gpt-command-b", "gpt-fallback-b", #"["rewrite b"]"#,
|
|
934
|
+
#"{"open ai":"OpenAI B"}"#,
|
|
935
|
+
] {
|
|
936
|
+
#expect(!args.contains(forbidden))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let optionLikeTextArgs = RecordingEngine.rewriteCLIArgs(
|
|
940
|
+
selectedText: "--literal-selection",
|
|
941
|
+
instruction: "instruction A",
|
|
942
|
+
activeProjectId: "project-a",
|
|
943
|
+
processingConfiguration: capturedRequest
|
|
944
|
+
)
|
|
945
|
+
#expect(Array(optionLikeTextArgs.suffix(2)) == ["--", "--literal-selection"])
|
|
946
|
+
#expect(optionLikeTextArgs.firstIndex(of: "--instruction")! < optionLikeTextArgs.firstIndex(of: "--")!)
|
|
947
|
+
}
|
|
948
|
+
|
|
122
949
|
@Test("Partial realtime text falls back for longer recordings")
|
|
123
950
|
func partialRealtimeFallback() {
|
|
124
951
|
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
|
|
@@ -126,7 +953,7 @@ struct RealtimeTranscriptionTests {
|
|
|
126
953
|
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
127
954
|
}
|
|
128
955
|
|
|
129
|
-
@Test("Realtime fast path accepts
|
|
956
|
+
@Test("Realtime fast path accepts safe text and rejects lexical cleanup")
|
|
130
957
|
func realtimeFastPathDecision() {
|
|
131
958
|
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: " this is a useful transcript ", pcmByteCount: 96_000))
|
|
132
959
|
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 12_000))
|
|
@@ -136,12 +963,12 @@ struct RealtimeTranscriptionTests {
|
|
|
136
963
|
realtimeText: "Actually 리수 Zoom your goal",
|
|
137
964
|
pcmByteCount: 96_000,
|
|
138
965
|
language: "en"
|
|
139
|
-
) ==
|
|
966
|
+
) == nil)
|
|
140
967
|
#expect(RecordingEngine.realtimeFastPathTranscript(
|
|
141
968
|
realtimeText: "어 Okay I don't know if this This is working어 Okay I don't know if this This is working",
|
|
142
969
|
pcmByteCount: 96_000,
|
|
143
970
|
language: "en"
|
|
144
|
-
) ==
|
|
971
|
+
) == nil)
|
|
145
972
|
#expect(RecordingEngine.realtimeFastPathTranscript(
|
|
146
973
|
realtimeText: "Actually Zoom your goal",
|
|
147
974
|
pcmByteCount: 96_000,
|
|
@@ -162,11 +989,11 @@ struct RealtimeTranscriptionTests {
|
|
|
162
989
|
#expect(cleaned == "Okay I don't know if this is working")
|
|
163
990
|
}
|
|
164
991
|
|
|
165
|
-
@Test("Realtime artifact cleanup
|
|
166
|
-
func
|
|
992
|
+
@Test("Realtime artifact cleanup preserves mixed-language lexical tokens")
|
|
993
|
+
func realtimeMixedLanguageCleanup() {
|
|
167
994
|
let cleaned = RecordingEngine.cleanRealtimeArtifactText(
|
|
168
995
|
"Actually 리수 Zoom your goal and do this work with sabi 度扫 agents actually"
|
|
169
996
|
)
|
|
170
|
-
#expect(cleaned == "Actually Zoom your goal and do this work with sabi agents actually")
|
|
997
|
+
#expect(cleaned == "Actually 리수 Zoom your goal and do this work with sabi 度扫 agents actually")
|
|
171
998
|
}
|
|
172
999
|
}
|